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

checkin-store.test.ts

Language TypeScript12.6 KB292 lines

apps/worker/test/checkin-store.test.ts

  1. /**
  2. * The end-of-day note's storage: the decision, the SQL, and the four states a
  3. * read can be in.
  4. *
  5. * Not the wording of the questions, which may be rewritten freely. What is
  6. * pinned is the machinery that
  7. * keeps the page honest: that a store which is absent, a store which is broken,
  8. * and a store which is empty produce three different answers; that the day a
  9. * row lands on comes from the clock and never from the form; and that an
  10. * all-blank note cannot be written, because a blank row and a day she never
  11. * opened the form read identically to whoever is looking.
  12. *
  13. * The SQL runs. `0001_checkin.sql` is applied to a real SQLite engine rather
  14. * than read, which is not ceremony: a table CHECK placed in the middle of the
  15. * column list makes SQLite refuse the whole CREATE, and nothing short of
  16. * executing it says so.
  17. */
  18. import { describe, expect, it } from 'vitest'
  19. import {
  20. decideCheckin,
  21. readCheckins,
  22. readCheckinsFor,
  23. saveCheckin,
  24. type CheckinEnv,
  25. } from '../src/checkin.js'
  26. import { makeBrokenD1, makeD1, migrationSql, sqliteAvailable } from './d1-sqlite.js'
  27. import { CHECKIN_QUESTIONS } from '../../../config/checkin.js'
  28. const PERSON = 'Rosa Galván'
  29. const EMAIL = 'accounts@meridiandisplay.example'
  30. /** 6:40 PM Chicago on Tuesday 15 September 2026, after the form opens. */
  31. const EVENING = new Date('2026-09-15T23:40:00.000Z')
  32. /** 12:30 AM Chicago on the 16th, which is still the 15th's note. */
  33. const AFTER_MIDNIGHT = new Date('2026-09-16T05:30:00.000Z')
  34. /** 10:00 AM Chicago on the 16th: a new day, and the form is folded shut. */
  35. const MORNING = new Date('2026-09-16T15:00:00.000Z')
  36. const input = (over: Partial<Parameters<typeof saveCheckin>[1]> = {}) => ({
  37. person: PERSON,
  38. authorEmail: EMAIL,
  39. claimedDay: '2026-09-15',
  40. did: 'Answered the new leads and sent two proofs.',
  41. unsure: 'Whether a rush order needs a second proof.',
  42. need: 'A walkthrough of the proof tool.',
  43. ...over,
  44. })
  45. describe('decideCheckin: what may be written, and to which day', () => {
  46. it('files an evening note against today', () => {
  47. const d = decideCheckin(input(), EVENING)
  48. expect(d).toMatchObject({ write: true, day: '2026-09-15' })
  49. })
  50. it('files a 12:30 AM note against the day that just ended, not the one starting', () => {
  51. // Without the grace window she files two wrong facts at once: a missed day
  52. // behind her, and a note on a day she has not worked.
  53. const d = decideCheckin(input({ claimedDay: '2026-09-15' }), AFTER_MIDNIGHT)
  54. expect(d).toMatchObject({ write: true, day: '2026-09-15' })
  55. })
  56. it('refuses a form that claims a different day rather than writing it somewhere', () => {
  57. // A tab left open overnight. Writing it into today's row would overwrite a
  58. // note with text about another day; refusing costs a reload.
  59. const d = decideCheckin(input({ claimedDay: '2026-09-14' }), EVENING)
  60. expect(d).toEqual({ write: false, outcome: 'stale', day: '2026-09-15' })
  61. })
  62. it('refuses an all-blank note, whitespace included', () => {
  63. const d = decideCheckin(input({ did: '', unsure: ' ', need: '\n' }), EVENING)
  64. expect(d).toEqual({ write: false, outcome: 'empty', day: '2026-09-15' })
  65. })
  66. it('accepts a note with two of three blank, since one honest answer is an answer', () => {
  67. const d = decideCheckin(input({ did: '', unsure: '', need: 'nothing today' }), EVENING)
  68. expect(d).toMatchObject({ write: true })
  69. })
  70. it('takes the day from the clock even when the form sends nothing', () => {
  71. expect(decideCheckin(input({ claimedDay: '' }), MORNING)).toMatchObject({
  72. write: true,
  73. day: '2026-09-16',
  74. })
  75. })
  76. })
  77. describe('an absent binding is not an empty store', () => {
  78. const none: CheckinEnv = {}
  79. it('reads as unconfigured, never as ok-with-no-notes', async () => {
  80. // The whole feature rests on this distinction. "No rows" means she has not
  81. // written one; "no binding" means nobody could have.
  82. expect(await readCheckins(none, PERSON, EVENING)).toEqual({ state: 'unconfigured' })
  83. })
  84. it('refuses the write and says so, rather than reporting a save that never happened', async () => {
  85. expect(await saveCheckin(none, input(), EVENING)).toEqual({
  86. outcome: 'failed',
  87. day: '2026-09-15',
  88. })
  89. })
  90. it('still refuses a stale or empty submission before it looks for a store', async () => {
  91. // The refusal is about the submission, not about the plumbing, so it must
  92. // not be reported as a store failure; those send the author looking in the
  93. // wrong place.
  94. expect(await saveCheckin(none, input({ did: '', unsure: '', need: '' }), EVENING)).toMatchObject(
  95. { outcome: 'empty' },
  96. )
  97. })
  98. })
  99. describe('a store that refuses is reported as failed, with what it said', () => {
  100. it('carries the store’s own message and no diagnosis of its own', async () => {
  101. const env = { CHECKINS: makeBrokenD1('D1_ERROR: no such table: checkin') } as CheckinEnv
  102. const out = await readCheckins(env, PERSON, EVENING)
  103. expect(out.state).toBe('failed')
  104. // Rule 1: report what happened, do not pick a cause. The 401 handler once
  105. // told somebody a token with eleven months left had expired.
  106. if (out.state === 'failed') {
  107. expect(out.error).toContain('no such table')
  108. expect(out.error).not.toMatch(/probably|most likely|forgot|you should/i)
  109. }
  110. })
  111. it('a failed write is reported as failed, not as saved', async () => {
  112. const env = { CHECKINS: makeBrokenD1() } as CheckinEnv
  113. expect(await saveCheckin(env, input(), EVENING)).toMatchObject({ outcome: 'failed' })
  114. })
  115. })
  116. const describeSql = sqliteAvailable() ? describe : describe.skip
  117. if (!sqliteAvailable()) {
  118. // Loud, not silent. A suite that quietly shrinks on an older runtime is rule
  119. // 8 with the volume down: the tests below would stop running and nothing
  120. // would say so.
  121. console.warn(
  122. 'checkin-store.test.ts: node:sqlite is unavailable on this runtime, so the SQL tests were ' +
  123. 'SKIPPED. The migration and the queries were not exercised. Node 22.5+ runs them.',
  124. )
  125. }
  126. describeSql('the migration and the queries, against a real engine', () => {
  127. const env = (): CheckinEnv => ({ CHECKINS: makeD1() })
  128. it('applies as written', () => {
  129. expect(() => makeD1()).not.toThrow()
  130. })
  131. it('has a column for every question, under the question’s own id', () => {
  132. // The INSERT names these columns and the form names the same strings.
  133. // Renaming a question id without a migration would fail at runtime, on the
  134. // one action whose failure costs somebody the text they just typed.
  135. // Named, now that there is more than one migration: this asserts something
  136. // about 0001 and would start passing for the wrong reason if a later file
  137. // happened to contain a matching column.
  138. const sql = migrationSql('0001_checkin.sql')
  139. for (const q of CHECKIN_QUESTIONS) expect(sql).toMatch(new RegExp(`\\b${q.id}\\s+TEXT NOT NULL`))
  140. })
  141. it('saves a note and reads it back', async () => {
  142. const e = env()
  143. expect(await saveCheckin(e, input(), EVENING)).toEqual({ outcome: 'saved', day: '2026-09-15' })
  144. const out = await readCheckins(e, PERSON, EVENING)
  145. expect(out.state).toBe('ok')
  146. if (out.state !== 'ok') return
  147. expect(out.notes).toHaveLength(1)
  148. expect(out.notes[0]).toMatchObject({
  149. person: PERSON,
  150. day: '2026-09-15',
  151. unsure: 'Whether a rush order needs a second proof.',
  152. revisions: 1,
  153. authorEmail: EMAIL,
  154. })
  155. })
  156. it('a second submission the same day updates the row instead of adding one', async () => {
  157. const e = env()
  158. await saveCheckin(e, input(), EVENING)
  159. const second = await saveCheckin(e, input({ need: 'The proof tool walkthrough, still.' }), AFTER_MIDNIGHT)
  160. expect(second.outcome).toBe('updated')
  161. const out = await readCheckins(e, PERSON, EVENING)
  162. if (out.state !== 'ok') throw new Error('expected ok')
  163. expect(out.notes).toHaveLength(1)
  164. expect(out.notes[0]?.revisions).toBe(2)
  165. expect(out.notes[0]?.need).toBe('The proof tool walkthrough, still.')
  166. })
  167. it('keeps the earlier version, so an edit cannot quietly rewrite what was read', async () => {
  168. const e = env()
  169. await saveCheckin(e, input({ unsure: 'Everything, honestly.' }), EVENING)
  170. await saveCheckin(e, input({ unsure: 'Nothing much.' }), AFTER_MIDNIGHT)
  171. const out = await readCheckins(e, PERSON, EVENING)
  172. if (out.state !== 'ok') throw new Error('expected ok')
  173. expect(out.earlier.map((r) => r.revision)).toEqual([2, 1])
  174. expect(out.earlier.find((r) => r.revision === 1)?.unsure).toBe('Everything, honestly.')
  175. })
  176. it('an identical resubmit writes nothing at all', async () => {
  177. // A double-click would otherwise leave the reader's card saying "edited"
  178. // about text that never changed.
  179. const e = env()
  180. await saveCheckin(e, input(), EVENING)
  181. expect(await saveCheckin(e, input(), EVENING)).toMatchObject({ outcome: 'unchanged' })
  182. const out = await readCheckins(e, PERSON, EVENING)
  183. if (out.state !== 'ok') throw new Error('expected ok')
  184. expect(out.notes[0]?.revisions).toBe(1)
  185. expect(out.earlier).toHaveLength(1)
  186. })
  187. it('keeps the first submission time across an edit', async () => {
  188. const e = env()
  189. await saveCheckin(e, input(), EVENING)
  190. await saveCheckin(e, input({ did: 'changed' }), AFTER_MIDNIGHT)
  191. const out = await readCheckins(e, PERSON, EVENING)
  192. if (out.state !== 'ok') throw new Error('expected ok')
  193. // "written at 6:40 PM" has to mean when she wrote it, not when she fixed a
  194. // typo in it.
  195. expect(out.notes[0]?.submittedAt).toBe(EVENING.toISOString())
  196. expect(out.notes[0]?.updatedAt).toBe(AFTER_MIDNIGHT.toISOString())
  197. })
  198. it('the storage layer refuses an all-blank row even if something routes around the handler', async () => {
  199. const db = makeD1()
  200. expect(() =>
  201. db
  202. .prepare(
  203. `INSERT INTO checkin (person, author_email, day, day_tz, did, unsure, need,
  204. submitted_at, updated_at, revisions)
  205. VALUES (?1, ?2, ?3, ?4, '', ' ', '', ?5, ?5, 1)`,
  206. )
  207. .bind(PERSON, EMAIL, '2026-09-15', 'America/Chicago', EVENING.toISOString())
  208. .__run(),
  209. ).toThrow(/CHECK constraint failed/)
  210. })
  211. it('refuses a malformed day, which would sort into the wrong place forever', async () => {
  212. const db = makeD1()
  213. expect(() =>
  214. db
  215. .prepare(
  216. `INSERT INTO checkin (person, author_email, day, day_tz, did, unsure, need,
  217. submitted_at, updated_at, revisions)
  218. VALUES (?1, ?2, '15/09/2026', 'America/Chicago', 'x', '', '', ?3, ?3, 1)`,
  219. )
  220. .bind(PERSON, EMAIL, EVENING.toISOString())
  221. .__run(),
  222. ).toThrow(/CHECK constraint failed/)
  223. })
  224. it('files a different person’s notes separately, which is the seat-change case', async () => {
  225. // accounts@ changes hands. The next holder signs in as the same address and
  226. // must start empty rather than inheriting weeks of "what I'm unsure about"
  227. // written by somebody else.
  228. const e = env()
  229. await saveCheckin(e, input(), EVENING)
  230. await saveCheckin(e, input({ person: 'Someone Else', did: 'their day' }), EVENING)
  231. const mine = await readCheckins(e, PERSON, EVENING)
  232. const theirs = await readCheckins(e, 'Someone Else', EVENING)
  233. if (mine.state !== 'ok' || theirs.state !== 'ok') throw new Error('expected ok')
  234. expect(mine.notes).toHaveLength(1)
  235. expect(theirs.notes).toHaveLength(1)
  236. expect(theirs.notes[0]?.did).toBe('their day')
  237. })
  238. it('an empty store reads as ok-and-empty, which is a different thing from both failures', async () => {
  239. const out = await readCheckins(env(), PERSON, EVENING)
  240. expect(out).toMatchObject({ state: 'ok', notes: [], earlier: [], truncated: false })
  241. })
  242. it('reads several people in one call, each with its own state', async () => {
  243. const e = env()
  244. await saveCheckin(e, input(), EVENING)
  245. const all = await readCheckinsFor(e, [PERSON, 'Nobody At All'], EVENING)
  246. expect(Object.keys(all).sort()).toEqual(['Nobody At All', 'Rosa Galván'])
  247. expect(all[PERSON]?.state).toBe('ok')
  248. // A person with no rows is ok-and-empty, never absent from the map, since a
  249. // missing key would render as nothing at all.
  250. expect(all['Nobody At All']).toMatchObject({ state: 'ok', notes: [] })
  251. })
  252. it('does not reach back past the window the card will render', async () => {
  253. // The query window and the rendered day list come from one function. If
  254. // this ever returns a day the card does not list, that note is invisible.
  255. const e = env()
  256. await saveCheckin(e, input({ claimedDay: '' }), new Date('2026-08-01T23:40:00.000Z'))
  257. const out = await readCheckins(e, PERSON, EVENING)
  258. if (out.state !== 'ok') throw new Error('expected ok')
  259. expect(out.notes.map((n) => n.day)).not.toContain('2026-08-01')
  260. })
  261. })