| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150 |
- package gog
- import (
- "context"
- "net/http"
- "net/http/httptest"
- "os"
- "strings"
- "testing"
- )
- // QUIRK — GOG has NO public giveaways API (api.gog.com/v1/giveaways and the
- // storefront-path variants all 404). The giveaway widget on the homepage is
- // Angular-rendered, BUT the page embeds its full section data server-side as
- // JSON in <script id="gogcom-store-state" type="application/json"> — the
- // same data the Angular app hydrates from. Pinned against the live homepage:
- // the script exists, the giveaway section inside carries product title/slug/
- // id and the endDate (Oct 2026: "A New Beginning: Final Cut",
- // slug a_new_beginning).
- func TestLiveFixture_GiveawayFromStoreState(t *testing.T) {
- html, err := os.ReadFile("testdata/home.html")
- if err != nil {
- t.Fatal(err)
- }
- state := extractStoreState(string(html))
- if state == "" {
- t.Fatal("gogcom-store-state script not found in live fixture")
- }
- sec, err := parseGiveawaySection([]byte(state))
- if err != nil {
- t.Fatalf("parse giveaway: %v", err)
- }
- if sec == nil {
- t.Fatal("no giveaway section found in live fixture")
- }
- if sec.Product == nil || sec.Product.Title == "" || sec.Product.Slug == "" {
- t.Fatalf("giveaway product incomplete: %+v", sec.Product)
- }
- if sec.Product.Slug != "a_new_beginning" {
- t.Errorf("slug = %q, want a_new_beginning (live giveaway Oct 2026)", sec.Product.Slug)
- }
- if sec.Product.ID == "" {
- t.Error("product id missing")
- }
- if sec.EndDate.IsZero() {
- t.Error("giveaway endDate missing")
- }
- }
- // QUIRK — the store-state contains MANY section objects (one per homepage
- // block), keyed by content-hash URLs like "sections.gog/v1/pages/2f/sections/
- // <uuid>...". Hardcoding a UUID breaks the next time GOG reshuffles the
- // homepage. Pinned: the giveaway is found by STRUCTURE (object with endDate +
- // product.slug/title) under an ARBITRARY key, and among multiple candidates
- // the one with the LATEST endDate wins (GOG keeps stale sections around).
- func TestGiveawayRecognitionIsStructural(t *testing.T) {
- // A section with endDate + product anywhere in the state is found,
- // even under an arbitrary key.
- 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"}}}`
- sec, err := parseGiveawaySection([]byte(state))
- if err != nil {
- t.Fatal(err)
- }
- if sec == nil || sec.Product.Slug != "test_game" {
- t.Fatalf("structural detection failed: %+v", sec)
- }
- }
- // QUIRK — if GOG changes the script id or drops the embedded state, the
- // homepage still returns HTTP 200 with a full HTML shell. A client that
- // treats "page loaded" as "giveaway found" would silently report an empty
- // list forever. Pinned: missing store-state is detectable, so the client
- // can fail loudly instead of pretending success.
- func TestMissingStoreStateIsAnError(t *testing.T) {
- if state := extractStoreState("<html><body>nothing here</body></html>"); state != "" {
- t.Fatalf("expected empty state, got %q", state)
- }
- }
- // QUIRK — GOG runs giveaways in waves; between waves the store-state still
- // exists but contains no object matching the giveaway shape. This is a
- // NORMAL state ("no active giveaway"), not an error. Pinned: nil section,
- // nil error.
- func TestStateWithoutGiveawayYieldsNoSection(t *testing.T) {
- sec, err := parseGiveawaySection([]byte(`{"pageMetadata":{"locale":"en-US"}}`))
- if err != nil {
- t.Fatal(err)
- }
- if sec != nil {
- t.Fatalf("expected no section, got %+v", sec)
- }
- }
- // Broken store-state JSON → error, not panic.
- func TestBrokenStoreStateJSONIsAnError(t *testing.T) {
- if _, err := parseGiveawaySection([]byte("{not json")); err == nil {
- t.Fatal("expected error for broken JSON")
- }
- }
- // QUIRK — the embedded state is one huge single-line JSON blob. The
- // extraction must cut exactly between the script's open and close tags;
- // greedy matching (to the LAST </script>) would swallow the whole rest of
- // the page. Pinned: exact slice, no trailing HTML.
- func TestExtractStoreStateTrimsScript(t *testing.T) {
- html := `<html><head><script id="gogcom-store-state" type="application/json">{"a":1}</script></head></html>`
- state := extractStoreState(html)
- if strings.TrimSpace(state) != `{"a":1}` {
- t.Fatalf("state = %q", state)
- }
- }
- // QUIRK (end-to-end) — the whole chain must hold together: homepage HTML →
- // store-state script → giveaway section → game.Game. Pinned invariants:
- // exactly ONE giveaway (GOG runs one at a time), store/kind correct, ID
- // carries the gog- prefix (namespace isolation against other providers'
- // IDs), URL built from the slug.
- func TestGetFreeGamesEndToEnd(t *testing.T) {
- html, err := os.ReadFile("testdata/home.html")
- if err != nil {
- t.Fatal(err)
- }
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Write(html)
- }))
- t.Cleanup(srv.Close)
- c := NewClient()
- c.HTTP = srv.Client()
- c.HomeURL = srv.URL
- games, err := c.GetFreeGames(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- if len(games) != 1 {
- t.Fatalf("want exactly 1 giveaway, got %d", len(games))
- }
- g := games[0]
- if g.Store != "gog" || g.Kind != "free-now" {
- t.Errorf("store/kind = %s/%s, want gog/free-now", g.Store, g.Kind)
- }
- if !strings.HasPrefix(g.ID, "gog-") {
- t.Errorf("id = %q, want gog- prefix", g.ID)
- }
- if g.URL == "" {
- t.Error("url missing")
- }
- }
|