client_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. package epic
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "os"
  8. "strings"
  9. "testing"
  10. "time"
  11. "gogs.netdome.biz/paul/cheapo/internal/game"
  12. )
  13. // serveFixture spins up an httptest server returning the given JSON file and
  14. // an Epic client pointed at it.
  15. func serveFixture(t *testing.T, path string) *Client {
  16. t.Helper()
  17. raw, err := os.ReadFile(path)
  18. if err != nil {
  19. t.Fatalf("read fixture: %v", err)
  20. }
  21. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  22. w.Header().Set("Content-Type", "application/json")
  23. w.Write(raw)
  24. }))
  25. t.Cleanup(srv.Close)
  26. c := NewClient()
  27. c.BaseURL = srv.URL
  28. return c
  29. }
  30. func findGame(t *testing.T, games []game.Game, titlePrefix string) *game.Game {
  31. t.Helper()
  32. for i := range games {
  33. if len(games[i].Title) >= len(titlePrefix) && games[i].Title[:len(titlePrefix)] == titlePrefix {
  34. return &games[i]
  35. }
  36. }
  37. t.Fatalf("game %q not found in %d games", titlePrefix, len(games))
  38. return nil
  39. }
  40. // QUIRK — Epic's free indicator lives in the ELEMENT price, not the offer.
  41. // The API duplicates discount info in two places, and they disagree:
  42. //
  43. // - offer.promotions.promotionalOffers[].discountSetting.discountPrice
  44. // is NULL (→ 0 in Go) even for offers that are NOT free. Naively
  45. // treating 0 as "free" marked Castlevania (real price 7,99 €) as free —
  46. // exactly what the store does NOT show.
  47. // - The element's price.totalPrice.discountPrice is the actual current
  48. // price in cents and is the field the storefront itself renders.
  49. //
  50. // This test pins: free ⟺ element price == 0, judged against the live
  51. // fixture (Oct 2026: only "Sorry We're Closed" is free; Castlevania must
  52. // not appear despite discountSetting.discountPrice being null).
  53. func TestLiveFixture_FreeIsDeterminedByElementPriceNotOfferDiscount(t *testing.T) {
  54. c := serveFixture(t, "testdata/promotions_live.json")
  55. games, err := c.GetFreeGames(context.Background())
  56. if err != nil {
  57. t.Fatal(err)
  58. }
  59. if len(games) != 1 {
  60. t.Fatalf("want exactly 1 free game (Sorry We're Closed), got %d: %+v", len(games), titles(games))
  61. }
  62. if got, want := normTitle(games[0].Title), "Sorry We're Closed"; got != want {
  63. t.Fatalf("free game = %q, want %q", got, want)
  64. }
  65. // Castlevania: element price 7,99 € → must NOT be in the free list.
  66. for _, g := range games {
  67. if strings.HasPrefix(normTitle(g.Title), "Castlevania") {
  68. t.Errorf("Castlevania must not be free (current price 7,99 €)")
  69. }
  70. }
  71. }
  72. // QUIRK — discountPercentage is meaningless for free offers. A genuinely
  73. // free element (price 0) can carry a promotionalOffers percentage of e.g.
  74. // 20 — nonsense as a "discount", just leftover campaign metadata. The old
  75. // filter (percentage != 100 → not free) would have dropped real free games.
  76. // Pinned: an element with price 0 and percentage 20 is reported FREE, and
  77. // without a DiscountPct (percent labels are only for real deals).
  78. func TestFreeOfferWithMisleadingPercentageIsStillFree(t *testing.T) {
  79. fixture := map[string]any{
  80. "data": map[string]any{
  81. "Catalog": map[string]any{
  82. "searchStore": map[string]any{
  83. "elements": []map[string]any{
  84. quirkElement("Free Game", "0", 0, 20), // price 0, offer percentage 20
  85. quirkElement("Paid Game", "19,99 €", 1999, 0),
  86. },
  87. },
  88. },
  89. },
  90. }
  91. c := serveJSON(t, fixture)
  92. games, err := c.GetFreeGames(context.Background())
  93. if err != nil {
  94. t.Fatal(err)
  95. }
  96. if len(games) != 1 || games[0].Title != "Free Game" {
  97. t.Fatalf("want only 'Free Game', got %v", titles(games))
  98. }
  99. if games[0].DiscountPct != 0 {
  100. t.Errorf("free game must not carry DiscountPct, got %d", games[0].DiscountPct)
  101. }
  102. }
  103. // QUIRK — discountPercentage is inconsistent for deals too. Live example
  104. // (Oct 2026): Castlevania carries percentage 40 while the store page shows
  105. // −60 %. The real discount must be computed from the prices:
  106. // (originalPriceCents − discountPriceCents) / originalPriceCents. Pinned
  107. // with the live fixture: Castlevania 19,99 € → 7,99 € = −60 %, Eternal
  108. // Threads 17,99 € → 3,59 € = −80 %.
  109. func TestLiveFixture_DealsComputePercentFromPrices(t *testing.T) {
  110. c := serveFixture(t, "testdata/promotions_live.json")
  111. deals, err := c.GetDeals(context.Background())
  112. if err != nil {
  113. t.Fatal(err)
  114. }
  115. cv := findGame(t, deals, "Castlevania")
  116. _ = cv
  117. if normPrice(cv.FinalPriceFmt) != "7,99 €" {
  118. t.Errorf("Castlevania final price = %q, want 7,99 € (from fmtPrice.discountPrice)", cv.FinalPriceFmt)
  119. }
  120. if normPrice(cv.PriceFmt) != "19,99 €" {
  121. t.Errorf("Castlevania original price = %q, want 19,99 €", cv.PriceFmt)
  122. }
  123. // 1999 cents → 799 cents = −60 % (store value), NOT the API's misleading 40.
  124. if cv.DiscountPct != 60 {
  125. t.Errorf("Castlevania discount = %d %%, want 60 (computed 1999→799)", cv.DiscountPct)
  126. }
  127. et := findGame(t, deals, "Eternal Threads")
  128. if normPrice(et.FinalPriceFmt) != "3,59 €" || et.DiscountPct != 80 {
  129. t.Errorf("Eternal Threads = %q −%d %%, want 3,59 € −80 %%", et.FinalPriceFmt, et.DiscountPct)
  130. }
  131. }
  132. // QUIRK — because discountSetting is unreliable (see above), a buggy filter
  133. // can classify the same element as free AND as deal, or drop it from both.
  134. // The lists are disjoint by construction (element price 0 = free, else
  135. // deal); this test guards that invariant against future filter changes.
  136. // The live fixture guarantees there ARE deals to check (Oct 2026: Eternal
  137. // Threads, Castlevania, Monument Valley as discounts).
  138. func TestLiveFixture_FreeAndDealsAreDisjoint(t *testing.T) {
  139. c := serveFixture(t, "testdata/promotions_live.json")
  140. free, err := c.GetFreeGames(context.Background())
  141. if err != nil {
  142. t.Fatal(err)
  143. }
  144. deals, err := c.GetDeals(context.Background())
  145. if err != nil {
  146. t.Fatal(err)
  147. }
  148. freeIDs := map[string]bool{}
  149. for _, g := range free {
  150. freeIDs[g.ID] = true
  151. }
  152. for _, g := range deals {
  153. if freeIDs[g.ID] {
  154. t.Errorf("game %q appears both free and as deal", g.Title)
  155. }
  156. }
  157. if len(deals) == 0 {
  158. t.Error("live fixture should contain deals (Eternal Threads, Castlevania, …)")
  159. }
  160. }
  161. // QUIRK — some elements come with an active promotion window but WITHOUT a
  162. // price object at all. In Go, decoding that yields discountPrice == 0, which
  163. // naively reads as "free". But an element without price info is simply not
  164. // classifiable — the storefront never renders it as free either. Pinned:
  165. // missing price data must be skipped, never reported as free.
  166. func TestOfferWithoutPriceFieldsIsSkipped(t *testing.T) {
  167. fixture := map[string]any{
  168. "data": map[string]any{
  169. "Catalog": map[string]any{
  170. "searchStore": map[string]any{
  171. "elements": []map[string]any{
  172. // No top-level "price" at all → discountPrice 0 would
  173. // naively look free; the store's real semantics here
  174. // (live element without price) are not free.
  175. quirkElement("Mystery Game", "", -1, 0),
  176. },
  177. },
  178. },
  179. },
  180. }
  181. c := serveJSON(t, fixture)
  182. games, err := c.GetFreeGames(context.Background())
  183. if err != nil {
  184. t.Fatal(err)
  185. }
  186. if len(games) != 0 {
  187. t.Fatalf("element without price must be skipped, got %v", titles(games))
  188. }
  189. }
  190. // QUIRK — Epic announces future giveaways in a SEPARATE array
  191. // (upcomingPromotionalOffers), not in promotionalOffers. The two arrays use
  192. // the same offer shape but only upcoming ones may have a future startDate.
  193. // Also pinned: sorting by start date (soonest first) and kind = "upcoming"
  194. // — even when the element is currently free in another window (an element
  195. // can be free now AND have an upcoming window; both listings are valid).
  196. func TestUpcomingGamesFromUpcomingWindows(t *testing.T) {
  197. c := serveFixture(t, "testdata/promotions_live.json")
  198. games, err := c.GetUpcomingGames(context.Background())
  199. if err != nil {
  200. t.Fatal(err)
  201. }
  202. if len(games) == 0 {
  203. t.Fatal("live fixture has upcoming offers (Flotsam, Idle Champions, …)")
  204. }
  205. for i := 1; i < len(games); i++ {
  206. if games[i].StartDate.Before(games[i-1].StartDate) {
  207. t.Fatalf("upcoming games not sorted by start date: %+v", titles(games))
  208. }
  209. }
  210. fl := findGame(t, games, "Flotsam")
  211. if fl.Kind != "upcoming" {
  212. t.Errorf("Flotsam kind = %q, want upcoming", fl.Kind)
  213. }
  214. if fl.StartDate.Before(time.Now()) {
  215. t.Errorf("upcoming game start date in the past: %v", fl.StartDate)
  216. }
  217. }
  218. // QUIRK — the same element can appear with MULTIPLE promotion windows (an
  219. // extended giveaway appends a second window with a later endDate). Without
  220. // dedup the game shows up twice in the list. Pinned: one entry per element
  221. // ID, carrying the LATEST end date.
  222. func TestDedupWindowsKeepsLatestEndDate(t *testing.T) {
  223. c := serveFixture(t, "testdata/promotions_live.json")
  224. games, err := c.GetFreeGames(context.Background())
  225. if err != nil {
  226. t.Fatal(err)
  227. }
  228. seen := map[string]int{}
  229. for _, g := range games {
  230. seen[g.ID]++
  231. }
  232. for id, n := range seen {
  233. if n > 1 {
  234. t.Errorf("game %s appears %d times, want 1 (dedup by ID)", id, n)
  235. }
  236. }
  237. }
  238. // --- helpers ---
  239. // QUIRK — Epic titles contain NBSP (\u00a0) instead of regular spaces
  240. // (e.g. "Sorry We're Closed" with a non-breaking space after "Sorry").
  241. // Comparisons against plain-space literals silently fail; all title/price
  242. // comparisons in tests normalize NBSP first. This test file pins that the
  243. // fixtures really contain NBSP — if Epic ever switches to plain spaces,
  244. // normTitle stays harmless.
  245. func normTitle(s string) string {
  246. return strings.ReplaceAll(s, "\u00a0", " ")
  247. }
  248. // normPrice normalizes price strings for comparisons (NBSP → space).
  249. func normPrice(s string) string { return normTitle(s) }
  250. func titles(games []game.Game) []string {
  251. var s []string
  252. for _, g := range games {
  253. s = append(s, g.Title)
  254. }
  255. return s
  256. }
  257. // serveJSON serves an arbitrary object as JSON.
  258. func serveJSON(t *testing.T, v any) *Client {
  259. t.Helper()
  260. raw, err := json.Marshal(v)
  261. if err != nil {
  262. t.Fatal(err)
  263. }
  264. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  265. w.Header().Set("Content-Type", "application/json")
  266. w.Write(raw)
  267. }))
  268. t.Cleanup(srv.Close)
  269. c := NewClient()
  270. c.BaseURL = srv.URL
  271. return c
  272. }
  273. // quirkElement builds one element with controlled price/discount fields to
  274. // pin the API quirks. fmtPrice "": omit the price object entirely (original
  275. // cents < 0); discountPrice cents < 0: omit discountSetting.discountPrice.
  276. func quirkElement(title, fmtOriginal string, originalCents, offerPercentage int) map[string]any {
  277. el := map[string]any{
  278. "id": "quirk-" + title,
  279. "title": title,
  280. "catalogNs": map[string]any{
  281. "mappings": []map[string]any{{"pageSlug": title}},
  282. },
  283. "seller": map[string]any{"displayName": "Quirk Seller"},
  284. "promotions": map[string]any{
  285. "promotionalOffers": []map[string]any{
  286. {
  287. "promotionalOffers": []map[string]any{
  288. {
  289. "startDate": "2026-10-01T00:00:00.000Z",
  290. "endDate": "2027-01-01T00:00:00.000Z",
  291. "discountSetting": map[string]any{
  292. // Omit discountPrice entirely (null case).
  293. "discountPercentage": offerPercentage,
  294. },
  295. },
  296. },
  297. },
  298. },
  299. },
  300. }
  301. if originalCents >= 0 {
  302. el["price"] = map[string]any{
  303. "totalPrice": map[string]any{
  304. "discountPrice": originalCents, // current price == list price here (not discounted)
  305. "originalPrice": originalCents,
  306. "fmtPrice": map[string]any{
  307. "originalPrice": fmtOriginal,
  308. "discountPrice": fmtOriginal,
  309. },
  310. },
  311. }
  312. }
  313. return el
  314. }