diff --git a/docs/plan.md b/docs/plan.md index 500a3c3..ba46736 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -195,6 +195,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `games-cache` | The `games` service polls Exophase only on request: the first request waits for the first poll, later ones get the latest list at once and start a poll in the background once the list is an hour old; after a failed poll the last good list stays, and the next poll waits 5 minutes, twice as long after each failure in a row, up to 6 hours. The third of `games-exophase`'s eleven steps (decision 302) | Done (#175) | | `games-service` | A new `games` service answers the games I played in the last 14 days at `GET /games/recent`, from the Exophase profile that `BAKHYARD_EXOPHASE_PLAYER` names, digits only; it runs on 8090 under `mise run up`, and its image builds with the others'. The fourth of `games-exophase`'s eleven steps (decision 303) | Done (#178) | | `twitch-token` | The `games` service gets a Twitch app token for IGDB with the client credentials grant, keeps it in memory until 9 tenths of its life, asks Twitch one request at a time, and forgets a token that IGDB refuses. The fifth of `games-exophase`'s eleven steps (decision 304) | Done (#181) | +| `igdb-search` | The `games` service's IGDB client finds a title's cover: the top result of a search filtered to main games, expansions, remakes, remasters, expanded games and ports, or a game named exactly the title when the top result has another name; it asks one request at a time, at least 250 ms apart, and with a new token once when IGDB refuses one. The sixth of `games-exophase`'s eleven steps (decision 305) | Done (#183) | | `vault-edit-sync` | Editing the vault from Bakhyard (§8.2 Phase 6), by the vault session in `../bakhyard-vault/`, which adds its steps' rows under this one and then does `copilot-planner` (decision 195) | In progress | | `vault-plan` | The vault session's first steps: where a save goes waits on the user's answer to §12's question on how edits get back to the Mac, so the version that a save checks and the editor come first; a note's version is the SHA-256 of its bytes, sent as its `ETag`, and a save is a `PUT` that sends it back in `If-Match` (decision 400) | Done (#167) | | `note-etag` | notes answers each note with its version as a strong `ETag`, the SHA-256 of its bytes, and a matching `If-None-Match` with a 304 (decision 400) | Done (#169) | @@ -1275,7 +1276,7 @@ Every item is pending unless it says done. The service an item lands in is in br - `games-cache`: the service fetches on request: the first request waits for the first fetch, later ones get the latest list at once and start a fetch in the background once it's an hour old; after a failure, the last good list stays and the next fetch backs off (decision 302). Done (#175). - `games-service`: a new `games` service answers the games I played in the last 14 days at `GET /games/recent` (decision 303). Done (#178). - `twitch-token`: the service gets a Twitch app token for IGDB, keeps it and renews it before it expires (decision 304). Done (#181). - - `igdb-search`: the service's IGDB client finds a title's cover with a filtered search and an exact-name fallback (decision 68), within IGDB's 4 requests a second. + - `igdb-search`: the service's IGDB client finds a title's cover with a filtered search and an exact-name fallback (decisions 68 and 305), within IGDB's 4 requests a second. Done (#183). - `games-covers`: each game in the answer carries its IGDB cover; each title is matched once and the match kept. - `homelab-games`: in the homelab repo, `games` runs on the laptop with its settings and secret, and the gateway's peers name it. - `games-gateway`: the gateway forwards `/api/games` to `games`. @@ -1793,6 +1794,17 @@ Decisions 300 to 399 are the `games-exophase` session's. - **Failures**: a failed request changes nothing, so the next call asks again. A status other than 200 gives `get a Twitch token:` and the status, with Twitch's `message` when it gives one, such as `400 Bad Request, invalid client`; an answer with no token, or with a lifetime of 0 seconds or less, gives `read Twitch's answer:` and why. No error shows the secret or a token. - **Rejected**: checking the token every hour at `/oauth2/validate`, which Twitch requires of apps that keep a user's session, not of an app token; renewing only when IGDB answers 401, which costs a refused search per token; asking for a token as the service starts, since only a title not matched yet needs one; a `sync.Mutex`, which a waiting call's context can't end; a user agent, which only Exophase's Cloudflare needed (decision 301). +305. **The IGDB search** (`igdb-search`): the lead's choices (2026-10-30), for the user to review. + - **The request**: `services/games/internal/igdb` posts its query to IGDB's games endpoint, `https://api.igdb.com/v4/games`, with the Twitch app's `Client-ID`, the app token (decision 304) as `Authorization: Bearer`, and `Accept: application/json`, as IGDB's docs show, and the query as `text/plain; charset=utf-8`. Each request gives up after 10 seconds, and an answer over 64 KiB is refused, as Twitch's are. + - **The match**: the first query searches for the title among main games, expansions, standalone expansions, remakes, remasters, expanded games and ports that aren't versions of another game, `game_type = (0,2,4,8,9,10,11) & version_parent = null` as in the spike (§2), and takes the top result. When its name is the title, ignoring case, it's the match. Otherwise a second query looks for a game of any type named exactly the title, ignoring case, with `name ~`. A game it finds wins, since the top result may be another game, as it was for the spike's 15th title. When it finds none, the top result stays, since Exophase's titles often differ from IGDB's names in subtitles or numerals. A title that IGDB knows by the same name costs one request, and any other two. + - **The cover**: the match's 264×374 cover, `https://images.igdb.com/igdb/image/upload/t_cover_big/.jpg` (decision 68), or none when the match has no cover or there's no match. The page loads it from IGDB, which the gateway's content security policy allows. IGDB keeps an image that it removes or replaces for 30 days, so `games-covers` keeps a match for a week at most. An empty title, which Exophase can give, gets no cover and asks nothing. + - **The title in a query**: a backslash before each backslash and quote, as the query language's strings take them, and no control characters, which no title needs. + - **IGDB's limits**: requests go one at a time, through a one-slot channel, and each starts at least 250 ms after the previous one began, so IGDB gets at most 4 a second and one open request, within its 4 a second and 8 open. A call whose context ends while it waits returns the context's error. + - **A refused token**: when IGDB answers 401, the client drops the token and asks once more with a new one. A second 401 is an error, so a client ID that doesn't fit its token can't make the client ask Twitch without end. + - **Failures**: errors start with `find the cover of "":`, then `ask IGDB:` and the status or the transport's error, `read IGDB's answer:`, or the token's error. The client retries no 429 and no 5xx: `games-covers` keeps no error, so a later fetch asks again. No error shows the token, which travels only in a header. + - **Not yet checked live**: the queries and headers follow the spike, IGDB's docs, and another IGDB client's working use of `name ~` without wildcards. IGDB itself waits for the user's Twitch app, which `games-covers` needs. + - **Rejected**: the exact-name search first, which would take any record with that name, such as an edition or a bundle, before the filtered search's better match; the fallback only when the filtered search finds nothing, which would keep the spike's 15th title's wrong match; requests in parallel up to IGDB's 8 open, which would need a rate limiter to save a few seconds on a dozen titles, each matched once; `golang.org/x/time/rate`, a new module for one gap; stripping the quotes from a title rather than escaping them, which would change the exact name; covers that Bakhyard stores and serves, which IGDB already serves. + Decisions 400 to 499 are the vault session's: `vault-edit-sync`, then `copilot-planner`. 400. **Editing notes, the first steps** (`vault-plan`, `note-etag`, `note-editor`): the lead's choices (2026-10-30), for the user to review. diff --git a/services/games/internal/igdb/ask.go b/services/games/internal/igdb/ask.go new file mode 100644 index 0000000..8831254 --- /dev/null +++ b/services/games/internal/igdb/ask.go @@ -0,0 +1,127 @@ +package igdb + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + // gap is the least time between the starts of two requests, so IGDB gets 4 a second at most. + gap = 250 * time.Millisecond + // maxAnswer bounds the size of each answer from IGDB: many times what a game's needs. + maxAnswer = 64 << 10 + // requestTimeout bounds each request to IGDB. + requestTimeout = 10 * time.Second + // asking starts the errors of a request that IGDB didn't answer with games. + asking = "ask IGDB" +) + +// errRefused is the error of a request whose token IGDB refused. +var errRefused = errors.New(asking + ": 401 Unauthorized") + +// game is a game in IGDB's answer, with the fields that the queries ask for. +type game struct { + Name string `json:"name"` + Cover struct { + ImageID string `json:"image_id"` + } `json:"cover"` +} + +// ask returns the games that IGDB answers to query. When IGDB refuses the token, which it may +// have revoked, ask asks once more with a new one. +func (c *Client) ask(ctx context.Context, query string) ([]game, error) { + games, err := c.askWithToken(ctx, query) + if errors.Is(err, errRefused) { + return c.askWithToken(ctx, query) + } + + return games, err +} + +// askWithToken asks query with the token that the Tokens give, and drops it when IGDB refuses it. +func (c *Client) askWithToken(ctx context.Context, query string) ([]game, error) { + token, err := c.tokens.Token(ctx) + if err != nil { + return nil, err + } + + games, err := c.send(ctx, token, query) + if errors.Is(err, errRefused) { + c.tokens.Drop(token) + } + + return games, err +} + +// send sends query with token in its turn, once the previous request started gap ago. If ctx +// ends while it waits, it returns ctx's error. +func (c *Client) send(ctx context.Context, token, query string) ([]game, error) { + select { + case c.turn <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + } + + defer func() { <-c.turn }() + + pause := time.NewTimer(time.Until(c.next)) + defer pause.Stop() + + select { + case <-pause.C: + case <-ctx.Done(): + return nil, ctx.Err() + } + + c.next = time.Now().Add(gap) + + return c.post(ctx, token, query) +} + +// post posts query to IGDB's games endpoint with token, and returns the games it answers. +func (c *Client) post(ctx context.Context, token, query string) ([]game, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, + strings.NewReader(query)) + if err != nil { + return nil, fmt.Errorf("%s: %w", asking, err) + } + + req.Header.Set("Client-ID", c.clientID) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + + res, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("%s: %w", asking, err) + } + + defer func() { _ = res.Body.Close() }() + + switch res.StatusCode { + case http.StatusOK: + return answered(res.Body) + case http.StatusUnauthorized: + return nil, errRefused + default: + return nil, fmt.Errorf("%s: %s", asking, res.Status) + } +} + +// answered returns the games in body, IGDB's answer to a query. +func answered(body io.Reader) ([]game, error) { + var games []game + + err := json.UnmarshalRead(io.LimitReader(body, maxAnswer), &games) + if err != nil { + return nil, fmt.Errorf("read IGDB's answer: %w", err) + } + + return games, nil +} diff --git a/services/games/internal/igdb/ask_internal_test.go b/services/games/internal/igdb/ask_internal_test.go new file mode 100644 index 0000000..bbb2a84 --- /dev/null +++ b/services/games/internal/igdb/ask_internal_test.go @@ -0,0 +1,180 @@ +package igdb + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "slices" + "strings" + "sync" + "testing" + "testing/synctest" + "time" +) + +// memory stands in for IGDB in memory, so that it answers in a bubble: it answers each request +// with no games once delay has passed, and records when each request began. +type memory struct { + delay time.Duration + began []time.Time +} + +func (m *memory) RoundTrip(*http.Request) (*http.Response, error) { + m.began = append(m.began, time.Now()) + time.Sleep(m.delay) + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("[]")), + }, nil +} + +// hanging stands in for an IGDB that never answers. +type hanging struct{} + +func (hanging) RoundTrip(r *http.Request) (*http.Response, error) { + <-r.Context().Done() + + return nil, r.Context().Err() +} + +// aToken is a token that IGDB never refuses. +type aToken struct{} + +func (aToken) Token(context.Context) (string, error) { return "a-token", nil } + +func (aToken) Drop(string) {} + +func TestRequestsStartAQuarterOfASecondApart(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + igdb := &memory{} + client := standIn(igdb) + start := time.Now() + + wantNoCover(t, client) + wantNoCover(t, client) + time.Sleep(time.Second) + wantNoCover(t, client) + + want := []time.Duration{0, 250, 500, 750, 1750, 2000} + if got := since(start, igdb.began); !slices.Equal(got, want) { + t.Errorf("the requests began after %v ms, want %v", got, want) + } + }) +} + +func TestRequestsGoOneAtATime(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + igdb := &memory{delay: time.Second} + client := standIn(igdb) + start := time.Now() + + var calls sync.WaitGroup + for range 2 { + calls.Go(func() { wantNoCover(t, client) }) + } + + calls.Wait() + + want := []time.Duration{0, 1000, 2000, 3000} + if got := since(start, igdb.began); !slices.Equal(got, want) { + t.Errorf("the requests began after %v ms, want %v", got, want) + } + }) +} + +func TestACallThatWaitsForTheRequestUnderWayEndsWithItsContext(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + igdb := &memory{delay: time.Second} + client := standIn(igdb) + + var first sync.WaitGroup + first.Go(func() { wantNoCover(t, client) }) + synctest.Wait() + + wantGivenUp(t, client) + first.Wait() + + if len(igdb.began) != 2 { + t.Errorf("IGDB got %d requests, want the first call's 2", len(igdb.began)) + } + }) +} + +func TestACallThatWaitsForItsQuarterOfASecondEndsWithItsContext(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + igdb := &memory{} + client := standIn(igdb) + + wantNoCover(t, client) + wantGivenUp(t, client) + + if len(igdb.began) != 2 { + t.Errorf("IGDB got %d requests, want the first call's 2", len(igdb.began)) + } + }) +} + +func TestARequestGivesUpAfter10Seconds(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + began := time.Now() + + got, err := standIn(hanging{}).Cover(t.Context(), "Moon Garden") + if got != "" || !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("got %q, %v, want the timeout's error", got, err) + } + + if waited := time.Since(began); waited != 10*time.Second { + t.Errorf("gave up after %s, want 10s", waited) + } + }) +} + +// standIn returns the Client whose requests go to igdb instead of IGDB. +func standIn(igdb http.RoundTripper) *Client { + client := New(&url.URL{Scheme: "https", Host: "api.igdb.com"}, "an-id", aToken{}) + client.client.Transport = igdb + + return client +} + +// wantNoCover checks that client finds no cover for Moon Garden, and no error. +func wantNoCover(t *testing.T, client *Client) { + t.Helper() + + if got, err := client.Cover(t.Context(), "Moon Garden"); got != "" || err != nil { + t.Errorf("got %q, %v, want no cover", got, err) + } +} + +// wantGivenUp checks that client gives up on Moon Garden's cover with its context's error, when +// the context ends 100 ms after the call. +func wantGivenUp(t *testing.T, client *Client) { + t.Helper() + + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + + began := time.Now() + + got, err := client.Cover(ctx, "Moon Garden") + if got != "" || !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("got %q, %v, want the context's error", got, err) + } + + if waited := time.Since(began); waited != 100*time.Millisecond { + t.Errorf("gave up after %s, want 100ms", waited) + } +} + +// since returns how many milliseconds after start each of times is. +func since(start time.Time, times []time.Time) []time.Duration { + waits := make([]time.Duration, 0, len(times)) + for _, at := range times { + waits = append(waits, at.Sub(start)/time.Millisecond) + } + + return waits +} diff --git a/services/games/internal/igdb/client.go b/services/games/internal/igdb/client.go new file mode 100644 index 0000000..8b0cf84 --- /dev/null +++ b/services/games/internal/igdb/client.go @@ -0,0 +1,120 @@ +// Package igdb finds the covers of games on IGDB, the Internet Game Database, with the app token +// that Twitch gives (docs/plan.md decisions 68 and 305). +package igdb + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + "unicode" +) + +const ( + // coverURL is the address of a cover's 264×374 JPEG, with the image's id for %s. + coverURL = "https://images.igdb.com/igdb/image/upload/t_cover_big/%s.jpg" + // filtered searches for a title, with the title for %s, among main games, expansions, + // standalone expansions, remakes, remasters, expanded games and ports that aren't versions of + // another game, so editions, DLC, bundles and packs don't come first. + filtered = `search "%s"; fields name,cover.image_id; ` + + `where game_type = (0,2,4,8,9,10,11) & version_parent = null; limit 1;` + // named finds a game of any type named the title, with the title for %s, ignoring case. + named = `fields name,cover.image_id; where name ~ "%s"; limit 1;` +) + +// Tokens gives the app token that IGDB takes, and forgets one that IGDB refuses: *twitch.Tokens. +type Tokens interface { + Token(ctx context.Context) (string, error) + Drop(token string) +} + +// Client finds covers on IGDB. It's safe for concurrent use. +type Client struct { + endpoint string + clientID string + tokens Tokens + client *http.Client + // turn holds a value while a request is under way, so that requests go one at a time. + turn chan struct{} + // next is the earliest time at which the next request may start. + next time.Time +} + +// New returns the Client that asks IGDB's API at api, https://api.igdb.com, as the Twitch app +// with clientID, with the tokens that tokens gives. +func New(api *url.URL, clientID string, tokens Tokens) *Client { + return &Client{ + endpoint: api.JoinPath("v4/games").String(), + clientID: clientID, + tokens: tokens, + client: &http.Client{Timeout: requestTimeout}, + turn: make(chan struct{}, 1), + } +} + +// Cover returns the address of the cover of the game named title, or "" when IGDB has no such +// game or no cover for it, or the title is empty. +func (c *Client) Cover(ctx context.Context, title string) (string, error) { + if title == "" { + return "", nil + } + + found, err := c.match(ctx, title) + if err != nil { + return "", fmt.Errorf("find the cover of %q: %w", title, err) + } + + if found.Cover.ImageID == "" { + return "", nil + } + + return fmt.Sprintf(coverURL, url.PathEscape(found.Cover.ImageID)), nil +} + +// match returns the game that IGDB has for title, or no game. The filtered search's top result +// is the match when its name is the title, ignoring case. Otherwise a game named exactly the +// title, of any type, wins, since the top result may be another game, and the top result stays +// when there's no such game, since titles often differ from IGDB's names in subtitles or numerals. +func (c *Client) match(ctx context.Context, title string) (game, error) { + quoted := quote(title) + + top, err := c.ask(ctx, fmt.Sprintf(filtered, quoted)) + if err != nil { + return game{}, err + } + + if len(top) > 0 && strings.EqualFold(top[0].Name, title) { + return top[0], nil + } + + same, err := c.ask(ctx, fmt.Sprintf(named, quoted)) + if err != nil { + return game{}, err + } + + if len(same) > 0 { + return same[0], nil + } + + if len(top) > 0 { + return top[0], nil + } + + return game{}, nil +} + +// quote returns title as a query's string holds it: with a backslash before each backslash and +// quote, and without control characters. +func quote(title string) string { + printable := strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + + return r + }, title) + + return strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(printable) +} diff --git a/services/games/internal/igdb/client_test.go b/services/games/internal/igdb/client_test.go new file mode 100644 index 0000000..c8461d4 --- /dev/null +++ b/services/games/internal/igdb/client_test.go @@ -0,0 +1,131 @@ +package igdb_test + +import ( + "errors" + "net/http" + "slices" + "testing" + + "github.com/codedogapp/bakhyard/services/games/internal/igdb" +) + +const ( + // searched is the filtered search for Moon Garden. + searched = `search "Moon Garden"; fields name,cover.image_id; ` + + `where game_type = (0,2,4,8,9,10,11) & version_parent = null; limit 1;` + // named is the search for a game named Moon Garden. + named = `fields name,cover.image_id; where name ~ "Moon Garden"; limit 1;` + // covers is where IGDB keeps the covers' JPEGs. + covers = "https://images.igdb.com/igdb/image/upload/t_cover_big/" +) + +func TestCoverAsksIGDBForTheTitle(t *testing.T) { + address, requests := igdbStandIn(t, + games(`[{"id":1,"name":"Moon Garden","cover":{"id":2,"image_id":"co1abc"}}]`)) + + got, err := cover(t, address, "Moon Garden") + if got != covers+"co1abc.jpg" || err != nil { + t.Errorf("got %q, %v, want the cover", got, err) + } + + want := []string{asked("token-1", searched)} + if got := requests(); !slices.Equal(got, want) { + t.Errorf("got the requests %q, want %q", got, want) + } +} + +func TestCoverFallsBackToAGameNamedTheTitle(t *testing.T) { + one := []string{asked("token-1", searched)} + both := []string{asked("token-1", searched), asked("token-1", named)} + + for _, test := range []struct { + name, top, same, want string + requests []string + }{ + { + "the top result has the title in another case", + `[{"name":"MOON garden","cover":{"image_id":"top"}}]`, `[]`, "top", one, + }, + {"the top result has the title and no cover", `[{"name":"Moon Garden"}]`, `[]`, "", one}, + {"the top result has a null cover", `[{"name":"Moon Garden","cover":null}]`, `[]`, "", one}, + { + "a game has the title", `[{"name":"Moon Garden 2","cover":{"image_id":"top"}}]`, + `[{"name":"Moon Garden","cover":{"image_id":"same"}}]`, "same", both, + }, + { + "the game that has the title has no cover", + `[{"name":"Moon Garden 2","cover":{"image_id":"top"}}]`, `[{"name":"Moon Garden"}]`, + "", both, + }, + { + "no game has the title", + `[{"name":"Moon Garden: Remastered","cover":{"image_id":"top"}}]`, `[]`, "top", both, + }, + { + "no top result", `[]`, `[{"name":"moon garden","cover":{"image_id":"same"}}]`, "same", + both, + }, + {"no game at all", `[]`, `[]`, "", both}, + {"null answers", `null`, `null`, "", both}, + { + "a cover whose id has a slash and a space", + `[{"name":"Moon Garden","cover":{"image_id":"a/b c"}}]`, `[]`, "a%2Fb%20c", one, + }, + } { + t.Run(test.name, func(t *testing.T) { + address, requests := igdbStandIn(t, func(_, query string) (int, string) { + if query == searched { + return http.StatusOK, test.top + } + + return http.StatusOK, test.same + }) + + want := "" + if test.want != "" { + want = covers + test.want + ".jpg" + } + + got, err := cover(t, address, "Moon Garden") + if got != want || err != nil { + t.Errorf("got %q, %v, want %q", got, err, want) + } + + if got := requests(); !slices.Equal(got, test.requests) { + t.Errorf("got the requests %q, want %q", got, test.requests) + } + }) + } +} + +func TestCoverEscapesTheTitle(t *testing.T) { + address, requests := igdbStandIn(t, games(`[]`)) + + got, err := cover(t, address, "Say \"Hi\" \\o/\n\t\x7f") + if got != "" || err != nil { + t.Errorf("got %q, %v, want no cover", got, err) + } + + want := []string{ + asked("token-1", `search "Say \"Hi\" \\o/"; fields name,cover.image_id; `+ + `where game_type = (0,2,4,8,9,10,11) & version_parent = null; limit 1;`), + asked("token-1", `fields name,cover.image_id; where name ~ "Say \"Hi\" \\o/"; limit 1;`), + } + if got := requests(); !slices.Equal(got, want) { + t.Errorf("got the requests %q, want %q", got, want) + } +} + +func TestCoverOfNoTitleAsksNothing(t *testing.T) { + address, requests := igdbStandIn(t, games(`[]`)) + noTokens := &tokens{err: errors.New("asked for a token")} + + got, err := igdb.New(address, clientID, noTokens).Cover(t.Context(), "") + if got != "" || err != nil { + t.Errorf("got %q, %v, want no cover", got, err) + } + + if got := requests(); len(got) != 0 { + t.Errorf("got the requests %q, want none", got) + } +} diff --git a/services/games/internal/igdb/fail_test.go b/services/games/internal/igdb/fail_test.go new file mode 100644 index 0000000..1701d0c --- /dev/null +++ b/services/games/internal/igdb/fail_test.go @@ -0,0 +1,156 @@ +package igdb_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "testing" + + "github.com/codedogapp/bakhyard/services/games/internal/igdb" +) + +// found is IGDB's answer with Moon Garden's cover. +const found = `[{"name":"Moon Garden","cover":{"image_id":"co1abc"}}]` + +func TestCoverAsksAgainWithANewTokenWhenIGDBRefusesIt(t *testing.T) { + address, requests := igdbStandIn(t, func(token, _ string) (int, string) { + if token == "token-1" { + return http.StatusUnauthorized, `{"message":"Authorization Failure"}` + } + + return http.StatusOK, found + }) + given := &tokens{} + + got, err := igdb.New(address, clientID, given).Cover(t.Context(), "Moon Garden") + if got != covers+"co1abc.jpg" || err != nil { + t.Errorf("got %q, %v, want the cover", got, err) + } + + if want := []string{"token-1"}; !slices.Equal(given.dropped, want) { + t.Errorf("dropped %q, want %q", given.dropped, want) + } + + want := []string{asked("token-1", searched), asked("token-2", searched)} + if got := requests(); !slices.Equal(got, want) { + t.Errorf("got the requests %q, want %q", got, want) + } +} + +func TestCoverAsksOnlyOnceMoreWhenIGDBRefusesTheNewTokenToo(t *testing.T) { + address, requests := igdbStandIn(t, answerWith(http.StatusUnauthorized)) + given := &tokens{} + + got, err := igdb.New(address, clientID, given).Cover(t.Context(), "Moon Garden") + + want := `find the cover of "Moon Garden": ask IGDB: 401 Unauthorized` + if got != "" || err == nil || err.Error() != want { + t.Errorf("got %q, %v, want %q", got, err, want) + } + + if want := []string{"token-1", "token-2"}; !slices.Equal(given.dropped, want) { + t.Errorf("dropped %q, want %q", given.dropped, want) + } + + if got := requests(); len(got) != 2 { + t.Errorf("got the requests %q, want 2", got) + } +} + +func TestCoverSaysWhyIGDBFailed(t *testing.T) { + for _, test := range []struct { + name string + answer answer + want string + }{ + {"an error", answerWith(http.StatusInternalServerError), "500 Internal Server Error"}, + {"too many requests", answerWith(http.StatusTooManyRequests), "429 Too Many Requests"}, + {"games found with another status", func(string, string) (int, string) { + return http.StatusAccepted, found + }, "202 Accepted"}, + {"the search for the title's name fails", func(_, query string) (int, string) { + if query == searched { + return http.StatusOK, `[{"name":"Moon Garden 2"}]` + } + + return http.StatusServiceUnavailable, "" + }, "503 Service Unavailable"}, + } { + t.Run(test.name, func(t *testing.T) { + address, _ := igdbStandIn(t, test.answer) + + got, err := cover(t, address, "Moon Garden") + + want := `find the cover of "Moon Garden": ask IGDB: ` + test.want + if got != "" || err == nil || err.Error() != want { + t.Errorf("got %q, %v, want %q", got, err, want) + } + }) + } +} + +func TestCoverRefusesAnAnswerItCannotRead(t *testing.T) { + // 64 KiB of games is the most an answer may hold, padded at its start, so that the cut + // that would end a longer answer takes its last bracket. + padded := func(size int) string { return strings.Repeat(" ", size-len(found)) + found } + + for _, body := range []string{ + `<html>`, `{"name":"Moon Garden"}`, `[{"name":1}]`, `[{"cover":"co1abc"}]`, padded(64<<10 + 1), + } { + address, _ := igdbStandIn(t, games(body)) + + got, err := cover(t, address, "Moon Garden") + + want := `find the cover of "Moon Garden": read IGDB's answer: ` + if got != "" || err == nil || !strings.HasPrefix(err.Error(), want) { + t.Errorf("%.20q: got %q, %v, want an error that starts %q", body, got, err, want) + } + } + + address, _ := igdbStandIn(t, games(padded(64<<10))) + if got, err := cover(t, address, "Moon Garden"); got != covers+"co1abc.jpg" || err != nil { + t.Errorf("64 KiB: got %q, %v, want the cover", got, err) + } +} + +func TestCoverSaysWhyItGotNoToken(t *testing.T) { + address, requests := igdbStandIn(t, games(found)) + noTokens := &tokens{err: errors.New("get a Twitch token: 400 Bad Request, invalid client")} + + got, err := igdb.New(address, clientID, noTokens).Cover(t.Context(), "Moon Garden") + + want := `find the cover of "Moon Garden": get a Twitch token: 400 Bad Request, invalid client` + if got != "" || err == nil || err.Error() != want { + t.Errorf("got %q, %v, want %q", got, err, want) + } + + if got := requests(); len(got) != 0 { + t.Errorf("got the requests %q, want none", got) + } +} + +func TestCoverSaysIGDBIsOutOfReach(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + server.Close() + + address, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + got, err := cover(t, address, "Moon Garden") + + want := `find the cover of "Moon Garden": ask IGDB: Post "` + server.URL + `/v4/games": ` + if got != "" || err == nil || !strings.HasPrefix(err.Error(), want) || + strings.Contains(err.Error(), "token-1") { + t.Errorf("got %q, %v, want an error that starts %q, without the token", got, err, want) + } +} + +// answerWith answers every query with status and no body. +func answerWith(status int) answer { + return func(string, string) (int, string) { return status, "" } +} diff --git a/services/games/internal/igdb/standin_test.go b/services/games/internal/igdb/standin_test.go new file mode 100644 index 0000000..ad37302 --- /dev/null +++ b/services/games/internal/igdb/standin_test.go @@ -0,0 +1,103 @@ +package igdb_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync" + "testing" + + "github.com/codedogapp/bakhyard/services/games/internal/igdb" +) + +// clientID is the stand-in Twitch app's client ID. +const clientID = "abcdefg12345" + +// answer gives the status and body of the stand-in IGDB's answer to query, asked with token. +type answer func(token, query string) (int, string) + +// games answers every query with 200 and body. +func games(body string) answer { + return func(string, string) (int, string) { return http.StatusOK, body } +} + +// tokens stands in for Twitch's tokens: it gives token-1 until that's dropped, then token-2, and so +// on, or fails with err when it's set. +type tokens struct { + dropped []string + err error +} + +func (t *tokens) Token(context.Context) (string, error) { + if t.err != nil { + return "", t.err + } + + return fmt.Sprintf("token-%d", len(t.dropped)+1), nil +} + +func (t *tokens) Drop(token string) { + t.dropped = append(t.dropped, token) +} + +// igdbStandIn starts a stand-in IGDB that answers with answer, and returns its address and a +// function that returns the requests it got, as asked writes them. +func igdbStandIn(t *testing.T, answer answer) (*url.URL, func() []string) { + t.Helper() + + var ( + lock sync.Mutex + requests []string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lock.Lock() + defer lock.Unlock() + + query, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + + requests = append(requests, fmt.Sprintf( + "%s %s, Client-ID %s, Authorization %s, Accept %s, Content-Type %s: %s", + r.Method, r.URL.Path, r.Header.Get("Client-ID"), r.Header.Get("Authorization"), + r.Header.Get("Accept"), r.Header.Get("Content-Type"), query)) + + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + status, body := answer(token, string(query)) + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + })) + t.Cleanup(server.Close) + + address, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + return address, func() []string { + lock.Lock() + defer lock.Unlock() + + return slices.Clone(requests) + } +} + +// asked returns the record of a request for query with token, as the stand-in IGDB writes it. +func asked(token, query string) string { + return "POST /v4/games, Client-ID " + clientID + ", Authorization Bearer " + token + + ", Accept application/json, Content-Type text/plain; charset=utf-8: " + query +} + +// cover returns the cover that a Client finds for title at the stand-in IGDB at address. +func cover(t *testing.T, address *url.URL, title string) (string, error) { + t.Helper() + + return igdb.New(address, clientID, &tokens{}).Cover(t.Context(), title) +}