diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e2be566..cd1e3d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,3 +16,18 @@ jobs: uses: actions/checkout@v4 - name: Install, build, and upload your site uses: withastro/action@v4 + + integration: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - name: Install dependencies + run: npm install + - name: Run integration tests + run: npm run test:integration diff --git a/package.json b/package.json index 6330f78..6d2d87a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "check": "astro check && prettier --check .", "format": "prettier --write .", "get-data": "tsx bin/get-data.ts", - "test": "vitest run", + "test": "vitest run --exclude \"**/*.integration.test.ts\"", + "test:unit": "npm run test", + "test:integration": "vitest run integration", + "test:all": "vitest run", "test:e2e": "playwright test" }, "comments": { diff --git a/src/content.config.ts b/src/content.config.ts index 086897b..9800bf8 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -5,6 +5,7 @@ import { seasonSchema, postSchema, notificationAttributesSchema, + manualFixturesSchema, } from "./content.types"; import * as MediaPost from "@/lib/mediaPost"; @@ -81,26 +82,7 @@ const fixturesCollection = defineCollection({ const manualFixturesCollection = defineCollection({ loader: glob({ pattern: "**/*.json", base: "src/content/manual-fixtures" }), - schema: z.object({ - filters: z.object({ - dateFrom: z.string().date().optional(), - dateTo: z.string().date().optional(), - permission: z.string().optional(), - competitions: z.coerce.number().optional(), - limit: z.number().optional(), - }), - resultSet: z.object({ - count: z.number(), - competitions: z.string().optional(), - first: z.string().date().optional(), - last: z.string().date().optional(), - played: z.number().optional(), - wins: z.number().optional(), - draws: z.number().optional(), - losses: z.number().optional(), - }), - matches: z.array(matchSchema), - }), + schema: manualFixturesSchema, }); const competitionsCollection = defineCollection({ diff --git a/src/content.types.ts b/src/content.types.ts index 9b426d3..cebf32c 100644 --- a/src/content.types.ts +++ b/src/content.types.ts @@ -99,6 +99,38 @@ export const seasonSchema = z.object({ export type MatchType = z.infer; +export const manualFixturesSchema = z.object({ + filters: z.object({ + dateFrom: z.string().date().optional(), + dateTo: z.string().date().optional(), + permission: z.string().optional(), + competitions: z.coerce.number().optional(), + limit: z.number().optional(), + }), + resultSet: z.object({ + count: z.number(), + competitions: z.string().optional(), + first: z.string().date().optional(), + last: z.string().date().optional(), + played: z.number().optional(), + wins: z.number().optional(), + draws: z.number().optional(), + losses: z.number().optional(), + }), + matches: z.array(matchSchema).check((ctx) => { + const ids = ctx.value.map((m) => m.id); + if (ids.length !== new Set(ids).size) { + ctx.issues.push({ + code: "custom", + message: "duplicate match id found in manual fixtures", + input: ctx.value, + }); + } + }), +}); + +export type ManualFixturesType = z.infer; + const simplePostSchema = z .object({ type: z.literal("simple").optional(), diff --git a/src/content/manual-fixtures.integration.test.ts b/src/content/manual-fixtures.integration.test.ts new file mode 100644 index 0000000..a6532c2 --- /dev/null +++ b/src/content/manual-fixtures.integration.test.ts @@ -0,0 +1,119 @@ +import { readFile, readdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { z } from "zod"; + +import { manualFixturesSchema, type ManualFixturesType } from "@/content.types"; + +const isIsoDate = (value: string): boolean => z.iso.date().safeParse(value).success; + +const fixturesDir = fileURLToPath( + new URL("./manual-fixtures", import.meta.url), +); + +const fixtureFileNames = async (): Promise => { + const files = (await readdir(fixturesDir, { recursive: true })).filter( + (name) => name.endsWith(".json"), + ); + return files.sort(); +}; + +const loadManualFixtures = async (): Promise => { + const fixtures: ManualFixturesType[] = []; + for (const file of await fixtureFileNames()) { + const raw = await readFile(`${fixturesDir}/${file}`, "utf8"); + fixtures.push(manualFixturesSchema.parse(JSON.parse(raw))); + } + return fixtures; +}; + +const referencedImageUrls = (fixture: ManualFixturesType): string[] => { + const urls: string[] = []; + for (const match of fixture.matches) { + urls.push(match.area.flag, match.competition.emblem); + urls.push(match.homeTeam.crest, match.awayTeam.crest); + } + return urls; +}; + +const knownBrokenImageUrls = new Set([ + "https://crests.football-data.org/FAC.png", + "https://crests.football-data.org/CARABAO_CUP.png", +]); + +test("each manual fixture file is a valid, parseable JSON document", async () => { + const files = await fixtureFileNames(); + + expect(files.length).toBeGreaterThan(0); + + for (const file of files) { + const raw = await readFile(`${fixturesDir}/${file}`, "utf8"); + expect(() => JSON.parse(raw), `${file} must be valid JSON`).not.toThrow(); + } +}); + +test("each manual fixture filename is a valid date that ids the API route", async () => { + const files = await fixtureFileNames(); + + for (const file of files) { + const date = file.replace(/\.json$/, ""); + expect(isIsoDate(date), `${file} must be named by a valid date`).toBe(true); + expect(date.length).toBe(10); + } +}); + +test("each manual fixture conforms to the content collection schema", async () => { + const fixtures = await loadManualFixtures(); + + expect(fixtures.length).toBeGreaterThan(0); +}); + +test("each manual fixture's match count agrees with its declared result set", async () => { + const files = await fixtureFileNames(); + + for (const file of files) { + const raw = await readFile(`${fixturesDir}/${file}`, "utf8"); + const fixture = JSON.parse(raw) as { + resultSet: { count?: number }; + matches?: unknown[]; + }; + + if (typeof fixture.resultSet?.count === "number") { + expect( + fixture.matches?.length, + `${file}: resultSet.count must match the matches array length`, + ).toBe(fixture.resultSet.count); + } + } +}); + +test("every referenced image URL is reachable", async () => { + const fixtures = await loadManualFixtures(); + + const urls = new Set( + fixtures + .flatMap(referencedImageUrls) + .filter((url) => !knownBrokenImageUrls.has(url)), + ); + expect(urls.size).toBeGreaterThan(0); + + const checkUrlReachability = async (url: string) => { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(15_000), + }); + return { url, reachable: response.ok, detail: response.statusText }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { url, reachable: false, detail: message }; + } + }; + + const results = await Promise.all([...urls].map(checkUrlReachability)); + const unreachable = results.filter((result) => !result.reachable); + const unreachableReport = unreachable.map( + (result) => `${result.url} (${result.detail})`, + ); + + expect(unreachableReport).toEqual([]); +}); diff --git a/src/lib/dataPipeline/json.ts b/src/lib/dataPipeline/json.ts index 449ad46..816daeb 100644 --- a/src/lib/dataPipeline/json.ts +++ b/src/lib/dataPipeline/json.ts @@ -1,10 +1,12 @@ -import * as fs from "fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; -export const stringifyToFile = async (filePath: string, data: any) => { - // Convert the JavaScript object to a JSON string - // The 'null' and '2' arguments format the JSON with an indentation of 2 spaces, making it readable +export const stringifyToFile = async ( + filePath: string, + data: unknown, +): Promise => { const jsonString = JSON.stringify(data, null, 2); - // TODO: await fs.writeFile - fs.writeFileSync(filePath, jsonString, "utf-8"); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, jsonString, "utf-8"); };