client_test.go 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  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 1 (regression): the ONLY reliable free indicator is the element's
  41. // price.totalPrice.discountPrice == 0. The offer's discountSetting.discountPrice
  42. // is null (→ 0) even for non-free offers. In the live fixture (Oct 2026),
  43. // Castlevania has offer.discountPrice == null but a real price of 7,99 € —
  44. // the old filter (offer.discountPrice == 0) wrongly reported it as free.
  45. func TestLiveFixture_FreeIsDeterminedByElementPriceNotOfferDiscount(t *testing.T) {
  46. c := serveFixture(t, "testdata/promotions_live.json")
  47. games, err := c.GetFreeGames(context.Background())
  48. if err != nil {
  49. t.Fatal(err)
  50. }
  51. if len(games) != 1 {
  52. t.Fatalf("want exactly 1 free game (Sorry We're Closed), got %d: %+v", len(games), titles(games))
  53. }
  54. if got, want := normTitle(games[0].Title), "Sorry We're Closed"; got != want {
  55. t.Fatalf("free game = %q, want %q", got, want)
  56. }
  57. // Castlevania: element price 7,99 € → must NOT be in the free list.
  58. for _, g := range games {
  59. if strings.HasPrefix(normTitle(g.Title), "Castlevania") {
  60. t.Errorf("Castlevania must not be free (current price 7,99 €)")
  61. }
  62. }
  63. }
  64. // Quirk 2 (regression): discountPercentage in the offer's discountSetting is
  65. // NOT the discount in percent for free offers — a free offer can carry a
  66. // misleading value (e.g. 20). A free offer must be reported free regardless.
  67. func TestFreeOfferWithMisleadingPercentageIsStillFree(t *testing.T) {
  68. fixture := map[string]any{
  69. "data": map[string]any{
  70. "Catalog": map[string]any{
  71. "searchStore": map[string]any{
  72. "elements": []map[string]any{
  73. quirkElement("Free Game", "0", 0, 20), // price 0, offer percentage 20
  74. quirkElement("Paid Game", "19,99 €", 1999, 0),
  75. },
  76. },
  77. },
  78. },
  79. }
  80. c := serveJSON(t, fixture)
  81. games, err := c.GetFreeGames(context.Background())
  82. if err != nil {
  83. t.Fatal(err)
  84. }
  85. if len(games) != 1 || games[0].Title != "Free Game" {
  86. t.Fatalf("want only 'Free Game', got %v", titles(games))
  87. }
  88. if games[0].DiscountPct != 0 {
  89. t.Errorf("free game must not carry DiscountPct, got %d", games[0].DiscountPct)
  90. }
  91. }
  92. // Quirk 3 (regression): discountPercentage in discountSetting is unreliable
  93. // for deals too (Castlevania: 40 while the store shows −60 %). Deals compute
  94. // the percent from the prices (original vs current, cents).
  95. func TestLiveFixture_DealsComputePercentFromPrices(t *testing.T) {
  96. c := serveFixture(t, "testdata/promotions_live.json")
  97. deals, err := c.GetDeals(context.Background())
  98. if err != nil {
  99. t.Fatal(err)
  100. }
  101. cv := findGame(t, deals, "Castlevania")
  102. _ = cv
  103. if normPrice(cv.FinalPriceFmt) != "7,99 €" {
  104. t.Errorf("Castlevania final price = %q, want 7,99 € (from fmtPrice.discountPrice)", cv.FinalPriceFmt)
  105. }
  106. if normPrice(cv.PriceFmt) != "19,99 €" {
  107. t.Errorf("Castlevania original price = %q, want 19,99 €", cv.PriceFmt)
  108. }
  109. // 1999 cents → 799 cents = −60 % (store value), NOT the API's misleading 40.
  110. if cv.DiscountPct != 60 {
  111. t.Errorf("Castlevania discount = %d %%, want 60 (computed 1999→799)", cv.DiscountPct)
  112. }
  113. et := findGame(t, deals, "Eternal Threads")
  114. if normPrice(et.FinalPriceFmt) != "3,59 €" || et.DiscountPct != 80 {
  115. t.Errorf("Eternal Threads = %q −%d %%, want 3,59 € −80 %%", et.FinalPriceFmt, et.DiscountPct)
  116. }
  117. }
  118. // Quirk 4: free offers must never appear in the deals list and vice versa.
  119. func TestLiveFixture_FreeAndDealsAreDisjoint(t *testing.T) {
  120. c := serveFixture(t, "testdata/promotions_live.json")
  121. free, err := c.GetFreeGames(context.Background())
  122. if err != nil {
  123. t.Fatal(err)
  124. }
  125. deals, err := c.GetDeals(context.Background())
  126. if err != nil {
  127. t.Fatal(err)
  128. }
  129. freeIDs := map[string]bool{}
  130. for _, g := range free {
  131. freeIDs[g.ID] = true
  132. }
  133. for _, g := range deals {
  134. if freeIDs[g.ID] {
  135. t.Errorf("game %q appears both free and as deal", g.Title)
  136. }
  137. }
  138. if len(deals) == 0 {
  139. t.Error("live fixture should contain deals (Eternal Threads, Castlevania, …)")
  140. }
  141. }
  142. // Quirk 5: offers without any price information (neither free nor deal) must
  143. // be skipped entirely (no phantom "free" entries).
  144. func TestOfferWithoutPriceFieldsIsSkipped(t *testing.T) {
  145. fixture := map[string]any{
  146. "data": map[string]any{
  147. "Catalog": map[string]any{
  148. "searchStore": map[string]any{
  149. "elements": []map[string]any{
  150. // No top-level "price" at all → discountPrice 0 would
  151. // naively look free; the store's real semantics here
  152. // (live element without price) are not free.
  153. quirkElement("Mystery Game", "", -1, 0),
  154. },
  155. },
  156. },
  157. },
  158. }
  159. c := serveJSON(t, fixture)
  160. games, err := c.GetFreeGames(context.Background())
  161. if err != nil {
  162. t.Fatal(err)
  163. }
  164. if len(games) != 0 {
  165. t.Fatalf("element without price must be skipped, got %v", titles(games))
  166. }
  167. }
  168. // Upcoming windows come from upcomingPromotionalOffers and are sorted by start.
  169. func TestUpcomingGamesFromUpcomingWindows(t *testing.T) {
  170. c := serveFixture(t, "testdata/promotions_live.json")
  171. games, err := c.GetUpcomingGames(context.Background())
  172. if err != nil {
  173. t.Fatal(err)
  174. }
  175. if len(games) == 0 {
  176. t.Fatal("live fixture has upcoming offers (Flotsam, Idle Champions, …)")
  177. }
  178. for i := 1; i < len(games); i++ {
  179. if games[i].StartDate.Before(games[i-1].StartDate) {
  180. t.Fatalf("upcoming games not sorted by start date: %+v", titles(games))
  181. }
  182. }
  183. fl := findGame(t, games, "Flotsam")
  184. if fl.Kind != "upcoming" {
  185. t.Errorf("Flotsam kind = %q, want upcoming", fl.Kind)
  186. }
  187. if fl.StartDate.Before(time.Now()) {
  188. t.Errorf("upcoming game start date in the past: %v", fl.StartDate)
  189. }
  190. }
  191. // A game with several promotion windows (extended giveaway) is reported once
  192. // with the latest end date.
  193. func TestDedupWindowsKeepsLatestEndDate(t *testing.T) {
  194. c := serveFixture(t, "testdata/promotions_live.json")
  195. games, err := c.GetFreeGames(context.Background())
  196. if err != nil {
  197. t.Fatal(err)
  198. }
  199. seen := map[string]int{}
  200. for _, g := range games {
  201. seen[g.ID]++
  202. }
  203. for id, n := range seen {
  204. if n > 1 {
  205. t.Errorf("game %s appears %d times, want 1 (dedup by ID)", id, n)
  206. }
  207. }
  208. }
  209. // --- helpers ---
  210. // normTitle normalizes titles for comparisons: NBSP (\u00a0) → space. The API
  211. // returns titles containing NBSP (e.g. "Sorry We're Closed").
  212. func normTitle(s string) string {
  213. return strings.ReplaceAll(s, "\u00a0", " ")
  214. }
  215. // normPrice normalizes price strings for comparisons (NBSP → space).
  216. func normPrice(s string) string { return normTitle(s) }
  217. func titles(games []game.Game) []string {
  218. var s []string
  219. for _, g := range games {
  220. s = append(s, g.Title)
  221. }
  222. return s
  223. }
  224. // serveJSON serves an arbitrary object as JSON.
  225. func serveJSON(t *testing.T, v any) *Client {
  226. t.Helper()
  227. raw, err := json.Marshal(v)
  228. if err != nil {
  229. t.Fatal(err)
  230. }
  231. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  232. w.Header().Set("Content-Type", "application/json")
  233. w.Write(raw)
  234. }))
  235. t.Cleanup(srv.Close)
  236. c := NewClient()
  237. c.BaseURL = srv.URL
  238. return c
  239. }
  240. // quirkElement builds one element with controlled price/discount fields to
  241. // pin the API quirks. fmtPrice "": omit the price object entirely (original
  242. // cents < 0); discountPrice cents < 0: omit discountSetting.discountPrice.
  243. func quirkElement(title, fmtOriginal string, originalCents, offerPercentage int) map[string]any {
  244. el := map[string]any{
  245. "id": "quirk-" + title,
  246. "title": title,
  247. "catalogNs": map[string]any{
  248. "mappings": []map[string]any{{"pageSlug": title}},
  249. },
  250. "seller": map[string]any{"displayName": "Quirk Seller"},
  251. "promotions": map[string]any{
  252. "promotionalOffers": []map[string]any{
  253. {
  254. "promotionalOffers": []map[string]any{
  255. {
  256. "startDate": "2026-10-01T00:00:00.000Z",
  257. "endDate": "2027-01-01T00:00:00.000Z",
  258. "discountSetting": map[string]any{
  259. // Omit discountPrice entirely (null case).
  260. "discountPercentage": offerPercentage,
  261. },
  262. },
  263. },
  264. },
  265. },
  266. },
  267. }
  268. if originalCents >= 0 {
  269. el["price"] = map[string]any{
  270. "totalPrice": map[string]any{
  271. "discountPrice": originalCents, // current price == list price here (not discounted)
  272. "originalPrice": originalCents,
  273. "fmtPrice": map[string]any{
  274. "originalPrice": fmtOriginal,
  275. "discountPrice": fmtOriginal,
  276. },
  277. },
  278. }
  279. }
  280. return el
  281. }