snapshot-file.test.ts
Language TypeScript9.4 KB228 lines
/*** `pnpm morning` renders, writes the briefings from that render's numbers, then* renders again so the prose lands on the board. If the second render re-synced,* `syncedAt` would move, the briefings would no longer match the numbers beside* them, and every clean run would declare its own output stale.** The staleness check is correct; the second render must reuse the same sync.* These tests guard that, and guard against "fixing" it by loosening the check* instead, which would remove the only thing telling a reader whether the two* halves of the page agree.*/import { mkdtemp, writeFile, rm } from 'node:fs/promises'import { tmpdir } from 'node:os'import { join } from 'node:path'import { pathToFileURL } from 'node:url'import { afterAll, beforeAll, describe, expect, it } from 'vitest'import { FIXTURE_SNAPSHOT } from '../src/fixtures.js'import { headline, type Snapshot } from '../src/collect.js'import { render } from '../src/render.js'import { readSnapshot, writeSnapshot } from '../src/snapshot-file.js'import type { Briefings } from '../src/briefings.js'let dir: stringconst at = (name: string) => pathToFileURL(join(dir, name))beforeAll(async () => {dir = await mkdtemp(join(tmpdir(), 'meridian-snap-'))})afterAll(async () => {await rm(dir, { recursive: true, force: true })})describe('the cached sync survives a round trip', () => {it('reads back what it wrote', async () => {const file = at('roundtrip.json')await writeSnapshot(FIXTURE_SNAPSHOT, file)const got = await readSnapshot(file)expect(got.ok).toBe(true)if (got.ok) expect(got.snap).toEqual(FIXTURE_SNAPSHOT)})it('preserves syncedAt exactly, which is the whole point', async () => {// If this drifts by even a millisecond the staleness check fires and the// page calls its own briefing stale, the bug this file is named for.const file = at('synced.json')await writeSnapshot(FIXTURE_SNAPSHOT, file)const got = await readSnapshot(file)expect(got.ok && got.snap.syncedAt).toBe(FIXTURE_SNAPSHOT.syncedAt)})it('keeps a failed source failed rather than turning it into an empty one', async () => {// The prime invariant, applied to the cache: a source that was down when the// sync ran must still read as down after a round trip, not as zero results.const broken: Snapshot = {...FIXTURE_SNAPSHOT,leads: { ok: false, data: [], error: 'Bitrix returned 503' },}const file = at('broken.json')await writeSnapshot(broken, file)const got = await readSnapshot(file)expect(got.ok).toBe(true)if (got.ok) {expect(got.snap.leads.ok).toBe(false)expect(got.snap.leads.error).toBe('Bitrix returned 503')}})it('keeps a null apart from a zero', async () => {const file = at('nulls.json')await writeSnapshot({ ...FIXTURE_SNAPSHOT, ninetySpread: null }, file)const got = await readSnapshot(file)expect(got.ok && got.snap.ninetySpread).toBeNull()})it('carries fixture provenance across, so a cached demo cannot read as live', async () => {const file = at('mode.json')await writeSnapshot(FIXTURE_SNAPSHOT, file)const got = await readSnapshot(file)expect(got.ok && got.snap.mode).toBe('fixtures')})})describe('an unusable cache is refused, not rendered around', () => {it('says a missing file is missing', async () => {const got = await readSnapshot(at('nope.json'))expect(got).toMatchObject({ ok: false, missing: true })})it('distinguishes a bad file from an absent one', async () => {// Different problems, different fixes: one means run a sync, the other means// something wrote a bad file.const file = at('truncated.json')await writeFile(file, '{"syncedAt":"2026-', 'utf8')const got = await readSnapshot(file)expect(got).toMatchObject({ ok: false, missing: false })})it('refuses a snapshot with a source missing', async () => {const file = at('partial.json')const { leads, ...rest } = FIXTURE_SNAPSHOTvoid leadsawait writeFile(file, JSON.stringify(rest), 'utf8')const got = await readSnapshot(file)expect(got.ok).toBe(false)if (!got.ok) expect(got.reason).toMatch(/leads/)})it('refuses a snapshot with no syncedAt, because nothing on it can be dated', async () => {const file = at('undated.json')const { syncedAt, ...rest } = FIXTURE_SNAPSHOTvoid syncedAtawait writeFile(file, JSON.stringify(rest), 'utf8')expect((await readSnapshot(file)).ok).toBe(false)})it('refuses a cache written before assignment coverage existed', async () => {// Same precedent as orderWindow above. A cache without assignment coverage// cannot say whether its per-person numbers were complete or blind, and// --render-only would put them on the page as though it could.const file = at('no-coverage.json')const { assignmentFields, ...window } = FIXTURE_SNAPSHOT.orderWindowvoid assignmentFieldsawait writeFile(file,JSON.stringify({ ...FIXTURE_SNAPSHOT, orderWindow: window }),'utf8',)const got = await readSnapshot(file)expect(got.ok).toBe(false)if (!got.ok) expect(got.reason).toMatch(/coverage|blind/)})it('refuses a cache written before per-key Fathom status existed', async () => {// Same precedent again, for the Fathom key list: neither default is safe. `[]` would render as "live,// from no keys", and leaving it undefined throws deep inside the view-model// instead of here, where the reason is actually known.const file = at('no-fathom-sources.json')const { fathomSources, ...rest } = FIXTURE_SNAPSHOTvoid fathomSourcesawait writeFile(file, JSON.stringify(rest), 'utf8')const got = await readSnapshot(file)expect(got.ok).toBe(false)if (!got.ok) expect(got.reason).toMatch(/key|dark/i)})it('accepts a cache with no Fathom keys configured at all', async () => {// `fathomConfigured: false` is a legitimate state, not an old cache; the// guard above must not turn "nobody was asked" into an unreadable file.const file = at('fathom-unconfigured.json')const { fathomSources, ...rest } = FIXTURE_SNAPSHOTvoid fathomSourcesawait writeFile(file,JSON.stringify({ ...rest, fathomConfigured: false, meetings: { ok: true, data: [], error: null } }),'utf8',)expect((await readSnapshot(file)).ok).toBe(true)})it('refuses an unrecognised mode rather than assuming live', async () => {const file = at('mode-bad.json')await writeFile(file, JSON.stringify({ ...FIXTURE_SNAPSHOT, mode: 'probably-live' }), 'utf8')expect((await readSnapshot(file)).ok).toBe(false)})it('names a reason in every refusal', async () => {const file = at('empty.json')await writeFile(file, 'null', 'utf8')const got = await readSnapshot(file)expect(got.ok).toBe(false)if (!got.ok) expect(got.reason.length).toBeGreaterThan(0)})})describe('re-rendering the same sync clears the staleness warning honestly', () => {const snap = FIXTURE_SNAPSHOTconst brief = (payloadGeneratedAt: string | null): Briefings => ({generatedAt: '2026-08-25T18:40:14+00:00',day: '2026-08-12',model: 'claude-opus-5',payloadGeneratedAt,mode: 'fixtures',// Both company variants: the default viewer is the company one, which reads// `company-allhands`. These tests are about staleness, not about who sees what.briefings: { company: 'The company brief.', 'company-allhands': 'The all-hands brief.' },})const page = (b: Briefings) => render(snap, headline(snap), [], '2026-08-12', b)/*** Either mode of the pairing caveat: same-day prose gets a note about scope and* earlier-day prose gets the re-run ask. The two negative assertions below* match either marker, because a negative assertion against a phrase no* longer emitted goes on passing while checking nothing.*/const PAIRING_CAVEAT = /refresh every 15 minutes|pnpm morning/it('is silent when the prose and the numbers come from one sync', async () => {// `buildBriefingPayload` sets generatedAt to snap.syncedAt, so a brief// written from the cached sync matches it after a re-render.expect(page(brief(snap.syncedAt))).not.toMatch(PAIRING_CAVEAT)})it('still warns when they do not: the check is intact', () => {// The fix must not have worked by weakening this. If the sequence regresses,// or someone loosens briefingAge(), one of these two tests fails. An earlier// DAY is the mode that asks for something, because that one means the// morning brief never ran.expect(page(brief('2026-08-01T00:00:00.000Z'))).toMatch(/pnpm morning/)})it('says nothing either way when the briefing records no sync', () => {// Null is "no claim", not "matches". Asserting a match here would be the// check quietly passing on missing evidence.expect(page(brief(null))).not.toMatch(PAIRING_CAVEAT)})it('a re-render from cache produces the same page as the sync that wrote it', async () => {const file = at('rerender.json')await writeSnapshot(snap, file)const got = await readSnapshot(file)expect(got.ok).toBe(true)if (!got.ok) returnconst first = render(snap, headline(snap), [], '2026-08-12', brief(snap.syncedAt))const second = render(got.snap, headline(got.snap), [], '2026-08-12', brief(got.snap.syncedAt))expect(second).toBe(first)})})