From 25f3caa341875287b78a0c3fba5b649ed29417bf Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:12:41 +0100 Subject: [PATCH] ci: gate unit tests and data freshness Signed-off-by: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> --- .github/workflows/ci.yml | 15 +++- .github/workflows/data-freshness.yml | 61 ++++++++++++++ package.json | 1 + scripts/check-data-freshness.mjs | 114 +++++++++++++++++++++++++++ src/lib/data-freshness.test.ts | 68 ++++++++++++++++ 5 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/data-freshness.yml create mode 100644 scripts/check-data-freshness.mjs create mode 100644 src/lib/data-freshness.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26b2370..7f4cec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,25 @@ on: jobs: validate-data: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - - run: node scripts/validate-places.mjs + - name: Validate place data + run: npm run validate + unit-tests: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run test:unit build: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/data-freshness.yml b/.github/workflows/data-freshness.yml new file mode 100644 index 0000000..bb0b2d6 --- /dev/null +++ b/.github/workflows/data-freshness.yml @@ -0,0 +1,61 @@ +name: Data freshness +on: + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: +permissions: + contents: read + issues: write +jobs: + freshness: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Validate place data + run: npm run validate + - name: Check data freshness + run: npm run check:freshness + - name: Open or update freshness issue + if: failure() + uses: actions/github-script@v7 + with: + script: | + const title = "Data freshness check failed"; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const body = [ + "The scheduled StudyMap data validation or freshness check failed.", + "", + `Run: ${runUrl}`, + "", + "Review the workflow output and refresh stale or broken data.", + ].join("\n"); + + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + per_page: 100, + }); + const existing = issues.find( + (issue) => !issue.pull_request && issue.title === title, + ); + + if (existing) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + }); + } diff --git a/package.json b/package.json index 3eda8b7..4863ce6 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "eslint", "validate": "node scripts/validate-places.mjs", + "check:freshness": "node scripts/check-data-freshness.mjs", "test:unit": "vitest run" }, "dependencies": { diff --git a/scripts/check-data-freshness.mjs b/scripts/check-data-freshness.mjs new file mode 100644 index 0000000..c4576a0 --- /dev/null +++ b/scripts/check-data-freshness.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +function isRealIsoDate(value) { + if (typeof value !== "string" || !ISO_DATE_RE.test(value)) return false; + const parsed = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} + +function parseArgs(argv) { + let today = new Date().toISOString().slice(0, 10); + let dataDir = resolve("data/places"); + + for (const arg of argv) { + if (arg.startsWith("--today=")) { + today = arg.slice("--today=".length); + } else if (arg.startsWith("--data-dir=")) { + dataDir = resolve(arg.slice("--data-dir=".length)); + } else { + throw new Error(`unknown argument "${arg}"`); + } + } + + if (!isRealIsoDate(today)) { + throw new Error(`--today must be a real ISO date (YYYY-MM-DD), got "${today}"`); + } + + return { today, dataDir }; +} + +function checkFreshness({ today, dataDir }) { + const files = readdirSync(dataDir) + .filter((file) => file.endsWith(".json")) + .sort(); + + let totalRecords = 0; + let datedRecords = 0; + let totalErrors = 0; + + for (const file of files) { + const path = resolve(dataDir, file); + let records; + + try { + records = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + console.error(`ERROR ${file}: invalid JSON: ${error.message}`); + totalErrors++; + continue; + } + + if (!Array.isArray(records)) { + console.error(`ERROR ${file}: root value must be a JSON array`); + totalErrors++; + continue; + } + + totalRecords += records.length; + + for (let index = 0; index < records.length; index++) { + const record = records[index]; + const loc = `${file}[${index}]`; + + if (record === null || typeof record !== "object" || Array.isArray(record)) { + console.error(`ERROR ${loc}: record must be a JSON object`); + totalErrors++; + continue; + } + + if (record.valid_till === undefined) continue; + + datedRecords++; + const recordLoc = `${loc} (id: ${record.id ?? "?"})`; + + if (!isRealIsoDate(record.valid_till)) { + console.error( + `ERROR ${recordLoc}: valid_till must be a real ISO date (YYYY-MM-DD), got "${record.valid_till}"`, + ); + totalErrors++; + continue; + } + + // Valid ISO YYYY-MM-DD strings sort in chronological order. + if (record.valid_till < today) { + console.error( + `ERROR ${recordLoc}: valid_till expired on ${record.valid_till}; re-verify this record for ${today}`, + ); + totalErrors++; + } + } + } + + if (totalErrors > 0) { + console.error( + `Freshness failed: ${totalErrors} stale or invalid record(s) as of ${today}.`, + ); + return 1; + } + + console.log( + `Freshness passed: ${totalRecords} record(s), ${datedRecords} dated record(s), current as of ${today}.`, + ); + return 0; +} + +try { + process.exitCode = checkFreshness(parseArgs(process.argv.slice(2))); +} catch (error) { + console.error(`Freshness failed: ${error.message}`); + process.exitCode = 1; +} diff --git a/src/lib/data-freshness.test.ts b/src/lib/data-freshness.test.ts new file mode 100644 index 0000000..b171cf8 --- /dev/null +++ b/src/lib/data-freshness.test.ts @@ -0,0 +1,68 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +// Vitest only discovers tests under src/, so this CLI integration test lives here. +const SCRIPT = resolve(process.cwd(), "scripts/check-data-freshness.mjs"); +const TODAY = "2026-08-22"; + +function runFreshness(records: unknown[], today = TODAY) { + const dataDir = mkdtempSync(join(tmpdir(), "studymap-freshness-")); + + try { + writeFileSync(join(dataDir, "sat_centre.json"), JSON.stringify(records)); + return spawnSync( + process.execPath, + [SCRIPT, `--data-dir=${dataDir}`, `--today=${today}`], + { encoding: "utf8" }, + ); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +} + +describe("data freshness check", () => { + it("accepts records expiring today or later and ignores undated records", () => { + const result = runFreshness([ + { id: "today", valid_till: "2026-08-22" }, + { id: "future", valid_till: "2026-11-07" }, + { id: "undated" }, + ]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("3 record(s), 2 dated record(s)"); + }); + + it("fails when a valid_till deadline has passed", () => { + const result = runFreshness([{ id: "stale", valid_till: "2026-08-21" }]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("id: stale"); + expect(result.stderr).toContain("valid_till expired on 2026-08-21"); + }); + + it("fails on an impossible valid_till date", () => { + const result = runFreshness([{ id: "bad-date", valid_till: "2026-02-30" }]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("id: bad-date"); + expect(result.stderr).toContain("valid_till must be a real ISO date"); + }); + + it("fails cleanly when a data row is not an object", () => { + const result = runFreshness([null]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("record must be a JSON object"); + expect(result.stderr).not.toContain("TypeError"); + }); + + it("rejects an invalid injected current date", () => { + const result = runFreshness([], "2026-02-30"); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("--today must be a real ISO date"); + }); +});