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

inbox-send.test.ts

Language TypeScript23.1 KB514 lines

apps/dashboard/test/inbox-send.test.ts

  1. /**
  2. * The reply surfaces: who may send, what each state renders as, and what the
  3. * pages must never contain.
  4. *
  5. * The bug this file guards against is the one that would be easiest to write:
  6. * reusing INBOX_READERS as the send check. The whole roster may read every
  7. * mailbox, and none of that is permission to write to a client as somebody
  8. * else. If `sendableMailboxes` ever starts returning what
  9. * `readableMailboxes` returns, the first test below fails.
  10. */
  11. import { describe, expect, it } from 'vitest'
  12. import {
  13. INBOX_CSS,
  14. composeForm,
  15. confirmPage,
  16. fileSize,
  17. newMessageForm,
  18. newMessagePage,
  19. offerable,
  20. sendStates,
  21. sentPage,
  22. signaturesPage,
  23. type InboxData,
  24. type MailboxSendState,
  25. type SendRecord,
  26. type SignatureStore,
  27. } from '../src/inbox-view.js'
  28. import {
  29. ATTACHMENT_LIMITS,
  30. INBOX_MAILBOXES,
  31. INBOX_SENDERS,
  32. RECIPIENT_CAP,
  33. canSendFrom,
  34. readableMailboxes,
  35. sendableMailboxes,
  36. } from '../../../config/inbox.js'
  37. import { viewerFor } from '../src/viewer.js'
  38. import { THEME_CSS } from '../src/theme.js'
  39. import type { MessageBody } from '@meridian/gmail-client'
  40. const viewer = (email: string) => viewerFor(email)
  41. const SIGS_OK: SignatureStore = {
  42. ok: true,
  43. map: { 'hello@meridiandisplay.example': { html: '<p>Sam</p>', by: 'Sam Whitaker', at: '2026-09-04T00:00:00.000Z' } },
  44. }
  45. const SIGS_DEAD: SignatureStore = { ok: false, error: 'inbox:signatures holds something that is not an object' }
  46. /** One mailbox read per state we want to exercise. */
  47. const DATA: InboxData = {
  48. syncedAt: '2026-09-04T12:00:00.000Z',
  49. mailboxes: [
  50. { mailbox: 'hello@meridiandisplay.example', ok: true, error: null, messages: [], canSend: true },
  51. { mailbox: 'orderdesk@meridiandisplay.example', ok: true, error: null, messages: [], canSend: false },
  52. { mailbox: 'studio@meridiandisplay.example', ok: true, error: null, messages: [], canSend: null },
  53. { mailbox: 'accounts@meridiandisplay.example', ok: false, error: 'invalid_grant', messages: [], canSend: null },
  54. { mailbox: 'help@meridiandisplay.example', ok: true, error: null, messages: [], canSend: true },
  55. { mailbox: 'service@meridiandisplay.example', ok: true, error: null, messages: [], canSend: true },
  56. { mailbox: 'exec@meridiandisplay.example', ok: true, error: null, messages: [], canSend: true },
  57. ],
  58. }
  59. const ORIG: MessageBody = {
  60. id: 'm1',
  61. mailbox: 'hello@meridiandisplay.example',
  62. sender: 'client@example.com',
  63. senderName: 'A Client',
  64. subject: 'Banner proof',
  65. date: '2026-09-04T10:00:00.000Z',
  66. to: 'hello@meridiandisplay.example',
  67. text: 'Where is my proof?',
  68. kind: 'plain',
  69. truncated: false,
  70. html: null,
  71. htmlTruncated: false,
  72. unread: true,
  73. attachments: [],
  74. threadId: 't1',
  75. messageId: '<abc@example.com>',
  76. references: null,
  77. replyTo: '',
  78. cc: 'accounts@example.com',
  79. }
  80. describe('sending is a separate grant from reading', () => {
  81. it('a viewer who may read everything and holds no send line may send nothing', () => {
  82. // Ines reads all seven (INBOX_READERS) and has no INBOX_SENDERS line: she
  83. // does not work client mail.
  84. const ines = viewer('projects@meridiandisplay.example')
  85. expect(readableMailboxes(ines)).toHaveLength(INBOX_MAILBOXES.length)
  86. expect(sendableMailboxes(ines)).toEqual([])
  87. for (const m of INBOX_MAILBOXES) expect(canSendFrom(ines, m.email)).toBe(false)
  88. })
  89. it('honours Sam’s seven lines exactly', () => {
  90. // Four of the five full grants are role mailboxes, so these assertions
  91. // outlive whoever holds those desks today: anyone who can sign in as one of
  92. // them can write to a client as any of the seven. Narrowing that is a
  93. // decision, not a cleanup.
  94. for (const full of [
  95. 'hello@meridiandisplay.example',
  96. 'frontdesk@meridiandisplay.example',
  97. 'accounts@meridiandisplay.example',
  98. 'help@meridiandisplay.example',
  99. 'service@meridiandisplay.example',
  100. ]) {
  101. expect(sendableMailboxes(viewer(full)), full).toHaveLength(INBOX_MAILBOXES.length)
  102. }
  103. expect(sendableMailboxes(viewer('studio@meridiandisplay.example'))).toEqual(['studio@meridiandisplay.example'])
  104. expect(sendableMailboxes(viewer('orderdesk@meridiandisplay.example'))).toEqual(['orderdesk@meridiandisplay.example'])
  105. expect(sendableMailboxes(viewer('nora.l@meridiandisplay.example'))).toEqual([])
  106. })
  107. it('a send grant is useless without a read grant, so every sender has both', () => {
  108. // The inert-grant trap: a send grant without a read grant does nothing,
  109. // because /inbox refuses the whole block before a Reply button is ever
  110. // rendered, while looking present in config. Both lists get the line, or
  111. // neither should. This fails if a future send grant is added to only one of them.
  112. for (const sender of Object.keys(INBOX_SENDERS)) {
  113. expect(readableMailboxes(viewer(sender)).length, sender).toBeGreaterThan(0)
  114. }
  115. })
  116. it('cannot be widened by a typo: a grant is intersected with the real list', () => {
  117. // Same guard readableMailboxes has: a misspelt mailbox in a grant is
  118. // dropped rather than inventing one.
  119. const marco = sendableMailboxes(viewer('studio@meridiandisplay.example'))
  120. for (const m of marco) expect(INBOX_MAILBOXES.map((x) => x.email)).toContain(m)
  121. })
  122. it('an unidentified viewer sends nothing', () => {
  123. expect(sendableMailboxes({ ...viewer('hello@meridiandisplay.example'), email: null })).toEqual([])
  124. })
  125. })
  126. describe('sendStates', () => {
  127. const all = INBOX_MAILBOXES.map((m) => m.email)
  128. it('produces five distinct states from one fixture, and no two are the same', () => {
  129. const st = sendStates(DATA, INBOX_MAILBOXES, all.filter((e) => e !== 'exec@meridiandisplay.example'), SIGS_OK)
  130. const by = (e: string) => st.find((x) => x.email === e)!
  131. expect(by('hello@meridiandisplay.example').state).toBe('sendable')
  132. expect(by('orderdesk@meridiandisplay.example').state).toBe('readonly-token')
  133. expect(by('studio@meridiandisplay.example').state).toBe('scope-unknown')
  134. expect(by('accounts@meridiandisplay.example').state).toBe('failed')
  135. expect(by('exec@meridiandisplay.example').state).toBe('not-granted')
  136. expect(new Set(st.map((x) => x.state)).size).toBe(5)
  137. })
  138. it('keeps read-only, unknown and failed apart; they need opposite fixes', () => {
  139. // read-only -> re-mint the token
  140. // unknown -> nothing to do, try it
  141. // failed -> the credential is broken
  142. // Collapsing any pair sends someone to the wrong page of the runbook.
  143. const st = sendStates(DATA, INBOX_MAILBOXES, all, SIGS_OK)
  144. const ro = st.find((x) => x.state === 'readonly-token')!
  145. const unk = st.find((x) => x.state === 'scope-unknown')!
  146. const failed = st.find((x) => x.state === 'failed')!
  147. expect(ro.error).toBeNull()
  148. expect(unk.error).toBeNull()
  149. expect(failed.error).toBe('invalid_grant')
  150. expect(offerable(st).map((x) => x.state).sort()).toEqual(['scope-unknown', 'sendable', 'sendable', 'sendable', 'sendable'])
  151. })
  152. it('not-granted wins over everything, even a perfect token', () => {
  153. const st = sendStates(DATA, INBOX_MAILBOXES, [], SIGS_OK)
  154. expect(st.every((x) => x.state === 'not-granted')).toBe(true)
  155. })
  156. it('distinguishes "no signature set" from "the store could not be read"', () => {
  157. // Both mean the reply goes out unsigned; only one of them means something
  158. // is broken, and a reader must be able to tell which.
  159. const set = sendStates(DATA, INBOX_MAILBOXES, all, SIGS_OK)
  160. expect(set.find((x) => x.email === 'hello@meridiandisplay.example')!.signature).toBe('set')
  161. expect(set.find((x) => x.email === 'exec@meridiandisplay.example')!.signature).toBe('none')
  162. const dead = sendStates(DATA, INBOX_MAILBOXES, all, SIGS_DEAD)
  163. expect(dead.every((x) => x.signature === 'store-failed')).toBe(true)
  164. })
  165. })
  166. /**
  167. * Per-mailbox send states for a viewer granted exactly `sendable`.
  168. *
  169. * Module-scoped because the new-message form takes the same states as the
  170. * reply form, and a second copy of this helper is a second definition
  171. * of what "may send" means in a test file about who may send.
  172. */
  173. const states = (sendable: string[], sigs = SIGS_OK): MailboxSendState[] =>
  174. sendStates(DATA, INBOX_MAILBOXES, sendable, sigs)
  175. describe('composeForm', () => {
  176. it('disables exactly the mailboxes that cannot send, and no others', () => {
  177. const html = composeForm(ORIG, states(INBOX_MAILBOXES.map((m) => m.email)), 'n1', SIGS_OK)
  178. const radios = [...html.matchAll(/<input type="radio" name="from" value="([^"]+)"([\s\S]*?)>/g)]
  179. const disabled = radios.filter((r) => r[2]!.includes('disabled')).map((r) => r[1])
  180. expect(disabled.sort()).toEqual(['accounts@meridiandisplay.example', 'orderdesk@meridiandisplay.example'])
  181. })
  182. it('says how many mailboxes are readable but not sendable, and where the grants live', () => {
  183. // Rule 9: where a surface withholds a row, it says how many and why. The
  184. // earlier version of this mistake counted thirteen and then showed nothing.
  185. const html = composeForm(ORIG, states(['hello@meridiandisplay.example']), 'n1', SIGS_OK)
  186. expect(html).toContain('6 more mailboxes')
  187. expect(html).toContain('INBOX_SENDERS')
  188. })
  189. it('has no To, Subject or Cc input; a REPLY\'s recipient is still decided server-side', () => {
  190. // A file input is allowed; To, Subject and Cc are not. An attachment cannot
  191. // change who gets the mail; a To field can, and the reply path's guarantee
  192. // is that nothing in the form steers that. Reply-all widens the recipients
  193. // through a boolean: the addresses still come off the original.
  194. const html = composeForm(ORIG, states(['hello@meridiandisplay.example']), 'n1', SIGS_OK)
  195. expect(html).not.toMatch(/name="to"/i)
  196. expect(html).not.toMatch(/name="subject"/i)
  197. expect(html).not.toMatch(/name="cc"/i)
  198. expect(html).toMatch(/type="file"[^>]*name="files"|name="files"[^>]*type="file"/i)
  199. expect(html).toContain('enctype="multipart/form-data"')
  200. // The reply-all control is a checkbox called `all`, and nothing else.
  201. expect(html).toMatch(/name="all" value="1"/)
  202. // The original's Cc is SHOWN, as text, with what including it would take.
  203. expect(html).toContain('accounts@example.com')
  204. expect(html).toContain('Reply to everyone')
  205. })
  206. it('offers no reply-all control when the original names nobody else', () => {
  207. // Rule 9 from the other side: a control that cannot do anything is not
  208. // shown. With no To and no Cc on the original there is nobody to add, and
  209. // a ticked box would then send exactly the same message as an unticked one.
  210. const alone = composeForm({ ...ORIG, to: '', cc: '' }, states(['hello@meridiandisplay.example']), 'n1', SIGS_OK)
  211. expect(alone).not.toMatch(/name="all"/)
  212. })
  213. it('gives the caveats their own emphasis class, not the muted metadata style', () => {
  214. // The caveats a sender must know about a shared mailbox must not look like
  215. // the muted To/Subject metadata they already know. Asserts the class is
  216. // applied and defined, not its wording. A caveat that has gone stale is
  217. // worse in red than in grey, so the assertion is that the box carries the
  218. // limits, not that it carries a sentence.
  219. const html = composeForm(ORIG, states(['hello@meridiandisplay.example']), 'n1', SIGS_OK)
  220. const para = html.match(/<p class="[^"]*compose-caveats[^"]*">([\s\S]*?)<\/p>/)
  221. expect(para).not.toBeNull()
  222. expect(para?.[1]).toContain('Reply to everyone')
  223. expect(para?.[1]).toMatch(/\d+ files at most/)
  224. expect(para?.[1]).not.toContain('cannot be added')
  225. // .compose-caveats overrides .compose-meta by ORDER, so a stylesheet that
  226. // defines it first silently restores the muted 12px it exists to replace.
  227. expect(INBOX_CSS.indexOf('.compose-caveats{')).toBeGreaterThan(INBOX_CSS.indexOf('.compose-meta{'))
  228. expect(INBOX_CSS).toContain('var(--caveat)')
  229. })
  230. it('defines --caveat in BOTH palettes', () => {
  231. // A token defined only on light renders as inherited text on dark, and the
  232. // caveats would silently go back to looking like everything else there,
  233. // which is the exact failure this change fixes.
  234. const dark = THEME_CSS.slice(THEME_CSS.indexOf(':root[data-theme="dark"]'))
  235. expect(THEME_CSS.slice(0, THEME_CSS.indexOf(':root[data-theme="dark"]'))).toContain('--caveat:')
  236. expect(dark).toContain('--caveat:')
  237. })
  238. it('carries the nonce, the message and the mailbox, and nothing that steers mail', () => {
  239. const html = composeForm(ORIG, states(['hello@meridiandisplay.example']), 'nonce-123', SIGS_OK)
  240. expect(html).toContain('name="nonce" value="nonce-123"')
  241. expect(html).toContain('name="id" value="m1"')
  242. expect(html).toContain('name="mailbox" value="hello@meridiandisplay.example"')
  243. })
  244. it('renders the two signature absences differently', () => {
  245. const none = composeForm(ORIG, states(['exec@meridiandisplay.example']), 'n', SIGS_OK)
  246. const dead = composeForm(ORIG, states(['exec@meridiandisplay.example'], SIGS_DEAD), 'n', SIGS_DEAD)
  247. expect(none).toContain('No signature is set')
  248. expect(dead).toContain('could not be read')
  249. expect(dead).toContain('failed read, not an empty signature')
  250. expect(none).not.toContain('could not be read')
  251. })
  252. it('names the read-only fix rather than saying only that it is unavailable', () => {
  253. const html = composeForm(ORIG, states(INBOX_MAILBOXES.map((m) => m.email)), 'n', SIGS_OK)
  254. expect(html).toContain('pnpm inbox:auth')
  255. expect(html).toContain('rechecks every 15 minutes')
  256. })
  257. })
  258. describe('the signatures page never renders a signature', () => {
  259. it('keeps user-supplied markup out of the board document entirely', () => {
  260. // Same rule, and the same reason, as a sender's message HTML: it reaches
  261. // only the sandboxed frame route and the outgoing MIME. Anything that puts
  262. // it in this page reintroduces the bug the frame exists to prevent.
  263. const nasty = '<img src=x onerror=alert(1)><script>steal()</script>'
  264. const page = signaturesPage(
  265. INBOX_MAILBOXES,
  266. { ok: true, map: { 'hello@meridiandisplay.example': { html: nasty, by: 'Sam', at: '2026-09-04T00:00:00.000Z' } } },
  267. ['hello@meridiandisplay.example'],
  268. null,
  269. )
  270. expect(page).not.toContain('onerror')
  271. expect(page).not.toContain('steal()')
  272. // It reports the facts about it instead, and frames it.
  273. expect(page).toContain('/inbox/signatures/frame?mailbox=hello%40meridiandisplay.example')
  274. expect(page).toContain(String(nasty.length))
  275. })
  276. it('offers an edit form only for mailboxes this viewer may send as', () => {
  277. const page = signaturesPage(INBOX_MAILBOXES, { ok: true, map: {} }, ['studio@meridiandisplay.example'], null)
  278. const forms = [...page.matchAll(/name="mailbox" value="([^"]+)"/g)].map((m) => m[1])
  279. expect(new Set(forms)).toEqual(new Set(['studio@meridiandisplay.example']))
  280. // Exactly one editable signature box on a page listing seven mailboxes.
  281. expect(page.match(/<textarea/g)).toHaveLength(1)
  282. })
  283. it('says a rejected signature was not written, rather than that it was truncated', () => {
  284. const page = signaturesPage(INBOX_MAILBOXES, { ok: true, map: {} }, ['hello@meridiandisplay.example'], {
  285. mailbox: 'hello@meridiandisplay.example',
  286. over: 41_000,
  287. })
  288. expect(page).toContain('Not saved')
  289. expect(page).toContain('Nothing was written')
  290. })
  291. it('reports a failed store as failed, not as "none set" for everyone', () => {
  292. // The distinction the prime invariant is about: a store nobody could read
  293. // must not render as seven mailboxes that happen to have no signature.
  294. const page = signaturesPage(INBOX_MAILBOXES, SIGS_DEAD, ['hello@meridiandisplay.example'], null)
  295. expect(page).toContain(SIGS_DEAD.ok ? '' : SIGS_DEAD.error)
  296. expect(page).not.toContain('Not set.')
  297. })
  298. })
  299. describe('the new-message form', () => {
  300. const st = states
  301. it('HAS the To, Cc and Subject fields the reply form must not have', () => {
  302. // The reply form's guarantee is that no form field steers where mail goes.
  303. // A compose box cannot keep it (there is no original to read a recipient
  304. // off), so the fields exist here and the guarantee is replaced by
  305. // parseRecipients, the cap and the confirmation step.
  306. const html = newMessageForm(st(['hello@meridiandisplay.example']), 'n1', SIGS_OK)
  307. expect(html).toMatch(/name="to"/)
  308. expect(html).toMatch(/name="cc"/)
  309. expect(html).toMatch(/name="subject"/)
  310. expect(html).toContain('name="mode" value="new"')
  311. expect(html).toContain('enctype="multipart/form-data"')
  312. })
  313. it('says in the red box that the sender is choosing the recipients', () => {
  314. const para = newMessageForm(st(['hello@meridiandisplay.example']), 'n1', SIGS_OK).match(
  315. /<p class="[^"]*compose-caveats[^"]*">([\s\S]*?)<\/p>/,
  316. )
  317. expect(para).not.toBeNull()
  318. expect(para?.[1]).toContain('choosing the recipients')
  319. expect(para?.[1]).toContain(String(RECIPIENT_CAP))
  320. })
  321. it('carries no message id, mailbox or threading field at all', () => {
  322. // A new message that carried an id would be a reply wearing a compose
  323. // box's clothes, and would thread into somebody's unrelated conversation.
  324. const html = newMessageForm(st(['hello@meridiandisplay.example']), 'n1', SIGS_OK)
  325. expect(html).not.toMatch(/name="mailbox"/)
  326. expect(html).not.toMatch(/name="id"/)
  327. expect(html).not.toMatch(/name="all"/)
  328. })
  329. it('offers only mailboxes the viewer may send from, with the rest counted', () => {
  330. const html = newMessageForm(st(['studio@meridiandisplay.example']), 'n1', SIGS_OK)
  331. expect(html).toContain('value="studio@meridiandisplay.example"')
  332. expect(html).toContain('6 more mailboxes')
  333. expect(html).toContain('INBOX_SENDERS')
  334. })
  335. it('states the attachment limits where the files are chosen', () => {
  336. const html = newMessageForm(st(['hello@meridiandisplay.example']), 'n1', SIGS_OK)
  337. expect(html).toContain(fileSize(ATTACHMENT_LIMITS.perFile))
  338. expect(html).toContain(fileSize(ATTACHMENT_LIMITS.total))
  339. expect(html).toContain(String(ATTACHMENT_LIMITS.count))
  340. })
  341. it('shows no form and says why when the viewer may send from nothing', () => {
  342. // Rule 9 again: not a dead button, and not silence either.
  343. const html = newMessagePage(st([]), 'n1', SIGS_OK)
  344. expect(html).not.toContain('<form')
  345. expect(html).toContain('not send from any mailbox')
  346. expect(html).toContain('INBOX_SENDERS')
  347. })
  348. it('distinguishes "no grant" from "the grant cannot send right now"', () => {
  349. // Same three credential states the reply control separates, because they
  350. // need three different fixes.
  351. // orderdesk@ is the fixture mailbox whose token reports no gmail.send.
  352. const readonly = newMessagePage(states(['orderdesk@meridiandisplay.example']), 'n', SIGS_OK)
  353. expect(readonly).toContain('read-only token')
  354. expect(readonly).not.toContain('not send from any mailbox')
  355. })
  356. })
  357. describe('the confirmation page', () => {
  358. const OUT = {
  359. from: 'hello@meridiandisplay.example',
  360. to: 'client@example.com',
  361. cc: null as string | null,
  362. subject: 'Re: Banner proof',
  363. text: 'Here it is.',
  364. inReplyTo: '<abc@example.com>',
  365. threadId: 't1',
  366. }
  367. const ST: MailboxSendState = {
  368. email: 'hello@meridiandisplay.example',
  369. label: 'Sam Whitaker',
  370. state: 'sendable',
  371. error: null,
  372. signature: 'none',
  373. }
  374. it('shows the Cc the SERVER settled on, and says it is after the exclusions', () => {
  375. const html = confirmPage({ ...OUT, cc: 'a@x.com, b@y.com' }, ORIG, ST, 'n1', SIGS_OK)
  376. expect(html).toContain('a@x.com, b@y.com')
  377. expect(html).toContain('2 addresses')
  378. expect(html).toContain('after taking out you and the sender')
  379. })
  380. it('carries the reply-all choice forward so the send re-derives the same list', () => {
  381. // `all=1` rather than the addresses: the send recomputes the Cc from the
  382. // original, so the confirmation cannot be edited into a different one.
  383. const html = confirmPage({ ...OUT, cc: 'a@x.com' }, ORIG, ST, 'n1', SIGS_OK)
  384. expect(html).toContain('name="all" value="1"')
  385. expect(html).not.toMatch(/name="cc" value/)
  386. })
  387. it('reads as a new message, not a reply, when there is no original', () => {
  388. const html = confirmPage({ ...OUT, inReplyTo: null, threadId: null }, null, ST, 'n1', SIGS_OK)
  389. expect(html).toContain('Send this message?')
  390. expect(html).toContain('name="mode" value="new"')
  391. expect(html).toContain('start its own conversation')
  392. // The typed recipients ride in the form, because there is no original to
  393. // re-read them from, and they are re-parsed server-side on the send.
  394. expect(html).toContain('name="to" value="client@example.com"')
  395. })
  396. it('lists every staged file with its size, and carries the bytes in the form', () => {
  397. const staged = [{ name: 'proof.pdf', mimeType: 'application/pdf', b64: btoa('x'.repeat(3000)) }]
  398. const html = confirmPage(OUT, ORIG, ST, 'n1', SIGS_OK, staged)
  399. expect(html).toContain('proof.pdf')
  400. expect(html).toContain('1 attachment')
  401. expect(html).toContain('3 KB')
  402. expect(html).toContain('name="staged"')
  403. })
  404. it('says "No attachments" rather than leaving the question open', () => {
  405. const html = confirmPage(OUT, ORIG, ST, 'n1', SIGS_OK)
  406. expect(html).toContain('No attachments')
  407. expect(html).not.toContain('name="staged"')
  408. })
  409. })
  410. describe('the sent record renders three states of Cc, not two', () => {
  411. const ROW: SendRecord = {
  412. nonce: 'n1',
  413. at: '2026-09-09T15:00:00.000Z',
  414. by: 'Sam Whitaker',
  415. byEmail: 'hello@meridiandisplay.example',
  416. from: 'hello@meridiandisplay.example',
  417. to: 'client@example.com',
  418. subject: 'Re: Banner proof',
  419. originalMailbox: 'hello@meridiandisplay.example',
  420. originalId: 'm1',
  421. originalMessageId: '<abc@example.com>',
  422. threadIdSent: 't1',
  423. signatureApplied: true,
  424. outcome: 'sent',
  425. gmailId: 'gm1',
  426. gmailThreadId: 'gt1',
  427. error: null,
  428. candidates: [],
  429. }
  430. it('says "not recorded" for a row written before reply-all existed', () => {
  431. // An old row does not record "no Cc"; it records nothing. Reading absent
  432. // as none would make this audit log claim something it never observed.
  433. const html = sentPage([], false, ROW, false)
  434. expect(html).toContain('not recorded')
  435. })
  436. it('says "none" when the send recorded that there was no Cc', () => {
  437. const html = sentPage([], false, { ...ROW, cc: null, attachments: [] }, false)
  438. expect(html).toContain('Cc none')
  439. expect(html).not.toContain('not recorded')
  440. })
  441. it('names the files and their sizes when there were some', () => {
  442. const html = sentPage(
  443. [],
  444. false,
  445. { ...ROW, cc: null, attachments: [{ name: 'proof.pdf', size: 240_000 }] },
  446. false,
  447. )
  448. expect(html).toContain('proof.pdf')
  449. expect(html).toContain('234 KB')
  450. })
  451. it('marks a new message as one, so the record shows who chose the recipient', () => {
  452. const html = sentPage([], false, { ...ROW, mode: 'new', originalMailbox: null, originalId: null }, false)
  453. expect(html).toContain('new message')
  454. })
  455. it('shows the listing count from metadata, and nothing when it is absent', () => {
  456. // The listing reads KV metadata, which carries a count rather than names
  457. // (1,024-byte cap). Null is "not recorded" and must not print as zero.
  458. const withCount = sentPage([{ ...ROW, attachmentCount: 2 }], false, null, false)
  459. expect(withCount).toContain('2 files')
  460. const without = sentPage([{ ...ROW, attachmentCount: null }], false, null, false)
  461. expect(without).not.toContain('files')
  462. })
  463. })