diff --git a/frontend/src/utils/__tests__/invariants.test.js b/frontend/src/utils/__tests__/invariants.test.js new file mode 100644 index 00000000..75233519 --- /dev/null +++ b/frontend/src/utils/__tests__/invariants.test.js @@ -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/) + }) +}) diff --git a/functions/event/__tests__/__snapshots__/slug.jsonld-golden.test.js.snap b/functions/event/__tests__/__snapshots__/slug.jsonld-golden.test.js.snap new file mode 100644 index 00000000..ae421f3e --- /dev/null +++ b/functions/event/__tests__/__snapshots__/slug.jsonld-golden.test.js.snap @@ -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, + }, + ], +} +`; diff --git a/functions/event/__tests__/slug.jsonld-golden.test.js b/functions/event/__tests__/slug.jsonld-golden.test.js new file mode 100644 index 00000000..289ebe52 --- /dev/null +++ b/functions/event/__tests__/slug.jsonld-golden.test.js @@ -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 = ` + + SetTimes +
`; + +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(/