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

own-list.test.ts

Language TypeScript14.9 KB371 lines

apps/dashboard/test/own-list.test.ts

  1. /**
  2. * The private list, rendered.
  3. *
  4. * Not the prose, which may be rewritten freely. What is pinned is the set of things that would be a bug rather than a rewrite: that a
  5. * viewer with no list gets nothing rather than an empty card; that a store
  6. * belonging to somebody else refuses to render; that the four facts a read can
  7. * report stay four different sentences; that "overdue" is decided from the clock
  8. * the page was served with; and that the numbers in the header can be accounted
  9. * for by the rows under them (rule 9).
  10. */
  11. import { describe, expect, it } from 'vitest'
  12. import {
  13. OWN_END,
  14. OWN_START,
  15. dueState,
  16. ownListBlock,
  17. ownListPlaceholder,
  18. parseOwnOutcome,
  19. sortOpen,
  20. stampOwnList,
  21. type OwnItem,
  22. type OwnStore,
  23. } from '../src/own-list-view.js'
  24. import { COMPANY_VIEWER, viewerFor } from '../src/viewer.js'
  25. /**
  26. * Collapse runs of whitespace before asserting on prose.
  27. *
  28. * The sentences below are written across several source lines, and a browser
  29. * collapses that run to one space, so a raw substring match would be asserting
  30. * the shape of the template literal rather than what the reader sees, and would
  31. * fail the next time somebody rewrapped a paragraph.
  32. */
  33. const flat = (html: string): string => html.replace(/\s+/g, ' ')
  34. const SAM = viewerFor('hello@meridiandisplay.example')
  35. const MARCO = viewerFor('studio@meridiandisplay.example')
  36. /** A real address on the inbox lists that is NOT a roster person. */
  37. const SUPPORT = viewerFor('help@meridiandisplay.example')
  38. /** 10:00 AM Chicago on Wednesday 23 September 2026. */
  39. const NOW = new Date('2026-09-23T15:00:00.000Z')
  40. let nextId = 1
  41. const item = (over: Partial<OwnItem> = {}): OwnItem => ({
  42. id: nextId++,
  43. text: 'Call the gym back about the backdrop proof',
  44. due: null,
  45. priority: 'normal',
  46. linkKey: null,
  47. createdAt: '2026-09-20T12:00:00.000Z',
  48. updatedAt: '2026-09-20T12:00:00.000Z',
  49. edits: 0,
  50. doneAt: null,
  51. ...over,
  52. })
  53. const ok = (open: OwnItem[] = [], done: OwnItem[] = [], person = 'Sam Whitaker'): OwnStore => ({
  54. state: 'ok',
  55. person,
  56. open,
  57. done,
  58. truncated: false,
  59. doneHidden: 0,
  60. })
  61. describe('who gets a list at all', () => {
  62. it('a roster person does', () => {
  63. expect(ownListBlock(SAM, ok(), NOW)).toContain('My own list')
  64. })
  65. it('every one of the seven does, with no config line written for any of them', () => {
  66. // No grant map. config/own-list.ts has the argument: rule 13 guards
  67. // permission over other people's things, and there is nobody to over-grant
  68. // to on a store with one reader. A hand-written list here invites the
  69. // failure where correct, reviewed, obviously-intended lines do nothing.
  70. for (const email of [
  71. 'hello@meridiandisplay.example',
  72. 'accounts@meridiandisplay.example',
  73. 'studio@meridiandisplay.example',
  74. 'orderdesk@meridiandisplay.example',
  75. 'projects@meridiandisplay.example',
  76. 'frontdesk@meridiandisplay.example',
  77. 'nora.l@meridiandisplay.example',
  78. ]) {
  79. expect(ownListBlock(viewerFor(email), ok(), NOW)).toContain('My own list')
  80. }
  81. })
  82. it('an off-roster shared mailbox gets NOTHING: not an empty card, not a hidden div', () => {
  83. // help@ and service@ are real addresses with real inbox grants and no
  84. // roster person. A private list on a shared mailbox is a list everyone who
  85. // signs into it can read.
  86. expect(SUPPORT.person).toBeNull()
  87. expect(ownListBlock(SUPPORT, ok(), NOW)).toBe('')
  88. expect(ownListBlock(COMPANY_VIEWER, ok(), NOW)).toBe('')
  89. })
  90. it('refuses to render a store that belongs to somebody else', () => {
  91. // Unreachable through any current call path, and checked anyway: the cost
  92. // of the refactor that breaks it is one person reading another's private
  93. // list, and the cost of the check is a string comparison.
  94. const out = ownListBlock(SAM, ok([item({ text: 'Marco’s line' })], [], 'Marco Ibáñez'), NOW)
  95. expect(out).not.toContain('Marco’s line')
  96. expect(flat(out)).toContain('refused to render')
  97. })
  98. it('never puts one person’s text in another person’s block', () => {
  99. const mine = ownListBlock(SAM, ok([item({ text: 'SAM-SENTINEL' })]), NOW)
  100. const theirs = ownListBlock(MARCO, ok([item({ text: 'MARCO-SENTINEL' })], [], 'Marco Ibáñez'), NOW)
  101. expect(mine).toContain('SAM-SENTINEL')
  102. expect(mine).not.toContain('MARCO-SENTINEL')
  103. expect(theirs).toContain('MARCO-SENTINEL')
  104. expect(theirs).not.toContain('SAM-SENTINEL')
  105. })
  106. })
  107. describe('the four facts a read can report', () => {
  108. const states: Record<string, OwnStore> = {
  109. unconfigured: { state: 'unconfigured' },
  110. failed: { state: 'failed', error: 'D1_ERROR: no such table: own_item' },
  111. empty: ok(),
  112. full: ok([item()]),
  113. }
  114. it('renders four different things, and none of them is silence', () => {
  115. const rendered = Object.values(states).map((s) => ownListBlock(SAM, s, NOW))
  116. for (const r of rendered) expect(r.length).toBeGreaterThan(0)
  117. expect(new Set(rendered).size).toBe(4)
  118. })
  119. it('an unconfigured store says it is a deploy step and offers no form', () => {
  120. // A form that could only lose what was typed into it is worse than none.
  121. // It is the same call bootNotes makes when the notes tag is absent.
  122. const out = ownListBlock(SAM, states.unconfigured!, NOW)
  123. expect(flat(out)).toContain('not set up')
  124. expect(out).toContain('CHECKINS')
  125. expect(out).not.toContain('<form')
  126. })
  127. it('a failed store says missing, not empty, and still offers the form', () => {
  128. // A read failure is not a write failure. Hiding the box guarantees the loss
  129. // that showing it only risks.
  130. const out = ownListBlock(SAM, states.failed!, NOW)
  131. expect(out).toContain('no such table: own_item')
  132. expect(flat(out)).toContain('missing, not empty')
  133. expect(out).toContain('<form')
  134. })
  135. it('neither absence prints a count', () => {
  136. // A store that could not be read has no number. "0 open" here would be the
  137. // prime invariant broken on the one surface where the reader is the only
  138. // person who could notice.
  139. for (const s of [states.unconfigured!, states.failed!]) {
  140. const out = ownListBlock(SAM, s, NOW)
  141. expect(out).toContain('not read')
  142. expect(out).not.toMatch(/\d+ open/)
  143. }
  144. })
  145. it('an empty list says it is empty BECAUSE NOTHING WAS WRITTEN, which is a true zero', () => {
  146. // Everywhere else on this board an empty list is a claim about the world
  147. // and must be kept apart from "not tracked". Here it is a claim about the
  148. // owner, and saying so is what stops it reading like every other absence.
  149. const out = ownListBlock(SAM, states.empty!, NOW)
  150. expect(flat(out)).toContain('Nothing on your own list')
  151. expect(flat(out)).toContain('have not added anything')
  152. expect(out).toContain('<form')
  153. })
  154. })
  155. describe('dates, decided from the clock the page was served with', () => {
  156. it('no date is never overdue', () => {
  157. expect(dueState(null, '2026-09-23')).toBe('none')
  158. })
  159. it('tells overdue, today and later apart', () => {
  160. expect(dueState('2026-09-22', '2026-09-23')).toBe('overdue')
  161. expect(dueState('2026-09-23', '2026-09-23')).toBe('today')
  162. expect(dueState('2026-09-24', '2026-09-23')).toBe('later')
  163. })
  164. it('the same store renders differently on two days', () => {
  165. // This is why the block is stamped at serve time rather than pre-rendered:
  166. // an overdue flag decided by the 6 a.m. cron would be wrong by lunchtime,
  167. // and silently wrong, which is the worst kind.
  168. const store = ok([item({ due: '2026-09-23' })])
  169. const onTheDay = ownListBlock(SAM, store, NOW)
  170. const theNextWeek = ownListBlock(SAM, store, new Date('2026-09-30T15:00:00.000Z'))
  171. expect(onTheDay).toContain('Due today')
  172. expect(onTheDay).not.toContain('1 overdue')
  173. expect(theNextWeek).toContain('Overdue')
  174. expect(theNextWeek).toContain('1 overdue')
  175. })
  176. it('crosses midnight in Meridian time, not UTC', () => {
  177. // 00:30 UTC on the 24th is still 7:30 PM Chicago on the 23rd, so a line due
  178. // on the 23rd is due TODAY and not yet late.
  179. const store = ok([item({ due: '2026-09-23' })])
  180. expect(ownListBlock(SAM, store, new Date('2026-09-24T00:30:00.000Z'))).toContain('Due today')
  181. })
  182. it('sorts dated before undated, soonest first, priority breaking ties', () => {
  183. const a = item({ due: '2026-09-25', priority: 'low' })
  184. const b = item({ due: null, priority: 'high' })
  185. const c = item({ due: '2026-09-24', priority: 'normal' })
  186. const d = item({ due: '2026-09-25', priority: 'high' })
  187. expect(sortOpen([a, b, c, d]).map((x) => x.id)).toEqual([c.id, d.id, a.id, b.id])
  188. })
  189. })
  190. describe('the number and the list under it account for each other (rule 9)', () => {
  191. const countRows = (html: string): number => (html.match(/class="own-row own-row-(?!done)/g) ?? []).length
  192. it('the open count equals the rows rendered', () => {
  193. const store = ok([
  194. item({ due: '2026-09-20' }),
  195. item({ due: '2026-09-23' }),
  196. item({ due: '2026-09-30' }),
  197. item({ due: null }),
  198. ])
  199. const out = ownListBlock(SAM, store, NOW)
  200. expect(out).toContain('4 open')
  201. expect(countRows(out)).toBe(4)
  202. expect(flat(out)).toContain('4 open, and all of them are listed above')
  203. })
  204. it('overdue is a labelled subset, never a fourth addend', () => {
  205. const store = ok([item({ due: '2026-09-20' }), item({ due: '2026-09-30' })])
  206. const out = ownListBlock(SAM, store, NOW)
  207. expect(out).toContain('2 open')
  208. expect(out).toContain('1 overdue')
  209. expect(flat(out)).toContain('past its date and counted in both numbers')
  210. expect(countRows(out)).toBe(2)
  211. })
  212. it('says this list is NOT inside "on my board" at the top of the card', () => {
  213. // The risk here is not a number above an empty list. It is a reader seeing
  214. // "on my board 4" and "3 open" and having no way to know whether the 3 is
  215. // inside the 4.
  216. const out = ownListBlock(SAM, ok([item()]), NOW)
  217. expect(out).toContain('on my board')
  218. expect(flat(out)).toContain('is not a source')
  219. })
  220. it('says the right thing when "on my board" is a dash rather than a count', () => {
  221. // Theo and Ines hold no Plinth assignment, so their load is null and the
  222. // tile reads "–". A sentence claiming this list is excluded from a number
  223. // that is not there would be nonsense.
  224. const out = ownListBlock(SAM, ok([item()]), NOW, null, true)
  225. expect(flat(out)).toContain('no source covers your work individually')
  226. expect(flat(out)).toContain('adding to it does not change that')
  227. })
  228. it('completed lines are folded, counted, and said to be in neither number', () => {
  229. const out = ownListBlock(SAM, ok([item()], [item({ doneAt: '2026-09-22T12:00:00.000Z' })]), NOW)
  230. expect(out).toContain('Done (1)')
  231. expect(flat(out)).toContain('folded below and in neither')
  232. })
  233. it('states how many completed lines are kept but not listed', () => {
  234. // Where a surface deliberately withholds a row it says how many and why:
  235. // the todosWithheld idiom, which rule 9 is written from.
  236. const store: OwnStore = {
  237. ...ok([], [item({ doneAt: '2026-09-22T12:00:00.000Z' })]),
  238. doneHidden: 12,
  239. }
  240. const out = ownListBlock(SAM, store, NOW)
  241. expect(flat(out)).toContain('12 older completed')
  242. expect(flat(out)).toContain('Nothing was deleted')
  243. })
  244. it('a truncated read says so rather than passing a short list off as whole', () => {
  245. const store: OwnStore = { ...ok([item()]), truncated: true }
  246. expect(ownListBlock(SAM, store, NOW)).toContain('should not be possible')
  247. })
  248. })
  249. describe('outcomes', () => {
  250. it('narrows against an allow-list, so only our own words reach the page', () => {
  251. expect(parseOwnOutcome('added')).toBe('added')
  252. expect(parseOwnOutcome('<img src=x onerror=1>')).toBeNull()
  253. expect(parseOwnOutcome('')).toBeNull()
  254. expect(parseOwnOutcome(null)).toBeNull()
  255. expect(parseOwnOutcome('nope')).toBeNull()
  256. })
  257. it('says loudly when a write did not land', () => {
  258. // A banner that is merely absent reads as success, on the one action whose
  259. // failure costs somebody the thing they just typed.
  260. const out = ownListBlock(SAM, ok(), NOW, 'failed')
  261. expect(flat(out)).toContain('<b>not</b> saved')
  262. expect(flat(out)).toContain('Copy what you typed')
  263. })
  264. it('says completing does not throw anything away', () => {
  265. expect(ownListBlock(SAM, ok(), NOW, 'completed')).toContain('never throws it away')
  266. })
  267. it('says a refused add was not trimmed to fit', () => {
  268. expect(ownListBlock(SAM, ok(), NOW, 'full')).toContain('nothing was trimmed')
  269. expect(ownListBlock(SAM, ok(), NOW, 'toolong')).toContain('rather than saving part of it')
  270. })
  271. })
  272. describe('user text cannot become markup', () => {
  273. const nasty = '</textarea><script>alert(1)</script>"><b>x'
  274. it('not in a row', () => {
  275. const out = ownListBlock(SAM, ok([item({ text: nasty })]), NOW)
  276. expect(out).not.toContain('<script>')
  277. expect(out).toContain('&lt;script&gt;')
  278. })
  279. it('not in the edit box', () => {
  280. const out = ownListBlock(SAM, ok([item({ text: nasty })]), NOW)
  281. expect(out).not.toContain('</textarea><script>')
  282. })
  283. it('not in the store’s error message', () => {
  284. const out = ownListBlock(SAM, { state: 'failed', error: '<script>alert(1)</script>' }, NOW)
  285. expect(out).not.toContain('<script>alert(1)</script>')
  286. })
  287. it('and a malformed link renders as nothing rather than as a chip of garbage', () => {
  288. const out = ownListBlock(SAM, ok([item({ linkKey: 'javascript:alert(1)' })]), NOW)
  289. expect(out).not.toContain('javascript:')
  290. expect(out).not.toContain('own-link')
  291. })
  292. it('a well-formed link renders as a label, not a link', () => {
  293. const out = ownListBlock(SAM, ok([item({ linkKey: 'order:MD2639582' })]), NOW)
  294. expect(out).toContain('Order MD2639582')
  295. expect(out).not.toContain('<a ')
  296. })
  297. })
  298. describe('the placeholder and the stamp', () => {
  299. it('the placeholder is not empty, and says nothing private is in the file', () => {
  300. // Rule 8. An empty div here is a feature that silently is not there, and
  301. // whoever is looking at a dumped variant needs to know at a glance that no
  302. // private text is in it.
  303. const p = ownListPlaceholder()
  304. expect(p.startsWith(OWN_START)).toBe(true)
  305. expect(p.endsWith(OWN_END)).toBe(true)
  306. expect(flat(p)).toContain('lives on the live board')
  307. expect(flat(p)).toContain('Nothing of yours is in this file')
  308. })
  309. it('stamping swaps the placeholder for the block', () => {
  310. const page = `<main>before${ownListPlaceholder()}after</main>`
  311. const out = stampOwnList(page, ownListBlock(SAM, ok([item({ text: 'SENTINEL' })]), NOW))
  312. expect(out).toContain('SENTINEL')
  313. expect(out).not.toContain(OWN_START)
  314. expect(out).toContain('before')
  315. expect(out).toContain('after')
  316. })
  317. it('a page with no markers comes back byte-identical', () => {
  318. const page = '<main>nothing to stamp</main>'
  319. expect(stampOwnList(page, 'anything')).toBe(page)
  320. })
  321. it('a viewer with no list stamps an empty string over the markers', () => {
  322. const page = `<main>${ownListPlaceholder()}</main>`
  323. expect(stampOwnList(page, ownListBlock(COMPANY_VIEWER, ok(), NOW))).toBe(
  324. '<main></main>',
  325. )
  326. })
  327. })