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
49 changes: 49 additions & 0 deletions frontend/src/utils/__tests__/invariants.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Executable regression guards for the prod-breaking invariants documented in
// CLAUDE.md. Prose + the pre-PR review hook only catch NEW violations in a
// diff; these assertions are a deterministic, permanent guard against the
// same bug classes recurring silently.
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { AFTER_MIDNIGHT_THRESHOLD_HOUR } from '../festivalDays'
import { AFTER_MIDNIGHT_THRESHOLD_MINUTES } from '../../admin/utils/timeUtils'

describe('after-midnight threshold invariant (#550, CLAUDE.md "After-midnight band sorting")', () => {
it('AFTER_MIDNIGHT_THRESHOLD_HOUR stays 6 — never remove or lower', () => {
// Bands starting before 6 AM belong to the previous evening's lineup.
// Lowering/removing this threshold resurrects the recurring bug where
// after-midnight sets sort to the TOP of the schedule instead of the end.
expect(AFTER_MIDNIGHT_THRESHOLD_HOUR).toBe(6)
})

it('AFTER_MIDNIGHT_THRESHOLD_MINUTES stays derived from the canonical hour, never a separate literal', () => {
// frontend/src/admin/utils/timeUtils.js derives its minutes-based
// threshold from festivalDays.js's AFTER_MIDNIGHT_THRESHOLD_HOUR rather
// than re-encoding its own literal. Pins the derivation so the two
// constants can never silently drift apart if one file is edited without
// the other.
expect(AFTER_MIDNIGHT_THRESHOLD_MINUTES).toBe(AFTER_MIDNIGHT_THRESHOLD_HOUR * 60)
})

it('bandUtils.js imports the threshold from festivalDays.js rather than re-encoding a literal 6', () => {
// CLAUDE.md: "every consumer imports it rather than re-encoding `6`" —
// festivalDays.js is the single canonical location (#550). A source-read
// check (rather than a value check) is the only way to catch a future
// edit that keeps the numeric behavior correct today but re-hardcodes the
// literal instead of importing, silently reopening the drift risk.
// Resolved via node:path rather than `new URL(relative, import.meta.url)`:
// jsdom's test environment overrides the global `URL` with its own WHATWG
// implementation, which Node's `fileURLToPath` rejects ("must be of
// scheme file") since it isn't `instanceof` Node's own URL class.
const currentFile = fileURLToPath(import.meta.url)
const bandUtilsPath = path.join(path.dirname(currentFile), '../bandUtils.js')
const source = readFileSync(bandUtilsPath, 'utf8')

expect(source).toMatch(/import\s*{[^}]*AFTER_MIDNIGHT_THRESHOLD_HOUR[^}]*}\s*from\s*['"][^'"]*festivalDays['"]/)
// Guards against shadowing the import with a locally re-encoded literal,
// e.g. `const AFTER_MIDNIGHT_THRESHOLD_HOUR = 6`, which would silently
// defeat the import assertion above while still "importing" the name.
expect(source).not.toMatch(/(?:const|let|var)\s+AFTER_MIDNIGHT_THRESHOLD_HOUR\s*=\s*6\b/)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`JSON-LD golden snapshot — /event/[slug] > MusicEvent + BreadcrumbList match the pinned golden shape 1`] = `
{
"@context": "https://schema.org",
"@type": "MusicEvent",
"description": "Six venues, one legendary night of live music in Waterloo Region.",
"endDate": "2026-08-02",
"eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
"eventStatus": "https://schema.org/EventScheduled",
"image": [
"https://band-photos.settimes.ca/event-posters/17-vol17.jpg",
],
"location": [
{
"@type": "MusicVenue",
"address": {
"@type": "PostalAddress",
"addressCountry": "CA",
"addressLocality": "Waterloo",
"addressRegion": "ON",
"postalCode": "N2J 2W7",
"streetAddress": "28 King St N",
},
"name": "Blue Room (Inside Revive Karaoke)",
},
{
"@type": "MusicVenue",
"address": {
"@type": "PostalAddress",
"addressCountry": "CA",
"addressLocality": "Waterloo",
"addressRegion": "ON",
"postalCode": "N2J 2W9",
"streetAddress": "47 King St N",
},
"name": "Room 47",
},
],
"name": "Long Weekend Band Crawl - Vol. 17",
"offers": {
"@type": "Offer",
"availability": "https://schema.org/InStock",
"priceCurrency": "CAD",
"url": "https://tickets.example.com/vol-17",
"validFrom": "2026-07-01",
},
"organizer": {
"@type": "Organization",
"name": "SetTimes",
"sameAs": [
"https://www.instagram.com/settimes.ca",
],
"url": "https://settimes.ca",
},
"performer": [
{
"@type": "MusicGroup",
"name": "The Anti-Queens",
"url": "https://settimes.ca/band/2",
},
{
"@type": "MusicGroup",
"name": "Midtown Static",
"url": "https://settimes.ca/band/1",
},
{
"@type": "MusicGroup",
"name": "Zebra Sound",
"url": "https://settimes.ca/band/3",
},
],
"startDate": "2026-08-02T18:45:00-04:00",
"url": "https://settimes.ca/event/golden-lwbc17",
}
`;

exports[`JSON-LD golden snapshot — /event/[slug] > MusicEvent + BreadcrumbList match the pinned golden shape 2`] = `
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"item": "https://settimes.ca/",
"name": "Events",
"position": 1,
},
{
"@type": "ListItem",
"item": "https://settimes.ca/event/golden-lwbc17",
"name": "Long Weekend Band Crawl - Vol. 17",
"position": 2,
},
],
}
`;
106 changes: 106 additions & 0 deletions functions/event/__tests__/slug.jsonld-golden.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* JSON-LD golden snapshot — /event/[slug] MusicEvent + BreadcrumbList
*
* This is a golden-master test: it seeds ONE deterministic event and snapshots
* the full parsed MusicEvent + BreadcrumbList JSON-LD objects the SSR route
* emits. This structured data is crawler-facing and SEO-critical, and it
* churned across 3 PRs this week (#504, #615, #616) with every existing test
* asserting only individual fields — none asserted the FULL shape. A golden
* snapshot makes every future change to this output visible as a diff in
* code review, not just a passing/failing assertion on one field.
*
* The committed .snap file reflects MAIN's current output: organizer,
* offers+validFrom, image, location, performer — deliberately NO subEvent
* (#634's per-venue MusicEvent nesting is not merged as of this test's
* creation).
*
* When a change to the JSON-LD shape is INTENTIONAL (e.g. #634 landing
* subEvent), regenerate with:
* npx vitest run -u functions/event/__tests__/slug.jsonld-golden.test.js
* then REVIEW the resulting snapshot diff before committing — the diff IS
* the change under review. Never accept a snapshot update blindly.
*/
import { describe, expect, test } from "vitest";
import { onRequest } from "../[slug].js";
import { createTestEnv, insertEvent, insertBand, insertVenue } from "../../api/test-utils.js";

const STUB_HTML = `<!doctype html><html><head>
<meta name="description" content="Homepage description" />
<title>SetTimes</title>
</head><body><div id="root"></div></body></html>`;

function makeContext({ env, slug }) {
env.ASSETS = {
fetch: async () => new Response(STUB_HTML, { status: 200, headers: { "content-type": "text/html" } }),
};
return {
request: new Request(`https://settimes.ca/event/${slug}`),
env,
params: { slug },
};
}

function extractJsonLd(html) {
const matches = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
return matches.map((m) => JSON.parse(m[1]));
}

describe("JSON-LD golden snapshot — /event/[slug]", () => {
test("MusicEvent + BreadcrumbList match the pinned golden shape", async () => {
const { env, rawDb } = createTestEnv();
env.PUBLIC_DATA_PUBLISH_ENABLED = "true";

// Two venues, ordered by v.name in the route's query: Blue Room sorts
// before Room 47 (B < R).
const blueRoom = insertVenue(rawDb, {
name: "Blue Room (Inside Revive Karaoke)",
address_line1: "28 King St N",
city: "Waterloo",
region: "ON",
postal_code: "N2J 2W7",
});
const room47 = insertVenue(rawDb, {
name: "Room 47",
address_line1: "47 King St N",
city: "Waterloo",
region: "ON",
postal_code: "N2J 2W9",
});

const event = insertEvent(rawDb, {
name: "Long Weekend Band Crawl - Vol. 17",
slug: "golden-lwbc17",
date: "2026-08-02",
});
rawDb.prepare("UPDATE events SET is_published = 1 WHERE id = ?").run(event.id);
// Pin created_at: offers.validFrom is derived from it (see [slug].js),
// so leaving the schema default (datetime('now')) would make the
// snapshot re-run non-deterministic — a new validFrom every day.
rawDb
.prepare("UPDATE events SET description = ?, ticket_url = ?, poster_url = ?, created_at = ? WHERE id = ?")
.run(
"Six venues, one legendary night of live music in Waterloo Region.",
"https://tickets.example.com/vol-17",
"https://band-photos.settimes.ca/event-posters/17-vol17.jpg",
"2026-07-01 12:00:00",
event.id,
);

// Band names + insertion order deliberately exercise the article-strip
// sort (#587): raw SQL `ORDER BY bp.name` would place these
// "Midtown Static", "The Anti-Queens", "Zebra Sound" (M, T, Z) — but the
// route re-sorts by the article-stripped key, so "The Anti-Queens" (key
// "anti-queens") must move to FIRST place, not stay second.
insertBand(rawDb, { name: "Midtown Static", event_id: event.id, venue_id: room47.id });
insertBand(rawDb, { name: "The Anti-Queens", event_id: event.id, venue_id: blueRoom.id });
insertBand(rawDb, { name: "Zebra Sound", event_id: event.id, venue_id: blueRoom.id });

const response = await onRequest(makeContext({ env, slug: "golden-lwbc17" }));
expect(response.status).toBe(200);
const html = await response.text();

const [musicEvent, breadcrumb] = extractJsonLd(html);
expect(musicEvent).toMatchSnapshot();
expect(breadcrumb).toMatchSnapshot();
});
});
52 changes: 52 additions & 0 deletions functions/utils/__tests__/invariants.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Executable regression guards for the prod-breaking invariants documented in
// CLAUDE.md. Prose + the pre-PR review hook only catch NEW violations in a
// diff; these assertions are a deterministic, permanent guard against the
// same bug classes recurring silently.
import { describe, expect, it } from "vitest";
import { checkAuthRateLimit } from "../authAttempts.js";

// toSqliteDateTime() is NOT exported from authAttempts.js — it's a private
// helper only reachable through checkAuthRateLimit's `windowStart` binding.
// Rather than exporting it solely for this test (a production-code change
// outside this task's scope) or reimplementing its logic in the test (which
// would test the copy, not the real code), this stub captures the exact
// value the real, unexported toSqliteDateTime() produces when
// checkAuthRateLimit binds it into the rate-limit query.
function createCapturingDB(queryResult = { count: 0, earliest_attempt: null }) {
let boundArgs = null;
const DB = {
prepare() {
return {
bind(...args) {
boundArgs = args;
return { first: async () => queryResult };
},
};
},
};
return { DB, getBoundArgs: () => boundArgs };
}

describe('SQLite datetime format invariant (CLAUDE.md "SQLite datetime format — do NOT use ISO 8601 T-separator")', () => {
it("toSqliteDateTime()'s output has a space separator, no T/Z/ms — the exact SEC-F1 bug shape", async () => {
// D1's datetime('now') returns `YYYY-MM-DD HH:MM:SS` (space separator).
// JS's toISOString() returns `YYYY-MM-DDTHH:MM:SS.mmmZ` (T separator,
// ms, Z suffix). A stored/compared value with a `T` silently breaks
// string comparison against datetime('now') — this exact bug caused a
// production invite-code expiry bypass (SEC-F1). checkAuthRateLimit's
// scope: "ip" binding order is [ipAddress, attemptType, windowStart]
// (see getRateLimitBindings in authAttempts.js), so windowStart —
// produced by toSqliteDateTime() — is the 3rd bound argument.
const { DB, getBoundArgs } = createCapturingDB();

await checkAuthRateLimit(DB, {
attemptType: "login",
ipAddress: "127.0.0.1",
scope: "ip",
});

const [, , windowStart] = getBoundArgs();
expect(windowStart).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(windowStart).not.toContain("T");
});
});
Loading