Ver Fonte

Tests for API quirks: epic price-based free detection, steam specials/featured parsing, gog structural giveaway detection (with live fixtures)

Paul Klumpp há 3 dias atrás
pai
commit
854275074f

+ 20 - 11
internal/epic/client.go

@@ -73,7 +73,7 @@ type element struct {
 	Seller struct {
 		Name string `json:"displayName"`
 	} `json:"seller"`
-	Price struct {
+	Price *struct {
 		TotalPrice struct {
 			DiscountPrice int `json:"discountPrice"` // current price in cents; 0 = free
 			OriginalPrice int `json:"originalPrice"` // list price in cents
@@ -154,16 +154,25 @@ func buildGames(el element, kind string, windows []offer, keep func(offer) bool,
 	}
 	for _, o := range windows {
 		// Semantics verified against the live API (Oct 2026): the ONLY
-		// reliable free indicator is el.price.totalPrice.discountPrice == 0
-		// (the element's current price). The offer's discountSetting fields
-		// are unreliable: discountPrice is null (=0 in Go) even for
-		// non-free offers, and discountPercentage carries inconsistent
-		// values (e.g. Castlevania 40 while the store shows −60 %).
-		//   - freeOnly:  element current price must be 0.
-		//   - deals:     current price > 0; discount display from
-		//                fmtPrice (original → current).
+		// reliable free indicator for CURRENT windows is
+		// el.price.totalPrice.discountPrice == 0 (the element's current
+		// price). The offer's discountSetting fields are unreliable:
+		// discountPrice is null (=0 in Go) even for non-free offers, and
+		// discountPercentage carries inconsistent values (e.g. Castlevania
+		// 40 while the store shows −60 %).
+		//
+		// UPCOMING windows announce a future giveaway — the element's
+		// current price says nothing about that window, so they are always
+		// included as "upcoming".
+		if el.Price == nil {
+			// No price information at all → cannot classify; skip. Missing
+			// price data must not read as "free" (same quirk class as the
+			// null discountPrice).
+			continue
+		}
 		currentlyFree := el.Price.TotalPrice.DiscountPrice == 0
-		if freeOnly != currentlyFree {
+		isUpcoming := kind == "upcoming"
+		if !isUpcoming && freeOnly != currentlyFree {
 			continue
 		}
 		g := game.Game{
@@ -179,7 +188,7 @@ func buildGames(el element, kind string, windows []offer, keep func(offer) bool,
 			EndDate:     o.EndDate,
 			PriceFmt:    el.Price.TotalPrice.FmtPrice.OriginalPrice,
 		}
-		if !currentlyFree {
+		if !isUpcoming && !currentlyFree {
 			g.Kind = "deal"
 			g.FinalPriceFmt = el.Price.TotalPrice.FmtPrice.DiscountPrice
 			if orig := el.Price.TotalPrice.FmtPrice.OriginalPrice; orig != "" {

+ 301 - 0
internal/epic/client_test.go

@@ -0,0 +1,301 @@
+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
+}

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
internal/epic/testdata/promotions_live.json


+ 23 - 12
internal/gog/client.go

@@ -27,7 +27,8 @@ const (
 
 // Client queries the public GOG storefront.
 type Client struct {
-	HTTP *http.Client
+	HTTP    *http.Client
+	HomeURL string // override for tests; defaults to homeURL
 }
 
 // NewClient returns a GOG client.
@@ -82,7 +83,11 @@ func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) {
 // fetchGiveaway loads the store homepage and extracts the giveaway section
 // from the embedded gogcom-store-state JSON. Returns nil if none is present.
 func (c *Client) fetchGiveaway(ctx context.Context) (*giveawaySection, error) {
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, homeURL, nil)
+	home := c.HomeURL
+	if home == "" {
+		home = homeURL
+	}
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, home, nil)
 	if err != nil {
 		return nil, fmt.Errorf("gog: %w", err)
 	}
@@ -101,24 +106,28 @@ func (c *Client) fetchGiveaway(ctx context.Context) (*giveawaySection, error) {
 	if err != nil {
 		return nil, fmt.Errorf("gog: read response: %w", err)
 	}
-	html := string(body)
 
-	state := extractStoreState(html)
+	state := extractStoreState(string(body))
 	if state == "" {
 		return nil, fmt.Errorf("gog: store state script not found on homepage")
 	}
+	return parseGiveawaySection([]byte(state))
+}
 
+// parseGiveawaySection finds the giveaway section in the embedded store-state
+// JSON. The section is identified by STRUCTURE (an object with `endDate` and
+// a `product` carrying slug/title), not by a fixed key/UUID — this survives
+// homepage layout changes. When several candidates exist, the one with the
+// latest endDate wins. Returns nil when no giveaway is present.
+func parseGiveawaySection(state []byte) (*giveawaySection, error) {
 	var doc map[string]any
-	if err := json.Unmarshal([]byte(state), &doc); err != nil {
+	if err := json.Unmarshal(state, &doc); err != nil {
 		return nil, fmt.Errorf("gog: decode store state: %w", err)
 	}
 
-	var found *giveawaySection
+	var best *giveawaySection
 	var hunt func(v any)
 	hunt = func(v any) {
-		if found != nil {
-			return
-		}
 		switch t := v.(type) {
 		case map[string]any:
 			_, hasEnd := t["endDate"]
@@ -127,8 +136,10 @@ func (c *Client) fetchGiveaway(ctx context.Context) (*giveawaySection, error) {
 				raw, _ := json.Marshal(t)
 				var sec giveawaySection
 				if json.Unmarshal(raw, &sec) == nil {
-					found = &sec
-					return
+					if best == nil || sec.EndDate.After(best.EndDate) {
+						secCopy := sec
+						best = &secCopy
+					}
 				}
 			}
 			for _, v2 := range t {
@@ -141,7 +152,7 @@ func (c *Client) fetchGiveaway(ctx context.Context) (*giveawaySection, error) {
 		}
 	}
 	hunt(doc)
-	return found, nil
+	return best, nil
 }
 
 // extractStoreState returns the content of the gogcom-store-state JSON script.

+ 144 - 0
internal/gog/client_test.go

@@ -0,0 +1,144 @@
+package gog
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+)
+
+// serveHome serves the given HTML for all requests.
+func serveHome(t *testing.T, html string) *Client {
+	t.Helper()
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "text/html; charset=UTF-8")
+		w.Write([]byte(html))
+	}))
+	t.Cleanup(srv.Close)
+	c := NewClient()
+	c.HTTP = srv.Client()
+	// Point the client at the test server by overriding the home URL via
+	// BaseURL-like behavior: homeURL is a package const, so we test through
+	// the extraction path instead.
+	_ = srv
+	return c
+}
+
+// The live homepage embeds the giveaway in gogcom-store-state JSON.
+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")
+	}
+}
+
+// Regression: the section is identified by STRUCTURE (endDate + product with
+// slug/title), not by a fixed section UUID — survives layout changes.
+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)
+	}
+}
+
+// No store-state script → explicit error (the client must not pretend
+// success with an empty list).
+func TestMissingStoreStateIsAnError(t *testing.T) {
+	if state := extractStoreState("<html><body>nothing here</body></html>"); state != "" {
+		t.Fatalf("expected empty state, got %q", state)
+	}
+}
+
+// A store-state without any giveaway object → nil section, no 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")
+	}
+}
+
+// The extraction handles the script tag with trailing content after </script>.
+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)
+	}
+}
+
+// End-to-end via HTTP: client fetches the (test) homepage and extracts the
+// giveaway. Uses a package-level URL override to avoid network.
+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")
+	}
+}

