Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 61 additions & 0 deletions .github/workflows/data-freshness.yml
Original file line number Diff line number Diff line change
@@ -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,
});
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
114 changes: 114 additions & 0 deletions scripts/check-data-freshness.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
68 changes: 68 additions & 0 deletions src/lib/data-freshness.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading