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

cron-split.test.ts

Language TypeScript18.2 KB431 lines

apps/worker/test/cron-split.test.ts

  1. /**
  2. * Two crons, one sync, one difference.
  3. *
  4. * Both the morning and the 15-minute cron run the full sync: statuses move all
  5. * day, and a board showing the 6am numbers at 3pm is one people learn to
  6. * distrust.
  7. *
  8. * The thing that must not come along with that is history. `history.ts` keeps
  9. * one row per America/Chicago day, and its header is explicit that re-running
  10. * "does not make yesterday mean an hour ago". Ninety-six rows a day would turn
  11. * the day-over-day arrows from "since yesterday morning" into "since the last
  12. * quarter hour": a change in what a number means, invisible on the page,
  13. * arriving without a DEFS_VERSION bump. So the morning run owns the baseline and
  14. * every other sync leaves it alone.
  15. *
  16. * These tests pin the branch by watching which KV keys each cron writes, since
  17. * that is the observable difference and the one a future edit could quietly
  18. * undo. They do not assert wording.
  19. */
  20. import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
  21. import worker, { decideSync, runRequestedSync, stampSyncState, sync, syncStateHtml } from '../src/index.js'
  22. const HISTORY_KEY = 'dashboard:history'
  23. // Every upstream call is stubbed, for two reasons. Speed is the small one. The
  24. // real one: without this, a run on a shell that has credentials exported would fire live Bitrix, Plinth, Ninety, Gmail
  25. // and Sheets requests from a unit test. `pnpm test` is offline and needs no
  26. // credentials, and a test suite that quietly stops being either is a trap.
  27. beforeAll(() => {
  28. vi.stubGlobal('fetch', vi.fn(async () => new Response('unavailable', { status: 503 })))
  29. })
  30. afterAll(() => {
  31. vi.unstubAllGlobals()
  32. })
  33. /** Records every put so a test can ask which keys a run touched. */
  34. function recordingKv(store: Record<string, string> = {}) {
  35. const puts: string[] = []
  36. return {
  37. puts,
  38. kv: {
  39. get: async (k: string) => store[k] ?? null,
  40. put: async (k: string, v: string) => {
  41. puts.push(k)
  42. store[k] = v
  43. },
  44. },
  45. }
  46. }
  47. /**
  48. * Runs the scheduled handler for one cron and waits for the work it deferred.
  49. *
  50. * The handler hands everything to ctx.waitUntil, so a test that does not collect
  51. * those promises sees an empty KV and passes for the wrong reason.
  52. */
  53. async function fire(cron: string, store: Record<string, string> = {}) {
  54. const { kv, puts } = recordingKv(store)
  55. const pending: Promise<unknown>[] = []
  56. const ctx = { waitUntil: (p: Promise<unknown>) => void pending.push(p) }
  57. // Every source fails, by way of the stubbed fetch above. That is the point for
  58. // this test: even an all-sources-down run reveals which branch it took by what
  59. // it writes, and a failing run must still not corrupt history.
  60. const env = { DASHBOARD: kv, PLINTH_WINDOW_DAYS: '60' } as never
  61. await worker.scheduled({ cron } as never, env, ctx as never)
  62. await Promise.all(pending)
  63. return { puts, store }
  64. }
  65. describe('both crons run the full sync', () => {
  66. it('the 15-minute cron renders the board, not just the inbox', async () => {
  67. // The regression this guards: reverting to inbox-only would leave the board
  68. // frozen at the morning numbers while looking perfectly healthy.
  69. const { puts } = await fire('*/15 * * * *')
  70. expect(puts.some((k) => k.startsWith('dashboard:html'))).toBe(true)
  71. expect(puts).toContain('dashboard:payload')
  72. })
  73. it('the morning cron renders the board too', async () => {
  74. const { puts } = await fire('0 11 * * 1-5')
  75. expect(puts.some((k) => k.startsWith('dashboard:html'))).toBe(true)
  76. expect(puts).toContain('dashboard:payload')
  77. })
  78. it('both refresh the inbox', async () => {
  79. for (const cron of ['*/15 * * * *', '0 11 * * 1-5']) {
  80. const { puts } = await fire(cron)
  81. expect(puts, cron).toContain('inbox:messages')
  82. }
  83. })
  84. })
  85. describe('only the morning cron owns the history row', () => {
  86. it('the 15-minute cron never writes history', async () => {
  87. // The whole point of the split. If this fails, the "vs yesterday" arrows
  88. // silently start describing the last quarter hour.
  89. const { puts } = await fire('*/15 * * * *')
  90. expect(puts).not.toContain(HISTORY_KEY)
  91. })
  92. it('leaves an existing history row untouched', async () => {
  93. const row = JSON.stringify([
  94. { day: '2026-09-01', at: '2026-09-01T11:00:00.000Z', defs: 3, ourMove: 27,
  95. designQueue: 8, waitingOnClient: 19, atVendor: 5, needsReply: 3, openLeads: 44 },
  96. ])
  97. const { store } = await fire('*/15 * * * *', { [HISTORY_KEY]: row })
  98. expect(store[HISTORY_KEY]).toBe(row)
  99. })
  100. it('an unrecognised cron is treated as a non-morning run', async () => {
  101. // Fail safe: a trigger added later must not start writing baselines just by
  102. // existing. Getting fewer history rows than intended is a visible gap;
  103. // getting extra ones corrupts the deltas retroactively.
  104. const { puts } = await fire('*/5 * * * *')
  105. expect(puts).not.toContain(HISTORY_KEY)
  106. })
  107. it('does not write history on the morning run either when a source failed', async () => {
  108. // Pre-existing rule, re-pinned here because the branch above now sits next
  109. // to it: a run where a source died reports zeros, and a zero recorded today
  110. // becomes tomorrow's "yesterday". No credentials here, so everything fails.
  111. const { puts } = await fire('0 11 * * 1-5')
  112. expect(puts).not.toContain(HISTORY_KEY)
  113. })
  114. })
  115. describe('a failing sync is recorded, not thrown', () => {
  116. it('writes the error key rather than rejecting', async () => {
  117. const { store } = await fire('*/15 * * * *')
  118. expect(store['sync:lastError']).toBeTruthy()
  119. })
  120. })
  121. /**
  122. * The button is the third caller of sync(), and it belongs in this file for
  123. * exactly the reason the file exists: what separates the callers is which KV
  124. * keys they write, and history is the one that must not move.
  125. *
  126. * A manual sync refreshes the whole dashboard and runs no model, with no flag
  127. * needed: sync() never calls a model; the written briefings are authored on a
  128. * laptop and only read from KV here. What
  129. * the button does need is writeHistory:false, or any click would overwrite the
  130. * morning baseline and silently redefine every day-over-day arrow.
  131. *
  132. * These test sync() and decideSync() directly rather than the route. /sync sits
  133. * behind Cloudflare Access and this suite cannot mint an assertion for it, which
  134. * is why the decision is a pure function in the first place. The one thing
  135. * asserted through the route is that it refuses an unidentified request.
  136. */
  137. describe('the Sync now button', () => {
  138. /** What the button actually calls. */
  139. async function manualSync(store: Record<string, string> = {}) {
  140. const { kv, puts } = recordingKv(store)
  141. const env = { DASHBOARD: kv, PLINTH_WINDOW_DAYS: '60' } as never
  142. await sync(env, { writeHistory: false })
  143. return { puts, store }
  144. }
  145. it('runs a full sync: the board, the payload and the inbox', async () => {
  146. const { puts } = await manualSync()
  147. expect(puts.some((k) => k.startsWith('dashboard:html'))).toBe(true)
  148. expect(puts).toContain('dashboard:payload')
  149. expect(puts).toContain('inbox:messages')
  150. })
  151. it('NEVER writes the history row', async () => {
  152. // If this fails, a button anyone can press has just redefined what "since
  153. // yesterday" means on every arrow, with nothing on the page showing it.
  154. const { puts } = await manualSync()
  155. expect(puts).not.toContain(HISTORY_KEY)
  156. })
  157. it('does not touch the written briefings; there is no model in this path', async () => {
  158. const { puts } = await manualSync()
  159. expect(puts).not.toContain('dashboard:briefings')
  160. })
  161. it('writes the same keys the 15-minute cron does, and no others', async () => {
  162. // The button is that cron, on demand. If the two ever diverge, one of them
  163. // is doing something the other is not and nobody decided that.
  164. const { puts: cron } = await fire('*/15 * * * *')
  165. const { puts: manual } = await manualSync()
  166. expect(new Set(manual)).toEqual(new Set(cron))
  167. })
  168. })
  169. describe('decideSync: the cooldown', () => {
  170. const NOW = Date.parse('2026-09-04T18:00:00.000Z')
  171. const at = (msAgo: number) => ({ at: new Date(NOW - msAgo).toISOString(), by: 'Sam Whitaker' })
  172. it('allows a press when nothing has run', () => {
  173. const d = decideSync(null, 'Sam Whitaker', NOW)
  174. expect(d.run).toBe(true)
  175. })
  176. it('refuses a second press inside the window', () => {
  177. // Two people clicking ten seconds apart each burn the Plinth and Bitrix rate
  178. // limits; Bitrix answers with QUERY_LIMIT_EXCEEDED, which lands in
  179. // sync:lastError and reads as a broken source rather than as two clicks.
  180. const d = decideSync(at(10_000), 'Marco Ibáñez', NOW)
  181. expect(d.run).toBe(false)
  182. })
  183. it('allows one again once the window has passed', () => {
  184. expect(decideSync(at(10 * 60_000), 'Sam Whitaker', NOW).run).toBe(true)
  185. })
  186. it('records who asked and when, so a second person can see a run is going', () => {
  187. const d = decideSync(null, 'Theo Bassett', NOW)
  188. expect(d.run && d.record.by).toBe('Theo Bassett')
  189. expect(d.run && Date.parse(d.record.at)).toBe(NOW)
  190. })
  191. it('FAILS OPEN on a corrupt or impossible record', () => {
  192. // The cooldown protects a rate limit; losing it costs one extra sync.
  193. // Failing closed would let one bad byte permanently disable the only manual
  194. // control on the board.
  195. for (const bad of [
  196. { at: 'not a date', by: 'x' },
  197. { at: new Date(NOW + 3_600_000).toISOString(), by: 'x' }, // future: clock skew
  198. ]) {
  199. expect(decideSync(bad, 'Sam Whitaker', NOW).run, JSON.stringify(bad)).toBe(true)
  200. }
  201. })
  202. })
  203. describe('the masthead line about a manual sync', () => {
  204. const NOW = Date.parse('2026-09-04T18:00:00.000Z')
  205. it('says nothing when no sync has been asked for', () => {
  206. // The placeholder must vanish entirely rather than render an empty chip.
  207. expect(syncStateHtml(null, NOW)).toBe('')
  208. expect(stampSyncState('<b>x</b><span id="sync-state"></span>', null, NOW)).toBe('<b>x</b>')
  209. })
  210. it('reports elapsed time, and never claims the sync finished', () => {
  211. // This route hands the work to waitUntil and cannot observe the outcome. A
  212. // "done" it did not see is exactly the unobserved claim the board must not
  213. // make. The Synced timestamp beside it is what changes when the run actually lands.
  214. const html = syncStateHtml({ at: new Date(NOW - 12_000).toISOString(), by: 'Sam Whitaker' }, NOW)
  215. expect(html).toContain('12s ago')
  216. expect(html).toContain('Sam Whitaker')
  217. expect(html).not.toMatch(/complete|finished|done|success/i)
  218. })
  219. it('stops mentioning it once the run is long past', () => {
  220. expect(syncStateHtml({ at: new Date(NOW - 600_000).toISOString(), by: 'Sam' }, NOW)).toBe('')
  221. })
  222. it('escapes the name, which is board-owned but still user data', () => {
  223. const html = syncStateHtml({ at: new Date(NOW - 1000).toISOString(), by: '<img src=x>' }, NOW)
  224. expect(html).not.toContain('<img')
  225. expect(html).toContain('&lt;img')
  226. })
  227. })
  228. describe('/sync fails closed without an identity', () => {
  229. it('refuses an unidentified request, like every other route here', async () => {
  230. const { kv } = recordingKv()
  231. const res = await worker.fetch(
  232. new Request('http://x/sync', { method: 'post' }),
  233. { DASHBOARD: kv } as never,
  234. { waitUntil: () => {} } as never,
  235. )
  236. expect(res.status).toBe(403)
  237. })
  238. })
  239. /**
  240. * The footer's claim and the fetch limit must be the same number, or the board
  241. * misstates its own coverage. The page and the Worker share one constant; this
  242. * checks the sync actually holds that many from a window larger than it.
  243. */
  244. describe('the inbox depth the page claims is the depth the sync fetches', () => {
  245. it('holds INBOX_MAX_PER_MAILBOX messages from a larger window and says it is truncated', async () => {
  246. const { INBOX_MAX_PER_MAILBOX } = await import('../../dashboard/src/inbox-view.js')
  247. const windowSize = INBOX_MAX_PER_MAILBOX + 50
  248. const ids = Array.from({ length: windowSize }, (_, i) => `m${i}`)
  249. const gmail = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
  250. const url = String(input)
  251. if (url.startsWith('https://oauth2.googleapis.com/token')) {
  252. return Response.json({ access_token: 'test-token', expires_in: 3600 })
  253. }
  254. if (url.startsWith('https://gmail.googleapis.com/gmail/v1/users/me/messages?')) {
  255. return Response.json({ messages: ids.map((id) => ({ id })) })
  256. }
  257. if (url.startsWith('https://gmail.googleapis.com/batch/')) {
  258. const asked = [...String(init?.body).matchAll(/messages\/([^?]+)\?format=metadata/g)].map((m) => m[1])
  259. const boundary = 'resp-boundary'
  260. const parts = asked.map(
  261. (id) =>
  262. `--${boundary}\r\nContent-Type: application/http\r\n\r\nHTTP/1.1 200 OK\r\n` +
  263. `Content-Type: application/json\r\n\r\n${JSON.stringify({ id, threadId: id, internalDate: '1' })}\r\n`,
  264. )
  265. return new Response(`${parts.join('')}--${boundary}--\r\n`, {
  266. headers: { 'content-type': `multipart/mixed; boundary=${boundary}` },
  267. })
  268. }
  269. return new Response('unavailable', { status: 503 })
  270. })
  271. vi.stubGlobal('fetch', gmail)
  272. // The batch requests are paced in real time; fake timers skip the waits.
  273. vi.useFakeTimers({ toFake: ['setTimeout'] })
  274. try {
  275. const { kv } = recordingKv()
  276. const env = {
  277. DASHBOARD: kv,
  278. PLINTH_WINDOW_DAYS: '60',
  279. GMAIL_CLIENT_ID: 'client',
  280. GMAIL_CLIENT_SECRET: 'secret',
  281. GMAIL_REFRESH_HELLO: 'refresh',
  282. } as never
  283. const run = sync(env, { writeHistory: false })
  284. await vi.runAllTimersAsync()
  285. await run
  286. const inbox = JSON.parse((await kv.get('inbox:messages'))!) as {
  287. mailboxes: { mailbox: string; ok: boolean; messages: unknown[]; windowTotal: number | null; truncated: boolean }[]
  288. }
  289. const hello = inbox.mailboxes.find((m) => m.ok)
  290. expect(hello?.mailbox).toBe('hello@meridiandisplay.example')
  291. expect(hello?.messages).toHaveLength(INBOX_MAX_PER_MAILBOX)
  292. expect(hello?.windowTotal).toBe(windowSize)
  293. expect(hello?.truncated).toBe(true)
  294. } finally {
  295. vi.useRealTimers()
  296. vi.stubGlobal('fetch', vi.fn(async () => new Response('unavailable', { status: 503 })))
  297. }
  298. })
  299. })
  300. /**
  301. * The button records; the minute cron runs.
  302. *
  303. * A fetch handler's waitUntil does not get the budget a scheduled one does, and
  304. * a two-minute sync does not fit in it: started from the route, it is cut off
  305. * with nothing written and nothing in sync:lastError. The same sync() completes
  306. * from a cron.
  307. *
  308. * These pin the split, because the failure mode is silence: a button that
  309. * records a request nobody runs looks exactly like a button that worked.
  310. */
  311. describe('the minute cron runs what the button asked for', () => {
  312. const request = (over: Record<string, unknown> = {}) =>
  313. JSON.stringify({ at: new Date().toISOString(), by: 'Sam Whitaker', ...over })
  314. it('runs a full sync when a request is pending', async () => {
  315. const { kv, puts } = recordingKv({ 'sync:request': request() })
  316. await runRequestedSync({ DASHBOARD: kv } as never)
  317. expect(puts.some((k) => k.startsWith('dashboard:html'))).toBe(true)
  318. expect(puts).toContain('inbox:messages')
  319. })
  320. it('still never writes the history row', async () => {
  321. const { kv, puts } = recordingKv({ 'sync:request': request() })
  322. await runRequestedSync({ DASHBOARD: kv } as never)
  323. expect(puts).not.toContain(HISTORY_KEY)
  324. })
  325. it('does nothing at all on a minute with no request', async () => {
  326. // Which is almost every minute. A cron that synced regardless would be a
  327. // fourth sync nobody asked for, at sixty times the intended rate.
  328. const { kv, puts } = recordingKv()
  329. expect(await runRequestedSync({ DASHBOARD: kv } as never)).toBe(false)
  330. expect(puts).toEqual([])
  331. })
  332. it('claims the request BEFORE running, so the next minute does not duplicate it', async () => {
  333. // The cron fires every minute and a sync takes two. Without the claim the
  334. // second minute starts a duplicate mid-run, and the two compete for the
  335. // rate limits the cooldown exists to protect.
  336. const store: Record<string, string> = { 'sync:request': request() }
  337. const { kv } = recordingKv(store)
  338. const order: string[] = []
  339. const watched = {
  340. ...kv,
  341. put: async (k: string, v: string) => {
  342. order.push(k)
  343. await kv.put(k, v)
  344. },
  345. }
  346. await runRequestedSync({ DASHBOARD: watched } as never)
  347. expect(order[0]).toBe('sync:request')
  348. expect(JSON.parse(store['sync:request']!).claimed).toBe(true)
  349. })
  350. it('refuses a request it has already claimed', async () => {
  351. const { kv, puts } = recordingKv({ 'sync:request': request({ claimed: true }) })
  352. expect(await runRequestedSync({ DASHBOARD: kv } as never)).toBe(false)
  353. expect(puts).toEqual([])
  354. })
  355. it('ignores a stale request rather than running a sync nobody is waiting for', async () => {
  356. // A press from an hour ago is not a press now: the tab is closed and the
  357. // quarter-hour cron has run four times since.
  358. const old = new Date(Date.now() - 60 * 60_000).toISOString()
  359. const { kv, puts } = recordingKv({ 'sync:request': JSON.stringify({ at: old, by: 'Sam Whitaker' }) })
  360. expect(await runRequestedSync({ DASHBOARD: kv } as never)).toBe(false)
  361. expect(puts).toEqual([])
  362. })
  363. it('the minute cron writes nothing when idle, through the real handler', async () => {
  364. const { puts } = await fire('* * * * *')
  365. expect(puts).toEqual([])
  366. })
  367. it('the minute cron is not a third full sync', async () => {
  368. // If this ever starts writing dashboard:html on an idle minute, the board
  369. // is syncing sixty times an hour and burning every upstream rate limit.
  370. const { puts } = await fire('* * * * *')
  371. expect(puts.some((k) => k.startsWith('dashboard:html'))).toBe(false)
  372. })
  373. })
  374. describe('POST /sync records the request and does not try to run it', () => {
  375. it('writes sync:request and nothing else', async () => {
  376. // The route cannot finish the work; that is the whole finding. What it can
  377. // do is record that somebody asked, which is what the minute cron reads.
  378. const { kv, puts } = recordingKv()
  379. const res = await worker.fetch(
  380. new Request('http://x/sync', { method: 'post' }),
  381. { DASHBOARD: kv } as never,
  382. { waitUntil: () => {} } as never,
  383. )
  384. // Unidentified in this suite, so it is refused. The identity-free
  385. // assertion below is the one that matters here.
  386. expect(res.status).toBe(403)
  387. expect(puts).toEqual([])
  388. })
  389. })