Diff do ficheiro suprimidas por serem muito extensas
+ 49 - 0
internal/gog/testdata/home.html


+ 23 - 2
internal/steam/client.go

@@ -297,6 +297,27 @@ type featuredSection struct {
 	Items []featuredItem `json:"items"`
 }
 
+// decodeFeaturedSections decodes the featuredcategories JSON. The response
+// mixes section objects with scalar values (e.g. "status": 1), which fails
+// strict map decoding — decode per key so scalar junk keys are skipped.
+func decodeFeaturedSections(body []byte) (map[string]featuredSection, error) {
+	var sections map[string]featuredSection
+	if err := json.Unmarshal(body, &sections); err != nil {
+		var loose map[string]json.RawMessage
+		if json.Unmarshal(body, &loose) != nil {
+			return nil, err
+		}
+		sections = make(map[string]featuredSection, len(loose))
+		for k, v := range loose {
+			var sec featuredSection
+			if json.Unmarshal(v, &sec) == nil && sec.ID != nil {
+				sections[k] = sec
+			}
+		}
+	}
+	return sections, nil
+}
+
 func (c *Client) getFreeWeekend(ctx context.Context) ([]game.Game, error) {
 	resp, err := c.get(ctx, featuredURL)
 	if err != nil {
@@ -308,8 +329,8 @@ func (c *Client) getFreeWeekend(ctx context.Context) ([]game.Game, error) {
 		return nil, fmt.Errorf("steam: read featured response: %w", err)
 	}
 
-	var sections map[string]featuredSection
-	if err := json.Unmarshal(body, &sections); err != nil {
+	sections, err := decodeFeaturedSections(body)
+	if err != nil {
 		return nil, fmt.Errorf("steam: decode featured response: %w", err)
 	}
 

+ 147 - 0
internal/steam/client_test.go

@@ -0,0 +1,147 @@
+package steam
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+)
+
+// The live free-search page (maxprice=free&specials=1) contains rows whose
+// price text is "Free" — those must be extracted with title, appid and URL.
+func TestLiveFixture_FreeToKeepExtracted(t *testing.T) {
+	// Use a server that serves the fixture for the search URL; GetFreeGames
+	// also queries featuredcategories — serve an empty JSON there.
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		raw, err := os.ReadFile("testdata/search_free.html")
+		if err != nil {
+			t.Errorf("fixture: %v", err)
+			return
+		}
+		w.Write(raw)
+	}))
+	t.Cleanup(srv.Close)
+
+	c := NewClient()
+	c.HTTP = srv.Client()
+	// Redirect the client to the test server by rewriting the package-level
+	// URLs through a custom transport? Simpler: the Client has fixed URLs, so
+	// instead we test parseSearchRows directly on the fixture file.
+	f, err := os.Open("testdata/search_free.html")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	games, err := c.parseSearchRows(f, parseResultRow)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(games) == 0 {
+		t.Fatal("no free-to-keep games parsed from live fixture")
+	}
+	for _, g := range games {
+		if g.ID == "" || g.Slug == "" || g.URL == "" {
+			t.Errorf("game %+v missing id/slug/url", g.Title)
+		}
+		if g.Kind != "free-to-keep" {
+			t.Errorf("game %q kind = %q, want free-to-keep", g.Title, g.Kind)
+		}
+	}
+}
+
+// The specials page (any discount) must yield rows with real prices and
+// percents — the deal data Paul asked for (e.g. "-60%", "19,99€", "7,99€").
+func TestLiveFixture_DealsHavePricesAndPercent(t *testing.T) {
+	c := NewClient()
+	f, err := os.Open("testdata/search_specials.html")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	games, err := c.parseSearchRows(f, parseDealRow)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(games) == 0 {
+		t.Fatal("no deals parsed from live specials fixture")
+	}
+	for _, g := range games {
+		if g.DiscountPct <= 0 {
+			t.Errorf("deal %q has DiscountPct %d, want > 0", g.Title, g.DiscountPct)
+		}
+		if g.FinalPriceFmt == "" {
+			t.Errorf("deal %q has no final price", g.Title)
+		}
+		if g.Kind != "deal" {
+			t.Errorf("deal %q kind = %q", g.Title, g.Kind)
+		}
+	}
+	// Sorted by discount (deepest first) is enforced by GetDeals.
+}
+
+// Regression: parseSearchRows must use the passed-in parse function, not
+// hardcode parseResultRow (the Oct 2026 bug that made GetDeals return 0).
+func TestParseSearchRowsUsesProvidedParseFn(t *testing.T) {
+	f, err := os.Open("testdata/search_specials.html")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	c := NewClient()
+	deals, err := c.parseSearchRows(f, parseDealRow)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(deals) == 0 {
+		t.Fatal("parseDealRow was not applied — parse fn wiring broken (regression)")
+	}
+}
+
+// Free rows on the free page must never carry a real price (they are free).
+func TestLiveFixture_FreeRowsHaveNoFinalPrice(t *testing.T) {
+	c := NewClient()
+	f, err := os.Open("testdata/search_free.html")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	games, err := c.parseSearchRows(f, parseResultRow)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, g := range games {
+		if g.FinalPriceFmt != "" {
+			t.Errorf("free game %q has final price %q", g.Title, g.FinalPriceFmt)
+		}
+	}
+}
+
+// The featured-categories API (free weekend) must parse without error on the
+// live fixture; free-weekend items carry kind "free-weekend".
+func TestLiveFixture_FeaturedCategoriesParses(t *testing.T) {
+	raw, err := os.ReadFile("testdata/featured.json")
+	if err != nil {
+		t.Fatal(err)
+	}
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Write(raw)
+	}))
+	t.Cleanup(srv.Close)
+
+	sections, err := decodeFeaturedSections(raw)
+	if err != nil {
+		t.Fatalf("decode featured fixture: %v", err)
+	}
+	if len(sections) == 0 {
+		t.Fatal("no sections in featured fixture")
+	}
+}
+
+// decodeFeatured decodes the featuredcategories JSON the same way
+// getFreeWeekend does.
+func decodeFeatured(raw []byte) (map[string]featuredSection, error) {
+	var sections map[string]featuredSection
+	err := json.Unmarshal(raw, &sections)
+	return sections, err
+}

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
internal/steam/testdata/featured.json


Diff do ficheiro suprimidas por serem muito extensas
+ 328 - 0
internal/steam/testdata/search_free.html


Diff do ficheiro suprimidas por serem muito extensas
+ 328 - 0
internal/steam/testdata/search_specials.html


Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff