| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334 |
- package epic
- import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "os"
- "strings"
- "testing"
- "time"
- "gogs.netdome.biz/paul/cheapo/internal/game"
- )
- // serveFixture spins up an httptest server returning the given JSON file and
- // an Epic client pointed at it.
- func serveFixture(t *testing.T, path string) *Client {
- t.Helper()
- raw, err := os.ReadFile(path)
- if err != nil {
- t.Fatalf("read fixture: %v", err)
- }
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- w.Write(raw)
- }))
- t.Cleanup(srv.Close)
- c := NewClient()
- c.BaseURL = srv.URL
- return c
- }
- func findGame(t *testing.T, games []game.Game, titlePrefix string) *game.Game {
- t.Helper()
- for i := range games {
- if len(games[i].Title) >= len(titlePrefix) && games[i].Title[:len(titlePrefix)] == titlePrefix {
- return &games[i]
- }
- }
- t.Fatalf("game %q not found in %d games", titlePrefix, len(games))
- return nil
- }
- // QUIRK — Epic's free indicator lives in the ELEMENT price, not the offer.
- // The API duplicates discount info in two places, and they disagree:
- //
- // - offer.promotions.promotionalOffers[].discountSetting.discountPrice
- // is NULL (→ 0 in Go) even for offers that are NOT free. Naively
- // treating 0 as "free" marked Castlevania (real price 7,99 €) as free —
- // exactly what the store does NOT show.
- // - The element's price.totalPrice.discountPrice is the actual current
- // price in cents and is the field the storefront itself renders.
- //
- // This test pins: free ⟺ element price == 0, judged against the live
- // fixture (Oct 2026: only "Sorry We're Closed" is free; Castlevania must
- // not appear despite discountSetting.discountPrice being null).
- func TestLiveFixture_FreeIsDeterminedByElementPriceNotOfferDiscount(t *testing.T) {
- c := serveFixture(t, "testdata/promotions_live.json")
- games, err := c.GetFreeGames(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- if len(games) != 1 {
- t.Fatalf("want exactly 1 free game (Sorry We're Closed), got %d: %+v", len(games), titles(games))
- }
- if got, want := normTitle(games[0].Title), "Sorry We're Closed"; got != want {
- t.Fatalf("free game = %q, want %q", got, want)
- }
- // Castlevania: element price 7,99 € → must NOT be in the free list.
- for _, g := range games {
- if strings.HasPrefix(normTitle(g.Title), "Castlevania") {
- t.Errorf("Castlevania must not be free (current price 7,99 €)")
- }
- }
- }
- // QUIRK — discountPercentage is meaningless for free offers. A genuinely
- // free element (price 0) can carry a promotionalOffers percentage of e.g.
- // 20 — nonsense as a "discount", just leftover campaign metadata. The old
- // filter (percentage != 100 → not free) would have dropped real free games.
- // Pinned: an element with price 0 and percentage 20 is reported FREE, and
- // without a DiscountPct (percent labels are only for real deals).
- func TestFreeOfferWithMisleadingPercentageIsStillFree(t *testing.T) {
- fixture := map[string]any{
- "data": map[string]any{
- "Catalog": map[string]any{
- "searchStore": map[string]any{
- "elements": []map[string]any{
- quirkElement("Free Game", "0", 0, 20), // price 0, offer percentage 20
- quirkElement("Paid Game", "19,99 €", 1999, 0),
- },
- },
- },
- },
- }
- c := serveJSON(t, fixture)
- games, err := c.GetFreeGames(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- if len(games) != 1 || games[0].Title != "Free Game" {
- t.Fatalf("want only 'Free Game', got %v", titles(games))
- }
- if games[0].DiscountPct != 0 {
- t.Errorf("free game must not carry DiscountPct, got %d", games[0].DiscountPct)
- }
- }
- // QUIRK — discountPercentage is inconsistent for deals too. Live example
- // (Oct 2026): Castlevania carries percentage 40 while the store page shows
- // −60 %. The real discount must be computed from the prices:
- // (originalPriceCents − discountPriceCents) / originalPriceCents. Pinned
- // with the live fixture: Castlevania 19,99 € → 7,99 € = −60 %, Eternal
- // Threads 17,99 € → 3,59 € = −80 %.
- func TestLiveFixture_DealsComputePercentFromPrices(t *testing.T) {
- c := serveFixture(t, "testdata/promotions_live.json")
- deals, err := c.GetDeals(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- cv := findGame(t, deals, "Castlevania")
- _ = cv
- if normPrice(cv.FinalPriceFmt) != "7,99 €" {
- t.Errorf("Castlevania final price = %q, want 7,99 € (from fmtPrice.discountPrice)", cv.FinalPriceFmt)
- }
- if normPrice(cv.PriceFmt) != "19,99 €" {
- t.Errorf("Castlevania original price = %q, want 19,99 €", cv.PriceFmt)
- }
- // 1999 cents → 799 cents = −60 % (store value), NOT the API's misleading 40.
- if cv.DiscountPct != 60 {
- t.Errorf("Castlevania discount = %d %%, want 60 (computed 1999→799)", cv.DiscountPct)
- }
- et := findGame(t, deals, "Eternal Threads")
- if normPrice(et.FinalPriceFmt) != "3,59 €" || et.DiscountPct != 80 {
- t.Errorf("Eternal Threads = %q −%d %%, want 3,59 € −80 %%", et.FinalPriceFmt, et.DiscountPct)
- }
- }
- // QUIRK — because discountSetting is unreliable (see above), a buggy filter
- // can classify the same element as free AND as deal, or drop it from both.
- // The lists are disjoint by construction (element price 0 = free, else
- // deal); this test guards that invariant against future filter changes.
- // The live fixture guarantees there ARE deals to check (Oct 2026: Eternal
- // Threads, Castlevania, Monument Valley as discounts).
- func TestLiveFixture_FreeAndDealsAreDisjoint(t *testing.T) {
- c := serveFixture(t, "testdata/promotions_live.json")
- free, err := c.GetFreeGames(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- deals, err := c.GetDeals(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- freeIDs := map[string]bool{}
- for _, g := range free {
- freeIDs[g.ID] = true
- }
- for _, g := range deals {
- if freeIDs[g.ID] {
- t.Errorf("game %q appears both free and as deal", g.Title)
- }
- }
- if len(deals) == 0 {
- t.Error("live fixture should contain deals (Eternal Threads, Castlevania, …)")
- }
- }
- // QUIRK — some elements come with an active promotion window but WITHOUT a
- // price object at all. In Go, decoding that yields discountPrice == 0, which
- // naively reads as "free". But an element without price info is simply not
- // classifiable — the storefront never renders it as free either. Pinned:
- // missing price data must be skipped, never reported as free.
- func TestOfferWithoutPriceFieldsIsSkipped(t *testing.T) {
- fixture := map[string]any{
- "data": map[string]any{
- "Catalog": map[string]any{
- "searchStore": map[string]any{
- "elements": []map[string]any{
- // No top-level "price" at all → discountPrice 0 would
- // naively look free; the store's real semantics here
- // (live element without price) are not free.
- quirkElement("Mystery Game", "", -1, 0),
- },
- },
- },
- },
- }
- c := serveJSON(t, fixture)
- games, err := c.GetFreeGames(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- if len(games) != 0 {
- t.Fatalf("element without price must be skipped, got %v", titles(games))
- }
- }
- // QUIRK — Epic announces future giveaways in a SEPARATE array
- // (upcomingPromotionalOffers), not in promotionalOffers. The two arrays use
- // the same offer shape but only upcoming ones may have a future startDate.
- // Also pinned: sorting by start date (soonest first) and kind = "upcoming"
- // — even when the element is currently free in another window (an element
- // can be free now AND have an upcoming window; both listings are valid).
- func TestUpcomingGamesFromUpcomingWindows(t *testing.T) {
- c := serveFixture(t, "testdata/promotions_live.json")
- games, err := c.GetUpcomingGames(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- if len(games) == 0 {
- t.Fatal("live fixture has upcoming offers (Flotsam, Idle Champions, …)")
- }
- for i := 1; i < len(games); i++ {
- if games[i].StartDate.Before(games[i-1].StartDate) {
- t.Fatalf("upcoming games not sorted by start date: %+v", titles(games))
- }
- }
- fl := findGame(t, games, "Flotsam")
- if fl.Kind != "upcoming" {
- t.Errorf("Flotsam kind = %q, want upcoming", fl.Kind)
- }
- if fl.StartDate.Before(time.Now()) {
- t.Errorf("upcoming game start date in the past: %v", fl.StartDate)
- }
- }
- // QUIRK — the same element can appear with MULTIPLE promotion windows (an
- // extended giveaway appends a second window with a later endDate). Without
- // dedup the game shows up twice in the list. Pinned: one entry per element
- // ID, carrying the LATEST end date.
- func TestDedupWindowsKeepsLatestEndDate(t *testing.T) {
- c := serveFixture(t, "testdata/promotions_live.json")
- games, err := c.GetFreeGames(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- seen := map[string]int{}
- for _, g := range games {
- seen[g.ID]++
- }
- for id, n := range seen {
- if n > 1 {
- t.Errorf("game %s appears %d times, want 1 (dedup by ID)", id, n)
- }
- }
- }
- // --- helpers ---
- // QUIRK — Epic titles contain NBSP (\u00a0) instead of regular spaces
- // (e.g. "Sorry We're Closed" with a non-breaking space after "Sorry").
- // Comparisons against plain-space literals silently fail; all title/price
- // comparisons in tests normalize NBSP first. This test file pins that the
- // fixtures really contain NBSP — if Epic ever switches to plain spaces,
- // normTitle stays harmless.
- func normTitle(s string) string {
- return strings.ReplaceAll(s, "\u00a0", " ")
- }
- // normPrice normalizes price strings for comparisons (NBSP → space).
- func normPrice(s string) string { return normTitle(s) }
- func titles(games []game.Game) []string {
- var s []string
- for _, g := range games {
- s = append(s, g.Title)
- }
- return s
- }
- // serveJSON serves an arbitrary object as JSON.
- func serveJSON(t *testing.T, v any) *Client {
- t.Helper()
- raw, err := json.Marshal(v)
- if err != nil {
- t.Fatal(err)
- }
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- w.Write(raw)
- }))
- t.Cleanup(srv.Close)
- c := NewClient()
- c.BaseURL = srv.URL
- return c
- }
- // quirkElement builds one element with controlled price/discount fields to
- // pin the API quirks. fmtPrice "": omit the price object entirely (original
- // cents < 0); discountPrice cents < 0: omit discountSetting.discountPrice.
- func quirkElement(title, fmtOriginal string, originalCents, offerPercentage int) map[string]any {
- el := map[string]any{
- "id": "quirk-" + title,
- "title": title,
- "catalogNs": map[string]any{
- "mappings": []map[string]any{{"pageSlug": title}},
- },
- "seller": map[string]any{"displayName": "Quirk Seller"},
- "promotions": map[string]any{
- "promotionalOffers": []map[string]any{
- {
- "promotionalOffers": []map[string]any{
- {
- "startDate": "2026-10-01T00:00:00.000Z",
- "endDate": "2027-01-01T00:00:00.000Z",
- "discountSetting": map[string]any{
- // Omit discountPrice entirely (null case).
- "discountPercentage": offerPercentage,
- },
- },
- },
- },
- },
- },
- }
- if originalCents >= 0 {
- el["price"] = map[string]any{
- "totalPrice": map[string]any{
- "discountPrice": originalCents, // current price == list price here (not discounted)
- "originalPrice": originalCents,
- "fmtPrice": map[string]any{
- "originalPrice": fmtOriginal,
- "discountPrice": fmtOriginal,
- },
- },
- }
- }
- return el
- }
|