client_test.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. package gog
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "os"
  7. "strings"
  8. "testing"
  9. )
  10. // QUIRK — GOG has NO public giveaways API (api.gog.com/v1/giveaways and the
  11. // storefront-path variants all 404). The giveaway widget on the homepage is
  12. // Angular-rendered, BUT the page embeds its full section data server-side as
  13. // JSON in <script id="gogcom-store-state" type="application/json"> — the
  14. // same data the Angular app hydrates from. Pinned against the live homepage:
  15. // the script exists, the giveaway section inside carries product title/slug/
  16. // id and the endDate (Oct 2026: "A New Beginning: Final Cut",
  17. // slug a_new_beginning).
  18. func TestLiveFixture_GiveawayFromStoreState(t *testing.T) {
  19. html, err := os.ReadFile("testdata/home.html")
  20. if err != nil {
  21. t.Fatal(err)
  22. }
  23. state := extractStoreState(string(html))
  24. if state == "" {
  25. t.Fatal("gogcom-store-state script not found in live fixture")
  26. }
  27. sec, err := parseGiveawaySection([]byte(state))
  28. if err != nil {
  29. t.Fatalf("parse giveaway: %v", err)
  30. }
  31. if sec == nil {
  32. t.Fatal("no giveaway section found in live fixture")
  33. }
  34. if sec.Product == nil || sec.Product.Title == "" || sec.Product.Slug == "" {
  35. t.Fatalf("giveaway product incomplete: %+v", sec.Product)
  36. }
  37. if sec.Product.Slug != "a_new_beginning" {
  38. t.Errorf("slug = %q, want a_new_beginning (live giveaway Oct 2026)", sec.Product.Slug)
  39. }
  40. if sec.Product.ID == "" {
  41. t.Error("product id missing")
  42. }
  43. if sec.EndDate.IsZero() {
  44. t.Error("giveaway endDate missing")
  45. }
  46. }
  47. // QUIRK — the store-state contains MANY section objects (one per homepage
  48. // block), keyed by content-hash URLs like "sections.gog/v1/pages/2f/sections/
  49. // <uuid>...". Hardcoding a UUID breaks the next time GOG reshuffles the
  50. // homepage. Pinned: the giveaway is found by STRUCTURE (object with endDate +
  51. // product.slug/title) under an ARBITRARY key, and among multiple candidates
  52. // the one with the LATEST endDate wins (GOG keeps stale sections around).
  53. func TestGiveawayRecognitionIsStructural(t *testing.T) {
  54. // A section with endDate + product anywhere in the state is found,
  55. // even under an arbitrary key.
  56. state := `{"someKey":{"x":{"endDate":"2026-11-02T16:00:59.000+02:00","product":{"id":"123","title":"Test Game","slug":"test_game"}}},"other":{"endDate":"2020-01-01T00:00:00Z","product":{"id":"0","title":"Old","slug":"old"}}}`
  57. sec, err := parseGiveawaySection([]byte(state))
  58. if err != nil {
  59. t.Fatal(err)
  60. }
  61. if sec == nil || sec.Product.Slug != "test_game" {
  62. t.Fatalf("structural detection failed: %+v", sec)
  63. }
  64. }
  65. // QUIRK — if GOG changes the script id or drops the embedded state, the
  66. // homepage still returns HTTP 200 with a full HTML shell. A client that
  67. // treats "page loaded" as "giveaway found" would silently report an empty
  68. // list forever. Pinned: missing store-state is detectable, so the client
  69. // can fail loudly instead of pretending success.
  70. func TestMissingStoreStateIsAnError(t *testing.T) {
  71. if state := extractStoreState("<html><body>nothing here</body></html>"); state != "" {
  72. t.Fatalf("expected empty state, got %q", state)
  73. }
  74. }
  75. // QUIRK — GOG runs giveaways in waves; between waves the store-state still
  76. // exists but contains no object matching the giveaway shape. This is a
  77. // NORMAL state ("no active giveaway"), not an error. Pinned: nil section,
  78. // nil error.
  79. func TestStateWithoutGiveawayYieldsNoSection(t *testing.T) {
  80. sec, err := parseGiveawaySection([]byte(`{"pageMetadata":{"locale":"en-US"}}`))
  81. if err != nil {
  82. t.Fatal(err)
  83. }
  84. if sec != nil {
  85. t.Fatalf("expected no section, got %+v", sec)
  86. }
  87. }
  88. // Broken store-state JSON → error, not panic.
  89. func TestBrokenStoreStateJSONIsAnError(t *testing.T) {
  90. if _, err := parseGiveawaySection([]byte("{not json")); err == nil {
  91. t.Fatal("expected error for broken JSON")
  92. }
  93. }
  94. // QUIRK — the embedded state is one huge single-line JSON blob. The
  95. // extraction must cut exactly between the script's open and close tags;
  96. // greedy matching (to the LAST </script>) would swallow the whole rest of
  97. // the page. Pinned: exact slice, no trailing HTML.
  98. func TestExtractStoreStateTrimsScript(t *testing.T) {
  99. html := `<html><head><script id="gogcom-store-state" type="application/json">{"a":1}</script></head></html>`
  100. state := extractStoreState(html)
  101. if strings.TrimSpace(state) != `{"a":1}` {
  102. t.Fatalf("state = %q", state)
  103. }
  104. }
  105. // QUIRK (end-to-end) — the whole chain must hold together: homepage HTML →
  106. // store-state script → giveaway section → game.Game. Pinned invariants:
  107. // exactly ONE giveaway (GOG runs one at a time), store/kind correct, ID
  108. // carries the gog- prefix (namespace isolation against other providers'
  109. // IDs), URL built from the slug.
  110. func TestGetFreeGamesEndToEnd(t *testing.T) {
  111. html, err := os.ReadFile("testdata/home.html")
  112. if err != nil {
  113. t.Fatal(err)
  114. }
  115. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  116. w.Write(html)
  117. }))
  118. t.Cleanup(srv.Close)
  119. c := NewClient()
  120. c.HTTP = srv.Client()
  121. c.HomeURL = srv.URL
  122. games, err := c.GetFreeGames(context.Background())
  123. if err != nil {
  124. t.Fatal(err)
  125. }
  126. if len(games) != 1 {
  127. t.Fatalf("want exactly 1 giveaway, got %d", len(games))
  128. }
  129. g := games[0]
  130. if g.Store != "gog" || g.Kind != "free-now" {
  131. t.Errorf("store/kind = %s/%s, want gog/free-now", g.Store, g.Kind)
  132. }
  133. if !strings.HasPrefix(g.ID, "gog-") {
  134. t.Errorf("id = %q, want gog- prefix", g.ID)
  135. }
  136. if g.URL == "" {
  137. t.Error("url missing")
  138. }
  139. }