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 1 (regression): the ONLY reliable free indicator is the element's // price.totalPrice.discountPrice == 0. The offer's discountSetting.discountPrice // is null (→ 0) even for non-free offers. In the live fixture (Oct 2026), // Castlevania has offer.discountPrice == null but a real price of 7,99 € — // the old filter (offer.discountPrice == 0) wrongly reported it as free. 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 2 (regression): discountPercentage in the offer's discountSetting is // NOT the discount in percent for free offers — a free offer can carry a // misleading value (e.g. 20). A free offer must be reported free regardless. 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 3 (regression): discountPercentage in discountSetting is unreliable // for deals too (Castlevania: 40 while the store shows −60 %). Deals compute // the percent from the prices (original vs current, cents). 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 4: free offers must never appear in the deals list and vice versa. 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 5: offers without any price information (neither free nor deal) must // be skipped entirely (no phantom "free" entries). 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)) } } // Upcoming windows come from upcomingPromotionalOffers and are sorted by start. 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) } } // A game with several promotion windows (extended giveaway) is reported once // with 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 --- // normTitle normalizes titles for comparisons: NBSP (\u00a0) → space. The API // returns titles containing NBSP (e.g. "Sorry We're Closed"). 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 }