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

checkin.test.ts

Language TypeScript14.8 KB383 lines

apps/dashboard/test/checkin.test.ts

  1. /**
  2. * The end-of-day note as a reader sees it: who gets the card, which day it is
  3. * about, and the four things that must never look like each other.
  4. *
  5. * The access tests are the point of this file. The tab the card sits on is
  6. * visible to everyone who reads the Leadership Team's to-dos (which is what
  7. * makes the onboarding plan visible to them), and the readers of the note
  8. * itself are a separate, hand-written list. Two grants that overlap in most
  9. * cases and differ in one are exactly the shape that gets widened by analogy
  10. * later (rule 13), so the difference is pinned by name.
  11. *
  12. * What is not pinned: the wording of the three questions. It may be rewritten
  13. * at any time, and tests assert properties, not wording.
  14. */
  15. import { describe, expect, it } from 'vitest'
  16. import {
  17. CHECKIN_END,
  18. CHECKIN_START,
  19. checkinBlock,
  20. checkinDay,
  21. checkinIsOpen,
  22. checkinPlaceholder,
  23. checkinWindow,
  24. expectedDays,
  25. isWeekday,
  26. localHour,
  27. parseOutcome,
  28. previousDay,
  29. stampCheckin,
  30. type CheckinNote,
  31. type CheckinRevision,
  32. type CheckinStore,
  33. } from '../src/checkin-view.js'
  34. import {
  35. CHECKIN_ASKED_FROM,
  36. CHECKIN_AUTHORS,
  37. CHECKIN_READERS,
  38. canReadCheckinsOf,
  39. canWriteCheckin,
  40. hasCheckin,
  41. readableCheckinAuthors,
  42. } from '../../../config/checkin.js'
  43. import { COMPANY_VIEWER, viewerFor } from '../src/viewer.js'
  44. import { onboardingSurface } from '../src/surfaces.js'
  45. import { render } from '../src/render.js'
  46. import { FIXTURE_SNAPSHOT } from '../src/fixtures.js'
  47. import { headline } from '../src/collect.js'
  48. const ROSA = 'Rosa Galván'
  49. const rosa = viewerFor('accounts@meridiandisplay.example')
  50. const sam = viewerFor('hello@meridiandisplay.example')
  51. const theo = viewerFor('frontdesk@meridiandisplay.example')
  52. const nora = viewerFor('nora.l@meridiandisplay.example')
  53. const ines = viewerFor('projects@meridiandisplay.example')
  54. const marco = viewerFor('studio@meridiandisplay.example')
  55. const EVENING = new Date('2026-09-15T23:40:00.000Z') // 6:40 PM Tue, Chicago
  56. const AFTERNOON = new Date('2026-09-15T18:00:00.000Z') // 1:00 PM Tue, Chicago
  57. const AFTER_MIDNIGHT = new Date('2026-09-16T05:30:00.000Z') // 12:30 AM Wed
  58. const note = (over: Partial<CheckinNote> = {}): CheckinNote => ({
  59. person: ROSA,
  60. day: '2026-09-15',
  61. did: 'Answered the new leads and sent two proofs.',
  62. unsure: 'Whether a rush order needs a second proof.',
  63. need: 'A walkthrough of the proof tool.',
  64. submittedAt: EVENING.toISOString(),
  65. updatedAt: EVENING.toISOString(),
  66. revisions: 1,
  67. authorEmail: 'accounts@meridiandisplay.example',
  68. ...over,
  69. })
  70. const ok = (notes: CheckinNote[] = [], earlier: CheckinRevision[] = []): CheckinStore => ({
  71. state: 'ok',
  72. notes,
  73. earlier,
  74. truncated: false,
  75. })
  76. describe('who gets the card at all', () => {
  77. it('Rosa writes one', () => {
  78. expect(canWriteCheckin(rosa)).toBe(true)
  79. expect(readableCheckinAuthors(rosa)).toEqual([ROSA])
  80. })
  81. it('Sam and Theo read hers, and write none of their own', () => {
  82. for (const v of [sam, theo]) {
  83. expect(canReadCheckinsOf(v, ROSA)).toBe(true)
  84. expect(canWriteCheckin(v)).toBe(false)
  85. }
  86. })
  87. it('Ines reads hers too, by Sam\u2019s grant of 2026-09-16', () => {
  88. // Ines is on the reader list by name, not by way of `seesLeadership`.
  89. expect(canReadCheckinsOf(ines, ROSA)).toBe(true)
  90. expect(canWriteCheckin(ines)).toBe(false)
  91. })
  92. it('Nora does NOT, though she sees the same tab and holds the same role flag', () => {
  93. // The whole point. Nora and Ines read Leadership to-dos alike, which is what makes Rosa's plan visible to
  94. // both; only Ines was granted the note. A reader list derived from
  95. // `seesLeadership` would have handed it to Nora without anybody deciding.
  96. expect(nora.seesLeadership).toBe(true)
  97. expect(ines.seesLeadership).toBe(true)
  98. expect(hasCheckin(nora)).toBe(false)
  99. expect(canReadCheckinsOf(nora, ROSA)).toBe(false)
  100. })
  101. it('nobody else on the roster, and no unidentified viewer', () => {
  102. for (const v of [marco, COMPANY_VIEWER]) expect(hasCheckin(v)).toBe(false)
  103. })
  104. it('renders literally nothing for a viewer with no grant', () => {
  105. // Not a hidden div. The per-person briefings were `hidden` in one shared
  106. // document once, and anybody through the Access door could read all seven
  107. // from view-source.
  108. for (const v of [nora, marco, COMPANY_VIEWER]) {
  109. expect(checkinBlock(v, { [ROSA]: ok([note()]) }, EVENING)).toBe('')
  110. }
  111. })
  112. it('does not put the placeholder in a page it will not fill', () => {
  113. const forNora = onboardingSurface(nora)
  114. expect(forNora).toContain('Onboarding') // she still sees the plan
  115. expect(forNora).not.toContain(CHECKIN_START)
  116. expect(onboardingSurface(ines)).toContain(CHECKIN_START)
  117. expect(onboardingSurface(sam)).toContain(CHECKIN_START)
  118. expect(onboardingSurface(rosa)).toContain(CHECKIN_START)
  119. })
  120. it('the whole page carries the placeholder for a reader and not for a non-reader', () => {
  121. const page = (v: typeof sam): string =>
  122. render(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], '2026-09-15', null, v)
  123. expect(page(sam)).toContain(CHECKIN_START)
  124. expect(page(theo)).toContain(CHECKIN_START)
  125. expect(page(rosa)).toContain(CHECKIN_START)
  126. expect(page(ines)).toContain(CHECKIN_START)
  127. expect(page(nora)).not.toContain(CHECKIN_START)
  128. expect(page(marco)).not.toContain(CHECKIN_START)
  129. })
  130. it('config keeps the author list and the reader list apart', () => {
  131. // Two lists, both written by hand. Neither derives from the other, and the
  132. // author is not on the reader list; she reads her own words through her
  133. // own card, which is a different code path.
  134. expect(Object.keys(CHECKIN_AUTHORS)).not.toEqual(expect.arrayContaining(CHECKIN_READERS))
  135. })
  136. })
  137. describe('which day a note is about', () => {
  138. it('an evening note is today', () => {
  139. expect(checkinDay(EVENING)).toBe('2026-09-15')
  140. })
  141. it('a 12:30 AM note is still yesterday', () => {
  142. // She finished at half past midnight. Filing it forward would show a missed
  143. // day behind her and a note on a day she has not worked.
  144. expect(checkinDay(AFTER_MIDNIGHT)).toBe('2026-09-15')
  145. })
  146. it('a 10 AM note is today again', () => {
  147. expect(checkinDay(new Date('2026-09-16T15:00:00.000Z'))).toBe('2026-09-16')
  148. })
  149. it('reads the hour in Meridian time, not UTC', () => {
  150. // 23:40 UTC is the next day in UTC and 6:40 PM in Chicago. Slicing an ISO
  151. // string here would file every evening note against tomorrow.
  152. expect(localHour(EVENING)).toBe(18)
  153. expect(EVENING.toISOString().slice(0, 10)).toBe('2026-09-15')
  154. expect(localHour(new Date('2026-09-16T02:00:00.000Z'))).toBe(21)
  155. })
  156. it('is open in the evening and through the small hours, folded during the day', () => {
  157. expect(checkinIsOpen(EVENING)).toBe(true)
  158. expect(checkinIsOpen(AFTER_MIDNIGHT)).toBe(true)
  159. expect(checkinIsOpen(AFTERNOON)).toBe(false)
  160. })
  161. it('crosses a month and a year boundary without arithmetic of its own', () => {
  162. expect(previousDay('2026-09-01')).toBe('2026-08-31')
  163. expect(previousDay('2027-01-01')).toBe('2026-12-31')
  164. expect(previousDay('2028-03-01')).toBe('2028-02-29')
  165. })
  166. })
  167. describe('the days a log accounts for', () => {
  168. it('lists weekdays back to the day the habit began, newest first', () => {
  169. const days = expectedDays('2026-09-18', '2026-09-15')
  170. expect(days).toEqual(['2026-09-18', '2026-09-17', '2026-09-16', '2026-09-15'])
  171. })
  172. it('leaves weekends out, so they are not reported as missed', () => {
  173. expect(expectedDays('2026-09-21', '2026-09-18')).toEqual(['2026-09-21', '2026-09-18'])
  174. expect(isWeekday('2026-09-19')).toBe(false)
  175. expect(isWeekday('2026-09-21')).toBe(true)
  176. })
  177. it('never reaches back past the day a note was first asked for', () => {
  178. // She started on the 14th; the habit started on the 15th. A log calling the
  179. // 14th a missed day would be reporting a failure against a rule that did
  180. // not exist yet.
  181. const days = expectedDays('2026-09-16', CHECKIN_ASKED_FROM)
  182. expect(days).not.toContain('2026-09-14')
  183. expect(days[days.length - 1]).toBe(CHECKIN_ASKED_FROM)
  184. })
  185. it('accounts for nothing when nothing has been asked for', () => {
  186. expect(expectedDays('2026-09-16', null)).toEqual([])
  187. })
  188. it('queries the same window it renders', () => {
  189. // If the query reached back less far than the card lists, a day with a note
  190. // would render as a day she missed, the one sentence this must never say.
  191. const { from, limit } = checkinWindow(ROSA, '2026-09-30')
  192. const days = expectedDays('2026-09-30', CHECKIN_ASKED_FROM)
  193. expect(from).toBe(days[days.length - 1])
  194. expect(limit).toBeGreaterThanOrEqual(days.length)
  195. })
  196. })
  197. describe('the four things that must not look alike', () => {
  198. const read = (store: CheckinStore): string => checkinBlock(sam, { [ROSA]: store }, EVENING)
  199. it('a store nobody has set up says so, and does not say "no notes"', () => {
  200. const html = read({ state: 'unconfigured' })
  201. expect(html).toMatch(/not set up/i)
  202. expect(html).not.toMatch(/no note</i)
  203. })
  204. it('a store that refused says so, in its own words', () => {
  205. const html = read({ state: 'failed', error: 'D1_ERROR: no such table: checkin' })
  206. expect(html).toContain('no such table')
  207. expect(html).toMatch(/missing, not empty/i)
  208. })
  209. it('a day with no note says "no note" and is counted as missed', () => {
  210. const html = read(ok([]))
  211. expect(html).toMatch(/no note/i)
  212. expect(html).toMatch(/missed/i)
  213. })
  214. it('a day with a note shows the note', () => {
  215. const html = read(ok([note()]))
  216. expect(html).toContain('Answered the new leads and sent two proofs.')
  217. expect(html).toContain('Whether a rush order needs a second proof.')
  218. })
  219. it('a question left blank renders as blank, not as an empty line', () => {
  220. const html = read(ok([note({ need: '' })]))
  221. expect(html).toMatch(/left blank/i)
  222. })
  223. it('the count and the list under it account for each other', () => {
  224. // Rule 9, which this board learned when My Day's header said 13 and its
  225. // list said nothing was on the board.
  226. const html = read(ok([note()]))
  227. const written = /(\d+) written/.exec(html)?.[1]
  228. expect(written).toBe('1')
  229. })
  230. it('a missing person entry is a failure, not an empty log', () => {
  231. // A reader whose store read never returned must not be shown a clean slate.
  232. const html = checkinBlock(sam, {}, EVENING)
  233. expect(html).toMatch(/could not be read/i)
  234. })
  235. })
  236. describe('the author’s own card', () => {
  237. const mine = (store: CheckinStore, now = EVENING, outcome = null): string =>
  238. checkinBlock(rosa, { [ROSA]: store }, now, outcome)
  239. it('offers the form, and names exactly who will read it', () => {
  240. const html = mine(ok([]))
  241. expect(html).toContain('action="/checkin"')
  242. for (const r of CHECKIN_READERS) expect(html).toContain(r)
  243. })
  244. it('carries the day it is writing, so a stale tab can be caught server-side', () => {
  245. expect(mine(ok([]))).toContain('name="day" value="2026-09-15"')
  246. })
  247. it('is folded shut in the afternoon and open in the evening', () => {
  248. expect(mine(ok([]), AFTERNOON)).toMatch(/opens at 4:00 PM/)
  249. expect(mine(ok([]), EVENING)).toMatch(/not written yet/)
  250. })
  251. it('accepts an early submission anyway, since a form that refuses typed text loses it', () => {
  252. expect(mine(ok([]), AFTERNOON)).toContain('action="/checkin"')
  253. })
  254. it('pre-fills tonight’s note for an edit, and says edits are kept', () => {
  255. const html = mine(ok([note()]))
  256. expect(html).toContain('Answered the new leads and sent two proofs.')
  257. expect(html).toMatch(/Edits are kept/i)
  258. })
  259. it('says out loud when a note is being filed against yesterday', () => {
  260. const html = mine(ok([]), AFTER_MIDNIGHT)
  261. expect(html).toMatch(/past midnight/i)
  262. expect(html).toContain('name="day" value="2026-09-15"')
  263. })
  264. it('still shows the boxes when the store could not be read', () => {
  265. // A read failure is not a write failure, and hiding the form guarantees the
  266. // loss that showing it only risks.
  267. const html = mine({ state: 'failed', error: 'D1_ERROR: timeout' })
  268. expect(html).toContain('action="/checkin"')
  269. expect(html).toMatch(/may not\s+save/i)
  270. })
  271. it('shows her what she wrote on earlier days', () => {
  272. const html = mine(ok([note({ day: '2026-09-14', did: 'day one' })]))
  273. expect(html).toContain('day one')
  274. })
  275. })
  276. describe('the outcome banner', () => {
  277. it('only ever echoes one of our own words back into the page', () => {
  278. expect(parseOutcome('saved')).toBe('saved')
  279. expect(parseOutcome('<img src=x onerror=1>')).toBeNull()
  280. expect(parseOutcome(null)).toBeNull()
  281. })
  282. it('says nothing was saved when nothing was', () => {
  283. const html = checkinBlock(rosa, { [ROSA]: ok([]) }, EVENING, 'empty')
  284. expect(html).toMatch(/nothing was saved/i)
  285. const failed = checkinBlock(rosa, { [ROSA]: ok([]) }, EVENING, 'failed')
  286. expect(failed).toMatch(/not<\/b> saved/i)
  287. })
  288. })
  289. describe('user text cannot become markup', () => {
  290. it('escapes a note that tries to close a tag', () => {
  291. const nasty = note({ did: '</textarea><img src=x onerror=alert(1)>' })
  292. for (const v of [sam, rosa]) {
  293. const html = checkinBlock(v, { [ROSA]: ok([nasty]) }, EVENING)
  294. expect(html).not.toContain('<img src=x')
  295. expect(html).toContain('&lt;img src=x')
  296. }
  297. })
  298. it('escapes it inside the edit box too, which is the easier one to miss', () => {
  299. const html = checkinBlock(rosa, { [ROSA]: ok([note({ did: '</textarea><b>x' })]) }, EVENING)
  300. expect(html).not.toContain('</textarea><b>')
  301. })
  302. it('escapes a store error, which is a string from somewhere else entirely', () => {
  303. const html = checkinBlock(sam, { [ROSA]: { state: 'failed', error: '<script>x</script>' } }, EVENING)
  304. expect(html).not.toContain('<script>x')
  305. })
  306. })
  307. describe('stamping the block into a pre-rendered page', () => {
  308. const PAGE = `<html><body><div>before</div>${checkinPlaceholder()}<div>after</div></body></html>`
  309. it('replaces the placeholder and everything inside it', () => {
  310. const out = stampCheckin(PAGE, '<p>the card</p>')
  311. expect(out).toContain('<p>the card</p>')
  312. expect(out).not.toContain(CHECKIN_START)
  313. expect(out).not.toContain(CHECKIN_END)
  314. expect(out).not.toMatch(/lives on the live board/)
  315. expect(out).toContain('before')
  316. expect(out).toContain('after')
  317. })
  318. it('an empty block leaves nothing behind, not an empty shell', () => {
  319. const out = stampCheckin(PAGE, '')
  320. expect(out).toBe('<html><body><div>before</div><div>after</div></body></html>')
  321. })
  322. it('a page with no placeholder is returned untouched', () => {
  323. const plain = '<html><body>nothing here</body></html>'
  324. expect(stampCheckin(plain, '<p>card</p>')).toBe(plain)
  325. })
  326. it('the placeholder itself says why it is empty, rather than being empty', () => {
  327. // A file render has no Worker behind it. Silence there is a feature that
  328. // silently is not there (rule 8).
  329. expect(checkinPlaceholder()).toMatch(/lives on the live board/i)
  330. })
  331. })