checkin-store.test.ts
Language TypeScript12.6 KB292 lines
/*** The end-of-day note's storage: the decision, the SQL, and the four states a* read can be in.** Not the wording of the questions, which may be rewritten freely. What is* pinned is the machinery that* keeps the page honest: that a store which is absent, a store which is broken,* and a store which is empty produce three different answers; that the day a* row lands on comes from the clock and never from the form; and that an* all-blank note cannot be written, because a blank row and a day she never* opened the form read identically to whoever is looking.** The SQL runs. `0001_checkin.sql` is applied to a real SQLite engine rather* than read, which is not ceremony: a table CHECK placed in the middle of the* column list makes SQLite refuse the whole CREATE, and nothing short of* executing it says so.*/import { describe, expect, it } from 'vitest'import {decideCheckin,readCheckins,readCheckinsFor,saveCheckin,type CheckinEnv,} from '../src/checkin.js'import { makeBrokenD1, makeD1, migrationSql, sqliteAvailable } from './d1-sqlite.js'import { CHECKIN_QUESTIONS } from '../../../config/checkin.js'const PERSON = 'Rosa Galván'const EMAIL = 'accounts@meridiandisplay.example'/** 6:40 PM Chicago on Tuesday 15 September 2026, after the form opens. */const EVENING = new Date('2026-09-15T23:40:00.000Z')/** 12:30 AM Chicago on the 16th, which is still the 15th's note. */const AFTER_MIDNIGHT = new Date('2026-09-16T05:30:00.000Z')/** 10:00 AM Chicago on the 16th: a new day, and the form is folded shut. */const MORNING = new Date('2026-09-16T15:00:00.000Z')const input = (over: Partial<Parameters<typeof saveCheckin>[1]> = {}) => ({person: PERSON,authorEmail: EMAIL,claimedDay: '2026-09-15',did: 'Answered the new leads and sent two proofs.',unsure: 'Whether a rush order needs a second proof.',need: 'A walkthrough of the proof tool.',...over,})describe('decideCheckin: what may be written, and to which day', () => {it('files an evening note against today', () => {const d = decideCheckin(input(), EVENING)expect(d).toMatchObject({ write: true, day: '2026-09-15' })})it('files a 12:30 AM note against the day that just ended, not the one starting', () => {// Without the grace window she files two wrong facts at once: a missed day// behind her, and a note on a day she has not worked.const d = decideCheckin(input({ claimedDay: '2026-09-15' }), AFTER_MIDNIGHT)expect(d).toMatchObject({ write: true, day: '2026-09-15' })})it('refuses a form that claims a different day rather than writing it somewhere', () => {// A tab left open overnight. Writing it into today's row would overwrite a// note with text about another day; refusing costs a reload.const d = decideCheckin(input({ claimedDay: '2026-09-14' }), EVENING)expect(d).toEqual({ write: false, outcome: 'stale', day: '2026-09-15' })})it('refuses an all-blank note, whitespace included', () => {const d = decideCheckin(input({ did: '', unsure: ' ', need: '\n' }), EVENING)expect(d).toEqual({ write: false, outcome: 'empty', day: '2026-09-15' })})it('accepts a note with two of three blank, since one honest answer is an answer', () => {const d = decideCheckin(input({ did: '', unsure: '', need: 'nothing today' }), EVENING)expect(d).toMatchObject({ write: true })})it('takes the day from the clock even when the form sends nothing', () => {expect(decideCheckin(input({ claimedDay: '' }), MORNING)).toMatchObject({write: true,day: '2026-09-16',})})})describe('an absent binding is not an empty store', () => {const none: CheckinEnv = {}it('reads as unconfigured, never as ok-with-no-notes', async () => {// The whole feature rests on this distinction. "No rows" means she has not// written one; "no binding" means nobody could have.expect(await readCheckins(none, PERSON, EVENING)).toEqual({ state: 'unconfigured' })})it('refuses the write and says so, rather than reporting a save that never happened', async () => {expect(await saveCheckin(none, input(), EVENING)).toEqual({outcome: 'failed',day: '2026-09-15',})})it('still refuses a stale or empty submission before it looks for a store', async () => {// The refusal is about the submission, not about the plumbing, so it must// not be reported as a store failure; those send the author looking in the// wrong place.expect(await saveCheckin(none, input({ did: '', unsure: '', need: '' }), EVENING)).toMatchObject({ outcome: 'empty' },)})})describe('a store that refuses is reported as failed, with what it said', () => {it('carries the store’s own message and no diagnosis of its own', async () => {const env = { CHECKINS: makeBrokenD1('D1_ERROR: no such table: checkin') } as CheckinEnvconst out = await readCheckins(env, PERSON, EVENING)expect(out.state).toBe('failed')// Rule 1: report what happened, do not pick a cause. The 401 handler once// told somebody a token with eleven months left had expired.if (out.state === 'failed') {expect(out.error).toContain('no such table')expect(out.error).not.toMatch(/probably|most likely|forgot|you should/i)}})it('a failed write is reported as failed, not as saved', async () => {const env = { CHECKINS: makeBrokenD1() } as CheckinEnvexpect(await saveCheckin(env, input(), EVENING)).toMatchObject({ outcome: 'failed' })})})const describeSql = sqliteAvailable() ? describe : describe.skipif (!sqliteAvailable()) {// Loud, not silent. A suite that quietly shrinks on an older runtime is rule// 8 with the volume down: the tests below would stop running and nothing// would say so.console.warn('checkin-store.test.ts: node:sqlite is unavailable on this runtime, so the SQL tests were ' +'SKIPPED. The migration and the queries were not exercised. Node 22.5+ runs them.',)}describeSql('the migration and the queries, against a real engine', () => {const env = (): CheckinEnv => ({ CHECKINS: makeD1() })it('applies as written', () => {expect(() => makeD1()).not.toThrow()})it('has a column for every question, under the question’s own id', () => {// The INSERT names these columns and the form names the same strings.// Renaming a question id without a migration would fail at runtime, on the// one action whose failure costs somebody the text they just typed.// Named, now that there is more than one migration: this asserts something// about 0001 and would start passing for the wrong reason if a later file// happened to contain a matching column.const sql = migrationSql('0001_checkin.sql')for (const q of CHECKIN_QUESTIONS) expect(sql).toMatch(new RegExp(`\\b${q.id}\\s+TEXT NOT NULL`))})it('saves a note and reads it back', async () => {const e = env()expect(await saveCheckin(e, input(), EVENING)).toEqual({ outcome: 'saved', day: '2026-09-15' })const out = await readCheckins(e, PERSON, EVENING)expect(out.state).toBe('ok')if (out.state !== 'ok') returnexpect(out.notes).toHaveLength(1)expect(out.notes[0]).toMatchObject({person: PERSON,day: '2026-09-15',unsure: 'Whether a rush order needs a second proof.',revisions: 1,authorEmail: EMAIL,})})it('a second submission the same day updates the row instead of adding one', async () => {const e = env()await saveCheckin(e, input(), EVENING)const second = await saveCheckin(e, input({ need: 'The proof tool walkthrough, still.' }), AFTER_MIDNIGHT)expect(second.outcome).toBe('updated')const out = await readCheckins(e, PERSON, EVENING)if (out.state !== 'ok') throw new Error('expected ok')expect(out.notes).toHaveLength(1)expect(out.notes[0]?.revisions).toBe(2)expect(out.notes[0]?.need).toBe('The proof tool walkthrough, still.')})it('keeps the earlier version, so an edit cannot quietly rewrite what was read', async () => {const e = env()await saveCheckin(e, input({ unsure: 'Everything, honestly.' }), EVENING)await saveCheckin(e, input({ unsure: 'Nothing much.' }), AFTER_MIDNIGHT)const out = await readCheckins(e, PERSON, EVENING)if (out.state !== 'ok') throw new Error('expected ok')expect(out.earlier.map((r) => r.revision)).toEqual([2, 1])expect(out.earlier.find((r) => r.revision === 1)?.unsure).toBe('Everything, honestly.')})it('an identical resubmit writes nothing at all', async () => {// A double-click would otherwise leave the reader's card saying "edited"// about text that never changed.const e = env()await saveCheckin(e, input(), EVENING)expect(await saveCheckin(e, input(), EVENING)).toMatchObject({ outcome: 'unchanged' })const out = await readCheckins(e, PERSON, EVENING)if (out.state !== 'ok') throw new Error('expected ok')expect(out.notes[0]?.revisions).toBe(1)expect(out.earlier).toHaveLength(1)})it('keeps the first submission time across an edit', async () => {const e = env()await saveCheckin(e, input(), EVENING)await saveCheckin(e, input({ did: 'changed' }), AFTER_MIDNIGHT)const out = await readCheckins(e, PERSON, EVENING)if (out.state !== 'ok') throw new Error('expected ok')// "written at 6:40 PM" has to mean when she wrote it, not when she fixed a// typo in it.expect(out.notes[0]?.submittedAt).toBe(EVENING.toISOString())expect(out.notes[0]?.updatedAt).toBe(AFTER_MIDNIGHT.toISOString())})it('the storage layer refuses an all-blank row even if something routes around the handler', async () => {const db = makeD1()expect(() =>db.prepare(`INSERT INTO checkin (person, author_email, day, day_tz, did, unsure, need,submitted_at, updated_at, revisions)VALUES (?1, ?2, ?3, ?4, '', ' ', '', ?5, ?5, 1)`,).bind(PERSON, EMAIL, '2026-09-15', 'America/Chicago', EVENING.toISOString()).__run(),).toThrow(/CHECK constraint failed/)})it('refuses a malformed day, which would sort into the wrong place forever', async () => {const db = makeD1()expect(() =>db.prepare(`INSERT INTO checkin (person, author_email, day, day_tz, did, unsure, need,submitted_at, updated_at, revisions)VALUES (?1, ?2, '15/09/2026', 'America/Chicago', 'x', '', '', ?3, ?3, 1)`,).bind(PERSON, EMAIL, EVENING.toISOString()).__run(),).toThrow(/CHECK constraint failed/)})it('files a different person’s notes separately, which is the seat-change case', async () => {// accounts@ changes hands. The next holder signs in as the same address and// must start empty rather than inheriting weeks of "what I'm unsure about"// written by somebody else.const e = env()await saveCheckin(e, input(), EVENING)await saveCheckin(e, input({ person: 'Someone Else', did: 'their day' }), EVENING)const mine = await readCheckins(e, PERSON, EVENING)const theirs = await readCheckins(e, 'Someone Else', EVENING)if (mine.state !== 'ok' || theirs.state !== 'ok') throw new Error('expected ok')expect(mine.notes).toHaveLength(1)expect(theirs.notes).toHaveLength(1)expect(theirs.notes[0]?.did).toBe('their day')})it('an empty store reads as ok-and-empty, which is a different thing from both failures', async () => {const out = await readCheckins(env(), PERSON, EVENING)expect(out).toMatchObject({ state: 'ok', notes: [], earlier: [], truncated: false })})it('reads several people in one call, each with its own state', async () => {const e = env()await saveCheckin(e, input(), EVENING)const all = await readCheckinsFor(e, [PERSON, 'Nobody At All'], EVENING)expect(Object.keys(all).sort()).toEqual(['Nobody At All', 'Rosa Galván'])expect(all[PERSON]?.state).toBe('ok')// A person with no rows is ok-and-empty, never absent from the map, since a// missing key would render as nothing at all.expect(all['Nobody At All']).toMatchObject({ state: 'ok', notes: [] })})it('does not reach back past the window the card will render', async () => {// The query window and the rendered day list come from one function. If// this ever returns a day the card does not list, that note is invisible.const e = env()await saveCheckin(e, input({ claimedDay: '' }), new Date('2026-08-01T23:40:00.000Z'))const out = await readCheckins(e, PERSON, EVENING)if (out.state !== 'ok') throw new Error('expected ok')expect(out.notes.map((n) => n.day)).not.toContain('2026-08-01')})})