own-list-store.test.ts
Language TypeScript15.8 KB391 lines
/*** The private list's storage: the decision, the SQL, and the states a read can* be in.** Not the wording of any sentence; those are rewritable. What is pinned is the machinery that keeps the feature honest:* that one person's row is unreachable from another person's session; that a* store which is absent, a store which is broken and a store which is empty* produce three different answers; that nothing is defaulted when a field is* missing; and that nothing is trimmed to fit.** The SQL runs. `0002_own_list.sql` is applied to a real SQLite engine rather* than read, for the reason checkin-store.test.ts gives: a malformed CREATE is* refused whole, and nothing short of executing it says so.*/import { describe, expect, it } from 'vitest'import {decideOwn,normalizeLink,readOwnList,saveOwn,type OwnInput,type OwnListEnv,} from '../src/own-list.js'import { makeBrokenD1, makeD1, migrationFiles, migrationSql, sqliteAvailable } from './d1-sqlite.js'import { OWN_OPEN_CAP, OWN_TEXT_CAP } from '../../../config/own-list.js'const SAM = 'Sam Whitaker'const SAM_EMAIL = 'hello@meridiandisplay.example'const MARCO = 'Marco Ibáñez'const NOW = new Date('2026-09-23T15:00:00.000Z')const input = (over: Partial<OwnInput> = {}): OwnInput => ({person: SAM,authorEmail: SAM_EMAIL,action: 'add',id: '',seen: '',text: 'Call the gym back about the 10 ft backdrop proof',due: '',priority: 'normal',link: '',...over,})describe('the decision, which takes no database', () => {it('refuses an unknown action rather than picking one', () => {expect(decideOwn(input({ action: 'delete' }))).toEqual({ write: false, outcome: 'badaction' })expect(decideOwn(input({ action: '' }))).toEqual({ write: false, outcome: 'badaction' })})it('refuses a blank line, because a bullet with no text cannot be acted on', () => {expect(decideOwn(input({ text: ' ' }))).toEqual({ write: false, outcome: 'empty' })})it('refuses text past the cap instead of storing the first 500 characters', () => {// Rule 5 pointed the other way: a silently truncated line would render as a// sentence that stops mid-word with nothing on the page saying why.const d = decideOwn(input({ text: 'x'.repeat(OWN_TEXT_CAP + 1) }))expect(d).toEqual({ write: false, outcome: 'toolong' })})it('refuses an absent or unknown priority rather than defaulting to one', () => {// Rule 2. The form always sends one, so a missing one means the post was// not the form, and a default here would stand in for knowledge nobody// has, which is how most of the pipeline silently became work we owed.expect(decideOwn(input({ priority: '' }))).toEqual({ write: false, outcome: 'badpriority' })expect(decideOwn(input({ priority: 'urgent' }))).toEqual({write: false,outcome: 'badpriority',})})it('refuses a date that is not a real day, including one that rolls over', () => {expect(decideOwn(input({ due: '2026-02-31' }))).toEqual({ write: false, outcome: 'baddate' })expect(decideOwn(input({ due: '23/09/2026' }))).toEqual({ write: false, outcome: 'baddate' })})it('allows a date in the past, since you can log something already late', () => {const d = decideOwn(input({ due: '2020-01-01' }))expect(d.write).toBe('add')})it('allows no date at all, which is not the same as overdue', () => {const d = decideOwn(input({ due: '' }))expect(d).toMatchObject({ write: 'add', fields: { due: null } })})it('normalises a link without guessing, and refuses what it cannot place', () => {expect(normalizeLink('MD2639582')).toBe('order:MD2639582')expect(normalizeLink('41837')).toBe('task:41837')expect(normalizeLink('task:41837')).toBe('task:41837')expect(normalizeLink('')).toBeNull()expect(normalizeLink('https://example.com/whatever')).toBe('bad')expect(normalizeLink('lead:99')).toBe('bad')})it('refuses a mutation with no row id or no stale token', () => {expect(decideOwn(input({ action: 'done', id: '', seen: 'x' }))).toMatchObject({outcome: 'badid',})expect(decideOwn(input({ action: 'done', id: '4', seen: '' }))).toMatchObject({outcome: 'badid',})expect(decideOwn(input({ action: 'done', id: '-1', seen: 'x' }))).toMatchObject({outcome: 'badid',})})})describe('the store', () => {if (!sqliteAvailable()) {it.skip('SQL not exercised: this runtime has no node:sqlite (needs Node >= 22.5)', () => {})return}const env = (): OwnListEnv => ({ CHECKINS: makeD1() })it('applies as written', () => {expect(() => makeD1()).not.toThrow()})it('the harness applies every migration, not just the first', () => {// A harness that applies only 0001 passes every test for a feature whose// table does not exist on it (rule 8).const files = migrationFiles()expect(files.length).toBeGreaterThan(1)expect(files.some((f) => f.endsWith('0002_own_list.sql'))).toBe(true)// Zero-padded, so the lexical sort IS the apply order. The day somebody// writes `10_x.sql` this should fail here rather than silently apply it// out of order.for (const f of files) expect(f.split('/').pop()).toMatch(/^\d{4}_/)})it('adds a line and reads it back', async () => {const e = env()expect(await saveOwn(e, input({ due: '2026-09-25', priority: 'high', link: 'MD2639582' }), NOW)).toEqual({ outcome: 'added' })const out = await readOwnList(e, SAM)expect(out.state).toBe('ok')if (out.state !== 'ok') returnexpect(out.person).toBe(SAM)expect(out.open).toHaveLength(1)expect(out.open[0]).toMatchObject({text: 'Call the gym back about the 10 ft backdrop proof',due: '2026-09-25',priority: 'high',linkKey: 'order:MD2639582',edits: 0,doneAt: null,})})it('completing keeps the row and reopening brings it back', async () => {// Completing must never look like losing. This is also what makes the whole// feature reversible from the page that wrote it (rule 12) with no// confirmation step anywhere.const e = env()await saveOwn(e, input(), NOW)const first = await readOwnList(e, SAM)if (first.state !== 'ok') throw new Error('expected ok')const item = first.open[0]!expect(await saveOwn(e, input({ action: 'done', id: String(item.id), seen: item.updatedAt }), NOW),).toEqual({ outcome: 'completed' })const after = await readOwnList(e, SAM)if (after.state !== 'ok') throw new Error('expected ok')expect(after.open).toHaveLength(0)expect(after.done).toHaveLength(1)expect(after.done[0]!.id).toBe(item.id)expect(after.done[0]!.text).toBe(item.text)const done = after.done[0]!expect(await saveOwn(e, input({ action: 'reopen', id: String(done.id), seen: done.updatedAt }), NOW),).toEqual({ outcome: 'reopened' })const back = await readOwnList(e, SAM)if (back.state !== 'ok') throw new Error('expected ok')expect(back.open).toHaveLength(1)expect(back.done).toHaveLength(0)})it('ONE PERSON CANNOT TOUCH ANOTHER PERSON’S LINE', async () => {// The single most important assertion in this feature. There is no grant// list guarding this table (the scoping clause IS the access control), so// if this ever stops holding, the failure is one colleague reading and// editing another's private list.const e = env()await saveOwn(e, input({ person: MARCO, text: 'Marco’s own line' }), NOW)const marco = await readOwnList(e, MARCO)if (marco.state !== 'ok') throw new Error('expected ok')const row = marco.open[0]!for (const action of ['done', 'reopen', 'edit'] as const) {expect(await saveOwn(e,input({ person: SAM, action, id: String(row.id), seen: row.updatedAt }),NOW,),).toEqual({ outcome: 'notfound' })}// And the row is untouched in the database, not merely reported as such.const still = await readOwnList(e, MARCO)if (still.state !== 'ok') throw new Error('expected ok')expect(still.open).toHaveLength(1)expect(still.open[0]).toMatchObject({ text: 'Marco’s own line', edits: 0, doneAt: null })// Sam's own list never saw it either.const mine = await readOwnList(e, SAM)if (mine.state !== 'ok') throw new Error('expected ok')expect(mine.open).toHaveLength(0)expect(mine.done).toHaveLength(0)})it('a stale form is refused rather than applied to a version nobody is looking at', async () => {const e = env()await saveOwn(e, input(), NOW)const first = await readOwnList(e, SAM)if (first.state !== 'ok') throw new Error('expected ok')const item = first.open[0]!// Somebody edits from another tab.await saveOwn(e,input({ action: 'edit', id: String(item.id), seen: item.updatedAt, text: 'Rewritten' }),new Date('2026-09-23T16:00:00.000Z'),)// The first tab still holds the old token.expect(await saveOwn(e, input({ action: 'done', id: String(item.id), seen: item.updatedAt }), NOW),).toEqual({ outcome: 'stale' })const after = await readOwnList(e, SAM)if (after.state !== 'ok') throw new Error('expected ok')expect(after.open[0]!.doneAt).toBeNull()})it('an edit that changes nothing writes nothing, so the row cannot claim it was edited', async () => {const e = env()await saveOwn(e, input({ due: '2026-09-25' }), NOW)const first = await readOwnList(e, SAM)if (first.state !== 'ok') throw new Error('expected ok')const item = first.open[0]!expect(await saveOwn(e,input({action: 'edit',id: String(item.id),seen: item.updatedAt,text: item.text,due: '2026-09-25',priority: item.priority,}),NOW,),).toEqual({ outcome: 'unchanged' })const after = await readOwnList(e, SAM)if (after.state !== 'ok') throw new Error('expected ok')expect(after.open[0]!.edits).toBe(0)})it('refuses an add past the open cap instead of trimming to fit', async () => {// Rule 5, and RECIPIENT_CAP's rule in rule 12: refuse over the cap rather// than throwing away the thing somebody just typed.const e = env()const db = (e.CHECKINS as unknown as { __raw: { prepare: (s: string) => { run: (...a: unknown[]) => void } } }).__rawconst stmt = db.prepare(`INSERT INTO own_item (person,author_email,text,due,priority,link_key,created_at,updated_at,edits,done_at,tz)VALUES (?,?,?,NULL,'normal',NULL,?,?,0,NULL,'America/Chicago')`,)for (let i = 0; i < OWN_OPEN_CAP; i++) {stmt.run(SAM, SAM_EMAIL, `line ${i}`, NOW.toISOString(), NOW.toISOString())}expect(await saveOwn(e, input({ text: 'one too many' }), NOW)).toEqual({ outcome: 'full' })const out = await readOwnList(e, SAM)if (out.state !== 'ok') throw new Error('expected ok')expect(out.open).toHaveLength(OWN_OPEN_CAP)expect(out.open.some((i) => i.text === 'one too many')).toBe(false)expect(out.truncated).toBe(false)})it('the database refuses a blank line too, where nothing can route around it', async () => {const e = env()const db = (e.CHECKINS as unknown as { __raw: { prepare: (s: string) => { run: (...a: unknown[]) => void } } }).__rawconst blank = () =>db.prepare(`INSERT INTO own_item (person,author_email,text,due,priority,link_key,created_at,updated_at,edits,done_at,tz)VALUES (?,?,' ',NULL,'normal',NULL,'t','t',0,NULL,'America/Chicago')`,).run(SAM, SAM_EMAIL)expect(blank).toThrow()})it('the database refuses a priority outside the domain the config declares', () => {// The route refuses one too; this is the same rule at the storage layer.// If the two ever disagree, the store is the one that cannot be bypassed.const sql = migrationSql('0002_own_list.sql')expect(sql).toContain(`priority IN ('high', 'normal', 'low')`)})it('an unreadable store, an unconfigured one and an empty one are three different answers', async () => {// The prime invariant, on the one surface where the reader is the only// person who could notice it being broken.const unconfigured = await readOwnList({}, SAM)const failed = await readOwnList({ CHECKINS: makeBrokenD1('D1_ERROR: no such table: own_item') }, SAM)const empty = await readOwnList(env(), SAM)expect(unconfigured).toEqual({ state: 'unconfigured' })expect(failed.state).toBe('failed')expect(empty.state).toBe('ok')if (empty.state !== 'ok') returnexpect(empty.open).toEqual([])expect(new Set([unconfigured.state, failed.state, empty.state]).size).toBe(3)})it('a failed read carries the store’s own words and no diagnosis', async () => {// Rule 1. The 401 handler once told somebody a token with eleven months// left had "most likely expired". Asserted as a property, not as wording:// the message is present, and no causal claim is bolted onto it.const out = await readOwnList({ CHECKINS: makeBrokenD1('D1_ERROR: no such table: own_item') }, SAM)if (out.state !== 'failed') throw new Error('expected failed')expect(out.error).toContain('no such table: own_item')expect(out.error).not.toMatch(/probably|most likely|you (forgot|need)|should have/i)})it('a write to an unconfigured store says it did not save, not why', async () => {expect(await saveOwn({}, input(), NOW)).toEqual({ outcome: 'failed' })})it('keeps no copy of superseded text, deliberately unlike the end-of-day note', async () => {// The check-in keeps revisions because a SECOND PERSON reads the note and an// edit would silently replace text they had already read. Nobody but the// author reads this one, so keeping versions would mean storing private text// its owner believed they had changed, forever. This test exists so that a// future "let's make these consistent" change fails with the reason// attached.const e = env()await saveOwn(e, input({ text: 'the original wording' }), NOW)const first = await readOwnList(e, SAM)if (first.state !== 'ok') throw new Error('expected ok')const item = first.open[0]!await saveOwn(e,input({ action: 'edit', id: String(item.id), seen: item.updatedAt, text: 'the new wording' }),NOW,)const db = (e.CHECKINS as unknown as {__raw: { prepare: (s: string) => { all: (...a: unknown[]) => unknown[] } }}).__rawconst tables = db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]const ownTables = tables.map((t) => t.name).filter((n) => n.startsWith('own_'))expect(ownTables).toEqual(['own_item'])const rows = db.prepare(`SELECT text FROM own_item`).all() as { text: string }[]expect(rows.map((r) => r.text)).toEqual(['the new wording'])expect(JSON.stringify(rows)).not.toContain('the original wording')})it('counts completed lines beyond the folded window rather than inferring them', async () => {const e = env()const db = (e.CHECKINS as unknown as { __raw: { prepare: (s: string) => { run: (...a: unknown[]) => void } } }).__rawconst stmt = db.prepare(`INSERT INTO own_item (person,author_email,text,due,priority,link_key,created_at,updated_at,edits,done_at,tz)VALUES (?,?,?,NULL,'normal',NULL,?,?,0,?,'America/Chicago')`,)for (let i = 0; i < 55; i++) {const at = `2026-09-${String(1 + (i % 28)).padStart(2, '0')}T12:00:00.000Z`stmt.run(SAM, SAM_EMAIL, `done ${i}`, at, at, at)}const out = await readOwnList(e, SAM)if (out.state !== 'ok') throw new Error('expected ok')expect(out.done).toHaveLength(50)expect(out.doneHidden).toBe(5)})})