diff --git a/.env.example b/.env.example index 5f074e3c..ca1ca6f6 100644 --- a/.env.example +++ b/.env.example @@ -59,14 +59,6 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 # it on only together with enable_confirmations = true and an SMTP provider. NEXT_PUBLIC_AUTH_EMAIL_DELIVERY=off -# Optional. on or off; unset or empty means off. on adds a GAMES tab to -# /watchlist, /upcoming and /watched and accepts ?type=game there (spec 0027). -# Production leaves it unset until people have a way to find games; set it on -# in .env.local and in Vercel Preview. Public on purpose: it only decides -# whether the tab renders, never who may read what. Inlined at build time, so a -# change needs a redeploy. -NEXT_PUBLIC_GAMES_LIBRARY=on - # --------------------------------------------------------------------------- # Deployment (set by the host, not by you) # --------------------------------------------------------------------------- diff --git a/AGENTS.md b/AGENTS.md index 32b11eaf..11ed915a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,7 +284,7 @@ Games are a third media type, planned in [docs/scope/games.md](docs/scope/games. - **Ratings:** a personal score from 1 to 10, only on Playing or Finished. Moving back to Want to play clears it, with Undo restoring status, score and timestamps; Playing to Finished and back keeps it. IGDB's community rating shows out of 10 and is always labelled as the community rating. - **Timestamps:** added, started (the first time a game becomes Playing) and finished (every time it becomes Finished). Want to play sorts by added, Playing by started, Finished by finished. - **Not tracked:** platform played on, hours played, 100% completion. -- **Navigation:** SHOWS | MOVIES | GAMES with `?type=game`; for games `/watchlist` is Want to play, `/upcoming` Playing, `/watched` Finished. Since G6 the GAMES tab shows on every page while `NEXT_PUBLIC_GAMES_LIBRARY` is `on` (spec 0028), and `/games` is the games landing with five paged list pages (`/games/{slug}`); game pages reach the sitemap only in G10. +- **Navigation:** SHOWS | MOVIES | GAMES with `?type=game`; for games `/watchlist` is Want to play, `/upcoming` Playing, `/watched` Finished. The GAMES tab shows on every page (spec 0028; the flag that gated it was removed in G10, spec 0032), and `/games` is the games landing with five paged list pages (`/games/{slug}`). Since G10 game pages, `/games` and its list pages are indexable and in the sitemap (spec 0032). ## Build approach @@ -316,11 +316,10 @@ Tracer Bullet: prove the whole pipe works with one thin real thread, then thicke - Images render through `next/image` and the one `loaderFile`, `lib/images/loader.ts`, which sends a TMDB URL to `lib/tmdb/image-loader.ts` and an IGDB cover to `lib/images/igdb-loader.ts` (spec 0025). The optimizer is closed: no `remotePatterns` and an empty `localPatterns`, so `/_next/image` fetches only the static import folders Next always appends (`/_next/static/media/**`), which hold no images (prompts/close-image-optimizer.md). Never add a remote pattern, and never import an image file into a component (that would open the optimizer to it); give a new host its own loader. - The visitor's country (Where to watch, the age rating chip) comes only from `getVisitorRegion()` in `lib/region.ts`, which reads Vercel's `x-vercel-ip-country` header with a `US` fallback (spec 0022). Call it only inside a streamed Suspense boundary, never in a page shell, a cached scope or an SEO surface, and never store, log or send it to TMDB. - The navbar is transparent (no background, border or blur) and not sticky, so it scrolls away with the page; every control in it is 40px tall at every width, with the `hit-area` utilities keeping a 44px tap target. The footer is borderless. See [components/AGENTS.md](components/AGENTS.md). -- Legal facts (operator handle and GitHub URL, contact email, processors, `LEGAL_LAST_UPDATED`) live only in `lib/legal/operator.ts`, and TMDB's notice only in `TMDB_ATTRIBUTION`; `lib/legal/boundary.test.ts` fails if either is pasted elsewhere. `/privacy` and `/terms` describe only what the code does, so a change that adds a processor, email provider or analytics updates `PROCESSORS`, the policy text and `LEGAL_LAST_UPDATED` in the same PR (spec 0017). `public/tmdb-logo.svg` is TMDB's official file, hash checked; never edit it. +- Legal facts (operator handle and GitHub URL, contact email, processors, `LEGAL_LAST_UPDATED`) live only in `lib/legal/operator.ts`, TMDB's notice only in `TMDB_ATTRIBUTION` and IGDB's credit only in `IGDB_ATTRIBUTION`; `lib/legal/boundary.test.ts` fails if any is pasted elsewhere. `/privacy` and `/terms` describe only what the code does, so a change that adds a processor, email provider or analytics updates `PROCESSORS`, the policy text and `LEGAL_LAST_UPDATED` in the same PR (spec 0017). `public/tmdb-logo.svg` and `public/igdb-logo.svg` are TMDB's and IGDB's official files, each hash checked by its own test; never edit either. - Production runs from [docs/deploy.md](docs/deploy.md), the runbook of spec 0018: push a migration with `supabase db push` before merging the PR that adds it, and auth settings reach the cloud only through `supabase config push` (never the dashboard). Run both pushes yourself; from an agent shell the CLI applies them without stopping at the prompt. `vercel.json` pins functions to `fra1`, next to the Supabase region `eu-central-1`. The `/api/search` rate limit is a Vercel Firewall rule, recorded only in `docs/deploy.md`. - `NEXT_PUBLIC_AUTH_EMAIL_DELIVERY` (`on` or `off`, unset meaning `off`) is read only through `getAuthEmailDelivery()` in `lib/env.ts`, separate from `getPublicEnv()` so previews with no Supabase variables still build. Production is `off`: no auth email is sent or promised. Being a `NEXT_PUBLIC_` value, a change takes a redeploy. -- `NEXT_PUBLIC_GAMES_LIBRARY` (`on` or `off`, unset meaning `off`) gates the games tabs (spec 0027). It is read only in `lib/env.ts`, through `getGamesLibrary()` (throws on an invalid value; `LibrarySection` calls it, and `/search` and `/api/search` call it only for a raw `type=game`, so a bad value never breaks a show or movie search, spec 0029) and `gamesLibraryEnabled()` (an invalid value reads as off; the navbar uses it). Production leaves it unset, Preview and `.env.local` set `on` (`docs/deploy.md`). -- `game` is a `LibraryType` (`MediaType | "game"`), never a `MediaType`: `parseLibraryTypeParam` and `typedHref` accept it on `/watchlist`, `/upcoming`, `/watched` and, since G7, `/search` (spec 0029); every TMDB read still takes `MediaType`, so `game` is narrowed away before one, and the title pages and the proxy never receive it. G6 widened `CATALOG_PATHS`, `CATALOG_SOURCES`, the list registry and the proxy's list slug rule to `game` (spec 0028). A games tab reads its stored status, not a classification: one counted `user_game_state` page of 24 plus one `getGamesByIds` batch for those ids (`lib/tracking/game-library.ts`), with no 500 title limit. +- `game` is a `LibraryType` (`MediaType | "game"`), never a `MediaType`: `parseLibraryTypeParam` and `typedHref` always accept it on `/watchlist`, `/upcoming`, `/watched` and, since G7, `/search` (spec 0029; no flag since spec 0032); every TMDB read still takes `MediaType`, so `game` is narrowed away before one, and the title pages and the proxy never receive it. G6 widened `CATALOG_PATHS`, `CATALOG_SOURCES`, the list registry and the proxy's list slug rule to `game` (spec 0028). A games tab reads its stored status, not a classification: one counted `user_game_state` page of 24 plus one `getGamesByIds` batch for those ids (`lib/tracking/game-library.ts`), with no 500 title limit. ## Code conventions diff --git a/app/api/search/route.test.ts b/app/api/search/route.test.ts index ab77ddf2..7e1dc79e 100644 --- a/app/api/search/route.test.ts +++ b/app/api/search/route.test.ts @@ -4,11 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { IgdbError, IgdbValidationError } from "@/lib/igdb/errors"; import { TmdbError } from "@/lib/tmdb/errors"; -/** covers: spec 0010, AC-20; spec 0029, AC-17 */ +/** covers: spec 0010, AC-20; spec 0029, AC-17; spec 0032, AC-12 */ const readSearchPage = vi.hoisted(() => vi.fn()); const searchGames = vi.hoisted(() => vi.fn()); -const games = vi.hoisted(() => ({ library: "on" as "on" | "off" })); vi.mock("@/lib/search/read", async (importOriginal) => { const actual = await importOriginal(); @@ -18,9 +17,6 @@ vi.mock("@/lib/igdb", async () => { const errors = await import("@/lib/igdb/errors"); return { ...errors, searchGames }; }); -vi.mock("@/lib/env", () => ({ - getGamesLibrary: vi.fn(() => games.library), -})); const { GET } = await import("./route"); @@ -46,7 +42,6 @@ const PAGE = { afterEach(() => { readSearchPage.mockReset(); searchGames.mockReset(); - games.library = "on"; }); describe("GET /api/search", () => { @@ -176,13 +171,6 @@ describe("GET /api/search for games (spec 0029, AC-17)", () => { }); }); - it("answers 400 with the games library off, without asking IGDB", async () => { - games.library = "off"; - const response = await GET(request("type=game&q=zelda")); - expect(response.status).toBe(400); - expect(searchGames).not.toHaveBeenCalled(); - }); - it("answers 400, never 500, for a query IGDB's cleaning refuses", async () => { searchGames.mockRejectedValue( new IgdbValidationError("query", "The search query is empty."), diff --git a/app/api/search/route.ts b/app/api/search/route.ts index dab9c08c..5c900c1d 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -2,7 +2,6 @@ import { type NextRequest, NextResponse } from "next/server"; import { IgdbError, IgdbValidationError, searchGames } from "@/lib/igdb"; import { toGameQuickSearchResponse } from "@/lib/search/game-read"; -import { gameSearchEnabled } from "@/lib/search/games-flag"; import { quickSearchRequestSchema } from "@/lib/search/quick"; import { readSearchPage, toQuickSearchResponse } from "@/lib/search/read"; import { TmdbError } from "@/lib/tmdb"; @@ -26,17 +25,16 @@ const SUCCESS_CACHE = "public, s-maxage=300, stale-while-revalidate=600"; * cached module read, so a popular query costs one TMDB request per cache * lifetime. Errors are never cached. * - * `type=game` is accepted only while the games library is on, and the flag is - * read only for that raw value; it reads the cached IGDB search page 1 at the - * default 20 and returns its first five games (spec 0029, AC-17). A query + * `type=game` reads the cached IGDB search page 1 at the default 20 and + * returns its first five games (spec 0029, AC-17). A query * IGDB's cleaning refuses answers 400, never 500. */ export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; - const rawType = searchParams.get("type"); - const parsed = quickSearchRequestSchema({ - gamesEnabled: gameSearchEnabled(rawType), - }).safeParse({ type: rawType, q: searchParams.get("q") }); + const parsed = quickSearchRequestSchema.safeParse({ + type: searchParams.get("type"), + q: searchParams.get("q"), + }); if (!parsed.success) return invalid(); const { type, q } = parsed.data; diff --git a/app/catalog-lists.test.ts b/app/catalog-lists.test.ts index 455fb81c..89bae6dd 100644 --- a/app/catalog-lists.test.ts +++ b/app/catalog-lists.test.ts @@ -36,9 +36,7 @@ describe.each([ const source = readFileSync(join(dir, slug, "page.tsx"), "utf8"); expect(source).toContain(`catalogList("${mediaType}", "${slug}")`); expect(source).toContain(" ({ const { default: GamePage, generateMetadata } = await import("./page"); +const ORIGIN = "https://bestats.example"; + +const ART = "https://images.igdb.com/igdb/image/upload/t_1080p/ar1.jpg"; +const SHOT = "https://images.igdb.com/igdb/image/upload/t_1080p/sc1.jpg"; +const COVER = + "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/co1wyy.jpg"; + const GAME = { name: "The Witcher 3: Wild Hunt", releaseYear: 2015, summary: "RPG and sequel to The Witcher 2 (2011).", + artUrl: ART, + screenshotUrls: [SHOT], + coverUrl: COVER, }; function metadataFor(id: string) { @@ -61,20 +74,74 @@ async function bodyFor(id: string) { return render(detail.props); } -afterEach(() => loadGame.mockReset()); +beforeEach(() => vi.stubEnv("NEXT_PUBLIC_SITE_URL", ORIGIN)); -describe("a found game's metadata (AC-10)", () => { - it("is the name and year, the summary, and noindex with nothing else", async () => { +afterEach(() => { + loadGame.mockReset(); + vi.unstubAllEnvs(); +}); + +describe("a found game's metadata (spec 0025, AC-10; spec 0032, AC-1)", () => { + it("is indexable, with the name and year, the summary, its canonical and its art", async () => { loadGame.mockResolvedValue({ kind: "found", game: GAME }); - expect(await metadataFor("1942")).toEqual({ - title: "The Witcher 3: Wild Hunt (2015)", - description: "RPG and sequel to The Witcher 2 (2011).", - robots: { index: false }, + const metadata = await metadataFor("1942"); + + expect(metadata.title).toBe("The Witcher 3: Wild Hunt (2015)"); + expect(metadata.description).toBe( + "RPG and sequel to The Witcher 2 (2011).", + ); + expect(metadata.robots).toBeUndefined(); + expect(metadata.alternates).toEqual({ canonical: `${ORIGIN}/games/1942` }); + expect(metadata.openGraph).toMatchObject({ + url: `${ORIGIN}/games/1942`, + type: "website", + siteName: "BeStats", + images: [ART], + }); + expect(metadata.twitter).toMatchObject({ + card: "summary_large_image", + images: [ART], }); expect(loadGame).toHaveBeenCalledWith(1942); }); + it.each([ + ["the first screenshot without art", { artUrl: null }, SHOT], + [ + "the cover without art or screenshots", + { artUrl: null, screenshotUrls: [] }, + COVER, + ], + [ + "the site card with no image at all", + { artUrl: null, screenshotUrls: [], coverUrl: null }, + `${ORIGIN}/opengraph-image`, + ], + ])("shares %s", async (_, override, image) => { + loadGame.mockResolvedValue({ + kind: "found", + game: { ...GAME, ...override }, + }); + + expect((await metadataFor("1942")).openGraph?.images).toEqual([image]); + }); + + it("builds the canonical from the id alone, never relative without a site URL", async () => { + vi.stubEnv("NEXT_PUBLIC_SITE_URL", undefined); + loadGame.mockResolvedValue({ + kind: "found", + game: { ...GAME, artUrl: null, screenshotUrls: [], coverUrl: null }, + }); + + const metadata = await metadataFor("1942"); + + expect(metadata.alternates).toBeUndefined(); + expect(metadata.openGraph?.url).toBeUndefined(); + expect(metadata.openGraph?.images).toBeUndefined(); + expect(metadata.robots).toBeUndefined(); + }); + it("leaves the year and description out when IGDB has none", async () => { loadGame.mockResolvedValue({ kind: "found", @@ -99,18 +166,21 @@ describe("a found game's metadata (AC-10)", () => { }); }); -describe("a game page with nothing to show (AC-7, AC-8)", () => { +describe("a game page with nothing to show (AC-7, AC-8; spec 0032, AC-2)", () => { it.each([ ["not found", { kind: "not_found" }, "Game not found"], ["a failed IGDB read", { kind: "failed" }, "Game"], - ])("is noindex for %s", async (_, result, title) => { - loadGame.mockResolvedValue(result); - - expect(await metadataFor("1942")).toEqual({ - title, - robots: { index: false }, - }); - }); + ])( + "is noindex with no canonical or share card for %s", + async (_, result, title) => { + loadGame.mockResolvedValue(result); + + expect(await metadataFor("1942")).toEqual({ + title, + robots: { index: false }, + }); + }, + ); it("is noindex for a malformed id without asking IGDB (AC-6)", async () => { expect(await metadataFor("abc")).toEqual({ @@ -160,8 +230,25 @@ describe("the streamed body", () => { platformReleases: [], }; - /** The article's children: the hero first, then the sections in order. */ - const sectionsOf = (article: Element) => article.props.children as Element[]; + /** + * The article's children after its JSON-LD: the hero first, then the + * sections in order. + */ + const sectionsOf = (article: Element) => + (article.props.children as Element[]).slice(1); + + it("opens with the game's VideoGame JSON-LD, only in the found branch (spec 0032, AC-3)", async () => { + loadGame.mockResolvedValue({ kind: "found", game: FOUND }); + + const [script] = (await bodyFor("1942")).props.children as Element[]; + + expect(script.type).toBe(JsonLd); + expect(script.props.data).toEqual(gameJsonLd(FOUND)); + expect(script.props.data).toMatchObject({ + "@type": "VideoGame", + url: `${ORIGIN}/games/1942`, + }); + }); it("hands the found game's facts to the hero and nothing it does not show (AC-1)", async () => { loadGame.mockResolvedValue({ kind: "found", game: FOUND }); @@ -253,6 +340,7 @@ describe("the streamed body", () => { const html = renderToStaticMarkup(await bodyFor("1942")); + expect(html).not.toContain("application/ld+json"); expect(html).toContain("Couldn't reach IGDB"); expect(html).toContain("IGDB didn't respond. Try again in a moment."); expect(html).toMatch(/]*>Try again<\/a>/); diff --git a/app/games/[id]/page.tsx b/app/games/[id]/page.tsx index 8ec5b784..530037c8 100644 --- a/app/games/[id]/page.tsx +++ b/app/games/[id]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { Suspense } from "react"; +import { JsonLd } from "@/components/catalog/json-ld"; import { GameDetailSkeleton } from "@/components/game/game-detail-skeleton"; import { GameFacts } from "@/components/game/game-facts"; import { GameHero } from "@/components/game/game-hero"; @@ -14,6 +15,8 @@ import { GameTrackingSlot } from "@/components/tracking/game-tracking-slot"; import { parseGameId } from "@/lib/catalog/ids"; import { gameHref } from "@/lib/catalog/media-type"; import { truncateAtWord } from "@/lib/format"; +import { gameJsonLd } from "@/lib/seo/json-ld"; +import { catalogMetadata } from "@/lib/seo/metadata"; import { loadGame } from "./load-game"; @@ -28,13 +31,17 @@ const NOT_FOUND_METADATA: Metadata = { /** * The tab title and description, from the same `loadGame` the body uses - * (spec 0025, AC-10). + * (spec 0025, AC-10), plus the canonical and share card of spec 0032, AC-1. * - * Every branch is `noindex` until G10 turns indexing on for games, so no - * canonical, share card or JSON-LD yet. The not found branch sets it itself - * because the status is already 200 when the body decides (a soft 404, - * AC-7), and the failed branch has it so an IGDB outage never gets a retry - * panel indexed (AC-8). + * Only a found, visible game is indexable. The not found branch sets + * `noindex` itself because the status is already 200 when the body decides + * (a soft 404, spec 0025, AC-7), and the failed branch has it so an IGDB + * outage never gets a retry panel indexed (AC-8; spec 0032, AC-2). The + * canonical is built from the id alone, so a query string never reaches it. + * + * The share image prefers the wide art, then a screenshot, then the cover, + * because a share card is 1200 by 630 and a portrait cover crops badly; the + * movie page's backdrop first rule (spec 0032, rationale). */ export async function generateMetadata({ params, @@ -48,13 +55,16 @@ export async function generateMetadata({ return { title: "Game", robots: { index: false } }; } - const { name, releaseYear, summary } = result.game; - return { + const { name, releaseYear, summary, artUrl, screenshotUrls, coverUrl } = + result.game; + return catalogMetadata({ title: releaseYear === null ? name : `${name} (${releaseYear})`, description: summary === null ? undefined : truncateAtWord(summary, DESCRIPTION_LIMIT), - robots: { index: false }, - }; + path: gameHref(id), + image: artUrl ?? screenshotUrls[0] ?? coverUrl, + ogType: "website", + }); } /** @@ -69,6 +79,9 @@ export async function generateMetadata({ * (AC-9). The status control reads the session in its own Suspense boundary * through the hero's `tracking` slot (spec 0026, AC-1). * + * The found branch, and only it, carries the `VideoGame` JSON-LD (spec 0032, + * AC-3), so a missing, hidden or failed game never describes itself. + * * Spec 0031 adds the backdrop and Trailer button to the hero, then Overview, * Screenshots, More like this and Facts, in that order (AC-1). All but More * like this come from the one `getGame` read; that section streams its own @@ -112,6 +125,7 @@ async function GameDetail({ return (
+ ): Promise { + return catalogListMetadata(LIST, searchParams); +} /** One games list, paged by 24 (spec 0028, AC-9). */ export default function ComingSoonGamesPage({ diff --git a/app/games/page.tsx b/app/games/page.tsx index fd6d1a39..66694e54 100644 --- a/app/games/page.tsx +++ b/app/games/page.tsx @@ -4,8 +4,8 @@ import { CatalogLanding } from "@/components/catalog/catalog-landing"; import { gamesMetadata } from "@/lib/landing-metadata"; /** - * The games landing's metadata (spec 0028, AC-13): `noindex`, with no - * canonical or share card of its own, until G10 opens games to search. + * The games landing's metadata (spec 0032, AC-4): its title and description, + * the canonical `/games` and the site card, like `/movies` and `/shows`. */ export const metadata: Metadata = gamesMetadata({ title: "Games", diff --git a/app/games/popular/page.tsx b/app/games/popular/page.tsx index b857afd4..5c2fe081 100644 --- a/app/games/popular/page.tsx +++ b/app/games/popular/page.tsx @@ -2,12 +2,16 @@ import type { Metadata } from "next"; import { CatalogListPage } from "@/components/catalog/catalog-list-page"; import { catalogList } from "@/lib/catalog/lists"; -import { gameListMetadata } from "@/lib/landing-metadata"; +import { catalogListMetadata } from "@/lib/landing-metadata"; const LIST = catalogList("game", "popular"); -/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */ -export const metadata: Metadata = gameListMetadata(LIST); +/** The list's title, note and canonical (spec 0032, AC-5). */ +export async function generateMetadata({ + searchParams, +}: PageProps<"/games/popular">): Promise { + return catalogListMetadata(LIST, searchParams); +} /** One games list, paged by 24 (spec 0028, AC-9). */ export default function PopularGamesPage({ diff --git a/app/games/recently-released/page.tsx b/app/games/recently-released/page.tsx index d0d8a44a..d1c34b0e 100644 --- a/app/games/recently-released/page.tsx +++ b/app/games/recently-released/page.tsx @@ -2,12 +2,16 @@ import type { Metadata } from "next"; import { CatalogListPage } from "@/components/catalog/catalog-list-page"; import { catalogList } from "@/lib/catalog/lists"; -import { gameListMetadata } from "@/lib/landing-metadata"; +import { catalogListMetadata } from "@/lib/landing-metadata"; const LIST = catalogList("game", "recently-released"); -/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */ -export const metadata: Metadata = gameListMetadata(LIST); +/** The list's title, note and canonical (spec 0032, AC-5). */ +export async function generateMetadata({ + searchParams, +}: PageProps<"/games/recently-released">): Promise { + return catalogListMetadata(LIST, searchParams); +} /** One games list, paged by 24 (spec 0028, AC-9). */ export default function RecentlyReleasedGamesPage({ diff --git a/app/games/request-scope.test.ts b/app/games/request-scope.test.ts index 79e3b622..9dd83abe 100644 --- a/app/games/request-scope.test.ts +++ b/app/games/request-scope.test.ts @@ -5,13 +5,15 @@ import { describe, expect, it } from "vitest"; import { GAME_LIST_SLUGS } from "@/lib/catalog/lists"; /** - * covers: spec 0028, AC-18, AC-19 + * covers: spec 0028, AC-18, AC-19; spec 0032, AC-14 * * `/games` and its list pages are public pages served from a prerendered * shell. The only session read on them is the hero's `GameTrackingSlot`, * inside its own Suspense boundary in `components/`, so no route file may * reach for a request API or a Supabase client, and none may opt out of the * static shell. `app/games/actions.ts` is a POST handler, not render code. + * The visitor's region is request state too, and since spec 0032 these pages + * feed search engines, so it stays out of them as well. */ const FORBIDDEN = [ "cookies(", @@ -19,6 +21,7 @@ const FORBIDDEN = [ "draftMode(", "@/lib/supabase", "@/lib/auth/user", + "getVisitorRegion", ]; const ACTIONS = join("app/games/actions.ts"); diff --git a/app/games/top-rated/page.tsx b/app/games/top-rated/page.tsx index 5d97701e..e7f003b0 100644 --- a/app/games/top-rated/page.tsx +++ b/app/games/top-rated/page.tsx @@ -2,12 +2,16 @@ import type { Metadata } from "next"; import { CatalogListPage } from "@/components/catalog/catalog-list-page"; import { catalogList } from "@/lib/catalog/lists"; -import { gameListMetadata } from "@/lib/landing-metadata"; +import { catalogListMetadata } from "@/lib/landing-metadata"; const LIST = catalogList("game", "top-rated"); -/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */ -export const metadata: Metadata = gameListMetadata(LIST); +/** The list's title, note and canonical (spec 0032, AC-5). */ +export async function generateMetadata({ + searchParams, +}: PageProps<"/games/top-rated">): Promise { + return catalogListMetadata(LIST, searchParams); +} /** One games list, paged by 24 (spec 0028, AC-9). */ export default function TopRatedGamesPage({ diff --git a/app/games/trending/page.tsx b/app/games/trending/page.tsx index 0075d642..51d7a9ed 100644 --- a/app/games/trending/page.tsx +++ b/app/games/trending/page.tsx @@ -2,12 +2,16 @@ import type { Metadata } from "next"; import { CatalogListPage } from "@/components/catalog/catalog-list-page"; import { catalogList } from "@/lib/catalog/lists"; -import { gameListMetadata } from "@/lib/landing-metadata"; +import { catalogListMetadata } from "@/lib/landing-metadata"; const LIST = catalogList("game", "trending"); -/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */ -export const metadata: Metadata = gameListMetadata(LIST); +/** The list's title, note and canonical (spec 0032, AC-5). */ +export async function generateMetadata({ + searchParams, +}: PageProps<"/games/trending">): Promise { + return catalogListMetadata(LIST, searchParams); +} /** One games list, paged by 24 (spec 0028, AC-9). */ export default function TrendingGamesPage({ diff --git a/app/sitemap.test.ts b/app/sitemap.test.ts index f66c7244..0622d571 100644 --- a/app/sitemap.test.ts +++ b/app/sitemap.test.ts @@ -1,18 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IgdbError, IgdbValidationError } from "@/lib/igdb/errors"; import { TmdbError } from "@/lib/tmdb/errors"; /** - * covers: spec 0016, AC-6, AC-7, AC-8, AC-9; spec 0017, AC-13; spec 0021, AC-16 + * covers: spec 0016, AC-6, AC-7, AC-8, AC-9; spec 0017, AC-13; spec 0021, + * AC-16; spec 0032, AC-6, AC-7 * - * The sitemap is the one SEO surface that reads TMDB, so its order, dedupe, - * independent failure and lifetime choice are pinned against mocked discover - * reads. `cacheLife` only means something inside Next, so it is recorded + * The sitemap is the one SEO surface that reads TMDB and IGDB, so its order, + * dedupe, independent failure and lifetime choice are pinned against mocked + * discover and Popular reads. `cacheLife` only means something inside Next, so it is recorded * rather than run; outside Next the `use cache` directive is an inert string. */ const cacheLife = vi.fn(); const discoverMovies = vi.fn(); const discoverTvShows = vi.fn(); +const getPopularGames = vi.fn(); vi.mock("next/cache", () => ({ cacheLife: (profile: string) => cacheLife(profile), @@ -26,11 +29,22 @@ vi.mock("@/lib/tmdb", async () => { }; }); +vi.mock("@/lib/igdb", async () => { + const errors = await import("@/lib/igdb/errors"); + return { + getPopularGames: (kind: unknown) => getPopularGames(kind), + IgdbError: errors.IgdbError, + }; +}); + const { default: sitemap } = await import("./sitemap"); const ORIGIN = "https://bestats.example"; -/** The ten list pages at page 1, movies first, in row order (spec 0021, AC-16). */ +/** + * The fifteen list pages at page 1, movies, then shows, then games, in row + * order (spec 0021, AC-16; spec 0032, AC-6). + */ const LISTS = [ "/movies/trending", "/movies/popular", @@ -42,12 +56,18 @@ const LISTS = [ "/shows/airing-today", "/shows/on-the-air", "/shows/top-rated", + "/games/trending", + "/games/popular", + "/games/recently-released", + "/games/coming-soon", + "/games/top-rated", ].map((path) => `${ORIGIN}${path}`); /** Everything listed before the titles. */ const FIXED = [ `${ORIGIN}/movies`, `${ORIGIN}/shows`, + `${ORIGIN}/games`, ...LISTS, `${ORIGIN}/privacy`, `${ORIGIN}/terms`, @@ -63,8 +83,15 @@ function page(ids: number[]) { }; } -/** Page N of movies holds ids N*100+1 and N*100+2; shows the same plus 5000. */ +/** Games 9001 and 9002, in IGDB's Popular rank order. */ +const GAMES = [{ id: 9001 }, { id: 9002 }]; + +/** + * Page N of movies holds ids N*100+1 and N*100+2; shows the same plus 5000; + * the Popular games list holds `GAMES`. + */ function happyReads() { + getPopularGames.mockResolvedValue(GAMES); discoverMovies.mockImplementation(async ({ page: n }: { page: number }) => page([n * 100 + 1, n * 100 + 2]), ); @@ -87,23 +114,51 @@ afterEach(() => { cacheLife.mockReset(); discoverMovies.mockReset(); discoverTvShows.mockReset(); + getPopularGames.mockReset(); }); describe("the sitemap on an indexable deployment", () => { - it("lists the landings, the list pages, the legal pages, then movies, then shows, in page order (AC-6; spec 0017, AC-13; spec 0021, AC-16)", async () => { + it("lists the landings, the list pages, the legal pages, then movies, shows and games, in page order (AC-6; spec 0017, AC-13; spec 0021, AC-16; spec 0032, AC-6)", async () => { happyReads(); const listed = urls(await sitemap()); - expect(listed.slice(0, 16)).toEqual([ + expect(listed.slice(0, 22)).toEqual([ ...FIXED, `${ORIGIN}/movies/101`, `${ORIGIN}/movies/102`, ]); - expect(listed).toHaveLength(14 + 20 + 20); - expect(listed[33]).toBe(`${ORIGIN}/movies/1002`); - expect(listed[34]).toBe(`${ORIGIN}/shows/5101`); - expect(listed.at(-1)).toBe(`${ORIGIN}/shows/6002`); + expect(listed).toHaveLength(20 + 20 + 20 + 2); + expect(listed[39]).toBe(`${ORIGIN}/movies/1002`); + expect(listed[40]).toBe(`${ORIGIN}/shows/5101`); + expect(listed[59]).toBe(`${ORIGIN}/shows/6002`); + expect(listed.slice(60)).toEqual([ + `${ORIGIN}/games/9001`, + `${ORIGIN}/games/9002`, + ]); + }); + + it("reads the games with the exact Popular call /games/popular makes (spec 0032, AC-6)", async () => { + happyReads(); + + await sitemap(); + + expect(getPopularGames.mock.calls).toEqual([["popular"]]); + }); + + it("lists at most 200 games, deduplicated in rank order (spec 0032, AC-6)", async () => { + happyReads(); + const ranked = [7, 7, ...Array.from({ length: 300 }, (_, i) => 10_000 + i)]; + getPopularGames.mockResolvedValue(ranked.map((id) => ({ id }))); + + const games = urls(await sitemap()).filter( + (url) => url.startsWith(`${ORIGIN}/games/`) && /\/games\/\d+$/.test(url), + ); + + expect(games).toHaveLength(200); + expect(games[0]).toBe(`${ORIGIN}/games/7`); + expect(games[1]).toBe(`${ORIGIN}/games/10000`); + expect(games.at(-1)).toBe(`${ORIGIN}/games/10198`); }); it("reads pages 1 to 10 of each landing's discover call (AC-6)", async () => { @@ -128,6 +183,7 @@ describe("the sitemap on an indexable deployment", () => { }); it("dedupes ids within a media type, keeping the first, but not across types (AC-6)", async () => { + getPopularGames.mockResolvedValue([{ id: 2 }]); discoverMovies.mockImplementation(async ({ page: n }: { page: number }) => n === 1 ? page([1, 2]) : n === 2 ? page([2, 3]) : page([]), ); @@ -141,6 +197,7 @@ describe("the sitemap on an indexable deployment", () => { `${ORIGIN}/movies/2`, `${ORIGIN}/movies/3`, `${ORIGIN}/shows/2`, + `${ORIGIN}/games/2`, ]); }); @@ -157,14 +214,6 @@ describe("the sitemap on an indexable deployment", () => { } }); - it("lists no games URL before G10 (spec 0028, AC-13)", async () => { - happyReads(); - - for (const url of urls(await sitemap())) { - expect(url.slice(ORIGIN.length)).not.toMatch(/^\/games(\/|$)/); - } - }); - it("keeps a complete list for days (AC-8)", async () => { happyReads(); @@ -190,14 +239,43 @@ describe("the sitemap on an indexable deployment", () => { const listed = urls(await sitemap()); - expect(listed).toHaveLength(14 + 18 + 18); + expect(listed).toHaveLength(20 + 18 + 18 + 2); expect(listed).not.toContain(`${ORIGIN}/movies/201`); expect(listed).not.toContain(`${ORIGIN}/shows/6001`); expect(listed).toContain(`${ORIGIN}/movies/301`); expect(cacheLife.mock.calls).toEqual([["minutes"]]); }); + it.each([ + ["unauthorized, as with missing credentials", "unauthorized"], + ["rate limited", "rate_limited"], + ] as const)( + "drops only the game ids when IGDB is %s, and keeps it for minutes (spec 0032, AC-7)", + async (_, kind) => { + happyReads(); + getPopularGames.mockRejectedValue(new IgdbError(kind, "games", "failed")); + + const listed = urls(await sitemap()); + + expect(listed.slice(0, 20)).toEqual(FIXED); + expect(listed).toHaveLength(20 + 20 + 20); + expect(listed.at(-1)).toBe(`${ORIGIN}/shows/6002`); + expect(listed.filter((url) => /\/games\/\d+$/.test(url))).toEqual([]); + expect(cacheLife.mock.calls).toEqual([["minutes"]]); + }, + ); + + it("rethrows an IGDB validation error, a bug rather than an outage (spec 0032, AC-7)", async () => { + happyReads(); + getPopularGames.mockRejectedValue(new IgdbValidationError("kind", "bad")); + + await expect(sitemap()).rejects.toBeInstanceOf(IgdbValidationError); + }); + it("still lists the landings, list pages and legal pages when every read fails (AC-8)", async () => { + getPopularGames.mockRejectedValue( + new IgdbError("upstream", "games", "failed"), + ); discoverMovies.mockRejectedValue( new TmdbError("rate_limited", "/discover", "failed"), ); @@ -225,12 +303,13 @@ describe("the sitemap on a deployment that is not indexable (AC-9)", () => { "production with no site URL", { VERCEL_ENV: "production", NEXT_PUBLIC_SITE_URL: undefined }, ], - ])("is empty on %s and asks TMDB nothing", async (_, env) => { + ])("is empty on %s and asks TMDB and IGDB nothing", async (_, env) => { for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value); happyReads(); expect(await sitemap()).toEqual([]); expect(discoverMovies).not.toHaveBeenCalled(); expect(discoverTvShows).not.toHaveBeenCalled(); + expect(getPopularGames).not.toHaveBeenCalled(); }); }); diff --git a/app/sitemap.ts b/app/sitemap.ts index a1b3db91..ecc2c720 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,36 +1,42 @@ import type { MetadataRoute } from "next"; import { cacheLife } from "next/cache"; +import { POPULAR_GAME_LISTS } from "@/lib/catalog/game-lists"; import { catalogListsFor, listPath } from "@/lib/catalog/lists"; +import { getPopularGames, IgdbError } from "@/lib/igdb"; import { isIndexableDeployment, siteUrl } from "@/lib/seo/site"; import { DISCOVER_PAGES_PER_TYPE, sitemapEntries } from "@/lib/seo/sitemap"; import { discoverMovies, discoverTvShows, TmdbError } from "@/lib/tmdb"; /** - * `/sitemap.xml` (spec 0016, AC-6 to AC-9): the two landings, the ten list - * pages (spec 0021, AC-16), the two legal pages (spec 0017, AC-13), then the 200 most popular movies and 200 most - * popular shows, all pages a signed out visitor can open from a landing. + * `/sitemap.xml` (spec 0016, AC-6 to AC-9): the three landings, every list + * page (spec 0021, AC-16; spec 0032, AC-6), the two legal pages (spec 0017, + * AC-13), then the 200 most popular movies, shows and games, all pages a + * signed out visitor can open from a landing. * * A deployment that is not indexable answers an empty `urlset` and asks TMDB - * nothing. Prerendered, so the build decides which branch it is, and it reads - * no request state, so nothing user specific can reach it (`lib/seo/AGENTS.md`, - * the prerendered routes and request state rules). + * and IGDB nothing. Prerendered, so the build decides which branch it is, and + * it reads no request state, so nothing user specific can reach it + * (`lib/seo/AGENTS.md`, the prerendered routes and request state rules). */ export default async function sitemap(): Promise { const origin = siteUrl(); if (!isIndexableDeployment() || origin === null) return []; - const { moviePages, showPages } = await popularTitleIds(); - return sitemapEntries(origin, LIST_PATHS, moviePages, showPages); + const { moviePages, showPages, gameIds } = await popularTitleIds(); + return sitemapEntries(origin, LIST_PATHS, moviePages, showPages, gameIds); } /** - * Every list page at page 1, movie lists first, each in row order (spec 0021, - * AC-16). From the registry, so a new list is listed with no change here. + * Every list page at page 1, movie lists first, then show lists, then games + * lists, each in row order (spec 0021, AC-16; spec 0032, AC-6). From the + * registry, so a new list is listed with no change here. */ -const LIST_PATHS = [...catalogListsFor("movie"), ...catalogListsFor("tv")].map( - listPath, -); +const LIST_PATHS = [ + ...catalogListsFor("movie"), + ...catalogListsFor("tv"), + ...catalogListsFor("game"), +].map(listPath); /** * The ids on discover pages 1 to 10 for each media type, read with exactly the @@ -38,23 +44,33 @@ const LIST_PATHS = [...catalogListsFor("movie"), ...catalogListsFor("tv")].map( * entries and every listed title is linked from a public page (spec 0016, * AC-6, AC-8; spec 0021, AC-3). * - * The 20 reads settle independently: a `TmdbError` drops only its own page, - * so a TMDB hiccup shrinks the list instead of failing the file. Any other - * rejection is a bug and is rethrown. A complete list lives for days; a - * partial one for minutes, so the full list returns soon after TMDB recovers - * (`lib/seo/AGENTS.md`, the sitemap cache rule). - * Only plain id arrays leave this scope (`AGENTS.md`, the cached scope rule). + * The games are IGDB's Popular list, cut to its first 200 by `sitemapEntries`, + * the exact read `/games/popular` makes (spec 0032, AC-6), so every listed + * game is linked from a public page too, and that read returns visible games + * only. + * + * The 21 reads settle independently: a `TmdbError` drops only its own page + * and an `IgdbError` only the game ids, so an upstream hiccup shrinks the + * list instead of failing the file (spec 0032, AC-7). `getPopularGames` + * rebuilds its error outside its own cached scope, so `instanceof` holds + * here. Any other rejection, a validation error included, is a bug and is + * rethrown. A complete list lives for days; a partial one for minutes, so the + * full list returns soon after TMDB or IGDB recovers (`lib/seo/AGENTS.md`, the + * sitemap cache rule). Only plain id arrays leave this scope (`AGENTS.md`, the + * cached scope rule). */ async function popularTitleIds(): Promise<{ moviePages: number[][]; showPages: number[][]; + gameIds: number[]; }> { "use cache"; const pages = Array.from( { length: DISCOVER_PAGES_PER_TYPE }, (_, index) => index + 1, ); - const settled = await Promise.allSettled([ + const [games, ...settled] = await Promise.allSettled([ + getPopularGames(POPULAR_GAME_LISTS.popular), ...pages.map((page) => discoverMovies({ page })), ...pages.map((page) => discoverTvShows({ page })), ]); @@ -72,6 +88,15 @@ async function popularTitleIds(): Promise<{ } } + let gameIds: number[] = []; + if (games.status === "fulfilled") { + gameIds = games.value.map((game) => game.id); + } else if (games.reason instanceof IgdbError) { + failed = true; + } else { + throw games.reason; + } + if (failed) { cacheLife("minutes"); } else { @@ -81,5 +106,6 @@ async function popularTitleIds(): Promise<{ return { moviePages: idPages.slice(0, DISCOVER_PAGES_PER_TYPE), showPages: idPages.slice(DISCOVER_PAGES_PER_TYPE), + gameIds, }; } diff --git a/app/terms/page.test.tsx b/app/terms/page.test.tsx index ea188553..21c6b805 100644 --- a/app/terms/page.test.tsx +++ b/app/terms/page.test.tsx @@ -7,6 +7,7 @@ import { COMPLAINT_RESPONSE_DAYS, CONTACT_EMAIL, DELETION_DAYS, + IGDB_ATTRIBUTION, LEGAL_LAST_UPDATED, MINIMUM_AGE, OPERATOR_COUNTRY, @@ -24,14 +25,14 @@ vi.mock("@/lib/tmdb", async () => { const { default: TermsPage, metadata } = await import("./page"); /** - * covers: spec 0017, AC-3, AC-7, AC-9, AC-10, AC-12 + * covers: spec 0017, AC-3, AC-7, AC-9, AC-10, AC-12; spec 0032, AC-9 */ const SECTIONS = [ "About BeStats", "Your account", "Technical requirements", "Acceptable use", - "Catalog data and TMDB", + "Catalog data, TMDB and IGDB", "Availability and changes to the service", "Ending your account", "Liability", @@ -75,6 +76,25 @@ describe("/terms", () => { expect(screen.getByText(TMDB_ATTRIBUTION)).toBeInTheDocument(); }); + it("credits IGDB after TMDB in the catalog section, in the spec's order (spec 0032, AC-9)", () => { + render(); + + const section = screen + .getByRole("heading", { level: 2, name: "Catalog data, TMDB and IGDB" }) + .closest("section"); + const paragraphs = [...(section?.querySelectorAll("p") ?? [])].map( + (p) => p.textContent, + ); + expect(paragraphs.slice(-3)).toEqual([ + "Game details and images come from IGDB. We can’t guarantee that they are accurate or complete.", + IGDB_ATTRIBUTION, + "IGDB community ratings belong to IGDB. Your own ratings are yours.", + ]); + expect(paragraphs.indexOf(TMDB_ATTRIBUTION)).toBeLessThan( + paragraphs.indexOf(IGDB_ATTRIBUTION), + ); + }); + it("states the operator facts from the one module (AC-10)", () => { const { container } = render(); const text = container.textContent ?? ""; diff --git a/app/terms/page.tsx b/app/terms/page.tsx index fbc7e608..861db991 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -12,6 +12,7 @@ import { import { COMPLAINT_RESPONSE_DAYS, DELETION_DAYS, + IGDB_ATTRIBUTION, MINIMUM_AGE, OPERATOR_COUNTRY, } from "@/lib/legal/operator"; @@ -31,8 +32,9 @@ export const metadata: Metadata = catalogMetadata({ * `/terms` (spec 0017, AC-6, AC-7, AC-9 to AC-12). * * Fully static, like `/privacy`. Operator facts come from - * `lib/legal/operator.ts` and the TMDB notice from `TMDB_ATTRIBUTION`, so - * neither is restated here. The text follows the spec's Terms of Service facts + * `lib/legal/operator.ts`, the TMDB notice from `TMDB_ATTRIBUTION` and the + * IGDB credit from `IGDB_ATTRIBUTION` (spec 0032, AC-9), so none is restated + * here. The text follows the spec's Terms of Service facts * table and promises nothing the app does not do. */ export default function TermsPage() { @@ -87,7 +89,7 @@ export default function TermsPage() {

- +

Titles, images and details come from TMDB. We can’t guarantee that they are accurate or complete. @@ -96,6 +98,14 @@ export default function TermsPage() {

TMDB community ratings belong to TMDB. Your own ratings are yours.

+

+ Game details and images come from IGDB. We can’t guarantee that + they are accurate or complete. +

+

{IGDB_ATTRIBUTION}

+

+ IGDB community ratings belong to IGDB. Your own ratings are yours. +

diff --git a/components/layout/brand-link.test.tsx b/components/layout/brand-link.test.tsx index 865c56c8..2fd0ad25 100644 --- a/components/layout/brand-link.test.tsx +++ b/components/layout/brand-link.test.tsx @@ -89,7 +89,6 @@ describe("BrandLink", () => { ["/games/1942", ""], ["/watched", "type=game"], ])("links %s?%s to /games and remembers game", (path, query) => { - vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); pathname.value = path; search.value = query; render(); @@ -98,25 +97,15 @@ describe("BrandLink", () => { expect(window.localStorage.getItem(LAST_MEDIA_TYPE_KEY)).toBe("game"); }); - it("sends a remembered game to /games only while the flag is on", () => { + it("sends a remembered game to /games", () => { window.localStorage.setItem(LAST_MEDIA_TYPE_KEY, "game"); pathname.value = "/account"; - vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); render().unmount(); expect(window.localStorage.getItem(LAST_MEDIA_TYPE_KEY)).toBe("game"); render(); expect(brandHref()).toBe("/games"); }); - - it("reads a stale remembered game as nothing with the flag off", () => { - window.localStorage.setItem(LAST_MEDIA_TYPE_KEY, "game"); - vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off"); - pathname.value = "/account"; - render(); - - expect(brandHref()).toBe("/shows"); - }); }); it("survives storage that throws", () => { diff --git a/components/layout/brand-link.tsx b/components/layout/brand-link.tsx index a2e8f5ce..f74ea3e2 100644 --- a/components/layout/brand-link.tsx +++ b/components/layout/brand-link.tsx @@ -8,7 +8,6 @@ import { isMediaType, type LibraryType, } from "@/lib/catalog/media-type"; -import { gamesLibraryEnabled } from "@/lib/env"; import { WithPageMediaType } from "./page-media-type"; @@ -72,15 +71,11 @@ function subscribe(onChange: () => void) { return () => window.removeEventListener("storage", onChange); } -/** - * The stored type, parsed as a `LibraryType`. A remembered `game` counts only - * while the games library is on, so a stale value never leads to a hidden - * tab (spec 0028, AC-16). - */ +/** The stored type, parsed as a `LibraryType` (spec 0028, AC-16). */ function readRemembered(): LibraryType | null { try { const value = window.localStorage.getItem(LAST_MEDIA_TYPE_KEY); - if (value === "game") return gamesLibraryEnabled() ? "game" : null; + if (value === "game") return "game"; return isMediaType(value) ? value : null; } catch { return null; diff --git a/components/layout/igdb-logo.test.ts b/components/layout/igdb-logo.test.ts new file mode 100644 index 00000000..dd0dc1c6 --- /dev/null +++ b/components/layout/igdb-logo.test.ts @@ -0,0 +1,25 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +/** + * covers: spec 0032, AC-10 + * + * The footer credits IGDB with its own logo, the *IGDB logo (SVG)* file from + * IGDB's press kit (https://www.igdb.com/press), downloaded unmodified on + * 2026-10-26. The Twitch Developer Services Agreement forbids altering + * attribution notices, so this hash, recorded at download, fails an edit of + * one byte, a recolour or a reformat by a tool before it ships, as + * `tmdb-logo.test.ts` does for TMDB's file. + */ +const LOGO = "public/igdb-logo.svg"; +const OFFICIAL_SHA256 = + "5269511c541a125f7dd8c71990e0c86ff4a6881be29ad8bd81e1ae4fe83527df"; + +describe("the IGDB logo", () => { + it("is the official file, byte for byte", () => { + expect(existsSync(LOGO)).toBe(true); + const hash = createHash("sha256").update(readFileSync(LOGO)).digest("hex"); + expect(hash).toBe(OFFICIAL_SHA256); + }); +}); diff --git a/components/layout/library-nav.test.tsx b/components/layout/library-nav.test.tsx index 1efce9b8..c3cdb9c5 100644 --- a/components/layout/library-nav.test.tsx +++ b/components/layout/library-nav.test.tsx @@ -114,8 +114,7 @@ describe("LibraryNav", () => { ); // covers: spec 0027, AC-4 - it("carries type=game on a games tab while the games library is on", () => { - vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); + it("carries type=game on a games tab", () => { pathname = "/upcoming"; search = "type=game"; render(); @@ -127,12 +126,10 @@ describe("LibraryNav", () => { "/upcoming?type=game", "/watched?type=game", ]); - vi.unstubAllEnvs(); }); // covers: spec 0028, AC-14 - it("carries type=game on /games while the games library is on", () => { - vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); + it("carries type=game on /games", () => { pathname = "/games/top-rated"; search = ""; render(); @@ -144,6 +141,5 @@ describe("LibraryNav", () => { "/upcoming?type=game", "/watched?type=game", ]); - vi.unstubAllEnvs(); }); }); diff --git a/components/layout/media-type-tabs.test.tsx b/components/layout/media-type-tabs.test.tsx index 6ca256e8..1bffb8bc 100644 --- a/components/layout/media-type-tabs.test.tsx +++ b/components/layout/media-type-tabs.test.tsx @@ -144,15 +144,8 @@ describe("MediaTypeTabs", () => { }, ); - // covers: spec 0027, AC-3 - describe("with the games library on", () => { - beforeEach(() => { - vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); - }); - afterEach(() => { - vi.unstubAllEnvs(); - }); - + // covers: spec 0027, AC-3; spec 0032, AC-12 + describe("the GAMES tab, always shown", () => { it.each(["/watchlist", "/upcoming", "/watched"])( "adds GAMES on %s and lights it on a games tab", (path) => { @@ -237,56 +230,13 @@ describe("MediaTypeTabs", () => { ); }); - it("shows no tabs on /games with the games library off", () => { - vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off"); - pathname.value = "/games"; - search.value = ""; - render(); - - expect(screen.queryByRole("link")).not.toBeInTheDocument(); - vi.unstubAllEnvs(); - }); - - it("never renders GAMES with the games library off", () => { - vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off"); - pathname.value = "/watchlist"; - search.value = "type=game"; - render(); - - expect( - screen.queryByRole("link", { name: "GAMES" }), - ).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "SHOWS" })).toHaveAttribute( - "aria-current", - "page", - ); - vi.unstubAllEnvs(); - }); - - /** covers: spec 0027, AC-1, AC-3 */ + /** covers: spec 0027, AC-3 */ describe("the games tab edges", () => { afterEach(() => { - vi.unstubAllEnvs(); suspendSearch.value = false; }); - it("reads an invalid flag as off, still rendering the two tabs", () => { - vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "maybe"); - pathname.value = "/watched"; - search.value = "type=game"; - render(); - - expect( - screen.queryByRole("link", { name: "GAMES" }), - ).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "SHOWS" })).toHaveAttribute( - "aria-current", - "page", - ); - }); - it("renders all three tabs with none lit before ?type is known", async () => { - vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); suspendSearch.value = true; pathname.value = "/upcoming"; await act(async () => { @@ -307,7 +257,6 @@ describe("MediaTypeTabs", () => { }); it("gives the /search fallback a GAMES link that stays on /search (spec 0029, AC-14)", async () => { - vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); suspendSearch.value = true; pathname.value = "/search"; await act(async () => { diff --git a/components/layout/media-type-tabs.tsx b/components/layout/media-type-tabs.tsx index 02145d47..734d8fd6 100644 --- a/components/layout/media-type-tabs.tsx +++ b/components/layout/media-type-tabs.tsx @@ -6,18 +6,16 @@ import Link from "next/link"; import { CATALOG_PATHS, type LibraryType, - type MediaType, type TypedPath, typedHref, } from "@/lib/catalog/media-type"; -import { gamesLibraryEnabled } from "@/lib/env"; import { WithLibraryTypedPage } from "./page-media-type"; /** * The SHOWS and MOVIES control in the navbar (spec 0004, AC-14; feature 22), - * with GAMES beside them on every page while the games library is on (spec - * 0027, AC-3; spec 0028, AC-14). + * with GAMES beside them on every page (spec 0027, AC-3; spec 0028, AC-14; + * spec 0032, AC-12). * * These are links, not toggles. Selection comes from the URL, so a deep link, * a browser back button and a server render all agree on which tab is lit; @@ -70,30 +68,29 @@ function MediaTypeTabs({ className }: { className?: string }) { } /** - * Each tab's link on a typed page: the same page with its own `type`, GAMES - * only while the games library is on (spec 0028, AC-14; spec 0029, AC-14). - * On `/search` each keeps the query, year and rating `typedHref` allows. + * Each tab's link on a typed page: the same page with its own `type` + * (spec 0028, AC-14; spec 0029, AC-14). On `/search` each keeps the query, + * year and rating `typedHref` allows. */ function typedHrefs(pathname: TypedPath, current?: URLSearchParams): TabHrefs { - const hrefs = { + return { tv: typedHref(pathname, "tv", { current }), movie: typedHref(pathname, "movie", { current }), + game: typedHref(pathname, "game", { current }), }; - if (!gamesLibraryEnabled()) return hrefs; - return { ...hrefs, game: typedHref(pathname, "game", { current }) }; } -/** - * Each tab's link off the typed pages: the three catalog landings, GAMES - * only while the games library is on (spec 0028, AC-14). - */ +/** Each tab's link off the typed pages, the three catalog landings (spec 0028, AC-14). */ function catalogHrefs(): TabHrefs { - const hrefs = { tv: CATALOG_PATHS.tv, movie: CATALOG_PATHS.movie }; - return gamesLibraryEnabled() ? { ...hrefs, game: CATALOG_PATHS.game } : hrefs; + return { + tv: CATALOG_PATHS.tv, + movie: CATALOG_PATHS.movie, + game: CATALOG_PATHS.game, + }; } -/** Where each tab leads; a GAMES tab renders only when it has a link. */ -type TabHrefs = Record & { game?: string }; +/** Where each tab leads. */ +type TabHrefs = Record; const TABS = [ { type: "tv", label: "SHOWS" }, @@ -106,9 +103,8 @@ const TABS = [ * which only the prerendered fallback uses: a page about neither catalog * renders no tabs at all. * - * @param hrefs Where each tab leads, `/shows`, `/movies` and, with the games - * library on, `/games` by default. The GAMES tab renders only when `hrefs` - * gives it a link. + * @param hrefs Where each tab leads, `/shows`, `/movies` and `/games` by + * default. */ function MediaTypeTabsView({ selected, @@ -119,10 +115,7 @@ function MediaTypeTabsView({ hrefs?: TabHrefs; className?: string; }) { - const tabs = TABS.flatMap((tab) => { - const href = hrefs[tab.type]; - return href === undefined ? [] : [{ ...tab, href }]; - }); + const tabs = TABS.map((tab) => ({ ...tab, href: hrefs[tab.type] })); return (