mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

own-list-store.test.ts

Language TypeScript15.8 KB391 lines

apps/worker/test/own-list-store.test.ts

  1. /**
  2. * The private list's storage: the decision, the SQL, and the states a read can
  3. * be in.
  4. *
  5. * Not the wording of any sentence; those are rewritable. What is pinned is the machinery that keeps the feature honest:
  6. * that one person's row is unreachable from another person's session; that a
  7. * store which is absent, a store which is broken and a store which is empty
  8. * produce three different answers; that nothing is defaulted when a field is
  9. * missing; and that nothing is trimmed to fit.
  10. *
  11. * The SQL runs. `0002_own_list.sql` is applied to a real SQLite engine rather
  12. * than read, for the reason checkin-store.test.ts gives: a malformed CREATE is
  13. * refused whole, and nothing short of executing it says so.
  14. */
  15. import { describe, expect, it } from 'vitest'
  16. import {
  17. decideOwn,
  18. normalizeLink,
  19. readOwnList,
  20. saveOwn,
  21. type OwnInput,
  22. type OwnListEnv,
  23. } from '../src/own-list.js'
  24. import { makeBrokenD1, makeD1, migrationFiles, migrationSql, sqliteAvailable } from './d1-sqlite.js'
  25. import { OWN_OPEN_CAP, OWN_TEXT_CAP } from '../../../config/own-list.js'
  26. const SAM = 'Sam Whitaker'
  27. const SAM_EMAIL = 'hello@meridiandisplay.example'
  28. const MARCO = 'Marco Ibáñez'
  29. const NOW = new Date('2026-09-23T15:00:00.000Z')
  30. const input = (over: Partial<OwnInput> = {}): OwnInput => ({
  31. person: SAM,
  32. authorEmail: SAM_EMAIL,
  33. action: 'add',
  34. id: '',
  35. seen: '',
  36. text: 'Call the gym back about the 10 ft backdrop proof',
  37. due: '',
  38. priority: 'normal',
  39. link: '',
  40. ...over,
  41. })
  42. describe('the decision, which takes no database', () => {
  43. it('refuses an unknown action rather than picking one', () => {
  44. expect(decideOwn(input({ action: 'delete' }))).toEqual({ write: false, outcome: 'badaction' })
  45. expect(decideOwn(input({ action: '' }))).toEqual({ write: false, outcome: 'badaction' })
  46. })
  47. it('refuses a blank line, because a bullet with no text cannot be acted on', () => {
  48. expect(decideOwn(input({ text: ' ' }))).toEqual({ write: false, outcome: 'empty' })
  49. })
  50. it('refuses text past the cap instead of storing the first 500 characters', () => {
  51. // Rule 5 pointed the other way: a silently truncated line would render as a
  52. // sentence that stops mid-word with nothing on the page saying why.
  53. const d = decideOwn(input({ text: 'x'.repeat(OWN_TEXT_CAP + 1) }))
  54. expect(d).toEqual({ write: false, outcome: 'toolong' })
  55. })
  56. it('refuses an absent or unknown priority rather than defaulting to one', () => {
  57. // Rule 2. The form always sends one, so a missing one means the post was
  58. // not the form, and a default here would stand in for knowledge nobody
  59. // has, which is how most of the pipeline silently became work we owed.
  60. expect(decideOwn(input({ priority: '' }))).toEqual({ write: false, outcome: 'badpriority' })
  61. expect(decideOwn(input({ priority: 'urgent' }))).toEqual({
  62. write: false,
  63. outcome: 'badpriority',
  64. })
  65. })
  66. it('refuses a date that is not a real day, including one that rolls over', () => {
  67. expect(decideOwn(input({ due: '2026-02-31' }))).toEqual({ write: false, outcome: 'baddate' })
  68. expect(decideOwn(input({ due: '23/09/2026' }))).toEqual({ write: false, outcome: 'baddate' })
  69. })
  70. it('allows a date in the past, since you can log something already late', () => {
  71. const d = decideOwn(input({ due: '2020-01-01' }))
  72. expect(d.write).toBe('add')
  73. })
  74. it('allows no date at all, which is not the same as overdue', () => {
  75. const d = decideOwn(input({ due: '' }))
  76. expect(d).toMatchObject({ write: 'add', fields: { due: null } })
  77. })
  78. it('normalises a link without guessing, and refuses what it cannot place', () => {
  79. expect(normalizeLink('MD2639582')).toBe('order:MD2639582')
  80. expect(normalizeLink('41837')).toBe('task:41837')
  81. expect(normalizeLink('task:41837')).toBe('task:41837')
  82. expect(normalizeLink('')).toBeNull()
  83. expect(normalizeLink('https://example.com/whatever')).toBe('bad')
  84. expect(normalizeLink('lead:99')).toBe('bad')
  85. })
  86. it('refuses a mutation with no row id or no stale token', () => {
  87. expect(decideOwn(input({ action: 'done', id: '', seen: 'x' }))).toMatchObject({
  88. outcome: 'badid',
  89. })
  90. expect(decideOwn(input({ action: 'done', id: '4', seen: '' }))).toMatchObject({
  91. outcome: 'badid',
  92. })
  93. expect(decideOwn(input({ action: 'done', id: '-1', seen: 'x' }))).toMatchObject({
  94. outcome: 'badid',
  95. })
  96. })
  97. })
  98. describe('the store', () => {
  99. if (!sqliteAvailable()) {
  100. it.skip('SQL not exercised: this runtime has no node:sqlite (needs Node >= 22.5)', () => {})
  101. return
  102. }
  103. const env = (): OwnListEnv => ({ CHECKINS: makeD1() })
  104. it('applies as written', () => {
  105. expect(() => makeD1()).not.toThrow()
  106. })
  107. it('the harness applies every migration, not just the first', () => {
  108. // A harness that applies only 0001 passes every test for a feature whose
  109. // table does not exist on it (rule 8).
  110. const files = migrationFiles()
  111. expect(files.length).toBeGreaterThan(1)
  112. expect(files.some((f) => f.endsWith('0002_own_list.sql'))).toBe(true)
  113. // Zero-padded, so the lexical sort IS the apply order. The day somebody
  114. // writes `10_x.sql` this should fail here rather than silently apply it
  115. // out of order.
  116. for (const f of files) expect(f.split('/').pop()).toMatch(/^\d{4}_/)
  117. })
  118. it('adds a line and reads it back', async () => {
  119. const e = env()
  120. expect(await saveOwn(e, input({ due: '2026-09-25', priority: 'high', link: 'MD2639582' }), NOW))
  121. .toEqual({ outcome: 'added' })
  122. const out = await readOwnList(e, SAM)
  123. expect(out.state).toBe('ok')
  124. if (out.state !== 'ok') return
  125. expect(out.person).toBe(SAM)
  126. expect(out.open).toHaveLength(1)
  127. expect(out.open[0]).toMatchObject({
  128. text: 'Call the gym back about the 10 ft backdrop proof',
  129. due: '2026-09-25',
  130. priority: 'high',
  131. linkKey: 'order:MD2639582',
  132. edits: 0,
  133. doneAt: null,
  134. })
  135. })
  136. it('completing keeps the row and reopening brings it back', async () => {
  137. // Completing must never look like losing. This is also what makes the whole
  138. // feature reversible from the page that wrote it (rule 12) with no
  139. // confirmation step anywhere.
  140. const e = env()
  141. await saveOwn(e, input(), NOW)
  142. const first = await readOwnList(e, SAM)
  143. if (first.state !== 'ok') throw new Error('expected ok')
  144. const item = first.open[0]!
  145. expect(
  146. await saveOwn(e, input({ action: 'done', id: String(item.id), seen: item.updatedAt }), NOW),
  147. ).toEqual({ outcome: 'completed' })
  148. const after = await readOwnList(e, SAM)
  149. if (after.state !== 'ok') throw new Error('expected ok')
  150. expect(after.open).toHaveLength(0)
  151. expect(after.done).toHaveLength(1)
  152. expect(after.done[0]!.id).toBe(item.id)
  153. expect(after.done[0]!.text).toBe(item.text)
  154. const done = after.done[0]!
  155. expect(
  156. await saveOwn(e, input({ action: 'reopen', id: String(done.id), seen: done.updatedAt }), NOW),
  157. ).toEqual({ outcome: 'reopened' })
  158. const back = await readOwnList(e, SAM)
  159. if (back.state !== 'ok') throw new Error('expected ok')
  160. expect(back.open).toHaveLength(1)
  161. expect(back.done).toHaveLength(0)
  162. })
  163. it('ONE PERSON CANNOT TOUCH ANOTHER PERSON’S LINE', async () => {
  164. // The single most important assertion in this feature. There is no grant
  165. // list guarding this table (the scoping clause IS the access control), so
  166. // if this ever stops holding, the failure is one colleague reading and
  167. // editing another's private list.
  168. const e = env()
  169. await saveOwn(e, input({ person: MARCO, text: 'Marco’s own line' }), NOW)
  170. const marco = await readOwnList(e, MARCO)
  171. if (marco.state !== 'ok') throw new Error('expected ok')
  172. const row = marco.open[0]!
  173. for (const action of ['done', 'reopen', 'edit'] as const) {
  174. expect(
  175. await saveOwn(
  176. e,
  177. input({ person: SAM, action, id: String(row.id), seen: row.updatedAt }),
  178. NOW,
  179. ),
  180. ).toEqual({ outcome: 'notfound' })
  181. }
  182. // And the row is untouched in the database, not merely reported as such.
  183. const still = await readOwnList(e, MARCO)
  184. if (still.state !== 'ok') throw new Error('expected ok')
  185. expect(still.open).toHaveLength(1)
  186. expect(still.open[0]).toMatchObject({ text: 'Marco’s own line', edits: 0, doneAt: null })
  187. // Sam's own list never saw it either.
  188. const mine = await readOwnList(e, SAM)
  189. if (mine.state !== 'ok') throw new Error('expected ok')
  190. expect(mine.open).toHaveLength(0)
  191. expect(mine.done).toHaveLength(0)
  192. })
  193. it('a stale form is refused rather than applied to a version nobody is looking at', async () => {
  194. const e = env()
  195. await saveOwn(e, input(), NOW)
  196. const first = await readOwnList(e, SAM)
  197. if (first.state !== 'ok') throw new Error('expected ok')
  198. const item = first.open[0]!
  199. // Somebody edits from another tab.
  200. await saveOwn(
  201. e,
  202. input({ action: 'edit', id: String(item.id), seen: item.updatedAt, text: 'Rewritten' }),
  203. new Date('2026-09-23T16:00:00.000Z'),
  204. )
  205. // The first tab still holds the old token.
  206. expect(
  207. await saveOwn(e, input({ action: 'done', id: String(item.id), seen: item.updatedAt }), NOW),
  208. ).toEqual({ outcome: 'stale' })
  209. const after = await readOwnList(e, SAM)
  210. if (after.state !== 'ok') throw new Error('expected ok')
  211. expect(after.open[0]!.doneAt).toBeNull()
  212. })
  213. it('an edit that changes nothing writes nothing, so the row cannot claim it was edited', async () => {
  214. const e = env()
  215. await saveOwn(e, input({ due: '2026-09-25' }), NOW)
  216. const first = await readOwnList(e, SAM)
  217. if (first.state !== 'ok') throw new Error('expected ok')
  218. const item = first.open[0]!
  219. expect(
  220. await saveOwn(
  221. e,
  222. input({
  223. action: 'edit',
  224. id: String(item.id),
  225. seen: item.updatedAt,
  226. text: item.text,
  227. due: '2026-09-25',
  228. priority: item.priority,
  229. }),
  230. NOW,
  231. ),
  232. ).toEqual({ outcome: 'unchanged' })
  233. const after = await readOwnList(e, SAM)
  234. if (after.state !== 'ok') throw new Error('expected ok')
  235. expect(after.open[0]!.edits).toBe(0)
  236. })
  237. it('refuses an add past the open cap instead of trimming to fit', async () => {
  238. // Rule 5, and RECIPIENT_CAP's rule in rule 12: refuse over the cap rather
  239. // than throwing away the thing somebody just typed.
  240. const e = env()
  241. const db = (e.CHECKINS as unknown as { __raw: { prepare: (s: string) => { run: (...a: unknown[]) => void } } })
  242. .__raw
  243. const stmt = db.prepare(
  244. `INSERT INTO own_item (person,author_email,text,due,priority,link_key,
  245. created_at,updated_at,edits,done_at,tz)
  246. VALUES (?,?,?,NULL,'normal',NULL,?,?,0,NULL,'America/Chicago')`,
  247. )
  248. for (let i = 0; i < OWN_OPEN_CAP; i++) {
  249. stmt.run(SAM, SAM_EMAIL, `line ${i}`, NOW.toISOString(), NOW.toISOString())
  250. }
  251. expect(await saveOwn(e, input({ text: 'one too many' }), NOW)).toEqual({ outcome: 'full' })
  252. const out = await readOwnList(e, SAM)
  253. if (out.state !== 'ok') throw new Error('expected ok')
  254. expect(out.open).toHaveLength(OWN_OPEN_CAP)
  255. expect(out.open.some((i) => i.text === 'one too many')).toBe(false)
  256. expect(out.truncated).toBe(false)
  257. })
  258. it('the database refuses a blank line too, where nothing can route around it', async () => {
  259. const e = env()
  260. const db = (e.CHECKINS as unknown as { __raw: { prepare: (s: string) => { run: (...a: unknown[]) => void } } })
  261. .__raw
  262. const blank = () =>
  263. db
  264. .prepare(
  265. `INSERT INTO own_item (person,author_email,text,due,priority,link_key,
  266. created_at,updated_at,edits,done_at,tz)
  267. VALUES (?,?,' ',NULL,'normal',NULL,'t','t',0,NULL,'America/Chicago')`,
  268. )
  269. .run(SAM, SAM_EMAIL)
  270. expect(blank).toThrow()
  271. })
  272. it('the database refuses a priority outside the domain the config declares', () => {
  273. // The route refuses one too; this is the same rule at the storage layer.
  274. // If the two ever disagree, the store is the one that cannot be bypassed.
  275. const sql = migrationSql('0002_own_list.sql')
  276. expect(sql).toContain(`priority IN ('high', 'normal', 'low')`)
  277. })
  278. it('an unreadable store, an unconfigured one and an empty one are three different answers', async () => {
  279. // The prime invariant, on the one surface where the reader is the only
  280. // person who could notice it being broken.
  281. const unconfigured = await readOwnList({}, SAM)
  282. const failed = await readOwnList({ CHECKINS: makeBrokenD1('D1_ERROR: no such table: own_item') }, SAM)
  283. const empty = await readOwnList(env(), SAM)
  284. expect(unconfigured).toEqual({ state: 'unconfigured' })
  285. expect(failed.state).toBe('failed')
  286. expect(empty.state).toBe('ok')
  287. if (empty.state !== 'ok') return
  288. expect(empty.open).toEqual([])
  289. expect(new Set([unconfigured.state, failed.state, empty.state]).size).toBe(3)
  290. })
  291. it('a failed read carries the store’s own words and no diagnosis', async () => {
  292. // Rule 1. The 401 handler once told somebody a token with eleven months
  293. // left had "most likely expired". Asserted as a property, not as wording:
  294. // the message is present, and no causal claim is bolted onto it.
  295. const out = await readOwnList({ CHECKINS: makeBrokenD1('D1_ERROR: no such table: own_item') }, SAM)
  296. if (out.state !== 'failed') throw new Error('expected failed')
  297. expect(out.error).toContain('no such table: own_item')
  298. expect(out.error).not.toMatch(/probably|most likely|you (forgot|need)|should have/i)
  299. })
  300. it('a write to an unconfigured store says it did not save, not why', async () => {
  301. expect(await saveOwn({}, input(), NOW)).toEqual({ outcome: 'failed' })
  302. })
  303. it('keeps no copy of superseded text, deliberately unlike the end-of-day note', async () => {
  304. // The check-in keeps revisions because a SECOND PERSON reads the note and an
  305. // edit would silently replace text they had already read. Nobody but the
  306. // author reads this one, so keeping versions would mean storing private text
  307. // its owner believed they had changed, forever. This test exists so that a
  308. // future "let's make these consistent" change fails with the reason
  309. // attached.
  310. const e = env()
  311. await saveOwn(e, input({ text: 'the original wording' }), NOW)
  312. const first = await readOwnList(e, SAM)
  313. if (first.state !== 'ok') throw new Error('expected ok')
  314. const item = first.open[0]!
  315. await saveOwn(
  316. e,
  317. input({ action: 'edit', id: String(item.id), seen: item.updatedAt, text: 'the new wording' }),
  318. NOW,
  319. )
  320. const db = (
  321. e.CHECKINS as unknown as {
  322. __raw: { prepare: (s: string) => { all: (...a: unknown[]) => unknown[] } }
  323. }
  324. ).__raw
  325. const tables = db
  326. .prepare(`SELECT name FROM sqlite_master WHERE type='table'`)
  327. .all() as { name: string }[]
  328. const ownTables = tables.map((t) => t.name).filter((n) => n.startsWith('own_'))
  329. expect(ownTables).toEqual(['own_item'])
  330. const rows = db.prepare(`SELECT text FROM own_item`).all() as { text: string }[]
  331. expect(rows.map((r) => r.text)).toEqual(['the new wording'])
  332. expect(JSON.stringify(rows)).not.toContain('the original wording')
  333. })
  334. it('counts completed lines beyond the folded window rather than inferring them', async () => {
  335. const e = env()
  336. const db = (e.CHECKINS as unknown as { __raw: { prepare: (s: string) => { run: (...a: unknown[]) => void } } })
  337. .__raw
  338. const stmt = db.prepare(
  339. `INSERT INTO own_item (person,author_email,text,due,priority,link_key,
  340. created_at,updated_at,edits,done_at,tz)
  341. VALUES (?,?,?,NULL,'normal',NULL,?,?,0,?,'America/Chicago')`,
  342. )
  343. for (let i = 0; i < 55; i++) {
  344. const at = `2026-09-${String(1 + (i % 28)).padStart(2, '0')}T12:00:00.000Z`
  345. stmt.run(SAM, SAM_EMAIL, `done ${i}`, at, at, at)
  346. }
  347. const out = await readOwnList(e, SAM)
  348. if (out.state !== 'ok') throw new Error('expected ok')
  349. expect(out.done).toHaveLength(50)
  350. expect(out.doneHidden).toBe(5)
  351. })
  352. })