|
@@ -0,0 +1,301 @@
|
|
|
|
|
+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
|
|
|
|
|
+}
|