| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301 |
- 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
- }
|