cron-split.test.ts
Language TypeScript18.2 KB431 lines
/*** Two crons, one sync, one difference.** Both the morning and the 15-minute cron run the full sync: statuses move all* day, and a board showing the 6am numbers at 3pm is one people learn to* distrust.** The thing that must not come along with that is history. `history.ts` keeps* one row per America/Chicago day, and its header is explicit that re-running* "does not make yesterday mean an hour ago". Ninety-six rows a day would turn* the day-over-day arrows from "since yesterday morning" into "since the last* quarter hour": a change in what a number means, invisible on the page,* arriving without a DEFS_VERSION bump. So the morning run owns the baseline and* every other sync leaves it alone.** These tests pin the branch by watching which KV keys each cron writes, since* that is the observable difference and the one a future edit could quietly* undo. They do not assert wording.*/import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'import worker, { decideSync, runRequestedSync, stampSyncState, sync, syncStateHtml } from '../src/index.js'const HISTORY_KEY = 'dashboard:history'// Every upstream call is stubbed, for two reasons. Speed is the small one. The// real one: without this, a run on a shell that has credentials exported would fire live Bitrix, Plinth, Ninety, Gmail// and Sheets requests from a unit test. `pnpm test` is offline and needs no// credentials, and a test suite that quietly stops being either is a trap.beforeAll(() => {vi.stubGlobal('fetch', vi.fn(async () => new Response('unavailable', { status: 503 })))})afterAll(() => {vi.unstubAllGlobals()})/** Records every put so a test can ask which keys a run touched. */function recordingKv(store: Record<string, string> = {}) {const puts: string[] = []return {puts,kv: {get: async (k: string) => store[k] ?? null,put: async (k: string, v: string) => {puts.push(k)store[k] = v},},}}/*** Runs the scheduled handler for one cron and waits for the work it deferred.** The handler hands everything to ctx.waitUntil, so a test that does not collect* those promises sees an empty KV and passes for the wrong reason.*/async function fire(cron: string, store: Record<string, string> = {}) {const { kv, puts } = recordingKv(store)const pending: Promise<unknown>[] = []const ctx = { waitUntil: (p: Promise<unknown>) => void pending.push(p) }// Every source fails, by way of the stubbed fetch above. That is the point for// this test: even an all-sources-down run reveals which branch it took by what// it writes, and a failing run must still not corrupt history.const env = { DASHBOARD: kv, PLINTH_WINDOW_DAYS: '60' } as neverawait worker.scheduled({ cron } as never, env, ctx as never)await Promise.all(pending)return { puts, store }}describe('both crons run the full sync', () => {it('the 15-minute cron renders the board, not just the inbox', async () => {// The regression this guards: reverting to inbox-only would leave the board// frozen at the morning numbers while looking perfectly healthy.const { puts } = await fire('*/15 * * * *')expect(puts.some((k) => k.startsWith('dashboard:html'))).toBe(true)expect(puts).toContain('dashboard:payload')})it('the morning cron renders the board too', async () => {const { puts } = await fire('0 11 * * 1-5')expect(puts.some((k) => k.startsWith('dashboard:html'))).toBe(true)expect(puts).toContain('dashboard:payload')})it('both refresh the inbox', async () => {for (const cron of ['*/15 * * * *', '0 11 * * 1-5']) {const { puts } = await fire(cron)expect(puts, cron).toContain('inbox:messages')}})})describe('only the morning cron owns the history row', () => {it('the 15-minute cron never writes history', async () => {// The whole point of the split. If this fails, the "vs yesterday" arrows// silently start describing the last quarter hour.const { puts } = await fire('*/15 * * * *')expect(puts).not.toContain(HISTORY_KEY)})it('leaves an existing history row untouched', async () => {const row = JSON.stringify([{ day: '2026-09-01', at: '2026-09-01T11:00:00.000Z', defs: 3, ourMove: 27,designQueue: 8, waitingOnClient: 19, atVendor: 5, needsReply: 3, openLeads: 44 },])const { store } = await fire('*/15 * * * *', { [HISTORY_KEY]: row })expect(store[HISTORY_KEY]).toBe(row)})it('an unrecognised cron is treated as a non-morning run', async () => {// Fail safe: a trigger added later must not start writing baselines just by// existing. Getting fewer history rows than intended is a visible gap;// getting extra ones corrupts the deltas retroactively.const { puts } = await fire('*/5 * * * *')expect(puts).not.toContain(HISTORY_KEY)})it('does not write history on the morning run either when a source failed', async () => {// Pre-existing rule, re-pinned here because the branch above now sits next// to it: a run where a source died reports zeros, and a zero recorded today// becomes tomorrow's "yesterday". No credentials here, so everything fails.const { puts } = await fire('0 11 * * 1-5')expect(puts).not.toContain(HISTORY_KEY)})})describe('a failing sync is recorded, not thrown', () => {it('writes the error key rather than rejecting', async () => {const { store } = await fire('*/15 * * * *')expect(store['sync:lastError']).toBeTruthy()})})/*** The button is the third caller of sync(), and it belongs in this file for* exactly the reason the file exists: what separates the callers is which KV* keys they write, and history is the one that must not move.** A manual sync refreshes the whole dashboard and runs no model, with no flag* needed: sync() never calls a model; the written briefings are authored on a* laptop and only read from KV here. What* the button does need is writeHistory:false, or any click would overwrite the* morning baseline and silently redefine every day-over-day arrow.** These test sync() and decideSync() directly rather than the route. /sync sits* behind Cloudflare Access and this suite cannot mint an assertion for it, which* is why the decision is a pure function in the first place. The one thing* asserted through the route is that it refuses an unidentified request.*/describe('the Sync now button', () => {/** What the button actually calls. */async function manualSync(store: Record<string, string> = {}) {const { kv, puts } = recordingKv(store)const env = { DASHBOARD: kv, PLINTH_WINDOW_DAYS: '60' } as neverawait sync(env, { writeHistory: false })return { puts, store }}it('runs a full sync: the board, the payload and the inbox', async () => {const { puts } = await manualSync()expect(puts.some((k) => k.startsWith('dashboard:html'))).toBe(true)expect(puts).toContain('dashboard:payload')expect(puts).toContain('inbox:messages')})it('NEVER writes the history row', async () => {// If this fails, a button anyone can press has just redefined what "since// yesterday" means on every arrow, with nothing on the page showing it.const { puts } = await manualSync()expect(puts).not.toContain(HISTORY_KEY)})it('does not touch the written briefings; there is no model in this path', async () => {const { puts } = await manualSync()expect(puts).not.toContain('dashboard:briefings')})it('writes the same keys the 15-minute cron does, and no others', async () => {// The button is that cron, on demand. If the two ever diverge, one of them// is doing something the other is not and nobody decided that.const { puts: cron } = await fire('*/15 * * * *')const { puts: manual } = await manualSync()expect(new Set(manual)).toEqual(new Set(cron))})})describe('decideSync: the cooldown', () => {const NOW = Date.parse('2026-09-04T18:00:00.000Z')const at = (msAgo: number) => ({ at: new Date(NOW - msAgo).toISOString(), by: 'Sam Whitaker' })it('allows a press when nothing has run', () => {const d = decideSync(null, 'Sam Whitaker', NOW)expect(d.run).toBe(true)})it('refuses a second press inside the window', () => {// Two people clicking ten seconds apart each burn the Plinth and Bitrix rate// limits; Bitrix answers with QUERY_LIMIT_EXCEEDED, which lands in// sync:lastError and reads as a broken source rather than as two clicks.const d = decideSync(at(10_000), 'Marco Ibáñez', NOW)expect(d.run).toBe(false)})it('allows one again once the window has passed', () => {expect(decideSync(at(10 * 60_000), 'Sam Whitaker', NOW).run).toBe(true)})it('records who asked and when, so a second person can see a run is going', () => {const d = decideSync(null, 'Theo Bassett', NOW)expect(d.run && d.record.by).toBe('Theo Bassett')expect(d.run && Date.parse(d.record.at)).toBe(NOW)})it('FAILS OPEN on a corrupt or impossible record', () => {// The cooldown protects a rate limit; losing it costs one extra sync.// Failing closed would let one bad byte permanently disable the only manual// control on the board.for (const bad of [{ at: 'not a date', by: 'x' },{ at: new Date(NOW + 3_600_000).toISOString(), by: 'x' }, // future: clock skew]) {expect(decideSync(bad, 'Sam Whitaker', NOW).run, JSON.stringify(bad)).toBe(true)}})})describe('the masthead line about a manual sync', () => {const NOW = Date.parse('2026-09-04T18:00:00.000Z')it('says nothing when no sync has been asked for', () => {// The placeholder must vanish entirely rather than render an empty chip.expect(syncStateHtml(null, NOW)).toBe('')expect(stampSyncState('<b>x</b><span id="sync-state"></span>', null, NOW)).toBe('<b>x</b>')})it('reports elapsed time, and never claims the sync finished', () => {// This route hands the work to waitUntil and cannot observe the outcome. A// "done" it did not see is exactly the unobserved claim the board must not// make. The Synced timestamp beside it is what changes when the run actually lands.const html = syncStateHtml({ at: new Date(NOW - 12_000).toISOString(), by: 'Sam Whitaker' }, NOW)expect(html).toContain('12s ago')expect(html).toContain('Sam Whitaker')expect(html).not.toMatch(/complete|finished|done|success/i)})it('stops mentioning it once the run is long past', () => {expect(syncStateHtml({ at: new Date(NOW - 600_000).toISOString(), by: 'Sam' }, NOW)).toBe('')})it('escapes the name, which is board-owned but still user data', () => {const html = syncStateHtml({ at: new Date(NOW - 1000).toISOString(), by: '<img src=x>' }, NOW)expect(html).not.toContain('<img')expect(html).toContain('<img')})})describe('/sync fails closed without an identity', () => {it('refuses an unidentified request, like every other route here', async () => {const { kv } = recordingKv()const res = await worker.fetch(new Request('http://x/sync', { method: 'post' }),{ DASHBOARD: kv } as never,{ waitUntil: () => {} } as never,)expect(res.status).toBe(403)})})/*** The footer's claim and the fetch limit must be the same number, or the board* misstates its own coverage. The page and the Worker share one constant; this* checks the sync actually holds that many from a window larger than it.*/describe('the inbox depth the page claims is the depth the sync fetches', () => {it('holds INBOX_MAX_PER_MAILBOX messages from a larger window and says it is truncated', async () => {const { INBOX_MAX_PER_MAILBOX } = await import('../../dashboard/src/inbox-view.js')const windowSize = INBOX_MAX_PER_MAILBOX + 50const ids = Array.from({ length: windowSize }, (_, i) => `m${i}`)const gmail = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {const url = String(input)if (url.startsWith('https://oauth2.googleapis.com/token')) {return Response.json({ access_token: 'test-token', expires_in: 3600 })}if (url.startsWith('https://gmail.googleapis.com/gmail/v1/users/me/messages?')) {return Response.json({ messages: ids.map((id) => ({ id })) })}if (url.startsWith('https://gmail.googleapis.com/batch/')) {const asked = [...String(init?.body).matchAll(/messages\/([^?]+)\?format=metadata/g)].map((m) => m[1])const boundary = 'resp-boundary'const parts = asked.map((id) =>`--${boundary}\r\nContent-Type: application/http\r\n\r\nHTTP/1.1 200 OK\r\n` +`Content-Type: application/json\r\n\r\n${JSON.stringify({ id, threadId: id, internalDate: '1' })}\r\n`,)return new Response(`${parts.join('')}--${boundary}--\r\n`, {headers: { 'content-type': `multipart/mixed; boundary=${boundary}` },})}return new Response('unavailable', { status: 503 })})vi.stubGlobal('fetch', gmail)// The batch requests are paced in real time; fake timers skip the waits.vi.useFakeTimers({ toFake: ['setTimeout'] })try {const { kv } = recordingKv()const env = {DASHBOARD: kv,PLINTH_WINDOW_DAYS: '60',GMAIL_CLIENT_ID: 'client',GMAIL_CLIENT_SECRET: 'secret',GMAIL_REFRESH_HELLO: 'refresh',} as neverconst run = sync(env, { writeHistory: false })await vi.runAllTimersAsync()await runconst inbox = JSON.parse((await kv.get('inbox:messages'))!) as {mailboxes: { mailbox: string; ok: boolean; messages: unknown[]; windowTotal: number | null; truncated: boolean }[]}const hello = inbox.mailboxes.find((m) => m.ok)expect(hello?.mailbox).toBe('hello@meridiandisplay.example')expect(hello?.messages).toHaveLength(INBOX_MAX_PER_MAILBOX)expect(hello?.windowTotal).toBe(windowSize)expect(hello?.truncated).toBe(true)} finally {vi.useRealTimers()vi.stubGlobal('fetch', vi.fn(async () => new Response('unavailable', { status: 503 })))}})})/*** The button records; the minute cron runs.** A fetch handler's waitUntil does not get the budget a scheduled one does, and* a two-minute sync does not fit in it: started from the route, it is cut off* with nothing written and nothing in sync:lastError. The same sync() completes* from a cron.** These pin the split, because the failure mode is silence: a button that* records a request nobody runs looks exactly like a button that worked.*/describe('the minute cron runs what the button asked for', () => {const request = (over: Record<string, unknown> = {}) =>JSON.stringify({ at: new Date().toISOString(), by: 'Sam Whitaker', ...over })it('runs a full sync when a request is pending', async () => {const { kv, puts } = recordingKv({ 'sync:request': request() })await runRequestedSync({ DASHBOARD: kv } as never)expect(puts.some((k) => k.startsWith('dashboard:html'))).toBe(true)expect(puts).toContain('inbox:messages')})it('still never writes the history row', async () => {const { kv, puts } = recordingKv({ 'sync:request': request() })await runRequestedSync({ DASHBOARD: kv } as never)expect(puts).not.toContain(HISTORY_KEY)})it('does nothing at all on a minute with no request', async () => {// Which is almost every minute. A cron that synced regardless would be a// fourth sync nobody asked for, at sixty times the intended rate.const { kv, puts } = recordingKv()expect(await runRequestedSync({ DASHBOARD: kv } as never)).toBe(false)expect(puts).toEqual([])})it('claims the request BEFORE running, so the next minute does not duplicate it', async () => {// The cron fires every minute and a sync takes two. Without the claim the// second minute starts a duplicate mid-run, and the two compete for the// rate limits the cooldown exists to protect.const store: Record<string, string> = { 'sync:request': request() }const { kv } = recordingKv(store)const order: string[] = []const watched = {...kv,put: async (k: string, v: string) => {order.push(k)await kv.put(k, v)},}await runRequestedSync({ DASHBOARD: watched } as never)expect(order[0]).toBe('sync:request')expect(JSON.parse(store['sync:request']!).claimed).toBe(true)})it('refuses a request it has already claimed', async () => {const { kv, puts } = recordingKv({ 'sync:request': request({ claimed: true }) })expect(await runRequestedSync({ DASHBOARD: kv } as never)).toBe(false)expect(puts).toEqual([])})it('ignores a stale request rather than running a sync nobody is waiting for', async () => {// A press from an hour ago is not a press now: the tab is closed and the// quarter-hour cron has run four times since.const old = new Date(Date.now() - 60 * 60_000).toISOString()const { kv, puts } = recordingKv({ 'sync:request': JSON.stringify({ at: old, by: 'Sam Whitaker' }) })expect(await runRequestedSync({ DASHBOARD: kv } as never)).toBe(false)expect(puts).toEqual([])})it('the minute cron writes nothing when idle, through the real handler', async () => {const { puts } = await fire('* * * * *')expect(puts).toEqual([])})it('the minute cron is not a third full sync', async () => {// If this ever starts writing dashboard:html on an idle minute, the board// is syncing sixty times an hour and burning every upstream rate limit.const { puts } = await fire('* * * * *')expect(puts.some((k) => k.startsWith('dashboard:html'))).toBe(false)})})describe('POST /sync records the request and does not try to run it', () => {it('writes sync:request and nothing else', async () => {// The route cannot finish the work; that is the whole finding. What it can// do is record that somebody asked, which is what the minute cron reads.const { kv, puts } = recordingKv()const res = await worker.fetch(new Request('http://x/sync', { method: 'post' }),{ DASHBOARD: kv } as never,{ waitUntil: () => {} } as never,)// Unidentified in this suite, so it is refused. The identity-free// assertion below is the one that matters here.expect(res.status).toBe(403)expect(puts).toEqual([])})})