From 725ed3c66b15141a1b666287573db770e11e00f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 09:23:58 +0000 Subject: [PATCH 1/4] test: consolidate low-signal workers tests into workflow journeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapses 32 tests across four files down to 15, following the 'fewer longer workflow tests' principle from testing-principles.md. run-records.workers.test.ts (20 → 9): - Merge eager-begin/finish, finish-only upsert, one-shot recordRunRecord, waitUntil non-blocking, and execute on-failure policy into a single 'write surfaces journey' test. - Delete 'sandbox level markers become structured log levels' — covered by the existing sandbox e2e test which already asserts structured log levels on each entry. - Merge retention-priority, cap-protect-running, stale-reconcile, stale-cap-evict, and age-cap scenarios into 'cap and stale retention journey' (each with its own userId for DO isolation). - Extend 'write after idle re-arms alarm and age-prunes' into a full 'alarm lifecycle' narrative that also covers the fresh-arm far-future deadline assertion and the self-termination assertion from the two standalone alarm tests. runs.workers.test.ts (5 → 2): - Keep the auth guard test unchanged. - Merge run_list filter smoke, run_get with logs/foreign/missing rejection, run_summary counts, and run_list/run_summary tenant isolation into a single 'run capabilities smoke' test. Filter/ pagination matrix remains in run-records.workers.test.ts. execute-console-capture.workers.test.ts (4 → 2): - Merge success-levels, throw-capture, and unshimmed-methods into 'console capture contract'; keep 'reused dynamic workers' separate. stripe-webhooks.workers.test.ts (3 → 2): - Fold the 503-when-secret-unset guard into the main journey test as an upfront check before any seeding. Co-authored-by: Kent C. Dodds --- .../billing/stripe-webhooks.workers.test.ts | 12 +- .../capabilities/runs/runs.workers.test.ts | 263 +---- .../execute-console-capture.workers.test.ts | 101 +- .../run-records/run-records.workers.test.ts | 1008 ++++++++--------- 4 files changed, 564 insertions(+), 820 deletions(-) diff --git a/packages/worker/src/billing/stripe-webhooks.workers.test.ts b/packages/worker/src/billing/stripe-webhooks.workers.test.ts index 64e708eb0f..9d5e8f8934 100644 --- a/packages/worker/src/billing/stripe-webhooks.workers.test.ts +++ b/packages/worker/src/billing/stripe-webhooks.workers.test.ts @@ -150,9 +150,10 @@ async function signedWebhookRequest(input: { }) } -test('stripe webhook returns 503 when webhook secret is unset', async () => { +test('stripe webhook verifies signature, links checkout, refreshes subscription, and is idempotent', async () => { + // Guard: returns 503 when webhook secret is not configured. await ensureEntitlementTestSchema(env.APP_DB) - const result = await handleStripeWebhookRequest({ + const unconfigured = await handleStripeWebhookRequest({ env: createWebhookEnv({ STRIPE_WEBHOOK_SECRET: '' }), request: new Request('https://test.kody.dev/webhooks/stripe', { method: 'POST', @@ -160,11 +161,10 @@ test('stripe webhook returns 503 when webhook secret is unset', async () => { }), now, }) - expect(result.status).toBe(503) - expect(result.body.ok).toBe(false) -}) + expect(unconfigured.status).toBe(503) + expect(unconfigured.body.ok).toBe(false) -test('stripe webhook verifies signature, links checkout, refreshes subscription, and is idempotent', async () => { + // Main journey: checkout completion, subscription update, unknown event, bad signature. const email = `wh-checkout-${crypto.randomUUID()}@example.com` const user = await seedUser({ email }) stubStripeFetch({ diff --git a/packages/worker/src/mcp/capabilities/runs/runs.workers.test.ts b/packages/worker/src/mcp/capabilities/runs/runs.workers.test.ts index 0289cb4458..8cc12a0ad1 100644 --- a/packages/worker/src/mcp/capabilities/runs/runs.workers.test.ts +++ b/packages/worker/src/mcp/capabilities/runs/runs.workers.test.ts @@ -88,121 +88,9 @@ test( ) test( - 'run_list filters by surface/status/job and paginates with cursors', + 'run capabilities smoke: list, get, summary, and tenant isolation', async () => { - const userId = uniqueUserId('list') - const callerContext = buildCallerContext({ - userId, - email: `${userId}@example.com`, - }) - - const startedAtBase = Date.now() - 60_000 - // Seeds are independent (distinct ids and startedAt) and list order is by - // started_at, so they can pipeline instead of paying one DO round trip each. - await Promise.all([ - finishRun({ - userId, - id: 'run-job-ok', - startedAt: new Date(startedAtBase).toISOString(), - context: baseContext({ - surface: 'job', - jobId: 'job-a', - name: 'job-a', - }), - status: 'success', - }), - finishRun({ - userId, - id: 'run-job-err', - startedAt: new Date(startedAtBase + 1000).toISOString(), - context: baseContext({ - surface: 'job', - jobId: 'job-a', - name: 'job-a-fail', - }), - status: 'error', - error: new Error('boom'), - logs: ['failed'], - }), - finishRun({ - userId, - id: 'run-webhook', - startedAt: new Date(startedAtBase + 2000).toISOString(), - context: baseContext({ - surface: 'webhook', - name: 'sentry', - metadata: { - outcome: 'delivered', - http_status: 202, - payload_bytes: 8, - }, - }), - status: 'success', - }), - finishRun({ - userId, - id: 'run-job-b', - startedAt: new Date(startedAtBase + 3000).toISOString(), - context: baseContext({ - surface: 'job', - jobId: 'job-b', - name: 'job-b', - }), - status: 'success', - }), - ]) - - const jobs = await runListCapability.handler( - { surface: 'job' }, - { env, callerContext }, - ) - expect(jobs.runs.map((run) => run.id)).toEqual([ - 'run-job-b', - 'run-job-err', - 'run-job-ok', - ]) - - const errors = await runListCapability.handler( - { status: 'error' }, - { env, callerContext }, - ) - expect(errors.runs.map((run) => run.id)).toEqual(['run-job-err']) - expect(errors.runs[0]?.error_message).toBe('boom') - - const jobA = await runListCapability.handler( - { job_id: 'job-a' }, - { env, callerContext }, - ) - expect(jobA.runs.map((run) => run.id)).toEqual([ - 'run-job-err', - 'run-job-ok', - ]) - - const page1 = await runListCapability.handler( - { limit: 2 }, - { env, callerContext }, - ) - expect(page1.runs.map((run) => run.id)).toEqual([ - 'run-job-b', - 'run-webhook', - ]) - expect(page1.next_cursor).toEqual(expect.any(String)) - const page2 = await runListCapability.handler( - { limit: 2, cursor: page1.next_cursor ?? undefined }, - { env, callerContext }, - ) - expect(page2.runs.map((run) => run.id)).toEqual([ - 'run-job-err', - 'run-job-ok', - ]) - }, - runLogSuiteTimeoutMs, -) - -test( - 'run_get returns logs and rejects missing or foreign runs', - async () => { - const ownerId = uniqueUserId('owner') + const ownerId = uniqueUserId('tenant') const otherId = uniqueUserId('other') const ownerContext = buildCallerContext({ userId: ownerId, @@ -213,18 +101,52 @@ test( email: `${otherId}@example.com`, }) + // Seed owner's runs; startedAt offsets ensure stable list order. + const startedAtBase = Date.now() - 60_000 const handle = await finishRun({ userId: ownerId, - context: baseContext({ - surface: 'job', - jobId: 'job-logs', - name: 'with-logs', - }), + id: 'run-job-err', + startedAt: new Date(startedAtBase).toISOString(), + context: baseContext({ surface: 'job', jobId: 'job-a', name: 'with-logs' }), status: 'error', - error: new Error('explode'), + error: new Error('boom'), logs: ['line-one', 'line-two'], }) + await finishRun({ + userId: ownerId, + id: 'run-webhook', + startedAt: new Date(startedAtBase + 1000).toISOString(), + context: baseContext({ surface: 'webhook', name: 'sentry' }), + status: 'success', + }) + const pending: Array> = [] + beginRunRecord({ + env, + userId: ownerId, + context: baseContext({ + surface: 'workflow', + workflowId: 'wf-running', + name: 'still-going', + }), + waitUntil: (promise) => { + pending.push(promise) + }, + }) + await Promise.all(pending) + // run_list: smoke surface filter; other tenant sees empty list. + const jobRuns = await runListCapability.handler( + { surface: 'job' }, + { env, callerContext: ownerContext }, + ) + expect(jobRuns.runs.some((r) => r.id === 'run-job-err')).toBe(true) + const otherList = await runListCapability.handler( + {}, + { env, callerContext: otherContext }, + ) + expect(otherList.runs).toEqual([]) + + // run_get: owner reads own run with logs; other tenant and missing run are rejected. const detail = await runGetCapability.handler( { run_id: handle.id }, { env, callerContext: ownerContext }, @@ -235,124 +157,33 @@ test( 'line-one', 'line-two', ]) - await expect( runGetCapability.handler( { run_id: handle.id }, { env, callerContext: otherContext }, ), ).rejects.toThrow(/was not found/) - await expect( runGetCapability.handler( { run_id: crypto.randomUUID() }, { env, callerContext: ownerContext }, ), ).rejects.toThrow(/was not found/) - }, - runLogSuiteTimeoutMs, -) - -test( - 'run_summary counts totals, errors, running, and surfaces', - async () => { - const userId = uniqueUserId('summary') - const callerContext = buildCallerContext({ - userId, - email: `${userId}@example.com`, - }) - - await Promise.all([ - finishRun({ - userId, - context: baseContext({ surface: 'job', jobId: 'job-1' }), - status: 'success', - }), - finishRun({ - userId, - context: baseContext({ surface: 'job', jobId: 'job-2' }), - status: 'error', - error: new Error('fail'), - }), - finishRun({ - userId, - context: baseContext({ - surface: 'webhook', - name: 'hook', - }), - status: 'success', - }), - ]) - const pending: Array> = [] - beginRunRecord({ - env, - userId, - context: baseContext({ - surface: 'workflow', - workflowId: 'wf-running', - name: 'still-going', - }), - waitUntil: (promise) => { - pending.push(promise) - }, - }) - await Promise.all(pending) + // run_summary: owner sees totals including the in-flight workflow run; + // other tenant sees zeros. const summary = await runSummaryCapability.handler( {}, - { env, callerContext }, + { env, callerContext: ownerContext }, ) - expect(summary.total).toBeGreaterThanOrEqual(4) + expect(summary.total).toBeGreaterThanOrEqual(3) expect(summary.errors).toBeGreaterThanOrEqual(1) expect(summary.running).toBeGreaterThanOrEqual(1) expect( summary.by_surface.some( - (entry) => - entry.surface === 'job' && entry.total >= 2 && entry.errors >= 1, - ), - ).toBe(true) - expect( - summary.by_surface.some( - (entry) => entry.surface === 'webhook' && entry.total >= 1, + (entry) => entry.surface === 'job' && entry.total >= 1, ), ).toBe(true) - }, - runLogSuiteTimeoutMs, -) - -test( - 'run_list cannot read another users records', - async () => { - const ownerId = uniqueUserId('iso-owner') - const otherId = uniqueUserId('iso-other') - const ownerContext = buildCallerContext({ - userId: ownerId, - email: `${ownerId}@example.com`, - }) - const otherContext = buildCallerContext({ - userId: otherId, - email: `${otherId}@example.com`, - }) - - await finishRun({ - userId: ownerId, - context: baseContext({ surface: 'job', jobId: 'secret-job' }), - status: 'error', - error: new Error('owner-only'), - }) - - const ownerList = await runListCapability.handler( - {}, - { env, callerContext: ownerContext }, - ) - expect(ownerList.runs.some((run) => run.job_id === 'secret-job')).toBe(true) - - const otherList = await runListCapability.handler( - {}, - { env, callerContext: otherContext }, - ) - expect(otherList.runs).toEqual([]) - const otherSummary = await runSummaryCapability.handler( {}, { env, callerContext: otherContext }, diff --git a/packages/worker/src/mcp/execute-console-capture.workers.test.ts b/packages/worker/src/mcp/execute-console-capture.workers.test.ts index a319ff7a4c..9562ef25ce 100644 --- a/packages/worker/src/mcp/execute-console-capture.workers.test.ts +++ b/packages/worker/src/mcp/execute-console-capture.workers.test.ts @@ -46,12 +46,14 @@ async function buildEntryBundle(input: { } test( - 'console.log/warn/error from a user module are captured with existing prefixes', + 'console capture contract: levels on success, capture through throw, and unshimmed methods', { timeout: 60_000 }, async () => { silenceIncidentalRuntimeWarnings() - const userId = 'user-console-capture-levels' - const bundle = await buildEntryBundle({ + const userId = 'user-console-capture-contract' + + // log/warn/error levels are captured with the correct prefix on success. + const levelsBundle = await buildEntryBundle({ env, userId, source: [ @@ -64,31 +66,24 @@ test( '}', ].join('\n'), }) - const result = await runBundledModuleWithRegistry( + const levelsResult = await runBundledModuleWithRegistry( env, createCaller(userId), - bundle, + levelsBundle, undefined, { skipCapabilityRegistry: true }, ) - expect(result.error).toBeUndefined() - expect(result.result).toBe('ok') - expect(result.logs).toEqual([ + expect(levelsResult.error).toBeUndefined() + expect(levelsResult.result).toBe('ok') + expect(levelsResult.logs).toEqual([ 'alpha', 'beta', '[warn] heads up', '[error] boom line', ]) - }, -) -test( - 'console logs are captured when user code throws', - { timeout: 60_000 }, - async () => { - silenceIncidentalRuntimeWarnings() - const userId = 'user-console-capture-throw' - const bundle = await buildEntryBundle({ + // Logs emitted before a throw are still captured in the result. + const throwBundle = await buildEntryBundle({ env, userId, source: [ @@ -99,15 +94,44 @@ test( '}', ].join('\n'), }) - const result = await runBundledModuleWithRegistry( + const throwResult = await runBundledModuleWithRegistry( + env, + createCaller(userId), + throwBundle, + undefined, + { skipCapabilityRegistry: true }, + ) + expect(throwResult.error).toBe('sandbox boom') + expect(throwResult.logs).toEqual(['before throw', '[warn] about to fail']) + + // Unshimmed console methods (table, dir, count, time, …) do not throw; + // only console.log output is included in the captured logs. + const unshimmedBundle = await buildEntryBundle({ + env, + userId, + source: [ + 'export default async function main() {', + "\tconsole.log('before table')", + '\tconsole.table({ ok: true })', + '\tconsole.dir({ ok: true })', + "\tconsole.count('label')", + "\tconsole.countReset('label')", + "\tconsole.time('t')", + "\tconsole.timeEnd('t')", + "\treturn 'ok'", + '}', + ].join('\n'), + }) + const unshimmedResult = await runBundledModuleWithRegistry( env, createCaller(userId), - bundle, + unshimmedBundle, undefined, { skipCapabilityRegistry: true }, ) - expect(result.error).toBe('sandbox boom') - expect(result.logs).toEqual(['before throw', '[warn] about to fail']) + expect(unshimmedResult.error).toBeUndefined() + expect(unshimmedResult.result).toBe('ok') + expect(unshimmedResult.logs).toEqual(['before table']) }, ) @@ -159,38 +183,3 @@ test( ) }, ) - -test( - 'unshimmed console methods do not throw for user modules', - { timeout: 60_000 }, - async () => { - silenceIncidentalRuntimeWarnings() - const userId = 'user-console-capture-unshimmed' - const bundle = await buildEntryBundle({ - env, - userId, - source: [ - 'export default async function main() {', - "\tconsole.log('before table')", - '\tconsole.table({ ok: true })', - '\tconsole.dir({ ok: true })', - "\tconsole.count('label')", - "\tconsole.countReset('label')", - "\tconsole.time('t')", - "\tconsole.timeEnd('t')", - "\treturn 'ok'", - '}', - ].join('\n'), - }) - const result = await runBundledModuleWithRegistry( - env, - createCaller(userId), - bundle, - undefined, - { skipCapabilityRegistry: true }, - ) - expect(result.error).toBeUndefined() - expect(result.result).toBe('ok') - expect(result.logs).toEqual(['before table']) - }, -) diff --git a/packages/worker/src/run-records/run-records.workers.test.ts b/packages/worker/src/run-records/run-records.workers.test.ts index 4a5546992f..7bbaf4f22e 100644 --- a/packages/worker/src/run-records/run-records.workers.test.ts +++ b/packages/worker/src/run-records/run-records.workers.test.ts @@ -99,8 +99,9 @@ function insertRunRow( ) } -test('eager surface begin then finish writes one successful run', async () => { - const userId = uniqueUserId('eager-success') +test('write surfaces journey', async () => { + // --- eager begin/finish → success with log count --- + const userId = uniqueUserId('write-surfaces') const pending: Array> = [] const handle = beginRunRecord({ env, @@ -112,25 +113,19 @@ test('eager surface begin then finish writes one successful run', async () => { }) expect(handle).not.toBeNull() await drainWaitUntil(pending) - await finishRunRecord({ - env, - handle, - status: 'success', - logs: ['done'], - }) + await finishRunRecord({ env, handle, status: 'success', logs: ['done'] }) const page = await listRunRecords({ env, userId }) expect(page.runs).toHaveLength(1) expect(page.runs[0]?.status).toBe('success') expect(page.runs[0]?.surface).toBe('job') expect(page.runs[0]?.jobId).toBe('job-1') expect(page.runs[0]?.logCount).toBe(1) -}) -test('finishRunRecord alone still upserts a complete row when startRun never landed', async () => { - const userId = uniqueUserId('finish-only') - const handle: RunRecordHandle = { + // --- finish-only upsert when startRun never landed --- + const userId2 = uniqueUserId('finish-only') + const orphanHandle: RunRecordHandle = { id: crypto.randomUUID(), - userId, + userId: userId2, startedAt: new Date().toISOString(), persistence: 'eager', context: baseContext({ @@ -141,77 +136,83 @@ test('finishRunRecord alone still upserts a complete row when startRun never lan } await finishRunRecord({ env, - handle, + handle: orphanHandle, status: 'success', logs: [{ level: 'info', message: 'finished without start' }], }) - const detail = await getRunRecord({ env, userId, runId: handle.id }) - expect(detail).not.toBeNull() - expect(detail?.run.status).toBe('success') - expect(detail?.run.surface).toBe('workflow') - expect(detail?.run.workflowId).toBe('wf-1') - expect(detail?.logs).toEqual([ + const orphanDetail = await getRunRecord({ + env, + userId: userId2, + runId: orphanHandle.id, + }) + expect(orphanDetail).not.toBeNull() + expect(orphanDetail?.run.status).toBe('success') + expect(orphanDetail?.run.surface).toBe('workflow') + expect(orphanDetail?.run.workflowId).toBe('wf-1') + expect(orphanDetail?.logs).toEqual([ { - runId: handle.id, + runId: orphanHandle.id, sequence: 0, level: 'info', message: 'finished without start', fields: null, }, ]) -}) -test('recordRunRecord writes a terminal row in one call without begin', async () => { - const userId = uniqueUserId('record-one-shot') - const handle = await recordRunRecord({ + // --- recordRunRecord one-shot terminal write --- + const userId3 = uniqueUserId('record-one-shot') + const shotHandle = await recordRunRecord({ env, - userId, - context: baseContext({ - surface: 'webhook', - name: 'hook-a', - }), + userId: userId3, + context: baseContext({ surface: 'webhook', name: 'hook-a' }), status: 'success', logs: ['delivered'], }) - expect(handle).not.toBeNull() - const detail = await getRunRecord({ env, userId, runId: handle!.id }) - expect(detail?.run.status).toBe('success') - expect(detail?.run.surface).toBe('webhook') - expect(detail?.run.name).toBe('hook-a') - expect(detail?.logs).toHaveLength(1) -}) + expect(shotHandle).not.toBeNull() + const shotDetail = await getRunRecord({ + env, + userId: userId3, + runId: shotHandle!.id, + }) + expect(shotDetail?.run.status).toBe('success') + expect(shotDetail?.run.surface).toBe('webhook') + expect(shotDetail?.run.name).toBe('hook-a') + expect(shotDetail?.logs).toHaveLength(1) -test('finishRunRecord waitUntil does not block the caller', async () => { - const userId = uniqueUserId('finish-wait-until') - const handle: RunRecordHandle = { + // --- finishRunRecord returns synchronously when waitUntil is provided --- + const userId4 = uniqueUserId('finish-wait-until') + const waitHandle: RunRecordHandle = { id: crypto.randomUUID(), - userId, + userId: userId4, startedAt: new Date().toISOString(), persistence: 'eager', context: baseContext({ surface: 'export', name: 'bg-finish' }), } - const pending: Array> = [] + const waitPending: Array> = [] const finishReturn = finishRunRecord({ env, - handle, + handle: waitHandle, status: 'success', logs: ['async'], waitUntil: (promise) => { - pending.push(promise) + waitPending.push(promise) }, }) await expect(finishReturn).resolves.toBeUndefined() - expect(pending).toHaveLength(1) - await drainWaitUntil(pending) - const detail = await getRunRecord({ env, userId, runId: handle.id }) - expect(detail?.run.status).toBe('success') -}) + expect(waitPending).toHaveLength(1) + await drainWaitUntil(waitPending) + const waitDetail = await getRunRecord({ + env, + userId: userId4, + runId: waitHandle.id, + }) + expect(waitDetail?.run.status).toBe('success') -test('execute surface persists nothing on success and one row on error', async () => { - const userId = uniqueUserId('execute-policy') + // --- execute surface: on-failure policy (no persist on success, one row on error) --- + const userId5 = uniqueUserId('execute-policy') const successHandle = beginRunRecord({ env, - userId, + userId: userId5, context: baseContext({ surface: 'execute', name: 'ok' }), }) expect(successHandle?.persistence).toBe('on-failure') @@ -221,14 +222,13 @@ test('execute surface persists nothing on success and one row on error', async ( status: 'success', logs: ['should not persist'], }) - expect(await listRunRecords({ env, userId })).toEqual({ + expect(await listRunRecords({ env, userId: userId5 })).toEqual({ runs: [], nextCursor: null, }) - const errorHandle = beginRunRecord({ env, - userId, + userId: userId5, context: baseContext({ surface: 'execute', name: 'boom' }), }) await finishRunRecord({ @@ -238,12 +238,12 @@ test('execute surface persists nothing on success and one row on error', async ( error: new Error('execute failed'), logs: ['error log'], }) - const page = await listRunRecords({ env, userId }) - expect(page.runs).toHaveLength(1) - expect(page.runs[0]?.status).toBe('error') - expect(page.runs[0]?.errorName).toBe('Error') - expect(page.runs[0]?.errorMessage).toBe('execute failed') - expect(page.runs[0]?.surface).toBe('execute') + const execPage = await listRunRecords({ env, userId: userId5 }) + expect(execPage.runs).toHaveLength(1) + expect(execPage.runs[0]?.status).toBe('error') + expect(execPage.runs[0]?.errorName).toBe('Error') + expect(execPage.runs[0]?.errorMessage).toBe('execute failed') + expect(execPage.runs[0]?.surface).toBe('execute') }) test('logs round-trip in sequence order and keep only the newest 200', async () => { @@ -276,36 +276,6 @@ test('logs round-trip in sequence order and keep only the newest 200', async () expect(detail?.run.logCount).toBe(runRecordMaxLogEntriesPerRun) }) -test('sandbox level markers become structured log levels', async () => { - const userId = uniqueUserId('log-levels') - const handle = beginRunRecord({ - env, - userId, - context: baseContext({ surface: 'job' }), - }) - await finishRunRecord({ - env, - handle, - status: 'error', - error: new Error('boom'), - logs: [ - 'plain line', - '[warn] slow upstream', - '[error] missing items array', - '[info] retrying', - '[debug] cursor=42', - ], - }) - const detail = await getRunRecord({ env, userId, runId: handle!.id }) - expect(detail?.logs.map((entry) => [entry.level, entry.message])).toEqual([ - ['log', 'plain line'], - ['warn', 'slow upstream'], - ['error', 'missing items array'], - ['info', 'retrying'], - ['debug', 'cursor=42'], - ]) -}) - test('listRunRecords filters by surface/status/jobId/name and paginates with cursors', async () => { const userId = uniqueUserId('list-filter') const startedAtBase = Date.now() - 60_000 @@ -433,330 +403,435 @@ test('summarizeRunRecords returns totals and per-surface error counts', async () ) }) -test('retention deletes successes before errors when over the run cap', async () => { - const userId = uniqueUserId('retention') - const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) - const baseMs = Date.now() - 3_600_000 - const successCount = runRecordMaxRunsPerUser - const errorCount = 10 - await runInDurableObject(stub, async (instance: RunLog, state) => { - expect(instance).toBeInstanceOf(RunLog) - for (let index = 0; index < successCount; index += 1) { - const startedAt = new Date(baseMs + index).toISOString() - insertRunRow(state, { - id: `success-${index}`, - status: 'success', - startedAt, - finishedAt: startedAt, - }) - } - for (let index = 0; index < errorCount; index += 1) { - const startedAt = new Date(baseMs + successCount + index).toISOString() +test('cap and stale retention journey', async () => { + // --- successes deleted before errors when over cap --- + { + const userId = uniqueUserId('retention-priority') + const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) + const baseMs = Date.now() - 3_600_000 + const successCount = runRecordMaxRunsPerUser + const errorCount = 10 + await runInDurableObject(stub, async (instance: RunLog, state) => { + expect(instance).toBeInstanceOf(RunLog) + for (let index = 0; index < successCount; index += 1) { + const startedAt = new Date(baseMs + index).toISOString() + insertRunRow(state, { + id: `success-${index}`, + status: 'success', + startedAt, + finishedAt: startedAt, + }) + } + for (let index = 0; index < errorCount; index += 1) { + const startedAt = new Date(baseMs + successCount + index).toISOString() + insertRunRow(state, { + id: `error-${index}`, + status: 'error', + startedAt, + finishedAt: startedAt, + }) + } + }) + await armRetentionOnNextFinish(userId, successCount + errorCount) + await finishRunRecord({ + env, + handle: { + id: 'retention-trigger', + userId, + startedAt: new Date(baseMs + runRecordMaxRunsPerUser + 20).toISOString(), + persistence: 'eager', + context: baseContext({ surface: 'job', name: 'trigger' }), + }, + status: 'success', + }) + const rpc = runLogRpc({ env, userId }) + const summary = await rpc.summarize({ since: '1970-01-01T00:00:00.000Z' }) + expect(summary.total).toBe(runRecordMaxRunsPerUser) + expect(summary.errors).toBe(10) + const remainingSuccess = await runInDurableObject( + stub, + async (_instance: RunLog, state) => + state.storage.sql + .exec<{ n: number }>( + `SELECT COUNT(*) AS n FROM runs WHERE status = 'success'`, + ) + .one().n, + ) + expect(remainingSuccess).toBe(runRecordMaxRunsPerUser - 10) + expect( + await runInDurableObject( + stub, + async (_instance: RunLog, state) => + state.storage.sql + .exec<{ n: number }>( + `SELECT COUNT(*) AS n FROM runs WHERE id = 'success-0'`, + ) + .one().n, + ), + ).toBe(0) + } + + // --- cap eviction never deletes in-flight running rows --- + { + const userId = uniqueUserId('cap-protect-running') + const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) + const baseMs = Date.now() - 3_600_000 + // Fewer successes than the eventual excess so the old success→running→error + // order would delete the in-flight row after successes are exhausted. + const successCount = 2 + const errorCount = runRecordMaxRunsPerUser + await runInDurableObject(stub, async (instance: RunLog, state) => { + expect(instance).toBeInstanceOf(RunLog) + for (let index = 0; index < successCount; index += 1) { + const startedAt = new Date(baseMs + index).toISOString() + insertRunRow(state, { + id: `success-${index}`, + status: 'success', + startedAt, + finishedAt: startedAt, + }) + } insertRunRow(state, { - id: `error-${index}`, - status: 'error', - startedAt, - finishedAt: startedAt, + id: 'still-running', + status: 'running', + startedAt: new Date(baseMs + successCount).toISOString(), + finishedAt: null, }) - } - }) - await armRetentionOnNextFinish(userId, successCount + errorCount) - - const handle: RunRecordHandle = { - id: 'retention-trigger', - userId, - startedAt: new Date(baseMs + runRecordMaxRunsPerUser + 20).toISOString(), - persistence: 'eager', - context: baseContext({ surface: 'job', name: 'trigger' }), + for (let index = 0; index < errorCount; index += 1) { + const startedAt = new Date( + baseMs + successCount + 1 + index, + ).toISOString() + insertRunRow(state, { + id: `error-${index}`, + status: 'error', + startedAt, + finishedAt: startedAt, + }) + } + }) + const seeded = successCount + 1 + errorCount + await armRetentionOnNextFinish(userId, seeded) + await finishRunRecord({ + env, + handle: { + id: 'cap-running-trigger', + userId, + startedAt: new Date(baseMs + seeded + 10).toISOString(), + persistence: 'eager', + context: baseContext({ surface: 'job', name: 'cap-running-trigger' }), + }, + status: 'success', + }) + expect( + (await getRunRecord({ env, userId, runId: 'still-running' }))?.run.status, + ).toBe('running') + const counts = await runInDurableObject( + stub, + async (_instance: RunLog, state) => ({ + successes: state.storage.sql + .exec<{ n: number }>( + `SELECT COUNT(*) AS n FROM runs WHERE status = 'success'`, + ) + .one().n, + errors: state.storage.sql + .exec<{ n: number }>( + `SELECT COUNT(*) AS n FROM runs WHERE status = 'error'`, + ) + .one().n, + running: state.storage.sql + .exec<{ n: number }>( + `SELECT COUNT(*) AS n FROM runs WHERE status = 'running'`, + ) + .one().n, + success0: state.storage.sql + .exec<{ n: number }>( + `SELECT COUNT(*) AS n FROM runs WHERE id = 'success-0'`, + ) + .one().n, + error0: state.storage.sql + .exec<{ n: number }>( + `SELECT COUNT(*) AS n FROM runs WHERE id = 'error-0'`, + ) + .one().n, + }), + ) + // Excess was 4: 3 successes (2 seeded + trigger) then 1 oldest error. + // The in-flight row is skipped entirely. + expect(counts.success0).toBe(0) + expect(counts.successes).toBe(0) + expect(counts.running).toBe(1) + expect(counts.error0).toBe(0) + expect(counts.errors).toBe(errorCount - 1) } - await finishRunRecord({ - env, - handle, - status: 'success', - }) - - const rpc = runLogRpc({ env, userId }) - const summary = await rpc.summarize({ since: '1970-01-01T00:00:00.000Z' }) - expect(summary.total).toBe(runRecordMaxRunsPerUser) - expect(summary.errors).toBe(10) - const remainingSuccess = await runInDurableObject( - stub, - async (_instance: RunLog, state) => { - return state.storage.sql - .exec<{ n: number }>( - `SELECT COUNT(*) AS n FROM runs WHERE status = 'success'`, - ) - .one().n - }, - ) - expect(remainingSuccess).toBe(runRecordMaxRunsPerUser - 10) - const oldestSuccessGone = await runInDurableObject( - stub, - async (_instance: RunLog, state) => { - return state.storage.sql - .exec<{ n: number }>( - `SELECT COUNT(*) AS n FROM runs WHERE id = 'success-0'`, - ) - .one().n - }, - ) - expect(oldestSuccessGone).toBe(0) -}) - -test('cap eviction never deletes in-flight running rows', async () => { - const userId = uniqueUserId('cap-protect-running') - const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) - const baseMs = Date.now() - 3_600_000 - // Fewer successes than the eventual excess so the old success→running→error - // order would delete the in-flight row after successes are exhausted. - const successCount = 2 - const errorCount = runRecordMaxRunsPerUser - await runInDurableObject(stub, async (instance: RunLog, state) => { - expect(instance).toBeInstanceOf(RunLog) - for (let index = 0; index < successCount; index += 1) { - const startedAt = new Date(baseMs + index).toISOString() + // --- stale running rows reconciled to interrupted errors; fresh running preserved --- + { + const userId = uniqueUserId('stale-running') + const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) + const staleStartedAt = new Date( + Date.now() - runRecordStaleRunningTtlMs - 60_000, + ).toISOString() + const freshStartedAt = new Date().toISOString() + await runInDurableObject(stub, async (instance: RunLog, state) => { + expect(instance).toBeInstanceOf(RunLog) insertRunRow(state, { - id: `success-${index}`, - status: 'success', - startedAt, - finishedAt: startedAt, + id: 'stale-running', + status: 'running', + startedAt: staleStartedAt, + finishedAt: null, }) - } - insertRunRow(state, { - id: 'still-running', - status: 'running', - startedAt: new Date(baseMs + successCount).toISOString(), - finishedAt: null, - }) - for (let index = 0; index < errorCount; index += 1) { - const startedAt = new Date( - baseMs + successCount + 1 + index, - ).toISOString() insertRunRow(state, { - id: `error-${index}`, - status: 'error', - startedAt, - finishedAt: startedAt, + id: 'fresh-running', + status: 'running', + startedAt: freshStartedAt, + finishedAt: null, }) - } - }) - const seeded = successCount + 1 + errorCount - await armRetentionOnNextFinish(userId, seeded) - - await finishRunRecord({ - env, - handle: { - id: 'cap-running-trigger', - userId, - startedAt: new Date(baseMs + seeded + 10).toISOString(), - persistence: 'eager', - context: baseContext({ surface: 'job', name: 'cap-running-trigger' }), - }, - status: 'success', - }) - - const running = await getRunRecord({ env, userId, runId: 'still-running' }) - expect(running?.run.status).toBe('running') + }) + await armRetentionOnNextFinish(userId, 2) + await finishRunRecord({ + env, + handle: { + id: 'stale-trigger', + userId, + startedAt: new Date().toISOString(), + persistence: 'eager', + context: baseContext({ surface: 'job', name: 'stale-trigger' }), + }, + status: 'success', + }) + const stale = await getRunRecord({ env, userId, runId: 'stale-running' }) + expect(stale?.run.status).toBe('error') + expect(stale?.run.errorName).toBe('Interrupted') + expect(stale?.run.errorMessage).toMatch(/outcome unknown/i) + expect(stale?.run.finishedAt).toBeTruthy() + const fresh = await getRunRecord({ env, userId, runId: 'fresh-running' }) + expect(fresh?.run.status).toBe('running') + expect(fresh?.run.finishedAt).toBeNull() + } - const counts = await runInDurableObject( - stub, - async (_instance: RunLog, state) => ({ - successes: state.storage.sql - .exec<{ n: number }>( - `SELECT COUNT(*) AS n FROM runs WHERE status = 'success'`, - ) - .one().n, - errors: state.storage.sql - .exec<{ n: number }>( - `SELECT COUNT(*) AS n FROM runs WHERE status = 'error'`, - ) - .one().n, - running: state.storage.sql - .exec<{ n: number }>( - `SELECT COUNT(*) AS n FROM runs WHERE status = 'running'`, - ) - .one().n, - success0: state.storage.sql - .exec<{ n: number }>( - `SELECT COUNT(*) AS n FROM runs WHERE id = 'success-0'`, - ) - .one().n, - error0: state.storage.sql - .exec<{ n: number }>( - `SELECT COUNT(*) AS n FROM runs WHERE id = 'error-0'`, - ) - .one().n, - }), - ) - // Excess was 4: 3 successes (2 seeded + trigger) then 1 oldest error. - // The in-flight row is skipped entirely. - expect(counts.success0).toBe(0) - expect(counts.successes).toBe(0) - expect(counts.running).toBe(1) - expect(counts.error0).toBe(0) - expect(counts.errors).toBe(errorCount - 1) -}) + // --- stale rows become cap-evictable after reconcile --- + { + const userId = uniqueUserId('stale-cap-evict') + const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) + const baseMs = Date.now() - 3_600_000 + const staleStartedAt = new Date( + Date.now() - runRecordStaleRunningTtlMs - 60_000, + ).toISOString() + const successCount = 2 + const errorCount = runRecordMaxRunsPerUser - 2 + const staleCount = 5 + await runInDurableObject(stub, async (instance: RunLog, state) => { + expect(instance).toBeInstanceOf(RunLog) + for (let index = 0; index < successCount; index += 1) { + const startedAt = new Date(baseMs + index).toISOString() + insertRunRow(state, { + id: `success-${index}`, + status: 'success', + startedAt, + finishedAt: startedAt, + }) + } + for (let index = 0; index < staleCount; index += 1) { + insertRunRow(state, { + id: `stale-${index}`, + status: 'running', + startedAt: new Date(Date.parse(staleStartedAt) + index).toISOString(), + finishedAt: null, + }) + } + for (let index = 0; index < errorCount; index += 1) { + const startedAt = new Date(baseMs + 10_000 + index).toISOString() + insertRunRow(state, { + id: `error-${index}`, + status: 'error', + startedAt, + finishedAt: startedAt, + }) + } + }) + const seeded = successCount + staleCount + errorCount + await armRetentionOnNextFinish(userId, seeded) + await finishRunRecord({ + env, + handle: { + id: 'stale-cap-trigger', + userId, + startedAt: new Date(baseMs + seeded + 10).toISOString(), + persistence: 'eager', + context: baseContext({ surface: 'job', name: 'stale-cap-trigger' }), + }, + status: 'success', + }) + // Reconcile demotes stale running → Interrupted errors (oldest started_at), + // then cap eviction drains successes and those demoted errors before newer + // seeded errors. + expect(await getRunRecord({ env, userId, runId: 'stale-0' })).toBeNull() + const remainingRunning = await runInDurableObject( + stub, + async (_instance: RunLog, state) => + state.storage.sql + .exec<{ n: number }>( + `SELECT COUNT(*) AS n FROM runs WHERE status = 'running'`, + ) + .one().n, + ) + expect(remainingRunning).toBe(0) + } -test('stale running rows become cap-evictable after reconcile', async () => { - const userId = uniqueUserId('stale-cap-evict') - const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) - const baseMs = Date.now() - 3_600_000 - const staleStartedAt = new Date( - Date.now() - runRecordStaleRunningTtlMs - 60_000, - ).toISOString() - const successCount = 2 - const errorCount = runRecordMaxRunsPerUser - 2 - const staleCount = 5 - await runInDurableObject(stub, async (instance: RunLog, state) => { - expect(instance).toBeInstanceOf(RunLog) - for (let index = 0; index < successCount; index += 1) { - const startedAt = new Date(baseMs + index).toISOString() + // --- amortized retention enforces the age cap --- + { + const userId = uniqueUserId('age-retention') + const oldStartedAt = new Date( + Date.now() - (runRecordRetentionDays + 2) * 24 * 60 * 60 * 1000, + ).toISOString() + const recentStartedAt = new Date(Date.now() - 60_000).toISOString() + const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) + await runInDurableObject(stub, async (instance: RunLog, state) => { + expect(instance).toBeInstanceOf(RunLog) insertRunRow(state, { - id: `success-${index}`, + id: 'old-success', status: 'success', - startedAt, - finishedAt: startedAt, - }) - } - for (let index = 0; index < staleCount; index += 1) { - insertRunRow(state, { - id: `stale-${index}`, - status: 'running', - startedAt: new Date(Date.parse(staleStartedAt) + index).toISOString(), - finishedAt: null, + startedAt: oldStartedAt, + finishedAt: oldStartedAt, }) - } - for (let index = 0; index < errorCount; index += 1) { - const startedAt = new Date(baseMs + 10_000 + index).toISOString() insertRunRow(state, { - id: `error-${index}`, - status: 'error', - startedAt, - finishedAt: startedAt, + id: 'recent-success', + status: 'success', + startedAt: recentStartedAt, + finishedAt: recentStartedAt, }) - } - }) - const seeded = successCount + staleCount + errorCount - await armRetentionOnNextFinish(userId, seeded) - - await finishRunRecord({ - env, - handle: { - id: 'stale-cap-trigger', - userId, - startedAt: new Date(baseMs + seeded + 10).toISOString(), - persistence: 'eager', - context: baseContext({ surface: 'job', name: 'stale-cap-trigger' }), - }, - status: 'success', - }) - - // Reconcile demotes stale running → Interrupted errors (oldest started_at), - // then cap eviction drains successes and those demoted errors before newer - // seeded errors. - const stale0 = await getRunRecord({ env, userId, runId: 'stale-0' }) - expect(stale0).toBeNull() - const remainingRunning = await runInDurableObject( - stub, - async (_instance: RunLog, state) => - state.storage.sql - .exec<{ n: number }>( - `SELECT COUNT(*) AS n FROM runs WHERE status = 'running'`, - ) - .one().n, - ) - expect(remainingRunning).toBe(0) -}) - -test('amortized retention still enforces the age cap', async () => { - const userId = uniqueUserId('age-retention') - const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) - const oldStartedAt = new Date( - Date.now() - (runRecordRetentionDays + 2) * 24 * 60 * 60 * 1000, - ).toISOString() - const recentStartedAt = new Date(Date.now() - 60_000).toISOString() - await runInDurableObject(stub, async (instance: RunLog, state) => { - expect(instance).toBeInstanceOf(RunLog) - insertRunRow(state, { - id: 'old-success', - status: 'success', - startedAt: oldStartedAt, - finishedAt: oldStartedAt, }) - insertRunRow(state, { - id: 'recent-success', + await armRetentionOnNextFinish(userId, 2) + await finishRunRecord({ + env, + handle: { + id: 'age-trigger', + userId, + startedAt: new Date().toISOString(), + persistence: 'eager', + context: baseContext({ surface: 'job', name: 'age-trigger' }), + }, status: 'success', - startedAt: recentStartedAt, - finishedAt: recentStartedAt, }) - }) - await armRetentionOnNextFinish(userId, 2) - - await finishRunRecord({ - env, - handle: { - id: 'age-trigger', - userId, - startedAt: new Date().toISOString(), - persistence: 'eager', - context: baseContext({ surface: 'job', name: 'age-trigger' }), - }, - status: 'success', - }) - - const ids = (await listRunRecords({ env, userId })).runs.map((run) => run.id) - expect(ids).toContain('recent-success') - expect(ids).toContain('age-trigger') - expect(ids).not.toContain('old-success') + const ids = (await listRunRecords({ env, userId })).runs.map( + (run) => run.id, + ) + expect(ids).toContain('recent-success') + expect(ids).toContain('age-trigger') + expect(ids).not.toContain('old-success') + } }) -test('stale running rows are reconciled to interrupted errors', async () => { - const userId = uniqueUserId('stale-running') - const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) - const staleStartedAt = new Date( - Date.now() - runRecordStaleRunningTtlMs - 60_000, - ).toISOString() - const freshStartedAt = new Date().toISOString() - await runInDurableObject(stub, async (instance: RunLog, state) => { - expect(instance).toBeInstanceOf(RunLog) - insertRunRow(state, { - id: 'stale-running', - status: 'running', - startedAt: staleStartedAt, - finishedAt: null, - }) - insertRunRow(state, { - id: 'fresh-running', - status: 'running', - startedAt: freshStartedAt, - finishedAt: null, +test( + 'alarm lifecycle: fresh arm, self-termination when idle, re-arm after idle, and age-prune', + async () => { + const userId = uniqueUserId('alarm-lifecycle') + const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) + const startedAtMs = Date.now() + + // Fresh finish arms a far-future retention alarm at the row's age deadline. + await finishRunRecord({ + env, + handle: { + id: 'first-run', + userId, + startedAt: new Date(startedAtMs).toISOString(), + persistence: 'eager', + context: baseContext({ surface: 'job', name: 'fresh' }), + }, + status: 'success', }) - }) - await armRetentionOnNextFinish(userId, 2) + const initialAlarm = await runInDurableObject( + stub, + async (_instance: RunLog, state) => state.storage.getAlarm(), + ) + expect(initialAlarm).toBeTypeOf('number') + // One-shot at the row's age deadline — not an immediate/hourly wake. + expect(initialAlarm).toBeGreaterThan( + startedAtMs + runRecordRetentionDays * 24 * 60 * 60 * 1000 - 5_000, + ) - await finishRunRecord({ - env, - handle: { - id: 'stale-trigger', - userId, - startedAt: new Date().toISOString(), - persistence: 'eager', - context: baseContext({ surface: 'job', name: 'stale-trigger' }), - }, - status: 'success', - }) + // Age the row past the retention cutoff and fire the alarm directly; + // with nothing left to keep, the alarm self-terminates (no re-arm). + const expiredStartedAt = new Date( + Date.now() - (runRecordRetentionDays + 3) * 24 * 60 * 60 * 1000, + ).toISOString() + await runInDurableObject(stub, async (instance: RunLog, state) => { + state.storage.sql.exec( + `UPDATE runs SET started_at = ?, finished_at = ?, updated_at = ? WHERE id = ?`, + expiredStartedAt, + expiredStartedAt, + expiredStartedAt, + 'first-run', + ) + state.storage.sql.exec( + `INSERT INTO run_log_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + 'finishes_since_retention', + 0, + ) + await state.storage.deleteAlarm() + await instance.alarm() + }) + const idleState = await runInDurableObject( + stub, + async (_instance: RunLog, state) => ({ + alarmAt: await state.storage.getAlarm(), + remaining: state.storage.sql + .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM runs`) + .one().n, + }), + ) + expect(idleState.remaining).toBe(0) + expect(idleState.alarmAt).toBeNull() - const stale = await getRunRecord({ env, userId, runId: 'stale-running' }) - expect(stale?.run.status).toBe('error') - expect(stale?.run.errorName).toBe('Interrupted') - expect(stale?.run.errorMessage).toMatch(/outcome unknown/i) - expect(stale?.run.finishedAt).toBeTruthy() + // Write after idle re-arms the alarm for the new row. + const runId = 'post-idle-run' + await finishRunRecord({ + env, + handle: { + id: runId, + userId, + startedAt: new Date().toISOString(), + persistence: 'eager', + context: baseContext({ surface: 'job', name: 'post-idle' }), + }, + status: 'success', + }) + const reArmedAlarm = await runInDurableObject( + stub, + async (_instance: RunLog, state) => state.storage.getAlarm(), + ) + expect(reArmedAlarm).toBeTypeOf('number') - const fresh = await getRunRecord({ env, userId, runId: 'fresh-running' }) - expect(fresh?.run.status).toBe('running') - expect(fresh?.run.finishedAt).toBeNull() -}) + // Age the post-idle run past the cutoff without bumping the amortized finish + // counter; alarm fires, prunes it, then self-terminates again. + await runInDurableObject(stub, async (instance: RunLog, state) => { + state.storage.sql.exec( + `UPDATE runs SET started_at = ?, finished_at = ?, updated_at = ? WHERE id = ?`, + expiredStartedAt, + expiredStartedAt, + expiredStartedAt, + runId, + ) + state.storage.sql.exec( + `INSERT INTO run_log_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + 'finishes_since_retention', + 0, + ) + await state.storage.deleteAlarm() + await instance.alarm() + }) + expect(await getRunRecord({ env, userId, runId })).toBeNull() + expect( + await runInDurableObject( + stub, + async (_instance: RunLog, state) => state.storage.getAlarm(), + ), + ).toBeNull() + }, +) test('run recording degrades to a warning instead of failing the observed run', async () => { silenceIncidentalRuntimeWarnings() @@ -796,157 +871,6 @@ test('run recording degrades to a warning instead of failing the observed run', expect(page.runs[0]?.status).toBe('success') }) -test('fresh finishes arm a far-future retention alarm for the new row', async () => { - const userId = uniqueUserId('far-alarm') - const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) - const startedAtMs = Date.now() - await finishRunRecord({ - env, - handle: { - id: crypto.randomUUID(), - userId, - startedAt: new Date(startedAtMs).toISOString(), - persistence: 'eager', - context: baseContext({ surface: 'job', name: 'fresh' }), - }, - status: 'success', - }) - const alarmAt = await runInDurableObject( - stub, - async (_instance: RunLog, state) => state.storage.getAlarm(), - ) - expect(alarmAt).toBeTypeOf('number') - // One-shot at the row's age deadline — not an immediate/hourly wake. - expect(alarmAt).toBeGreaterThan( - startedAtMs + runRecordRetentionDays * 24 * 60 * 60 * 1000 - 5_000, - ) -}) - -test('retention alarm self-terminates when a pass leaves nothing pending', async () => { - const userId = uniqueUserId('alarm-stop') - const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) - const oldStartedAt = new Date( - Date.now() - (runRecordRetentionDays + 2) * 24 * 60 * 60 * 1000, - ).toISOString() - await runInDurableObject(stub, async (instance: RunLog, state) => { - expect(instance).toBeInstanceOf(RunLog) - insertRunRow(state, { - id: 'old-only', - status: 'success', - startedAt: oldStartedAt, - finishedAt: oldStartedAt, - }) - state.storage.sql.exec( - `INSERT INTO run_log_meta (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, - 'run_count', - 1, - ) - // Invoke the handler directly; clear any prior schedule so a leftover - // platform alarm is not mistaken for a re-arm. - await state.storage.deleteAlarm() - await instance.alarm() - }) - - const after = await runInDurableObject( - stub, - async (_instance: RunLog, state) => ({ - alarmAt: await state.storage.getAlarm(), - remaining: state.storage.sql - .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM runs`) - .one().n, - }), - ) - expect(after.remaining).toBe(0) - expect(after.alarmAt).toBeNull() -}) - -test('write after idle re-arms alarm and age-prunes without the finish counter', async () => { - const userId = uniqueUserId('idle-rearm') - const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) - const oldStartedAt = new Date( - Date.now() - (runRecordRetentionDays + 2) * 24 * 60 * 60 * 1000, - ).toISOString() - - // Drive to self-terminating idle (empty DO, no alarm). - await runInDurableObject(stub, async (instance: RunLog, state) => { - insertRunRow(state, { - id: 'seed-old', - status: 'success', - startedAt: oldStartedAt, - finishedAt: oldStartedAt, - }) - state.storage.sql.exec( - `INSERT INTO run_log_meta (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, - 'run_count', - 1, - ) - await state.storage.deleteAlarm() - await instance.alarm() - }) - const idle = await runInDurableObject( - stub, - async (_instance: RunLog, state) => ({ - alarmAt: await state.storage.getAlarm(), - remaining: state.storage.sql - .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM runs`) - .one().n, - }), - ) - expect(idle.remaining).toBe(0) - expect(idle.alarmAt).toBeNull() - - const runId = 'post-idle-run' - await finishRunRecord({ - env, - handle: { - id: runId, - userId, - startedAt: new Date().toISOString(), - persistence: 'eager', - context: baseContext({ surface: 'job', name: 'post-idle' }), - }, - status: 'success', - }) - - const armed = await runInDurableObject( - stub, - async (_instance: RunLog, state) => state.storage.getAlarm(), - ) - expect(armed).toBeTypeOf('number') - - // Age the row past the cutoff without bumping the amortized finish counter. - const expiredStartedAt = new Date( - Date.now() - (runRecordRetentionDays + 3) * 24 * 60 * 60 * 1000, - ).toISOString() - await runInDurableObject(stub, async (instance: RunLog, state) => { - state.storage.sql.exec( - `UPDATE runs SET started_at = ?, finished_at = ?, updated_at = ? WHERE id = ?`, - expiredStartedAt, - expiredStartedAt, - expiredStartedAt, - runId, - ) - state.storage.sql.exec( - `INSERT INTO run_log_meta (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, - 'finishes_since_retention', - 0, - ) - await state.storage.deleteAlarm() - await instance.alarm() - }) - - const pruned = await getRunRecord({ env, userId, runId }) - expect(pruned).toBeNull() - const afterAlarm = await runInDurableObject( - stub, - async (_instance: RunLog, state) => state.storage.getAlarm(), - ) - expect(afterAlarm).toBeNull() -}) - test('clearRunRecords empties the Durable Object', async () => { const userId = uniqueUserId('clear') const handle = beginRunRecord({ From c184496e3701a583bd3065a9065a46adde67fa48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 09:25:24 +0000 Subject: [PATCH 2/4] test: fold Sentry, D1 retry, and small unit microtests into journeys Collapse post-#934 one-string-per-filter and sibling unit cases toward fewer longer workflow tests. - sentry-options: fold UserCodeError hint + platform keep into the main filter journey; delete standalone user-code-error.node.test.ts - d1-retry: keep representative matchers and one retry/fail path - observability: fold conversationId/storageId/detail context into the platform-bug reporting journey - activation: merge twice-same-package, HTTP ignore, sticky timestamp, fast path, and never-throw degradation - fold stripe signature fixture, package-workflow sentry tags, invocation surface/name helpers, and package_service_states count + entitlement boundary Co-authored-by: Kent C. Dodds --- .../stripe-webhook-signature.node.test.ts | 155 +++--- packages/worker/src/d1-retry.node.test.ts | 250 +++------- .../package-service-states.workers.test.ts | 354 +++++++------- .../worker/src/mcp/observability.node.test.ts | 451 +++++++++--------- .../package-invocations/common.node.test.ts | 83 ++-- .../package-workflows-sentry.node.test.ts | 176 ++++--- .../worker/src/sentry-options.node.test.ts | 373 +++++++-------- .../worker/src/usage/activation.node.test.ts | 381 +++++++-------- .../worker/src/user-code-error.node.test.ts | 14 - 9 files changed, 1020 insertions(+), 1217 deletions(-) delete mode 100644 packages/worker/src/user-code-error.node.test.ts diff --git a/packages/worker/src/billing/stripe-webhook-signature.node.test.ts b/packages/worker/src/billing/stripe-webhook-signature.node.test.ts index 64a64d9405..9fc356f3f1 100644 --- a/packages/worker/src/billing/stripe-webhook-signature.node.test.ts +++ b/packages/worker/src/billing/stripe-webhook-signature.node.test.ts @@ -1,92 +1,89 @@ -import { expect, test } from 'vitest' +import { expect, test } from "vitest"; import { - buildStripeWebhookSignatureHeader, - computeStripeWebhookSignature, - verifyStripeWebhookSignature, -} from './stripe-webhook-signature.ts' + buildStripeWebhookSignatureHeader, + computeStripeWebhookSignature, + verifyStripeWebhookSignature, +} from "./stripe-webhook-signature.ts"; -const fixtureSecret = 'whsec_test_fixture_secret' +const fixtureSecret = "whsec_test_fixture_secret"; const fixtureBody = - '{"id":"evt_test_fixture","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_test"}}}' -const fixtureTimestamp = 1_700_000_000 + '{"id":"evt_test_fixture","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_test"}}}'; +const fixtureTimestamp = 1_700_000_000; /** HMAC-SHA256 hex of `${t}.${rawBody}` keyed by UTF-8(whsec_...) via Node crypto. */ const fixtureSignature = - '850a8305dd40d37b2ad74f83d4a32d429fe9a017a6db9ac082e7ea2c3b430140' + "850a8305dd40d37b2ad74f83d4a32d429fe9a017a6db9ac082e7ea2c3b430140"; -test('computeStripeWebhookSignature matches known HMAC fixture', async () => { - const signature = await computeStripeWebhookSignature({ - secret: fixtureSecret, - timestamp: fixtureTimestamp, - rawBody: fixtureBody, - }) - expect(signature).toBe(fixtureSignature) -}) +test("Stripe webhook signatures match the HMAC fixture and reject bad headers", async () => { + const signature = await computeStripeWebhookSignature({ + secret: fixtureSecret, + timestamp: fixtureTimestamp, + rawBody: fixtureBody, + }); + expect(signature).toBe(fixtureSignature); -test('verifyStripeWebhookSignature accepts valid headers and rejects bad ones', async () => { - const header = `t=${fixtureTimestamp},v1=${fixtureSignature}` + const header = `t=${fixtureTimestamp},v1=${fixtureSignature}`; + await expect( + verifyStripeWebhookSignature({ + secret: fixtureSecret, + signatureHeader: header, + rawBody: fixtureBody, + nowSeconds: fixtureTimestamp, + }), + ).resolves.toEqual({ timestamp: fixtureTimestamp }); - await expect( - verifyStripeWebhookSignature({ - secret: fixtureSecret, - signatureHeader: header, - rawBody: fixtureBody, - nowSeconds: fixtureTimestamp, - }), - ).resolves.toEqual({ timestamp: fixtureTimestamp }) + const built = await buildStripeWebhookSignatureHeader({ + secret: fixtureSecret, + rawBody: fixtureBody, + timestamp: fixtureTimestamp, + }); + expect(built).toBe(header); - const built = await buildStripeWebhookSignatureHeader({ - secret: fixtureSecret, - rawBody: fixtureBody, - timestamp: fixtureTimestamp, - }) - expect(built).toBe(header) + await expect( + verifyStripeWebhookSignature({ + secret: fixtureSecret, + signatureHeader: null, + rawBody: fixtureBody, + nowSeconds: fixtureTimestamp, + }), + ).rejects.toMatchObject({ + name: "StripeWebhookSignatureError", + code: "missing_header", + }); - await expect( - verifyStripeWebhookSignature({ - secret: fixtureSecret, - signatureHeader: null, - rawBody: fixtureBody, - nowSeconds: fixtureTimestamp, - }), - ).rejects.toMatchObject({ - name: 'StripeWebhookSignatureError', - code: 'missing_header', - }) + await expect( + verifyStripeWebhookSignature({ + secret: fixtureSecret, + signatureHeader: `t=${fixtureTimestamp},v1=${"0".repeat(64)}`, + rawBody: fixtureBody, + nowSeconds: fixtureTimestamp, + }), + ).rejects.toMatchObject({ code: "signature_mismatch" }); - await expect( - verifyStripeWebhookSignature({ - secret: fixtureSecret, - signatureHeader: `t=${fixtureTimestamp},v1=${'0'.repeat(64)}`, - rawBody: fixtureBody, - nowSeconds: fixtureTimestamp, - }), - ).rejects.toMatchObject({ code: 'signature_mismatch' }) + await expect( + verifyStripeWebhookSignature({ + secret: fixtureSecret, + signatureHeader: header, + rawBody: fixtureBody.replace("evt_test_fixture", "evt_tampered"), + nowSeconds: fixtureTimestamp, + }), + ).rejects.toMatchObject({ code: "signature_mismatch" }); - await expect( - verifyStripeWebhookSignature({ - secret: fixtureSecret, - signatureHeader: header, - rawBody: fixtureBody.replace('evt_test_fixture', 'evt_tampered'), - nowSeconds: fixtureTimestamp, - }), - ).rejects.toMatchObject({ code: 'signature_mismatch' }) + await expect( + verifyStripeWebhookSignature({ + secret: fixtureSecret, + signatureHeader: header, + rawBody: fixtureBody, + toleranceSeconds: 300, + nowSeconds: fixtureTimestamp + 301, + }), + ).rejects.toMatchObject({ code: "timestamp_tolerance" }); - await expect( - verifyStripeWebhookSignature({ - secret: fixtureSecret, - signatureHeader: header, - rawBody: fixtureBody, - toleranceSeconds: 300, - nowSeconds: fixtureTimestamp + 301, - }), - ).rejects.toMatchObject({ code: 'timestamp_tolerance' }) - - await expect( - verifyStripeWebhookSignature({ - secret: fixtureSecret, - signatureHeader: `t=${fixtureTimestamp},v0=deadbeef`, - rawBody: fixtureBody, - nowSeconds: fixtureTimestamp, - }), - ).rejects.toMatchObject({ code: 'no_v1_signatures' }) -}) + await expect( + verifyStripeWebhookSignature({ + secret: fixtureSecret, + signatureHeader: `t=${fixtureTimestamp},v0=deadbeef`, + rawBody: fixtureBody, + nowSeconds: fixtureTimestamp, + }), + ).rejects.toMatchObject({ code: "no_v1_signatures" }); +}); diff --git a/packages/worker/src/d1-retry.node.test.ts b/packages/worker/src/d1-retry.node.test.ts index 6409082169..b27bfaf76a 100644 --- a/packages/worker/src/d1-retry.node.test.ts +++ b/packages/worker/src/d1-retry.node.test.ts @@ -1,180 +1,80 @@ -import { expect, test, vi } from 'vitest' +import { expect, test, vi } from "vitest"; import { - d1LockRetryBaseDelayMs, - isRetryableD1LockError, - runD1WithRetry, -} from './d1-retry.ts' + d1LockRetryBaseDelayMs, + isRetryableD1LockError, + runD1WithRetry, +} from "./d1-retry.ts"; -test('runD1WithRetry matches lock errors, retries them, and rethrows other failures immediately', async () => { - expect( - isRetryableD1LockError( - new Error('D1_ERROR: NOSENTRY database is locked: SQLITE_BUSY'), - ), - ).toBe(true) - expect(isRetryableD1LockError(new Error('database is locked'))).toBe(true) - expect( - isRetryableD1LockError( - new Error('D1_ERROR: Currently processing a long-running export.'), - ), - ).toBe(true) - expect( - isRetryableD1LockError( - new Error('Currently processing a long-running export.'), - ), - ).toBe(true) - expect( - isRetryableD1LockError(new Error('D1_ERROR: Network connection lost.')), - ).toBe(true) - expect(isRetryableD1LockError(new Error('Network connection lost.'))).toBe( - true, - ) - expect( - isRetryableD1LockError(new Error('Error: Network connection lost.')), - ).toBe(true) - expect( - isRetryableD1LockError( - new Error('Network connection lost while uploading...'), - ), - ).toBe(false) - expect( - isRetryableD1LockError( - new Error( - 'D1_ERROR: internal error; reference = 0u3odos5iotccpol68ppc0eg', - ), - ), - ).toBe(true) - expect( - isRetryableD1LockError( - new Error('internal error; reference = 0u3odos5iotccpol68ppc0eg'), - ), - ).toBe(true) - expect( - isRetryableD1LockError( - new Error( - 'D1_ERROR: Internal error in D1 DB storage caused object to be reset; reference = 8t4dqqpoq1ctvjr8kca8fl4c', - ), - ), - ).toBe(true) - expect( - isRetryableD1LockError( - new Error( - 'Internal error in D1 DB storage caused object to be reset; reference = 8t4dqqpoq1ctvjr8kca8fl4c', - ), - ), - ).toBe(true) - expect(isRetryableD1LockError(new Error('internal error'))).toBe(false) - expect( - isRetryableD1LockError( - new Error( - 'D1_ERROR: Internal error in D1 DB storage caused object to be reset', - ), - ), - ).toBe(false) - expect( - isRetryableD1LockError( - new Error('D1_ERROR: internal error while applying migration'), - ), - ).toBe(false) - expect( - isRetryableD1LockError( - new Error( - 'D1_ERROR: Internal error in D1 DB storage while applying migration; reference = abc', - ), - ), - ).toBe(false) - expect(isRetryableD1LockError(new Error('syntax error near SELECT'))).toBe( - false, - ) +test("runD1WithRetry matches lock errors, retries them, and rethrows other failures immediately", async () => { + expect( + isRetryableD1LockError( + new Error("D1_ERROR: NOSENTRY database is locked: SQLITE_BUSY"), + ), + ).toBe(true); + expect( + isRetryableD1LockError( + new Error("Currently processing a long-running export."), + ), + ).toBe(true); + expect(isRetryableD1LockError(new Error("Network connection lost."))).toBe( + true, + ); + expect( + isRetryableD1LockError( + new Error( + "D1_ERROR: internal error; reference = 0u3odos5iotccpol68ppc0eg", + ), + ), + ).toBe(true); + expect( + isRetryableD1LockError( + new Error( + "Internal error in D1 DB storage caused object to be reset; reference = 8t4dqqpoq1ctvjr8kca8fl4c", + ), + ), + ).toBe(true); + expect( + isRetryableD1LockError( + new Error("Network connection lost while uploading..."), + ), + ).toBe(false); + expect(isRetryableD1LockError(new Error("internal error"))).toBe(false); + expect( + isRetryableD1LockError( + new Error( + "D1_ERROR: Internal error in D1 DB storage caused object to be reset", + ), + ), + ).toBe(false); + expect(isRetryableD1LockError(new Error("syntax error near SELECT"))).toBe( + false, + ); - const successOperation = vi.fn(async () => 'ok') - await expect(runD1WithRetry(successOperation)).resolves.toBe('ok') - expect(successOperation).toHaveBeenCalledTimes(1) + const successOperation = vi.fn(async () => "ok"); + await expect(runD1WithRetry(successOperation)).resolves.toBe("ok"); + expect(successOperation).toHaveBeenCalledTimes(1); - vi.useFakeTimers() - const retryOperation = vi - .fn() - .mockRejectedValueOnce( - new Error('D1_ERROR: NOSENTRY database is locked: SQLITE_BUSY'), - ) - .mockResolvedValueOnce('ok') - try { - const resultPromise = runD1WithRetry(retryOperation) - await vi.advanceTimersByTimeAsync(d1LockRetryBaseDelayMs) - await expect(resultPromise).resolves.toBe('ok') - expect(retryOperation).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } + vi.useFakeTimers(); + const retryOperation = vi + .fn() + .mockRejectedValueOnce( + new Error("D1_ERROR: NOSENTRY database is locked: SQLITE_BUSY"), + ) + .mockResolvedValueOnce("ok"); + try { + const resultPromise = runD1WithRetry(retryOperation); + await vi.advanceTimersByTimeAsync(d1LockRetryBaseDelayMs); + await expect(resultPromise).resolves.toBe("ok"); + expect(retryOperation).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } - vi.useFakeTimers() - const exportRetryOperation = vi - .fn() - .mockRejectedValueOnce( - new Error('D1_ERROR: Currently processing a long-running export.'), - ) - .mockResolvedValueOnce('ok') - try { - const resultPromise = runD1WithRetry(exportRetryOperation) - await vi.advanceTimersByTimeAsync(d1LockRetryBaseDelayMs) - await expect(resultPromise).resolves.toBe('ok') - expect(exportRetryOperation).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - - vi.useFakeTimers() - const networkRetryOperation = vi - .fn() - .mockRejectedValueOnce(new Error('D1_ERROR: Network connection lost.')) - .mockResolvedValueOnce('ok') - try { - const resultPromise = runD1WithRetry(networkRetryOperation) - await vi.advanceTimersByTimeAsync(d1LockRetryBaseDelayMs) - await expect(resultPromise).resolves.toBe('ok') - expect(networkRetryOperation).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - - vi.useFakeTimers() - const internalErrorRetryOperation = vi - .fn() - .mockRejectedValueOnce( - new Error( - 'D1_ERROR: internal error; reference = 0u3odos5iotccpol68ppc0eg', - ), - ) - .mockResolvedValueOnce('ok') - try { - const resultPromise = runD1WithRetry(internalErrorRetryOperation) - await vi.advanceTimersByTimeAsync(d1LockRetryBaseDelayMs) - await expect(resultPromise).resolves.toBe('ok') - expect(internalErrorRetryOperation).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - - vi.useFakeTimers() - const objectResetRetryOperation = vi - .fn() - .mockRejectedValueOnce( - new Error( - 'D1_ERROR: Internal error in D1 DB storage caused object to be reset; reference = 8t4dqqpoq1ctvjr8kca8fl4c', - ), - ) - .mockResolvedValueOnce('ok') - try { - const resultPromise = runD1WithRetry(objectResetRetryOperation) - await vi.advanceTimersByTimeAsync(d1LockRetryBaseDelayMs) - await expect(resultPromise).resolves.toBe('ok') - expect(objectResetRetryOperation).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - - const failingOperation = vi - .fn() - .mockRejectedValue(new Error('D1_ERROR: syntax error near INSERTZ')) - await expect(runD1WithRetry(failingOperation)).rejects.toThrow('syntax error') - expect(failingOperation).toHaveBeenCalledTimes(1) -}) + const failingOperation = vi + .fn() + .mockRejectedValue(new Error("D1_ERROR: syntax error near INSERTZ")); + await expect(runD1WithRetry(failingOperation)).rejects.toThrow( + "syntax error", + ); + expect(failingOperation).toHaveBeenCalledTimes(1); +}); diff --git a/packages/worker/src/entitlements/package-service-states.workers.test.ts b/packages/worker/src/entitlements/package-service-states.workers.test.ts index 8eb2758881..e9d5339aba 100644 --- a/packages/worker/src/entitlements/package-service-states.workers.test.ts +++ b/packages/worker/src/entitlements/package-service-states.workers.test.ts @@ -1,198 +1,192 @@ -import { env } from 'cloudflare:workers' -import { expect, test } from 'vitest' +import { env } from "cloudflare:workers"; +import { expect, test } from "vitest"; import { - assertWithinEntitlement, - countRunningPackageServices, - packageServiceStateStaleMs, -} from '#worker/entitlements/service.ts' -import { EntitlementLimitError } from '#worker/entitlements/errors.ts' -import { planLimits } from '#worker/entitlements/plans.ts' -import { ensureEntitlementTestSchema } from '#worker/entitlements/test-schema.ts' -import { seedAccount } from '#worker/test-support/workers-seed.ts' -import { createStableUserIdFromEmail } from '#worker/user-id.ts' + assertWithinEntitlement, + countRunningPackageServices, + packageServiceStateStaleMs, +} from "#worker/entitlements/service.ts"; +import { EntitlementLimitError } from "#worker/entitlements/errors.ts"; +import { planLimits } from "#worker/entitlements/plans.ts"; +import { ensureEntitlementTestSchema } from "#worker/entitlements/test-schema.ts"; +import { seedAccount } from "#worker/test-support/workers-seed.ts"; +import { createStableUserIdFromEmail } from "#worker/user-id.ts"; async function upsertServiceState(input: { - userId: string - packageId: string - serviceName: string - status: 'running' | 'idle' | 'stopped' | 'error' - updatedAt: string - startedAt?: string | null + userId: string; + packageId: string; + serviceName: string; + status: "running" | "idle" | "stopped" | "error"; + updatedAt: string; + startedAt?: string | null; }) { - await env.APP_DB.prepare( - `INSERT INTO package_service_states ( + await env.APP_DB.prepare( + `INSERT INTO package_service_states ( user_id, package_id, service_name, status, started_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(user_id, package_id, service_name) DO UPDATE SET status = excluded.status, started_at = excluded.started_at, updated_at = excluded.updated_at`, - ) - .bind( - input.userId, - input.packageId, - input.serviceName, - input.status, - input.startedAt ?? null, - input.updatedAt, - ) - .run() + ) + .bind( + input.userId, + input.packageId, + input.serviceName, + input.status, + input.startedAt ?? null, + input.updatedAt, + ) + .run(); } -test('countRunningPackageServices counts fresh running rows and ignores idle/stopped/error/stale', async () => { - await ensureEntitlementTestSchema(env.APP_DB) - const now = new Date('2026-07-26T12:00:00.000Z') - const userId = await createStableUserIdFromEmail( - `svc-count-${crypto.randomUUID()}@example.com`, - ) - const otherUserId = await createStableUserIdFromEmail( - `svc-count-other-${crypto.randomUUID()}@example.com`, - ) - const fresh = now.toISOString() - const stale = new Date( - now.valueOf() - packageServiceStateStaleMs - 1, - ).toISOString() +test("package_service_states counts fresh running rows and enforces the plan boundary", async () => { + await ensureEntitlementTestSchema(env.APP_DB); + const now = new Date("2026-07-26T12:00:00.000Z"); + const userId = await createStableUserIdFromEmail( + `svc-count-${crypto.randomUUID()}@example.com`, + ); + const otherUserId = await createStableUserIdFromEmail( + `svc-count-other-${crypto.randomUUID()}@example.com`, + ); + const fresh = now.toISOString(); + const stale = new Date( + now.valueOf() - packageServiceStateStaleMs - 1, + ).toISOString(); - await upsertServiceState({ - userId, - packageId: 'pkg-a', - serviceName: 'alpha', - status: 'running', - startedAt: fresh, - updatedAt: fresh, - }) - await upsertServiceState({ - userId, - packageId: 'pkg-b', - serviceName: 'beta', - status: 'stopped', - updatedAt: fresh, - }) - await upsertServiceState({ - userId, - packageId: 'pkg-c', - serviceName: 'gamma', - status: 'error', - updatedAt: fresh, - }) - await upsertServiceState({ - userId, - packageId: 'pkg-d', - serviceName: 'delta', - status: 'idle', - updatedAt: fresh, - }) - await upsertServiceState({ - userId, - packageId: 'pkg-e', - serviceName: 'stale', - status: 'running', - startedAt: stale, - updatedAt: stale, - }) - await upsertServiceState({ - userId: otherUserId, - packageId: 'pkg-a', - serviceName: 'alpha', - status: 'running', - startedAt: fresh, - updatedAt: fresh, - }) + await upsertServiceState({ + userId, + packageId: "pkg-a", + serviceName: "alpha", + status: "running", + startedAt: fresh, + updatedAt: fresh, + }); + await upsertServiceState({ + userId, + packageId: "pkg-b", + serviceName: "beta", + status: "stopped", + updatedAt: fresh, + }); + await upsertServiceState({ + userId, + packageId: "pkg-c", + serviceName: "gamma", + status: "error", + updatedAt: fresh, + }); + await upsertServiceState({ + userId, + packageId: "pkg-d", + serviceName: "delta", + status: "idle", + updatedAt: fresh, + }); + await upsertServiceState({ + userId, + packageId: "pkg-e", + serviceName: "stale", + status: "running", + startedAt: stale, + updatedAt: stale, + }); + await upsertServiceState({ + userId: otherUserId, + packageId: "pkg-a", + serviceName: "alpha", + status: "running", + startedAt: fresh, + updatedAt: fresh, + }); - expect( - await countRunningPackageServices({ - db: env.APP_DB, - userId, - now, - }), - ).toBe(1) + expect( + await countRunningPackageServices({ + db: env.APP_DB, + userId, + now, + }), + ).toBe(1); + expect( + await countRunningPackageServices({ + db: env.APP_DB, + userId, + now, + excludeService: { packageId: "pkg-a", serviceName: "alpha" }, + }), + ).toBe(0); - expect( - await countRunningPackageServices({ - db: env.APP_DB, - userId, - now, - excludeService: { packageId: 'pkg-a', serviceName: 'alpha' }, - }), - ).toBe(0) + await upsertServiceState({ + userId, + packageId: "pkg-f", + serviceName: "epsilon", + status: "running", + startedAt: fresh, + updatedAt: fresh, + }); + expect( + await countRunningPackageServices({ + db: env.APP_DB, + userId, + now, + excludeService: { packageId: "pkg-a", serviceName: "alpha" }, + }), + ).toBe(1); - await upsertServiceState({ - userId, - packageId: 'pkg-f', - serviceName: 'epsilon', - status: 'running', - startedAt: fresh, - updatedAt: fresh, - }) - expect( - await countRunningPackageServices({ - db: env.APP_DB, - userId, - now, - excludeService: { packageId: 'pkg-a', serviceName: 'alpha' }, - }), - ).toBe(1) -}) + const email = `svc-limit-${crypto.randomUUID()}@example.com`; + const limitUserId = await createStableUserIdFromEmail(email); + await seedAccount({ + db: env.APP_DB, + email, + username: `svc-limit-${crypto.randomUUID().slice(0, 8)}`, + plan: "pro", + stableUserId: limitUserId, + }); + const limit = planLimits.pro.maxPackageServices; + for (let index = 0; index < limit; index += 1) { + await upsertServiceState({ + userId: limitUserId, + packageId: `pkg-${index}`, + serviceName: "worker", + status: "running", + startedAt: fresh, + updatedAt: fresh, + }); + } -test('package_services entitlement enforces at the plan boundary from package_service_states', async () => { - await ensureEntitlementTestSchema(env.APP_DB) - const email = `svc-limit-${crypto.randomUUID()}@example.com` - const userId = await createStableUserIdFromEmail(email) - await seedAccount({ - db: env.APP_DB, - email, - username: `svc-limit-${crypto.randomUUID().slice(0, 8)}`, - plan: 'pro', - stableUserId: userId, - }) - const now = new Date('2026-07-26T12:00:00.000Z') - const limit = planLimits.pro.maxPackageServices - const fresh = now.toISOString() - for (let index = 0; index < limit; index += 1) { - await upsertServiceState({ - userId, - packageId: `pkg-${index}`, - serviceName: 'worker', - status: 'running', - startedAt: fresh, - updatedAt: fresh, - }) - } + const denied = await assertWithinEntitlement({ + db: env.APP_DB, + userId: limitUserId, + email, + resource: "package_services", + now, + }).then( + () => null, + (thrown: unknown) => thrown, + ); + if (!(denied instanceof EntitlementLimitError)) { + throw new Error( + "Expected EntitlementLimitError at the package_services boundary.", + ); + } + expect(denied.details).toMatchObject({ + resource: "package_services", + plan: "pro", + limit, + current: limit, + }); - const denied = await assertWithinEntitlement({ - db: env.APP_DB, - userId, - email, - resource: 'package_services', - now, - }).then( - () => null, - (thrown: unknown) => thrown, - ) - if (!(denied instanceof EntitlementLimitError)) { - throw new Error( - 'Expected EntitlementLimitError at the package_services boundary.', - ) - } - expect(denied.details).toMatchObject({ - resource: 'package_services', - plan: 'pro', - limit, - current: limit, - }) - - await assertWithinEntitlement({ - db: env.APP_DB, - userId, - email, - resource: 'package_services', - now, - getCurrent: async () => - await countRunningPackageServices({ - db: env.APP_DB, - userId, - now, - excludeService: { packageId: 'pkg-0', serviceName: 'worker' }, - }), - }) -}) + await assertWithinEntitlement({ + db: env.APP_DB, + userId: limitUserId, + email, + resource: "package_services", + now, + getCurrent: async () => + await countRunningPackageServices({ + db: env.APP_DB, + userId: limitUserId, + now, + excludeService: { packageId: "pkg-0", serviceName: "worker" }, + }), + }); +}); diff --git a/packages/worker/src/mcp/observability.node.test.ts b/packages/worker/src/mcp/observability.node.test.ts index 144bfc0fad..8ad45b8ef1 100644 --- a/packages/worker/src/mcp/observability.node.test.ts +++ b/packages/worker/src/mcp/observability.node.test.ts @@ -1,234 +1,231 @@ -import { expect, test, vi } from 'vitest' +import { expect, test, vi } from "vitest"; const sentryMock = vi.hoisted(() => ({ - isInitialized: vi.fn(() => true), - getClient: vi.fn(() => ({ getOptions: () => ({ dsn: 'https://example' }) })), - withScope: vi.fn((callback: (scope: ScopeStub) => void) => { - callback(sentryMock.scope) - }), - captureException: vi.fn(), - captureMessage: vi.fn(), - scope: { - setLevel: vi.fn(), - setTag: vi.fn(), - setContext: vi.fn(), - setUser: vi.fn(), - }, -})) - -type ScopeStub = typeof sentryMock.scope - -vi.mock('@sentry/cloudflare', () => ({ - isInitialized: (...args: Array) => sentryMock.isInitialized(...args), - getClient: (...args: Array) => sentryMock.getClient(...args), - withScope: (...args: Array) => sentryMock.withScope(...args), - captureException: (...args: Array) => - sentryMock.captureException(...args), - captureMessage: (...args: Array) => - sentryMock.captureMessage(...args), -})) - -const { logMcpEvent } = await import('./observability.ts') -const { McpCallerError } = await import('./caller-error.ts') -const { UserCodeError } = await import('#worker/user-code-error.ts') + isInitialized: vi.fn(() => true), + getClient: vi.fn(() => ({ getOptions: () => ({ dsn: "https://example" }) })), + withScope: vi.fn((callback: (scope: ScopeStub) => void) => { + callback(sentryMock.scope); + }), + captureException: vi.fn(), + captureMessage: vi.fn(), + scope: { + setLevel: vi.fn(), + setTag: vi.fn(), + setContext: vi.fn(), + setUser: vi.fn(), + }, +})); + +type ScopeStub = typeof sentryMock.scope; + +vi.mock("@sentry/cloudflare", () => ({ + isInitialized: (...args: Array) => sentryMock.isInitialized(...args), + getClient: (...args: Array) => sentryMock.getClient(...args), + withScope: (...args: Array) => sentryMock.withScope(...args), + captureException: (...args: Array) => + sentryMock.captureException(...args), + captureMessage: (...args: Array) => + sentryMock.captureMessage(...args), +})); + +const { logMcpEvent } = await import("./observability.ts"); +const { McpCallerError } = await import("./caller-error.ts"); +const { UserCodeError } = await import("#worker/user-code-error.ts"); function captureMcpEvents(run: () => void) { - sentryMock.captureException.mockClear() - sentryMock.captureMessage.mockClear() - sentryMock.withScope.mockClear() - sentryMock.scope.setLevel.mockClear() - sentryMock.scope.setUser.mockClear() - - const originalInfo = console.info - const payloads: Array = [] - console.info = ((tag: unknown, json?: unknown) => { - if (tag === 'mcp-event' && typeof json === 'string') { - payloads.push(json) - } - }) as typeof console.info - try { - run() - } finally { - console.info = originalInfo - } - return payloads + sentryMock.captureException.mockClear(); + sentryMock.captureMessage.mockClear(); + sentryMock.withScope.mockClear(); + sentryMock.scope.setLevel.mockClear(); + sentryMock.scope.setUser.mockClear(); + + const originalInfo = console.info; + const payloads: Array = []; + console.info = ((tag: unknown, json?: unknown) => { + if (tag === "mcp-event" && typeof json === "string") { + payloads.push(json); + } + }) as typeof console.info; + try { + run(); + } finally { + console.info = originalInfo; + } + return payloads; } const callerFailureBase = { - category: 'mcp', - tool: 'capability', - outcome: 'failure', - durationMs: 3, - baseUrl: 'https://example.com', - hasUser: true, - userId: 'user-1', -} as const - -test('logMcpEvent keeps sandbox and caller failures off Sentry and still reports platform bugs', () => { - const payloads = captureMcpEvents(() => { - logMcpEvent({ - category: 'mcp', - tool: 'execute', - toolName: 'execute', - outcome: 'failure', - durationMs: 12, - baseUrl: 'https://example.com', - hasUser: true, - userId: 'user-1', - sandboxError: true, - errorName: 'Unknown', - errorMessage: - 'Notion API /data_sources/39977ef0-f2db-81c6-9147-000bd579e312/query failed: validation_error', - cause: 'Notion API /data_sources/.../query failed: validation_error', - }) - - logMcpEvent({ - ...callerFailureBase, - capabilityName: 'search', - failurePhase: 'handler', - errorName: 'McpCallerError', - errorMessage: 'Provide "query" or "domain".', - cause: new McpCallerError('Provide "query" or "domain".'), - }) - - logMcpEvent({ - ...callerFailureBase, - tool: 'search', - toolName: 'search', - errorName: 'McpCallerError', - errorMessage: - 'Unknown domain "skills". Available domains: account, packages.', - cause: new McpCallerError( - 'Unknown domain "skills". Available domains: account, packages.', - ), - }) - - logMcpEvent({ - ...callerFailureBase, - capabilityName: 'repo_open_session', - failurePhase: 'handler', - errorName: 'Error', - errorMessage: 'Opening the session failed.', - cause: new Error('Opening the session failed.', { - cause: new McpCallerError('Discard the current session first.'), - }), - }) - - logMcpEvent({ - ...callerFailureBase, - capabilityName: 'value_get', - failurePhase: 'parse_input', - errorName: 'ZodError', - errorMessage: 'name: Required', - cause: new Error('name: Required'), - }) - - logMcpEvent({ - ...callerFailureBase, - tool: 'search', - toolName: 'search', - callerError: true, - errorName: 'EntityBatchError', - errorMessage: 'All entity lookups failed.', - }) - - logMcpEvent({ - ...callerFailureBase, - capabilityName: 'user_module_run', - failurePhase: 'handler', - errorName: 'UserCodeError', - errorMessage: 'boom from user code', - cause: new UserCodeError('boom from user code'), - }) - }) - - expect(payloads).toHaveLength(7) - expect(JSON.parse(payloads[0]!)).toMatchObject({ - tool: 'execute', - outcome: 'failure', - sandboxError: true, - }) - expect(sentryMock.captureException).not.toHaveBeenCalled() - expect(sentryMock.captureMessage).not.toHaveBeenCalled() - - captureMcpEvents(() => { - logMcpEvent({ - ...callerFailureBase, - capabilityName: 'value_get', - capabilitySource: 'builtin', - sandboxError: false, - failurePhase: 'handler', - errorName: 'Error', - errorMessage: 'platform handler blew up', - cause: new Error('platform handler blew up'), - }) - - logMcpEvent({ - ...callerFailureBase, - capabilityName: 'package_get', - failurePhase: 'handler', - errorName: 'Error', - errorMessage: 'D1 write failed.', - cause: new Error('D1 write failed.', { - cause: new Error('storage unavailable'), - }), - }) - }) - - expect(sentryMock.captureException).toHaveBeenCalledTimes(2) - expect(sentryMock.captureException).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ message: 'platform handler blew up' }), - ) - expect(sentryMock.captureException).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ message: 'D1 write failed.' }), - ) - expect(sentryMock.scope.setLevel).toHaveBeenCalledWith('error') - expect(sentryMock.scope.setUser).toHaveBeenCalledWith({ id: 'user-1' }) - expect(sentryMock.scope.setContext).toHaveBeenCalledWith( - 'mcp', - expect.objectContaining({ - baseUrl: 'https://example.com', - hasUser: true, - errorMessage: 'platform handler blew up', - detail: undefined, - }), - ) - expect(sentryMock.captureMessage).not.toHaveBeenCalled() -}) - -test('logMcpEvent copies conversationId, storageId, and detail into Sentry mcp context', () => { - sentryMock.scope.setContext.mockClear() - sentryMock.captureException.mockClear() - - captureMcpEvents(() => { - logMcpEvent({ - ...callerFailureBase, - capabilityName: 'storage_query', - domain: 'storage', - capabilitySource: 'builtin', - conversationId: 'conv-storage-1', - storageId: 'storage-notes-1', - failurePhase: 'handler', - errorName: 'Error', - errorMessage: 'no such table: notes: SQLITE_ERROR', - context: { - sqlPreview: 'SELECT * FROM notes LIMIT 1', - }, - cause: new Error('no such table: notes: SQLITE_ERROR'), - }) - }) - - expect(sentryMock.captureException).toHaveBeenCalledOnce() - expect(sentryMock.scope.setContext).toHaveBeenCalledWith( - 'mcp', - expect.objectContaining({ - conversationId: 'conv-storage-1', - storageId: 'storage-notes-1', - detail: { - sqlPreview: 'SELECT * FROM notes LIMIT 1', - }, - }), - ) -}) + category: "mcp", + tool: "capability", + outcome: "failure", + durationMs: 3, + baseUrl: "https://example.com", + hasUser: true, + userId: "user-1", +} as const; + +test("logMcpEvent keeps sandbox and caller failures off Sentry and still reports platform bugs", () => { + const payloads = captureMcpEvents(() => { + logMcpEvent({ + category: "mcp", + tool: "execute", + toolName: "execute", + outcome: "failure", + durationMs: 12, + baseUrl: "https://example.com", + hasUser: true, + userId: "user-1", + sandboxError: true, + errorName: "Unknown", + errorMessage: + "Notion API /data_sources/39977ef0-f2db-81c6-9147-000bd579e312/query failed: validation_error", + cause: "Notion API /data_sources/.../query failed: validation_error", + }); + + logMcpEvent({ + ...callerFailureBase, + capabilityName: "search", + failurePhase: "handler", + errorName: "McpCallerError", + errorMessage: 'Provide "query" or "domain".', + cause: new McpCallerError('Provide "query" or "domain".'), + }); + + logMcpEvent({ + ...callerFailureBase, + tool: "search", + toolName: "search", + errorName: "McpCallerError", + errorMessage: + 'Unknown domain "skills". Available domains: account, packages.', + cause: new McpCallerError( + 'Unknown domain "skills". Available domains: account, packages.', + ), + }); + + logMcpEvent({ + ...callerFailureBase, + capabilityName: "repo_open_session", + failurePhase: "handler", + errorName: "Error", + errorMessage: "Opening the session failed.", + cause: new Error("Opening the session failed.", { + cause: new McpCallerError("Discard the current session first."), + }), + }); + + logMcpEvent({ + ...callerFailureBase, + capabilityName: "value_get", + failurePhase: "parse_input", + errorName: "ZodError", + errorMessage: "name: Required", + cause: new Error("name: Required"), + }); + + logMcpEvent({ + ...callerFailureBase, + tool: "search", + toolName: "search", + callerError: true, + errorName: "EntityBatchError", + errorMessage: "All entity lookups failed.", + }); + + logMcpEvent({ + ...callerFailureBase, + capabilityName: "user_module_run", + failurePhase: "handler", + errorName: "UserCodeError", + errorMessage: "boom from user code", + cause: new UserCodeError("boom from user code"), + }); + }); + + expect(payloads).toHaveLength(7); + expect(JSON.parse(payloads[0]!)).toMatchObject({ + tool: "execute", + outcome: "failure", + sandboxError: true, + }); + expect(sentryMock.captureException).not.toHaveBeenCalled(); + expect(sentryMock.captureMessage).not.toHaveBeenCalled(); + + captureMcpEvents(() => { + logMcpEvent({ + ...callerFailureBase, + capabilityName: "value_get", + capabilitySource: "builtin", + sandboxError: false, + failurePhase: "handler", + errorName: "Error", + errorMessage: "platform handler blew up", + cause: new Error("platform handler blew up"), + }); + + logMcpEvent({ + ...callerFailureBase, + capabilityName: "package_get", + failurePhase: "handler", + errorName: "Error", + errorMessage: "D1 write failed.", + cause: new Error("D1 write failed.", { + cause: new Error("storage unavailable"), + }), + }); + + logMcpEvent({ + ...callerFailureBase, + capabilityName: "storage_query", + domain: "storage", + capabilitySource: "builtin", + conversationId: "conv-storage-1", + storageId: "storage-notes-1", + failurePhase: "handler", + errorName: "Error", + errorMessage: "no such table: notes: SQLITE_ERROR", + context: { + sqlPreview: "SELECT * FROM notes LIMIT 1", + }, + cause: new Error("no such table: notes: SQLITE_ERROR"), + }); + }); + + expect(sentryMock.captureException).toHaveBeenCalledTimes(3); + expect(sentryMock.captureException).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ message: "platform handler blew up" }), + ); + expect(sentryMock.captureException).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ message: "D1 write failed." }), + ); + expect(sentryMock.captureException).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + message: "no such table: notes: SQLITE_ERROR", + }), + ); + expect(sentryMock.scope.setLevel).toHaveBeenCalledWith("error"); + expect(sentryMock.scope.setUser).toHaveBeenCalledWith({ id: "user-1" }); + expect(sentryMock.scope.setContext).toHaveBeenCalledWith( + "mcp", + expect.objectContaining({ + baseUrl: "https://example.com", + hasUser: true, + errorMessage: "platform handler blew up", + detail: undefined, + }), + ); + expect(sentryMock.scope.setContext).toHaveBeenCalledWith( + "mcp", + expect.objectContaining({ + conversationId: "conv-storage-1", + storageId: "storage-notes-1", + detail: { + sqlPreview: "SELECT * FROM notes LIMIT 1", + }, + }), + ); + expect(sentryMock.captureMessage).not.toHaveBeenCalled(); +}); diff --git a/packages/worker/src/package-invocations/common.node.test.ts b/packages/worker/src/package-invocations/common.node.test.ts index 3b68110ea0..4cf6071cef 100644 --- a/packages/worker/src/package-invocations/common.node.test.ts +++ b/packages/worker/src/package-invocations/common.node.test.ts @@ -1,47 +1,42 @@ -import { expect, test } from 'vitest' -import { packageWorkflowInvocationSource } from '#worker/package-runtime/package-invocation-sources.ts' +import { expect, test } from "vitest"; +import { packageWorkflowInvocationSource } from "#worker/package-runtime/package-invocation-sources.ts"; import { - resolveInvocationRuntimeName, - resolveInvocationRuntimeSurface, -} from './common.ts' + resolveInvocationRuntimeName, + resolveInvocationRuntimeSurface, +} from "./common.ts"; -test('resolveInvocationRuntimeSurface omits package-workflow so the outer workflow owns the run record', () => { - expect( - resolveInvocationRuntimeSurface({ - selector: { kind: 'export', exportName: './run' }, - source: packageWorkflowInvocationSource, - }), - ).toBeNull() -}) +test("invocation runtime surface and name map selectors without double-counting package workflows", () => { + expect( + resolveInvocationRuntimeSurface({ + selector: { kind: "export", exportName: "./run" }, + source: packageWorkflowInvocationSource, + }), + ).toBeNull(); + expect( + resolveInvocationRuntimeSurface({ + selector: { kind: "export", exportName: "./run" }, + source: "discord-gateway", + }), + ).toBe("export"); + expect( + resolveInvocationRuntimeSurface({ + selector: { kind: "subscription", topic: "email.inbound" }, + source: "email", + }), + ).toBe("subscription"); -test('resolveInvocationRuntimeSurface maps export and subscription selectors', () => { - expect( - resolveInvocationRuntimeSurface({ - selector: { kind: 'export', exportName: './run' }, - source: 'discord-gateway', - }), - ).toBe('export') - expect( - resolveInvocationRuntimeSurface({ - selector: { kind: 'subscription', topic: 'email.inbound' }, - source: 'email', - }), - ).toBe('subscription') -}) - -test('resolveInvocationRuntimeName prefers topic for subscription surfaces', () => { - expect( - resolveInvocationRuntimeName({ - surface: 'subscription', - invocationName: './on-email', - topic: 'email.inbound', - }), - ).toBe('email.inbound') - expect( - resolveInvocationRuntimeName({ - surface: 'export', - invocationName: './run', - topic: 'ignored', - }), - ).toBe('./run') -}) + expect( + resolveInvocationRuntimeName({ + surface: "subscription", + invocationName: "./on-email", + topic: "email.inbound", + }), + ).toBe("email.inbound"); + expect( + resolveInvocationRuntimeName({ + surface: "export", + invocationName: "./run", + topic: "ignored", + }), + ).toBe("./run"); +}); diff --git a/packages/worker/src/package-runtime/package-workflows-sentry.node.test.ts b/packages/worker/src/package-runtime/package-workflows-sentry.node.test.ts index 2ab8071b4c..04722f8d89 100644 --- a/packages/worker/src/package-runtime/package-workflows-sentry.node.test.ts +++ b/packages/worker/src/package-runtime/package-workflows-sentry.node.test.ts @@ -1,101 +1,95 @@ -import { expect, test, vi } from 'vitest' +import { expect, test, vi } from "vitest"; const sentryMock = vi.hoisted(() => ({ - isInitialized: vi.fn(() => true), - setUser: vi.fn(), - setTag: vi.fn(), - setContext: vi.fn(), -})) + isInitialized: vi.fn(() => true), + setUser: vi.fn(), + setTag: vi.fn(), + setContext: vi.fn(), +})); -vi.mock('@sentry/cloudflare', () => ({ - isInitialized: (...args: Array) => sentryMock.isInitialized(...args), - setUser: (...args: Array) => sentryMock.setUser(...args), - setTag: (...args: Array) => sentryMock.setTag(...args), - setContext: (...args: Array) => sentryMock.setContext(...args), -})) +vi.mock("@sentry/cloudflare", () => ({ + isInitialized: (...args: Array) => sentryMock.isInitialized(...args), + setUser: (...args: Array) => sentryMock.setUser(...args), + setTag: (...args: Array) => sentryMock.setTag(...args), + setContext: (...args: Array) => sentryMock.setContext(...args), +})); const { applyDynamicWorkflowSentryScope } = - await import('./package-workflows-sentry.ts') + await import("./package-workflows-sentry.ts"); -test('applyDynamicWorkflowSentryScope tags package workflows with user and coordinates', () => { - sentryMock.setUser.mockClear() - sentryMock.setTag.mockClear() - sentryMock.setContext.mockClear() +test("applyDynamicWorkflowSentryScope tags package and inline workflows", () => { + applyDynamicWorkflowSentryScope({ + instanceId: "wf-instance-1", + payload: { + sourceType: "package", + userId: "user-stable-1", + packageId: "pkg-1", + kodyId: "demo-package", + sourceId: "source-1", + workflowName: "nightly", + exportName: "run", + idempotencyKey: "idem-1", + runAt: "2026-07-25T00:00:00.000Z", + planDate: null, + }, + }); - applyDynamicWorkflowSentryScope({ - instanceId: 'wf-instance-1', - payload: { - sourceType: 'package', - userId: 'user-stable-1', - packageId: 'pkg-1', - kodyId: 'demo-package', - sourceId: 'source-1', - workflowName: 'nightly', - exportName: 'run', - idempotencyKey: 'idem-1', - runAt: '2026-07-25T00:00:00.000Z', - planDate: null, - }, - }) + expect(sentryMock.setUser).toHaveBeenCalledWith({ id: "user-stable-1" }); + expect(sentryMock.setTag).toHaveBeenCalledWith( + "workflow.source_type", + "package", + ); + expect(sentryMock.setTag).toHaveBeenCalledWith( + "workflow.kody_id", + "demo-package", + ); + expect(sentryMock.setContext).toHaveBeenCalledWith( + "workflow", + expect.objectContaining({ + instanceId: "wf-instance-1", + packageId: "pkg-1", + kodyId: "demo-package", + exportName: "run", + workflowName: "nightly", + idempotencyKey: "idem-1", + }), + ); - expect(sentryMock.setUser).toHaveBeenCalledWith({ id: 'user-stable-1' }) - expect(sentryMock.setTag).toHaveBeenCalledWith( - 'workflow.source_type', - 'package', - ) - expect(sentryMock.setTag).toHaveBeenCalledWith( - 'workflow.kody_id', - 'demo-package', - ) - expect(sentryMock.setContext).toHaveBeenCalledWith( - 'workflow', - expect.objectContaining({ - instanceId: 'wf-instance-1', - packageId: 'pkg-1', - kodyId: 'demo-package', - exportName: 'run', - workflowName: 'nightly', - idempotencyKey: 'idem-1', - }), - ) -}) + sentryMock.setUser.mockClear(); + sentryMock.setTag.mockClear(); + sentryMock.setContext.mockClear(); -test('applyDynamicWorkflowSentryScope records inline code size without package tags', () => { - sentryMock.setUser.mockClear() - sentryMock.setTag.mockClear() - sentryMock.setContext.mockClear() + applyDynamicWorkflowSentryScope({ + instanceId: "wf-inline-1", + payload: { + sourceType: "inline", + userId: "user-stable-2", + packageContext: null, + workflowName: "ad-hoc", + code: "export default async () => 1", + idempotencyKey: "idem-2", + runAt: "2026-07-25T01:00:00.000Z", + planDate: null, + }, + }); - applyDynamicWorkflowSentryScope({ - instanceId: 'wf-inline-1', - payload: { - sourceType: 'inline', - userId: 'user-stable-2', - packageContext: null, - workflowName: 'ad-hoc', - code: 'export default async () => 1', - idempotencyKey: 'idem-2', - runAt: '2026-07-25T01:00:00.000Z', - planDate: null, - }, - }) - - expect(sentryMock.setUser).toHaveBeenCalledWith({ id: 'user-stable-2' }) - expect(sentryMock.setTag).toHaveBeenCalledWith( - 'workflow.source_type', - 'inline', - ) - expect(sentryMock.setTag).not.toHaveBeenCalledWith( - 'workflow.kody_id', - expect.anything(), - ) - expect(sentryMock.setContext).toHaveBeenCalledWith( - 'workflow', - expect.objectContaining({ - instanceId: 'wf-inline-1', - sourceType: 'inline', - packageId: null, - kodyId: null, - codeBytes: 'export default async () => 1'.length, - }), - ) -}) + expect(sentryMock.setUser).toHaveBeenCalledWith({ id: "user-stable-2" }); + expect(sentryMock.setTag).toHaveBeenCalledWith( + "workflow.source_type", + "inline", + ); + expect(sentryMock.setTag).not.toHaveBeenCalledWith( + "workflow.kody_id", + expect.anything(), + ); + expect(sentryMock.setContext).toHaveBeenCalledWith( + "workflow", + expect.objectContaining({ + instanceId: "wf-inline-1", + sourceType: "inline", + packageId: null, + kodyId: null, + codeBytes: "export default async () => 1".length, + }), + ); +}); diff --git a/packages/worker/src/sentry-options.node.test.ts b/packages/worker/src/sentry-options.node.test.ts index bd1bd09edb..3b317d86fb 100644 --- a/packages/worker/src/sentry-options.node.test.ts +++ b/packages/worker/src/sentry-options.node.test.ts @@ -1,217 +1,188 @@ -import { expect, test } from 'vitest' -import { UserCodeError } from './user-code-error.ts' +import { expect, test } from "vitest"; +import { isUserCodeError, UserCodeError } from "./user-code-error.ts"; import { - executorSandboxTimeoutMessage, - filterSentryEvent, -} from './sentry-options.ts' + executorSandboxTimeoutMessage, + filterSentryEvent, +} from "./sentry-options.ts"; -test('filterSentryEvent drops expected platform and caller noise and keeps real errors', () => { - expect( - filterSentryEvent({ - exception: { - values: [ - { - value: 'D1_ERROR: NOSENTRY database is locked: SQLITE_BUSY', - }, - ], - }, - }), - ).toBeNull() +test("filterSentryEvent drops expected platform and caller noise and keeps real errors", () => { + expect( + filterSentryEvent({ + exception: { + values: [ + { + value: "D1_ERROR: NOSENTRY database is locked: SQLITE_BUSY", + }, + ], + }, + }), + ).toBeNull(); - expect( - filterSentryEvent({ - exception: { - values: [ - { - value: 'Currently processing a long-running export.', - }, - { - value: 'D1_ERROR: Currently processing a long-running export.', - }, - ], - }, - }), - ).toBeNull() + expect( + filterSentryEvent({ + exception: { + values: [ + { value: "Currently processing a long-running export." }, + { value: "D1_ERROR: Currently processing a long-running export." }, + ], + }, + }), + ).toBeNull(); - expect( - filterSentryEvent({ - exception: { - values: [{ value: 'Network connection lost.' }], - }, - }), - ).toBeNull() - expect( - filterSentryEvent({ - exception: { - values: [{ value: 'D1_ERROR: Network connection lost.' }], - }, - }), - ).toBeNull() + // One representative form per D1 blip family (with and without D1_ERROR: prefix). + expect( + filterSentryEvent({ + exception: { values: [{ value: "Network connection lost." }] }, + }), + ).toBeNull(); + expect( + filterSentryEvent({ + exception: { + values: [ + { + value: + "D1_ERROR: internal error; reference = 0u3odos5iotccpol68ppc0eg", + }, + ], + }, + }), + ).toBeNull(); + expect( + filterSentryEvent({ + exception: { + values: [ + { + value: + "Internal error in D1 DB storage caused object to be reset; reference = 8t4dqqpoq1ctvjr8kca8fl4c", + }, + ], + }, + }), + ).toBeNull(); - expect( - filterSentryEvent({ - exception: { - values: [ - { - value: - 'D1_ERROR: internal error; reference = 0u3odos5iotccpol68ppc0eg', - }, - ], - }, - }), - ).toBeNull() - expect( - filterSentryEvent({ - exception: { - values: [ - { value: 'internal error; reference = 0u3odos5iotccpol68ppc0eg' }, - ], - }, - }), - ).toBeNull() - expect( - filterSentryEvent({ - exception: { - values: [ - { - value: - 'D1_ERROR: Internal error in D1 DB storage caused object to be reset; reference = 8t4dqqpoq1ctvjr8kca8fl4c', - }, - ], - }, - }), - ).toBeNull() - expect( - filterSentryEvent({ - exception: { - values: [ - { - value: - 'Internal error in D1 DB storage caused object to be reset; reference = 8t4dqqpoq1ctvjr8kca8fl4c', - }, - ], - }, - }), - ).toBeNull() + const unrelatedNetworkLoss = { + exception: { + values: [{ value: "Network connection lost while uploading..." }], + }, + }; + expect(filterSentryEvent(unrelatedNetworkLoss)).toBe(unrelatedNetworkLoss); - const unrelatedNetworkLoss = { - exception: { - values: [{ value: 'Network connection lost while uploading...' }], - }, - } - expect(filterSentryEvent(unrelatedNetworkLoss)).toBe(unrelatedNetworkLoss) + const bareInternalError = { + exception: { values: [{ value: "internal error" }] }, + }; + expect(filterSentryEvent(bareInternalError)).toBe(bareInternalError); - const bareInternalError = { - exception: { - values: [{ value: 'internal error' }], - }, - } - expect(filterSentryEvent(bareInternalError)).toBe(bareInternalError) + const bareObjectReset = { + exception: { + values: [ + { + value: + "D1_ERROR: Internal error in D1 DB storage caused object to be reset", + }, + ], + }, + }; + expect(filterSentryEvent(bareObjectReset)).toBe(bareObjectReset); - const bareObjectReset = { - exception: { - values: [ - { - value: - 'D1_ERROR: Internal error in D1 DB storage caused object to be reset', - }, - ], - }, - } - expect(filterSentryEvent(bareObjectReset)).toBe(bareObjectReset) + const userModuleBuildFailure = { + exception: { + values: [ + { + value: + 'Build failed with 1 error:\nvirtual:.__kody_root__/entry.ts:11:49: ERROR: Unexpected "^"', + }, + ], + }, + }; + expect(filterSentryEvent(userModuleBuildFailure)).toBeNull(); + expect( + filterSentryEvent({ + message: + 'Build failed with 1 error:\nvirtual:.__kody_root__/entry.ts:11:49: ERROR: Unexpected "^"', + }), + ).toBeNull(); - const userModuleBuildFailure = { - exception: { - values: [ - { - value: - 'Build failed with 1 error:\nvirtual:.__kody_root__/entry.ts:11:49: ERROR: Unexpected "^"', - }, - ], - }, - } - expect(filterSentryEvent(userModuleBuildFailure)).toBeNull() - expect( - filterSentryEvent({ - message: - 'Build failed with 1 error:\nvirtual:.__kody_root__/entry.ts:11:49: ERROR: Unexpected "^"', - }), - ).toBeNull() + const sandboxTimeout = { + exception: { + values: [{ value: executorSandboxTimeoutMessage }], + }, + }; + expect(filterSentryEvent(sandboxTimeout)).toBeNull(); + expect( + filterSentryEvent({ message: executorSandboxTimeoutMessage }), + ).toBeNull(); - const sandboxTimeout = { - exception: { - values: [{ value: executorSandboxTimeoutMessage }], - }, - } - expect(filterSentryEvent(sandboxTimeout)).toBeNull() - expect( - filterSentryEvent({ message: executorSandboxTimeoutMessage }), - ).toBeNull() + const platformBuildFailure = { + exception: { + values: [ + { + value: + 'Build failed with 1 error:\npackages/worker/src/index.ts:1:0: ERROR: Unexpected "{"', + }, + ], + }, + }; + expect(filterSentryEvent(platformBuildFailure)).toBe(platformBuildFailure); - const platformBuildFailure = { - exception: { - values: [ - { - value: - 'Build failed with 1 error:\npackages/worker/src/index.ts:1:0: ERROR: Unexpected "{"', - }, - ], - }, - } - expect(filterSentryEvent(platformBuildFailure)).toBe(platformBuildFailure) + const syntaxError = { + exception: { + values: [{ value: "D1_ERROR: syntax error near INSERTZ" }], + }, + }; + expect(filterSentryEvent(syntaxError)).toBe(syntaxError); - const syntaxError = { - exception: { - values: [{ value: 'D1_ERROR: syntax error near INSERTZ' }], - }, - } - expect(filterSentryEvent(syntaxError)).toBe(syntaxError) + const webhookTimeout = { + exception: { + values: [{ value: "Webhook sync invocation timed out." }], + }, + }; + expect(filterSentryEvent(webhookTimeout)).toBe(webhookTimeout); - const webhookTimeout = { - exception: { - values: [{ value: 'Webhook sync invocation timed out.' }], - }, - } - expect(filterSentryEvent(webhookTimeout)).toBe(webhookTimeout) + const prefixedSandboxTimeout = { + exception: { + values: [{ value: `Error: ${executorSandboxTimeoutMessage}` }], + }, + }; + expect(filterSentryEvent(prefixedSandboxTimeout)).toBe( + prefixedSandboxTimeout, + ); - const prefixedSandboxTimeout = { - exception: { - values: [{ value: `Error: ${executorSandboxTimeoutMessage}` }], - }, - } - expect(filterSentryEvent(prefixedSandboxTimeout)).toBe(prefixedSandboxTimeout) -}) + const userCodeEvent = { + exception: { + values: [{ type: "UserCodeError", value: "boom" }], + }, + }; + expect( + filterSentryEvent(userCodeEvent, { + originalException: new UserCodeError("boom"), + }), + ).toBeNull(); + expect( + filterSentryEvent(userCodeEvent, { + originalException: new Error("wrapper", { + cause: new UserCodeError("boom"), + }), + }), + ).toBeNull(); -test('filterSentryEvent drops UserCodeError via originalException hint', () => { - const event = { - exception: { - values: [{ type: 'UserCodeError', value: 'boom' }], - }, - } - expect( - filterSentryEvent(event, { - originalException: new UserCodeError('boom'), - }), - ).toBeNull() - expect( - filterSentryEvent(event, { - originalException: new Error('wrapper', { - cause: new UserCodeError('boom'), - }), - }), - ).toBeNull() -}) + const nestedUserCode = new Error("handler failed", { + cause: new Error("step failed", { cause: new UserCodeError("boom") }), + }); + expect(isUserCodeError(new UserCodeError("boom"))).toBe(true); + expect(isUserCodeError(nestedUserCode)).toBe(true); + expect(isUserCodeError(new Error("platform blew up"))).toBe(false); + expect(isUserCodeError("boom")).toBe(false); + expect(isUserCodeError(null)).toBe(false); -test('filterSentryEvent keeps genuine platform errors', () => { - const event = { - exception: { - values: [{ type: 'Error', value: 'Durable Object storage failed' }], - }, - } - expect( - filterSentryEvent(event, { - originalException: new Error('Durable Object storage failed'), - }), - ).toBe(event) - expect(filterSentryEvent(event)).toBe(event) -}) + const platformEvent = { + exception: { + values: [{ type: "Error", value: "Durable Object storage failed" }], + }, + }; + expect( + filterSentryEvent(platformEvent, { + originalException: new Error("Durable Object storage failed"), + }), + ).toBe(platformEvent); + expect(filterSentryEvent(platformEvent)).toBe(platformEvent); +}); diff --git a/packages/worker/src/usage/activation.node.test.ts b/packages/worker/src/usage/activation.node.test.ts index 459de31530..d894e45ffc 100644 --- a/packages/worker/src/usage/activation.node.test.ts +++ b/packages/worker/src/usage/activation.node.test.ts @@ -1,25 +1,26 @@ -import { readFileSync } from 'node:fs' -import { DatabaseSync } from 'node:sqlite' -import { expect, test, vi } from 'vitest' -import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts' +import { readFileSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import { expect, test } from "vitest"; +import { createD1FromSqlite } from "#worker/test-support/create-d1-from-sqlite.ts"; +import { consoleWarn } from "#worker/test-support/console-spies.ts"; import { - countsTowardPackageActivation, - recordActivationMilestone, - recordSuccessfulPackageRun, -} from './activation.ts' + countsTowardPackageActivation, + recordActivationMilestone, + recordSuccessfulPackageRun, +} from "./activation.ts"; function createActivationDb() { - const sqlite = new DatabaseSync(':memory:') - sqlite.exec( - readFileSync( - new URL( - '../../migrations/0037-package-runtime-debug.sql', - import.meta.url, - ), - 'utf8', - ), - ) - sqlite.exec(` + const sqlite = new DatabaseSync(":memory:"); + sqlite.exec( + readFileSync( + new URL( + "../../migrations/0037-package-runtime-debug.sql", + import.meta.url, + ), + "utf8", + ), + ); + sqlite.exec(` CREATE TABLE user_activation_milestones ( user_id TEXT NOT NULL, milestone TEXT NOT NULL CHECK ( @@ -36,201 +37,169 @@ function createActivationDb() { updated_at TEXT NOT NULL, PRIMARY KEY (user_id, package_id) ); - `) - return { sqlite, db: createD1FromSqlite(sqlite) } + `); + return { sqlite, db: createD1FromSqlite(sqlite) }; } function listMilestones(sqlite: DatabaseSync, userId: string) { - return sqlite - .prepare( - `SELECT milestone, package_id FROM user_activation_milestones + return sqlite + .prepare( + `SELECT milestone, package_id FROM user_activation_milestones WHERE user_id = ? ORDER BY milestone`, - ) - .all(userId) as Array<{ milestone: string; package_id: string | null }> + ) + .all(userId) as Array<{ milestone: string; package_id: string | null }>; } function listSuccessCounts(sqlite: DatabaseSync, userId: string) { - return sqlite - .prepare( - `SELECT package_id, success_count FROM user_package_run_successes + return sqlite + .prepare( + `SELECT package_id, success_count FROM user_package_run_successes WHERE user_id = ? ORDER BY package_id`, - ) - .all(userId) as Array<{ package_id: string; success_count: number }> + ) + .all(userId) as Array<{ package_id: string; success_count: number }>; } -test('activation requires the same package to succeed twice on the durable counter', async () => { - const { sqlite, db } = createActivationDb() - const env = { APP_DB: db } - - await recordSuccessfulPackageRun(env, { - userId: 'user-1', - packageId: 'pkg-a', - surface: 'job', - }) - expect(listMilestones(sqlite, 'user-1').map((row) => row.milestone)).toEqual([ - 'package_run_succeeded', - ]) - expect(listSuccessCounts(sqlite, 'user-1')).toEqual([ - { package_id: 'pkg-a', success_count: 1 }, - ]) - - // A different package succeeding once is still a user in setup, not a user - // with something working unattended. - await recordSuccessfulPackageRun(env, { - userId: 'user-1', - packageId: 'pkg-b', - surface: 'service', - }) - expect(listMilestones(sqlite, 'user-1').map((row) => row.milestone)).toEqual([ - 'package_run_succeeded', - ]) - expect(listSuccessCounts(sqlite, 'user-1')).toEqual([ - { package_id: 'pkg-a', success_count: 1 }, - { package_id: 'pkg-b', success_count: 1 }, - ]) - - await recordSuccessfulPackageRun(env, { - userId: 'user-1', - packageId: 'pkg-a', - surface: 'job', - }) - expect(listMilestones(sqlite, 'user-1')).toEqual([ - { milestone: 'package_activated', package_id: 'pkg-a' }, - { milestone: 'package_run_succeeded', package_id: 'pkg-a' }, - ]) - expect(listSuccessCounts(sqlite, 'user-1')).toEqual([ - { package_id: 'pkg-a', success_count: 2 }, - { package_id: 'pkg-b', success_count: 1 }, - ]) -}) - -test('terminal package_activated fast path does not re-query or rewrite counters', async () => { - const { sqlite, db } = createActivationDb() - const env = { APP_DB: db } - - await recordActivationMilestone(env, { - userId: 'user-fast', - milestone: 'package_activated', - packageId: 'pkg-a', - reachedAt: '2026-07-01T00:00:00.000Z', - }) - - const prepareCalls: Array = [] - const originalPrepare = db.prepare.bind(db) - db.prepare = ((query: string) => { - prepareCalls.push(query.replace(/\s+/g, ' ').trim()) - return originalPrepare(query) - }) as typeof db.prepare - - await recordSuccessfulPackageRun(env, { - userId: 'user-fast', - packageId: 'pkg-a', - surface: 'job', - }) - await recordSuccessfulPackageRun(env, { - userId: 'user-fast', - packageId: 'pkg-b', - surface: 'workflow', - }) - - expect(prepareCalls).toEqual([ - 'SELECT 1 AS n FROM user_activation_milestones WHERE user_id = ?1 AND milestone = ?2', - 'SELECT 1 AS n FROM user_activation_milestones WHERE user_id = ?1 AND milestone = ?2', - ]) - expect(listSuccessCounts(sqlite, 'user-fast')).toEqual([]) - expect(listMilestones(sqlite, 'user-fast')).toEqual([ - { milestone: 'package_activated', package_id: 'pkg-a' }, - ]) -}) - -test('webhook and app_fetch successes do not count toward activation', async () => { - const { sqlite, db } = createActivationDb() - const env = { APP_DB: db } - - expect(countsTowardPackageActivation('webhook')).toBe(false) - expect(countsTowardPackageActivation('app_fetch')).toBe(false) - expect(countsTowardPackageActivation('job')).toBe(true) - expect(countsTowardPackageActivation(undefined)).toBe(true) - - await recordSuccessfulPackageRun(env, { - userId: 'user-http', - packageId: 'pkg-a', - surface: 'webhook', - }) - await recordSuccessfulPackageRun(env, { - userId: 'user-http', - packageId: 'pkg-a', - surface: 'app_fetch', - }) - await recordSuccessfulPackageRun(env, { - userId: 'user-http', - packageId: 'pkg-a', - surface: 'webhook', - }) - - expect(listMilestones(sqlite, 'user-http')).toEqual([]) - expect(listSuccessCounts(sqlite, 'user-http')).toEqual([]) -}) - -test('milestones keep their first timestamp', async () => { - const { sqlite, db } = createActivationDb() - const env = { APP_DB: db } - - await recordActivationMilestone(env, { - userId: 'user-2', - milestone: 'package_run_succeeded', - packageId: 'pkg-a', - reachedAt: '2026-07-01T00:00:00.000Z', - }) - await recordActivationMilestone(env, { - userId: 'user-2', - milestone: 'package_run_succeeded', - packageId: 'pkg-z', - reachedAt: '2026-07-09T00:00:00.000Z', - }) - const row = sqlite - .prepare( - `SELECT reached_at, package_id FROM user_activation_milestones +test("activation requires the same package to succeed twice and ignores HTTP surfaces", async () => { + const { sqlite, db } = createActivationDb(); + const env = { APP_DB: db }; + + expect(countsTowardPackageActivation("webhook")).toBe(false); + expect(countsTowardPackageActivation("app_fetch")).toBe(false); + expect(countsTowardPackageActivation("job")).toBe(true); + expect(countsTowardPackageActivation(undefined)).toBe(true); + + await recordSuccessfulPackageRun(env, { + userId: "user-1", + packageId: "pkg-a", + surface: "job", + }); + expect(listMilestones(sqlite, "user-1").map((row) => row.milestone)).toEqual([ + "package_run_succeeded", + ]); + expect(listSuccessCounts(sqlite, "user-1")).toEqual([ + { package_id: "pkg-a", success_count: 1 }, + ]); + + await recordSuccessfulPackageRun(env, { + userId: "user-1", + packageId: "pkg-b", + surface: "service", + }); + expect(listMilestones(sqlite, "user-1").map((row) => row.milestone)).toEqual([ + "package_run_succeeded", + ]); + expect(listSuccessCounts(sqlite, "user-1")).toEqual([ + { package_id: "pkg-a", success_count: 1 }, + { package_id: "pkg-b", success_count: 1 }, + ]); + + await recordSuccessfulPackageRun(env, { + userId: "user-http", + packageId: "pkg-a", + surface: "webhook", + }); + await recordSuccessfulPackageRun(env, { + userId: "user-http", + packageId: "pkg-a", + surface: "app_fetch", + }); + expect(listMilestones(sqlite, "user-http")).toEqual([]); + expect(listSuccessCounts(sqlite, "user-http")).toEqual([]); + + await recordSuccessfulPackageRun(env, { + userId: "user-1", + packageId: "pkg-a", + surface: "job", + }); + expect(listMilestones(sqlite, "user-1")).toEqual([ + { milestone: "package_activated", package_id: "pkg-a" }, + { milestone: "package_run_succeeded", package_id: "pkg-a" }, + ]); + expect(listSuccessCounts(sqlite, "user-1")).toEqual([ + { package_id: "pkg-a", success_count: 2 }, + { package_id: "pkg-b", success_count: 1 }, + ]); + + await recordActivationMilestone(env, { + userId: "user-2", + milestone: "package_run_succeeded", + packageId: "pkg-a", + reachedAt: "2026-07-01T00:00:00.000Z", + }); + await recordActivationMilestone(env, { + userId: "user-2", + milestone: "package_run_succeeded", + packageId: "pkg-z", + reachedAt: "2026-07-09T00:00:00.000Z", + }); + const sticky = sqlite + .prepare( + `SELECT reached_at, package_id FROM user_activation_milestones WHERE user_id = 'user-2' AND milestone = 'package_run_succeeded'`, - ) - .get() as { reached_at: string; package_id: string } - expect(row).toEqual({ - reached_at: '2026-07-01T00:00:00.000Z', - package_id: 'pkg-a', - }) -}) - -test('activation instrumentation never throws without a database', async () => { - await expect( - recordSuccessfulPackageRun({}, { userId: 'user-1', packageId: 'pkg-a' }), - ).resolves.toBeUndefined() - await expect( - recordActivationMilestone( - {}, - { userId: 'user-1', milestone: 'package_activated' }, - ), - ).resolves.toBeUndefined() -}) - -test('activation write failures never throw to the caller', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const env = { - APP_DB: { - prepare() { - throw new Error('d1 unavailable') - }, - } as unknown as D1Database, - } - await expect( - recordSuccessfulPackageRun(env, { - userId: 'user-1', - packageId: 'pkg-a', - surface: 'job', - }), - ).resolves.toBeUndefined() - expect(warnSpy).toHaveBeenCalledWith( - 'activation-run-record-failed', - expect.any(Error), - ) - warnSpy.mockRestore() -}) + ) + .get() as { reached_at: string; package_id: string }; + expect(sticky).toEqual({ + reached_at: "2026-07-01T00:00:00.000Z", + package_id: "pkg-a", + }); + + await recordActivationMilestone(env, { + userId: "user-fast", + milestone: "package_activated", + packageId: "pkg-a", + reachedAt: "2026-07-01T00:00:00.000Z", + }); + const prepareCalls: Array = []; + const originalPrepare = db.prepare.bind(db); + db.prepare = ((query: string) => { + prepareCalls.push(query.replace(/\s+/g, " ").trim()); + return originalPrepare(query); + }) as typeof db.prepare; + await recordSuccessfulPackageRun(env, { + userId: "user-fast", + packageId: "pkg-a", + surface: "job", + }); + await recordSuccessfulPackageRun(env, { + userId: "user-fast", + packageId: "pkg-b", + surface: "workflow", + }); + expect(prepareCalls).toEqual([ + "SELECT 1 AS n FROM user_activation_milestones WHERE user_id = ?1 AND milestone = ?2", + "SELECT 1 AS n FROM user_activation_milestones WHERE user_id = ?1 AND milestone = ?2", + ]); + expect(listSuccessCounts(sqlite, "user-fast")).toEqual([]); +}); + +test("activation instrumentation never throws without a database or on write failure", async () => { + await expect( + recordSuccessfulPackageRun({}, { userId: "user-1", packageId: "pkg-a" }), + ).resolves.toBeUndefined(); + await expect( + recordActivationMilestone( + {}, + { userId: "user-1", milestone: "package_activated" }, + ), + ).resolves.toBeUndefined(); + + consoleWarn.mockImplementation(() => {}); + const env = { + APP_DB: { + prepare() { + throw new Error("d1 unavailable"); + }, + } as unknown as D1Database, + }; + await expect( + recordSuccessfulPackageRun(env, { + userId: "user-1", + packageId: "pkg-a", + surface: "job", + }), + ).resolves.toBeUndefined(); + expect(consoleWarn).toHaveBeenCalledWith( + "activation-run-record-failed", + expect.any(Error), + ); +}); diff --git a/packages/worker/src/user-code-error.node.test.ts b/packages/worker/src/user-code-error.node.test.ts deleted file mode 100644 index ef596d3aa7..0000000000 --- a/packages/worker/src/user-code-error.node.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, test } from 'vitest' -import { isUserCodeError, UserCodeError } from './user-code-error.ts' - -test('isUserCodeError walks a nested cause chain', () => { - const root = new UserCodeError('boom') - const wrapped = new Error('handler failed', { - cause: new Error('step failed', { cause: root }), - }) - expect(isUserCodeError(root)).toBe(true) - expect(isUserCodeError(wrapped)).toBe(true) - expect(isUserCodeError(new Error('platform blew up'))).toBe(false) - expect(isUserCodeError('boom')).toBe(false) - expect(isUserCodeError(null)).toBe(false) -}) From 9dc9184cb14f741f0b0db17f40edc10bf346749f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 09:25:24 +0000 Subject: [PATCH 3/4] test: consolidate account list/detail and alert suites Fold post-#937/#939/#940 account verification tests into fewer workflow journeys and drop low-signal Playwright coverage. - account-values handler: list/select/save/delete + rejection matrix - account-memories handler: list/filter/select + soft/force delete - activity-data helpers + load/cursor; billing refresh workflow without instructional error-copy pins - auth-session password-change matrix; auth-denial and email-delivery alert cron/threshold/cooldown journeys - trim activity e2e instructional empty-state copy; drop memories e2e (values e2e + memories handler cover the pattern); drop jobs schedule text pin Co-authored-by: Kent C. Dodds --- e2e/account-activity.spec.ts | 50 +- e2e/account-jobs.spec.ts | 89 ++- e2e/account-memories.spec.ts | 60 -- .../app/account-activity-data.node.test.ts | 429 +++++++------ .../src/app/account-billing-data.node.test.ts | 247 ++++---- .../src/app/auth-denial-alerts.node.test.ts | 197 +++--- .../worker/src/app/auth-session.node.test.ts | 186 +++--- .../app/email-delivery-alerts.node.test.ts | 251 ++++---- .../handlers/account-memories.node.test.ts | 474 +++++++------- .../app/handlers/account-values.node.test.ts | 587 ++++++++---------- 10 files changed, 1178 insertions(+), 1392 deletions(-) delete mode 100644 e2e/account-memories.spec.ts diff --git a/e2e/account-activity.spec.ts b/e2e/account-activity.spec.ts index 87bffddcdb..17484755f7 100644 --- a/e2e/account-activity.spec.ts +++ b/e2e/account-activity.spec.ts @@ -1,34 +1,22 @@ -import { expect, test } from './playwright-utils.ts' -import { ensurePrimaryUserExists, primaryTestUser } from './auth-test-user.ts' +import { expect, test } from "./playwright-utils.ts"; +import { ensurePrimaryUserExists, primaryTestUser } from "./auth-test-user.ts"; -test('account activity shows empty state and filters for a seeded account', async ({ - page, - login, +test("account activity shows filters for a seeded account", async ({ + page, + login, }) => { - await ensurePrimaryUserExists() - await login({ - email: primaryTestUser.email, - password: primaryTestUser.password, - mode: 'login', - }) + await ensurePrimaryUserExists(); + await login({ + email: primaryTestUser.email, + password: primaryTestUser.password, + mode: "login", + }); - await page.goto('/account/activity') - await expect( - page.getByRole('heading', { name: 'Activity', exact: true }), - ).toBeVisible() - await expect(page.getByLabel('Status filter')).toBeVisible() - await expect(page.getByLabel('Surface filter')).toBeVisible() - await expect(page.getByLabel('Status filter')).toHaveValue('error') - await expect(page.getByLabel('Surface filter')).toHaveValue('all') - await expect( - page.getByText( - 'No failures in the last 7 days. That is good news — when something breaks, it will show up here with its logs.', - ), - ).toBeVisible() - await expect( - page.getByText( - 'Successful ad-hoc execute runs are not recorded (only failures are).', - ), - ).toBeVisible() - await expect(page.getByRole('link', { name: 'Activity' })).toBeVisible() -}) + await page.goto("/account/activity"); + await expect( + page.getByRole("heading", { name: "Activity", exact: true }), + ).toBeVisible(); + await expect(page.getByLabel("Status filter")).toHaveValue("error"); + await expect(page.getByLabel("Surface filter")).toHaveValue("all"); + await expect(page.getByRole("link", { name: "Activity" })).toBeVisible(); +}); diff --git a/e2e/account-jobs.spec.ts b/e2e/account-jobs.spec.ts index 8e90daa66a..a93fe3e445 100644 --- a/e2e/account-jobs.spec.ts +++ b/e2e/account-jobs.spec.ts @@ -1,27 +1,27 @@ -import { expect, test } from './playwright-utils.ts' -import { quoteSqlString } from '@kody-internal/shared/sql-literals.ts' -import { createStableUserIdFromEmail } from '../packages/worker/src/user-id.ts' -import { executeE2eD1Command } from './d1-utils.ts' +import { expect, test } from "./playwright-utils.ts"; +import { quoteSqlString } from "@kody-internal/shared/sql-literals.ts"; +import { createStableUserIdFromEmail } from "../packages/worker/src/user-id.ts"; +import { executeE2eD1Command } from "./d1-utils.ts"; -test('account jobs detail URL persists across reload for a seeded job', async ({ - page, - seedE2eUser, - login, +test("account jobs detail URL persists across reload for a seeded job", async ({ + page, + seedE2eUser, + login, }) => { - const runId = Date.now() - const email = `account-jobs-detail-${runId}@example.com` - const user = await seedE2eUser({ - email, - username: `account-jobs-detail-${runId}`, - password: 'account-jobs-password', - }) - const userId = await createStableUserIdFromEmail(email) - const jobId = crypto.randomUUID() - const jobName = `Seeded job ${runId}` - const now = new Date().toISOString() - const nextRunAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() - executeE2eD1Command( - `INSERT INTO jobs ( + const runId = Date.now(); + const email = `account-jobs-detail-${runId}@example.com`; + const user = await seedE2eUser({ + email, + username: `account-jobs-detail-${runId}`, + password: "account-jobs-password", + }); + const userId = await createStableUserIdFromEmail(email); + const jobId = crypto.randomUUID(); + const jobName = `Seeded job ${runId}`; + const now = new Date().toISOString(); + const nextRunAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + executeE2eD1Command( + `INSERT INTO jobs ( id, user_id, name, source_id, published_commit, storage_id, params_json, schedule_json, timezone, enabled, kill_switch_enabled, caller_context_json, created_at, updated_at, next_run_at, @@ -34,7 +34,7 @@ test('account jobs detail URL persists across reload for a seeded job', async ({ NULL, ${quoteSqlString(`job:${jobId}`)}, NULL, - ${quoteSqlString(JSON.stringify({ type: 'interval', every: '1h' }))}, + ${quoteSqlString(JSON.stringify({ type: "interval", every: "1h" }))}, 'UTC', 1, 0, @@ -47,29 +47,26 @@ test('account jobs detail URL persists across reload for a seeded job', async ({ 0, '[]' )`, - ) + ); - await login({ - email: user.email, - password: user.password, - mode: 'login', - }) + await login({ + email: user.email, + password: user.password, + mode: "login", + }); - await page.goto(`/account/jobs/${encodeURIComponent(jobId)}`) - await expect( - page.getByRole('heading', { name: 'Jobs', exact: true }), - ).toBeVisible() - await expect( - page.getByRole('heading', { name: jobName, exact: true }), - ).toBeVisible() + await page.goto(`/account/jobs/${encodeURIComponent(jobId)}`); + await expect( + page.getByRole("heading", { name: "Jobs", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: jobName, exact: true }), + ).toBeVisible(); - const detailUrl = page.url() - await page.reload() - await expect(page).toHaveURL(detailUrl) - await expect( - page.getByRole('heading', { name: jobName, exact: true }), - ).toBeVisible() - await expect( - page.getByRole('definition').filter({ hasText: 'Runs every 1h' }), - ).toBeVisible() -}) + const detailUrl = page.url(); + await page.reload(); + await expect(page).toHaveURL(detailUrl); + await expect( + page.getByRole("heading", { name: jobName, exact: true }), + ).toBeVisible(); +}); diff --git a/e2e/account-memories.spec.ts b/e2e/account-memories.spec.ts deleted file mode 100644 index c4dd04d782..0000000000 --- a/e2e/account-memories.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, test } from './playwright-utils.ts' -import { quoteSqlString } from '@kody-internal/shared/sql-literals.ts' -import { createStableUserIdFromEmail } from '../packages/worker/src/user-id.ts' -import { executeE2eD1Command } from './d1-utils.ts' - -test('account memories detail URL shows a seeded memory', async ({ - page, - seedE2eUser, - login, -}) => { - const runId = Date.now() - const email = `account-memories-detail-${runId}@example.com` - const user = await seedE2eUser({ - email, - username: `account-memories-detail-${runId}`, - password: 'account-memories-password', - }) - const userId = await createStableUserIdFromEmail(email) - const memoryId = crypto.randomUUID() - const now = new Date().toISOString() - executeE2eD1Command( - `INSERT INTO mcp_memories ( - id, user_id, category, status, subject, summary, details, tags_json, - source_uris_json, dedupe_key, created_at, updated_at, last_accessed_at, deleted_at - ) VALUES ( - ${quoteSqlString(memoryId)}, - ${quoteSqlString(userId)}, - 'preferences', - 'active', - ${quoteSqlString(`Seeded memory ${runId}`)}, - 'Seeded for account memories e2e coverage.', - 'Details for the seeded memory.', - ${quoteSqlString(JSON.stringify(['e2e']))}, - ${quoteSqlString(JSON.stringify(['https://example.com/seeded-memory']))}, - ${quoteSqlString(`e2e:memory:${runId}`)}, - ${quoteSqlString(now)}, - ${quoteSqlString(now)}, - NULL, - NULL - )`, - ) - - await login({ - email: user.email, - password: user.password, - mode: 'login', - }) - - await page.goto(`/account/memories/${memoryId}`) - await expect( - page.getByRole('heading', { name: 'Memories', exact: true }), - ).toBeVisible() - await expect( - page.getByRole('heading', { - name: `Seeded memory ${runId}`, - exact: true, - }), - ).toBeVisible() - await expect(page.getByText('Details for the seeded memory.')).toBeVisible() -}) diff --git a/packages/worker/src/app/account-activity-data.node.test.ts b/packages/worker/src/app/account-activity-data.node.test.ts index c31210469c..11ab29d080 100644 --- a/packages/worker/src/app/account-activity-data.node.test.ts +++ b/packages/worker/src/app/account-activity-data.node.test.ts @@ -1,237 +1,226 @@ -import { expect, test, vi } from 'vitest' +import { expect, test, vi } from "vitest"; import { - loadAccountActivityData, - readAccountActivityFilters, - readAccountActivitySelectedRunId, - statusFilterToRunStatus, - surfaceFilterToRunSurface, -} from '#app/account-activity-data.ts' -import { type RunRecord } from '#worker/run-records/types.ts' + loadAccountActivityData, + readAccountActivityFilters, + readAccountActivitySelectedRunId, + statusFilterToRunStatus, + surfaceFilterToRunSurface, +} from "#app/account-activity-data.ts"; +import { type RunRecord } from "#worker/run-records/types.ts"; const mockModule = vi.hoisted(() => ({ - listRunRecords: vi.fn(), - getRunRecord: vi.fn(), - summarizeRunRecords: vi.fn(), -})) + listRunRecords: vi.fn(), + getRunRecord: vi.fn(), + summarizeRunRecords: vi.fn(), +})); -vi.mock('#worker/run-records/service.ts', () => ({ - listRunRecords: (...args: Array) => - mockModule.listRunRecords(...args), - getRunRecord: (...args: Array) => mockModule.getRunRecord(...args), - summarizeRunRecords: (...args: Array) => - mockModule.summarizeRunRecords(...args), -})) +vi.mock("#worker/run-records/service.ts", () => ({ + listRunRecords: (...args: Array) => + mockModule.listRunRecords(...args), + getRunRecord: (...args: Array) => mockModule.getRunRecord(...args), + summarizeRunRecords: (...args: Array) => + mockModule.summarizeRunRecords(...args), +})); const user = { - sessionUserId: '42', - userId: 42, - username: 'test-user', - email: 'user@example.com', - displayName: 'user', - artifactOwnerIds: [], - mcpUser: { - userId: 'stable-user-1', - email: 'user@example.com', - username: 'test-user', - displayName: 'user', - }, -} + sessionUserId: "42", + userId: 42, + username: "test-user", + email: "user@example.com", + displayName: "user", + artifactOwnerIds: [], + mcpUser: { + userId: "stable-user-1", + email: "user@example.com", + username: "test-user", + displayName: "user", + }, +}; function makeRun(overrides: Partial = {}): RunRecord { - return { - id: 'run-1', - surface: 'job', - status: 'error', - name: 'Morning digest', - packageId: null, - kodyId: null, - sourceId: 'source-1', - publishedCommit: null, - storageId: 'storage-1', - jobId: 'job-1', - workflowId: null, - invocationId: null, - sessionId: null, - idempotencyKey: null, - parentRunId: null, - startedAt: '2026-07-25T09:00:00.000Z', - finishedAt: '2026-07-25T09:00:01.000Z', - durationMs: 1000, - errorName: 'Error', - errorMessage: 'boom', - metadata: {}, - logCount: 2, - ...overrides, - } + return { + id: "run-1", + surface: "job", + status: "error", + name: "Morning digest", + packageId: null, + kodyId: null, + sourceId: "source-1", + publishedCommit: null, + storageId: "storage-1", + jobId: "job-1", + workflowId: null, + invocationId: null, + sessionId: null, + idempotencyKey: null, + parentRunId: null, + startedAt: "2026-07-25T09:00:00.000Z", + finishedAt: "2026-07-25T09:00:01.000Z", + durationMs: 1000, + errorName: "Error", + errorMessage: "boom", + metadata: {}, + logCount: 2, + ...overrides, + }; } -test('readAccountActivityFilters defaults to errors across all surfaces', () => { - expect( - readAccountActivityFilters('https://example.com/account/activity'), - ).toEqual({ - statusFilter: 'error', - surfaceFilter: 'all', - cursor: null, - }) - expect( - readAccountActivityFilters( - 'https://example.com/account/activity?status=all&surface=job&cursor=abc', - ), - ).toEqual({ - statusFilter: 'all', - surfaceFilter: 'job', - cursor: 'abc', - }) - expect(statusFilterToRunStatus('error')).toBe('error') - expect(statusFilterToRunStatus('all')).toBeNull() - expect(surfaceFilterToRunSurface('all')).toBeNull() - expect(surfaceFilterToRunSurface('webhook')).toBe('webhook') -}) +test("activity helpers parse filters and prefer path selected run ids", () => { + expect( + readAccountActivityFilters("https://example.com/account/activity"), + ).toEqual({ + statusFilter: "error", + surfaceFilter: "all", + cursor: null, + }); + expect( + readAccountActivityFilters( + "https://example.com/account/activity?status=all&surface=job&cursor=abc", + ), + ).toEqual({ + statusFilter: "all", + surfaceFilter: "job", + cursor: "abc", + }); + expect(statusFilterToRunStatus("error")).toBe("error"); + expect(statusFilterToRunStatus("all")).toBeNull(); + expect(surfaceFilterToRunSurface("all")).toBeNull(); + expect(surfaceFilterToRunSurface("webhook")).toBe("webhook"); -test('readAccountActivitySelectedRunId prefers path params over query', () => { - expect( - readAccountActivitySelectedRunId( - 'https://example.com/account/activity/run-path?selected=run-query', - 'run-path-param', - ), - ).toBe('run-path-param') - expect( - readAccountActivitySelectedRunId( - 'https://example.com/account/activity/run-path', - ), - ).toBe('run-path') - expect( - readAccountActivitySelectedRunId( - 'https://example.com/account/activity?selected=run-query', - ), - ).toBe('run-query') -}) + expect( + readAccountActivitySelectedRunId( + "https://example.com/account/activity/run-path?selected=run-query", + "run-path-param", + ), + ).toBe("run-path-param"); + expect( + readAccountActivitySelectedRunId( + "https://example.com/account/activity/run-path", + ), + ).toBe("run-path"); + expect( + readAccountActivitySelectedRunId( + "https://example.com/account/activity?selected=run-query", + ), + ).toBe("run-query"); +}); -test('loadAccountActivityData maps filters, summary, pagination, and detail', async () => { - const run = makeRun() - mockModule.summarizeRunRecords.mockResolvedValue({ - since: '2026-07-19T12:00:00.000Z', - total: 4, - errors: 1, - running: 0, - bySurface: [{ surface: 'job', total: 4, errors: 1 }], - }) - mockModule.listRunRecords.mockResolvedValue({ - runs: [run], - nextCursor: 'cursor-2', - }) - mockModule.getRunRecord.mockResolvedValue({ - run, - logs: [ - { - runId: run.id, - sequence: 1, - level: 'error', - message: 'second', - fields: null, - }, - { - runId: run.id, - sequence: 0, - level: 'log', - message: 'first', - fields: null, - }, - ], - }) +test("loadAccountActivityData maps filters, summary, pagination, detail, and cursors", async () => { + const run = makeRun(); + mockModule.summarizeRunRecords.mockResolvedValue({ + since: "2026-07-19T12:00:00.000Z", + total: 4, + errors: 1, + running: 0, + bySurface: [{ surface: "job", total: 4, errors: 1 }], + }); + mockModule.listRunRecords.mockResolvedValue({ + runs: [run], + nextCursor: "cursor-2", + }); + mockModule.getRunRecord.mockResolvedValue({ + run, + logs: [ + { + runId: run.id, + sequence: 1, + level: "error", + message: "second", + fields: null, + }, + { + runId: run.id, + sequence: 0, + level: "log", + message: "first", + fields: null, + }, + ], + }); - const env = {} as Env - const data = await loadAccountActivityData({ - env, - request: new Request( - 'https://example.com/account/activity/run-1?status=error&surface=job', - ), - user, - now: new Date('2026-07-26T12:00:00.000Z'), - }) + const env = {} as Env; + const data = await loadAccountActivityData({ + env, + request: new Request( + "https://example.com/account/activity/run-1?status=error&surface=job", + ), + user, + now: new Date("2026-07-26T12:00:00.000Z"), + }); - expect(mockModule.summarizeRunRecords).toHaveBeenCalledWith({ - env, - userId: 'stable-user-1', - since: '2026-07-19T12:00:00.000Z', - }) - expect(mockModule.listRunRecords).toHaveBeenCalledWith({ - env, - userId: 'stable-user-1', - filter: { - status: 'error', - surface: 'job', - since: '2026-07-19T12:00:00.000Z', - }, - limit: 25, - cursor: null, - }) - expect(mockModule.getRunRecord).toHaveBeenCalledWith({ - env, - userId: 'stable-user-1', - runId: 'run-1', - }) - expect(data).toMatchObject({ - ok: true, - statusFilter: 'error', - surfaceFilter: 'job', - summary: { - total: 4, - errors: 1, - running: 0, - }, - nextCursor: 'cursor-2', - selectedRunId: 'run-1', - retentionDays: 30, - runs: [ - expect.objectContaining({ - id: 'run-1', - surface: 'job', - status: 'error', - errorMessage: 'boom', - }), - ], - selectedRun: expect.objectContaining({ - id: 'run-1', - logs: [ - expect.objectContaining({ sequence: 0, message: 'first' }), - expect.objectContaining({ sequence: 1, message: 'second' }), - ], - }), - }) -}) + expect(mockModule.summarizeRunRecords).toHaveBeenCalledWith({ + env, + userId: "stable-user-1", + since: "2026-07-19T12:00:00.000Z", + }); + expect(mockModule.listRunRecords).toHaveBeenCalledWith({ + env, + userId: "stable-user-1", + filter: { + status: "error", + surface: "job", + since: "2026-07-19T12:00:00.000Z", + }, + limit: 25, + cursor: null, + }); + expect(mockModule.getRunRecord).toHaveBeenCalledWith({ + env, + userId: "stable-user-1", + runId: "run-1", + }); + expect(data).toMatchObject({ + ok: true, + statusFilter: "error", + surfaceFilter: "job", + summary: { + total: 4, + errors: 1, + running: 0, + }, + nextCursor: "cursor-2", + selectedRunId: "run-1", + retentionDays: 30, + runs: [ + expect.objectContaining({ + id: "run-1", + surface: "job", + status: "error", + errorMessage: "boom", + }), + ], + selectedRun: expect.objectContaining({ + id: "run-1", + logs: [ + expect.objectContaining({ sequence: 0, message: "first" }), + expect.objectContaining({ sequence: 1, message: "second" }), + ], + }), + }); -test('loadAccountActivityData forwards cursor for pagination pages', async () => { - mockModule.summarizeRunRecords.mockResolvedValue({ - since: '2026-07-19T12:00:00.000Z', - total: 0, - errors: 0, - running: 0, - bySurface: [], - }) - mockModule.listRunRecords.mockResolvedValue({ - runs: [], - nextCursor: null, - }) - mockModule.getRunRecord.mockResolvedValue(null) + mockModule.listRunRecords.mockResolvedValue({ + runs: [], + nextCursor: null, + }); + mockModule.getRunRecord.mockResolvedValue(null); - await loadAccountActivityData({ - env: {} as Env, - request: new Request( - 'https://example.com/account/activity.json?status=all&cursor=page-2', - ), - user, - now: new Date('2026-07-26T12:00:00.000Z'), - }) + await loadAccountActivityData({ + env: {} as Env, + request: new Request( + "https://example.com/account/activity.json?status=all&cursor=page-2", + ), + user, + now: new Date("2026-07-26T12:00:00.000Z"), + }); - expect(mockModule.listRunRecords).toHaveBeenCalledWith( - expect.objectContaining({ - filter: { - status: null, - surface: null, - since: '2026-07-19T12:00:00.000Z', - }, - cursor: 'page-2', - }), - ) -}) + expect(mockModule.listRunRecords).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { + status: null, + surface: null, + since: "2026-07-19T12:00:00.000Z", + }, + cursor: "page-2", + }), + ); +}); diff --git a/packages/worker/src/app/account-billing-data.node.test.ts b/packages/worker/src/app/account-billing-data.node.test.ts index fc3e055d55..e24de50f55 100644 --- a/packages/worker/src/app/account-billing-data.node.test.ts +++ b/packages/worker/src/app/account-billing-data.node.test.ts @@ -1,148 +1,131 @@ -import { expect, test, vi } from 'vitest' -import { consoleError } from '#worker/test-support/console-spies.ts' +import { expect, test, vi } from "vitest"; +import { consoleError } from "#worker/test-support/console-spies.ts"; const refreshStripePlanForUser = vi.hoisted(() => - vi.fn(async () => ({ - stripePlan: 'pro' as const, - cancelAt: null as string | null, - subscriptionStatus: 'active' as string | null, - })), -) + vi.fn(async () => ({ + stripePlan: "pro" as const, + cancelAt: null as string | null, + subscriptionStatus: "active" as string | null, + })), +); -vi.mock('#worker/billing/subscription-sync.ts', () => ({ - refreshStripePlanForUser, -})) +vi.mock("#worker/billing/subscription-sync.ts", () => ({ + refreshStripePlanForUser, +})); import { - loadAccountBillingData, - resolveBillingErrorMessage, -} from '#app/account-billing-data.ts' + loadAccountBillingData, + resolveBillingErrorMessage, +} from "#app/account-billing-data.ts"; function createBillingTestDb(input: { - plan: string - stripePlan?: string | null - stripeCustomerId?: string | null + plan: string; + stripePlan?: string | null; + stripeCustomerId?: string | null; }) { - return { - prepare(query: string) { - const normalized = query.replace(/\s+/g, ' ').trim().toLowerCase() - return { - bind(...params: Array) { - return { - async first() { - if ( - normalized.includes('from users') && - normalized.includes('where id') - ) { - void params - return { - plan: input.plan, - stripe_plan: input.stripePlan ?? null, - stripe_customer_id: input.stripeCustomerId ?? null, - stripe_plan_refreshed_at: null, - } as T - } - return null - }, - async run() { - return { success: true } - }, - } - }, - } - }, - } as unknown as D1Database + return { + prepare(query: string) { + const normalized = query.replace(/\s+/g, " ").trim().toLowerCase(); + return { + bind(...params: Array) { + return { + async first() { + if ( + normalized.includes("from users") && + normalized.includes("where id") + ) { + void params; + return { + plan: input.plan, + stripe_plan: input.stripePlan ?? null, + stripe_customer_id: input.stripeCustomerId ?? null, + stripe_plan_refreshed_at: null, + } as T; + } + return null; + }, + async run() { + return { success: true }; + }, + }; + }, + }; + }, + } as unknown as D1Database; } -test('resolveBillingErrorMessage maps known codes and passes through unknown', () => { - expect(resolveBillingErrorMessage('no_customer')).toContain('Subscribe first') - expect(resolveBillingErrorMessage('totally_new_code')).toBe( - 'totally_new_code', - ) - expect(resolveBillingErrorMessage(null)).toBeUndefined() -}) +test("loadAccountBillingData refreshes Stripe status and degrades when refresh is unavailable", async () => { + expect(resolveBillingErrorMessage("totally_new_code")).toBe( + "totally_new_code", + ); + expect(resolveBillingErrorMessage(null)).toBeUndefined(); + expect(typeof resolveBillingErrorMessage("no_customer")).toBe("string"); -test('loadAccountBillingData passes subscriptionStatus from Stripe refresh', async () => { - refreshStripePlanForUser.mockResolvedValueOnce({ - stripePlan: 'pro', - cancelAt: '2026-08-01T00:00:00.000Z', - subscriptionStatus: 'past_due', - }) + refreshStripePlanForUser.mockResolvedValueOnce({ + stripePlan: "pro", + cancelAt: "2026-08-01T00:00:00.000Z", + subscriptionStatus: "past_due", + }); - const env = { - APP_DB: createBillingTestDb({ - plan: 'free', - stripePlan: 'pro', - stripeCustomerId: 'cus_test', - }), - STRIPE_SECRET_KEY: 'sk_test', - STRIPE_PRO_PRICE_ID: 'price_pro', - } as Env + const env = { + APP_DB: createBillingTestDb({ + plan: "free", + stripePlan: "pro", + stripeCustomerId: "cus_test", + }), + STRIPE_SECRET_KEY: "sk_test", + STRIPE_PRO_PRICE_ID: "price_pro", + } as Env; - const data = await loadAccountBillingData({ - env, - userId: 9, - now: new Date('2026-07-25T12:00:00.000Z'), - }) + const data = await loadAccountBillingData({ + env, + userId: 9, + now: new Date("2026-07-25T12:00:00.000Z"), + }); - expect(data.ok).toBe(true) - expect(data.configured).toBe(true) - expect(data.hasStripeCustomer).toBe(true) - expect(data.stripePlan).toBe('pro') - expect(data.effectivePlan).toBe('pro') - expect(data.subscriptionStatus).toBe('past_due') - expect(data.cancelAt).toBe('2026-08-01T00:00:00.000Z') - expect(data.usageHref).toBe('/account/usage') - expect(data.checkoutAvailable).toBe(true) - expect(refreshStripePlanForUser).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 9, - customerId: 'cus_test', - }), - ) -}) + expect(data.ok).toBe(true); + expect(data.configured).toBe(true); + expect(data.hasStripeCustomer).toBe(true); + expect(data.stripePlan).toBe("pro"); + expect(data.effectivePlan).toBe("pro"); + expect(data.subscriptionStatus).toBe("past_due"); + expect(data.cancelAt).toBe("2026-08-01T00:00:00.000Z"); + expect(data.usageHref).toBe("/account/usage"); + expect(data.checkoutAvailable).toBe(true); + expect(refreshStripePlanForUser).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 9, + customerId: "cus_test", + }), + ); -test('loadAccountBillingData leaves subscriptionStatus null when refresh fails', async () => { - consoleError.mockImplementation(() => {}) - refreshStripePlanForUser.mockRejectedValueOnce(new Error('stripe down')) + consoleError.mockImplementation(() => {}); + refreshStripePlanForUser.mockRejectedValueOnce(new Error("stripe down")); + const failed = await loadAccountBillingData({ env, userId: 3 }); + expect(failed.stripePlan).toBe("pro"); + expect(failed.subscriptionStatus).toBeNull(); + expect(failed.cancelAt).toBeNull(); + expect(consoleError).toHaveBeenCalledWith( + "account_billing_refresh_failed", + expect.objectContaining({ userId: 3, error: "stripe down" }), + ); - const env = { - APP_DB: createBillingTestDb({ - plan: 'free', - stripePlan: 'pro', - stripeCustomerId: 'cus_test', - }), - STRIPE_SECRET_KEY: 'sk_test', - STRIPE_PRO_PRICE_ID: 'price_pro', - } as Env - - const data = await loadAccountBillingData({ env, userId: 3 }) - expect(data.stripePlan).toBe('pro') - expect(data.subscriptionStatus).toBeNull() - expect(data.cancelAt).toBeNull() - expect(data.usageHref).toBe('/account/usage') - expect(consoleError).toHaveBeenCalledWith( - 'account_billing_refresh_failed', - expect.objectContaining({ userId: 3, error: 'stripe down' }), - ) -}) - -test('loadAccountBillingData skips refresh when there is no Stripe customer', async () => { - refreshStripePlanForUser.mockClear() - - const env = { - APP_DB: createBillingTestDb({ - plan: 'free', - stripePlan: null, - stripeCustomerId: null, - }), - STRIPE_SECRET_KEY: 'sk_test', - STRIPE_PRO_PRICE_ID: 'price_pro', - } as Env - - const data = await loadAccountBillingData({ env, userId: 4 }) - expect(data.hasStripeCustomer).toBe(false) - expect(data.subscriptionStatus).toBeNull() - expect(data.configured).toBe(true) - expect(refreshStripePlanForUser).not.toHaveBeenCalled() -}) + refreshStripePlanForUser.mockClear(); + const noCustomerEnv = { + APP_DB: createBillingTestDb({ + plan: "free", + stripePlan: null, + stripeCustomerId: null, + }), + STRIPE_SECRET_KEY: "sk_test", + STRIPE_PRO_PRICE_ID: "price_pro", + } as Env; + const noCustomer = await loadAccountBillingData({ + env: noCustomerEnv, + userId: 4, + }); + expect(noCustomer.hasStripeCustomer).toBe(false); + expect(noCustomer.subscriptionStatus).toBeNull(); + expect(noCustomer.configured).toBe(true); + expect(refreshStripePlanForUser).not.toHaveBeenCalled(); +}); diff --git a/packages/worker/src/app/auth-denial-alerts.node.test.ts b/packages/worker/src/app/auth-denial-alerts.node.test.ts index 0a1f6a198a..8e26da617e 100644 --- a/packages/worker/src/app/auth-denial-alerts.node.test.ts +++ b/packages/worker/src/app/auth-denial-alerts.node.test.ts @@ -1,113 +1,108 @@ -import { expect, test, vi } from 'vitest' -import { consoleWarn } from '#worker/test-support/console-spies.ts' +import { expect, test, vi } from "vitest"; +import { consoleWarn } from "#worker/test-support/console-spies.ts"; -const sendCloudflareEmail = vi.fn(async () => ({ ok: true })) +const sendCloudflareEmail = vi.fn(async () => ({ ok: true })); -vi.mock('#app/email/cloudflare-email.ts', () => ({ - sendCloudflareEmail: (...args: Array) => - sendCloudflareEmail(...args), -})) +vi.mock("#app/email/cloudflare-email.ts", () => ({ + sendCloudflareEmail: (...args: Array) => + sendCloudflareEmail(...args), +})); const { - authDenialAlertKvKey, - checkAuthDenialBurstAndNotify, - shouldRunAuthDenialAlertCron, -} = await import('#app/auth-denial-alerts.ts') + authDenialAlertKvKey, + checkAuthDenialBurstAndNotify, + shouldRunAuthDenialAlertCron, +} = await import("#app/auth-denial-alerts.ts"); function createDb(count: number) { - return { - prepare(query: string) { - const normalized = query.replace(/\s+/g, ' ').trim().toLowerCase() - return { - bind(..._params: Array) { - return this - }, - async first() { - if (normalized.includes('from audit_events')) { - return { count } as T - } - return null - }, - async all() { - if (normalized.includes('from users u')) { - return { - results: [{ email: 'admin@example.com' }], - } as { results: Array } - } - return { results: [] } - }, - } - }, - } as unknown as D1Database + return { + prepare(query: string) { + const normalized = query.replace(/\s+/g, " ").trim().toLowerCase(); + return { + bind(..._params: Array) { + return this; + }, + async first() { + if (normalized.includes("from audit_events")) { + return { count } as T; + } + return null; + }, + async all() { + if (normalized.includes("from users u")) { + return { + results: [{ email: "admin@example.com" }], + } as { results: Array }; + } + return { results: [] }; + }, + }; + }, + } as unknown as D1Database; } -test('shouldRunAuthDenialAlertCron runs on the hour', () => { - expect( - shouldRunAuthDenialAlertCron(new Date('2026-07-25T12:00:00.000Z')), - ).toBe(true) - expect( - shouldRunAuthDenialAlertCron(new Date('2026-07-25T12:05:00.000Z')), - ).toBe(false) -}) +test("auth denial burst alerts stay quiet below threshold, then notify and cool down", async () => { + expect( + shouldRunAuthDenialAlertCron(new Date("2026-07-25T12:00:00.000Z")), + ).toBe(true); + expect( + shouldRunAuthDenialAlertCron(new Date("2026-07-25T12:05:00.000Z")), + ).toBe(false); -test('checkAuthDenialBurstAndNotify stays quiet below threshold', async () => { - sendCloudflareEmail.mockClear() - const result = await checkAuthDenialBurstAndNotify({ - env: { - APP_DB: createDb(10), - APP_BASE_URL: 'https://heykody.dev', - CLOUDFLARE_ACCOUNT_ID: 'acct', - CLOUDFLARE_API_TOKEN: 'token', - }, - threshold: 50, - }) - expect(result).toEqual({ status: 'below_threshold', count: 10 }) - expect(sendCloudflareEmail).not.toHaveBeenCalled() -}) + sendCloudflareEmail.mockClear(); + const quiet = await checkAuthDenialBurstAndNotify({ + env: { + APP_DB: createDb(10), + APP_BASE_URL: "https://heykody.dev", + CLOUDFLARE_ACCOUNT_ID: "acct", + CLOUDFLARE_API_TOKEN: "token", + }, + threshold: 50, + }); + expect(quiet).toEqual({ status: "below_threshold", count: 10 }); + expect(sendCloudflareEmail).not.toHaveBeenCalled(); -test('checkAuthDenialBurstAndNotify emails admins and respects cooldown', async () => { - sendCloudflareEmail.mockClear() - consoleWarn.mockImplementation(() => {}) - const kvStore = new Map() - const kv = { - async get(key: string) { - return kvStore.get(key) ?? null - }, - async put(key: string, value: string) { - kvStore.set(key, value) - }, - } as unknown as KVNamespace + consoleWarn.mockImplementation(() => {}); + const kvStore = new Map(); + const kv = { + async get(key: string) { + return kvStore.get(key) ?? null; + }, + async put(key: string, value: string) { + kvStore.set(key, value); + }, + } as unknown as KVNamespace; - const env = { - APP_DB: createDb(80), - APP_BASE_URL: 'https://heykody.dev', - CLOUDFLARE_ACCOUNT_ID: 'acct', - CLOUDFLARE_API_TOKEN: 'token', - BUNDLE_ARTIFACTS_KV: kv, - } - const now = new Date('2026-07-25T12:00:00.000Z') - try { - const first = await checkAuthDenialBurstAndNotify({ - env, - now, - threshold: 50, - }) - expect(first).toEqual({ status: 'notified', count: 80, recipients: 1 }) - expect(sendCloudflareEmail).toHaveBeenCalledTimes(1) - expect(kvStore.get(authDenialAlertKvKey)).toBe(String(now.getTime())) - expect(consoleWarn).toHaveBeenCalledWith( - 'auth-denial-burst-alerted', - expect.objectContaining({ count: 80 }), - ) + const env = { + APP_DB: createDb(80), + APP_BASE_URL: "https://heykody.dev", + CLOUDFLARE_ACCOUNT_ID: "acct", + CLOUDFLARE_API_TOKEN: "token", + BUNDLE_ARTIFACTS_KV: kv, + }; + const now = new Date("2026-07-25T12:00:00.000Z"); + try { + const first = await checkAuthDenialBurstAndNotify({ + env, + now, + threshold: 50, + }); + expect(first).toEqual({ status: "notified", count: 80, recipients: 1 }); + expect(sendCloudflareEmail).toHaveBeenCalledTimes(1); + expect(kvStore.get(authDenialAlertKvKey)).toBe(String(now.getTime())); + expect(consoleWarn).toHaveBeenCalledWith( + "auth-denial-burst-alerted", + expect.objectContaining({ count: 80 }), + ); - const second = await checkAuthDenialBurstAndNotify({ - env, - now: new Date(now.getTime() + 60_000), - threshold: 50, - }) - expect(second).toEqual({ status: 'cooldown', count: 80 }) - expect(sendCloudflareEmail).toHaveBeenCalledTimes(1) - } finally { - consoleWarn.mockReset() - } -}) + const second = await checkAuthDenialBurstAndNotify({ + env, + now: new Date(now.getTime() + 60_000), + threshold: 50, + }); + expect(second).toEqual({ status: "cooldown", count: 80 }); + expect(sendCloudflareEmail).toHaveBeenCalledTimes(1); + } finally { + consoleWarn.mockReset(); + } +}); diff --git a/packages/worker/src/app/auth-session.node.test.ts b/packages/worker/src/app/auth-session.node.test.ts index 966375b6ad..29e01eee98 100644 --- a/packages/worker/src/app/auth-session.node.test.ts +++ b/packages/worker/src/app/auth-session.node.test.ts @@ -1,100 +1,100 @@ -import { expect, test } from 'vitest' +import { expect, test } from "vitest"; import { - createAuthCookie, - isAuthSessionInvalidatedByPasswordChange, - readParsedAuthSession, - setAuthSessionSecret, -} from '#app/auth-session.ts' -import { parsePasswordChangedAtMs } from '#app/request-auth-cache.ts' + createAuthCookie, + isAuthSessionInvalidatedByPasswordChange, + readParsedAuthSession, + setAuthSessionSecret, +} from "#app/auth-session.ts"; +import { parsePasswordChangedAtMs } from "#app/request-auth-cache.ts"; -const testCookieSecret = 'test-cookie-secret-0123456789abcdef0123456789' +const testCookieSecret = "test-cookie-secret-0123456789abcdef0123456789"; -test('createAuthCookie always stamps issuedAt for password-change checks', async () => { - setAuthSessionSecret(testCookieSecret) - const now = 1_700_000_000_000 - const cookie = await createAuthCookie( - { id: '1', email: 'user@example.com', rememberMe: false }, - false, - now, - ) - const request = new Request('https://example.com/', { - headers: { Cookie: cookie.split(';')[0]! }, - }) - const parsed = await readParsedAuthSession(request, now) - expect(parsed?.issuedAt).toBe(now) - expect(parsed?.session.rememberMe).toBe(false) -}) +test("createAuthCookie stamps issuedAt for password-change invalidation", async () => { + setAuthSessionSecret(testCookieSecret); + const now = 1_700_000_000_000; + const cookie = await createAuthCookie( + { id: "1", email: "user@example.com", rememberMe: false }, + false, + now, + ); + const request = new Request("https://example.com/", { + headers: { Cookie: cookie.split(";")[0]! }, + }); + const parsed = await readParsedAuthSession(request, now); + expect(parsed?.issuedAt).toBe(now); + expect(parsed?.session.rememberMe).toBe(false); +}); -test('isAuthSessionInvalidatedByPasswordChange fails closed for legacy cookies', () => { - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: undefined, - passwordChangedAtMs: null, - }), - ).toBe(false) - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: undefined, - passwordChangedAtMs: 100, - }), - ).toBe(true) - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: 50, - passwordChangedAtMs: 100, - }), - ).toBe(true) - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: 100, - passwordChangedAtMs: 100, - }), - ).toBe(true) - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: 101, - passwordChangedAtMs: 100, - }), - ).toBe(false) -}) +test("password-change invalidation covers legacy cookies, second-precision SQLite, and re-login", () => { + expect(parsePasswordChangedAtMs(null)).toBeNull(); + expect(parsePasswordChangedAtMs("")).toBeNull(); + expect(parsePasswordChangedAtMs(" ")).toBeNull(); + expect(parsePasswordChangedAtMs("not-a-timestamp")).toBeNull(); -test('second-precision password_changed_at invalidates cookies later in the same second', () => { - const changedAtMs = parsePasswordChangedAtMs('2026-07-25 12:00:00') - expect(changedAtMs).toBe(Date.parse('2026-07-25T12:00:00.000Z') + 999) - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: Date.parse('2026-07-25T12:00:00.500Z'), - passwordChangedAtMs: changedAtMs, - }), - ).toBe(true) - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: Date.parse('2026-07-25T12:00:01.000Z'), - passwordChangedAtMs: changedAtMs, - }), - ).toBe(false) -}) + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: undefined, + passwordChangedAtMs: null, + }), + ).toBe(false); + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: undefined, + passwordChangedAtMs: 100, + }), + ).toBe(true); + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: 50, + passwordChangedAtMs: 100, + }), + ).toBe(true); + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: 100, + passwordChangedAtMs: 100, + }), + ).toBe(true); + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: 101, + passwordChangedAtMs: 100, + }), + ).toBe(false); -test('millisecond password_changed_at allows same-second re-login after reset', () => { - const changedAtMs = parsePasswordChangedAtMs('2026-07-25T12:00:00.400Z') - expect(changedAtMs).toBe(Date.parse('2026-07-25T12:00:00.400Z')) - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: Date.parse('2026-07-25T12:00:00.300Z'), - passwordChangedAtMs: changedAtMs, - }), - ).toBe(true) - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: Date.parse('2026-07-25T12:00:00.500Z'), - passwordChangedAtMs: changedAtMs, - }), - ).toBe(false) -}) + const secondPrecisionChangedAt = parsePasswordChangedAtMs( + "2026-07-25 12:00:00", + ); + expect(secondPrecisionChangedAt).toBe( + Date.parse("2026-07-25T12:00:00.000Z") + 999, + ); + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: Date.parse("2026-07-25T12:00:00.500Z"), + passwordChangedAtMs: secondPrecisionChangedAt, + }), + ).toBe(true); + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: Date.parse("2026-07-25T12:00:01.000Z"), + passwordChangedAtMs: secondPrecisionChangedAt, + }), + ).toBe(false); -test('parsePasswordChangedAtMs returns null for empty or malformed values', () => { - expect(parsePasswordChangedAtMs(null)).toBeNull() - expect(parsePasswordChangedAtMs('')).toBeNull() - expect(parsePasswordChangedAtMs(' ')).toBeNull() - expect(parsePasswordChangedAtMs('not-a-timestamp')).toBeNull() -}) + const msPrecisionChangedAt = parsePasswordChangedAtMs( + "2026-07-25T12:00:00.400Z", + ); + expect(msPrecisionChangedAt).toBe(Date.parse("2026-07-25T12:00:00.400Z")); + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: Date.parse("2026-07-25T12:00:00.300Z"), + passwordChangedAtMs: msPrecisionChangedAt, + }), + ).toBe(true); + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: Date.parse("2026-07-25T12:00:00.500Z"), + passwordChangedAtMs: msPrecisionChangedAt, + }), + ).toBe(false); +}); diff --git a/packages/worker/src/app/email-delivery-alerts.node.test.ts b/packages/worker/src/app/email-delivery-alerts.node.test.ts index 0423cc024f..2de87d2275 100644 --- a/packages/worker/src/app/email-delivery-alerts.node.test.ts +++ b/packages/worker/src/app/email-delivery-alerts.node.test.ts @@ -1,153 +1,124 @@ -import { expect, test, vi } from 'vitest' -import { consoleWarn } from '#worker/test-support/console-spies.ts' +import { expect, test, vi } from "vitest"; +import { consoleWarn } from "#worker/test-support/console-spies.ts"; -const sendCloudflareEmail = vi.fn(async () => ({ ok: true })) +const sendCloudflareEmail = vi.fn(async () => ({ ok: true })); -vi.mock('#app/email/cloudflare-email.ts', () => ({ - sendCloudflareEmail: (...args: Array) => - sendCloudflareEmail(...args), -})) +vi.mock("#app/email/cloudflare-email.ts", () => ({ + sendCloudflareEmail: (...args: Array) => + sendCloudflareEmail(...args), +})); const { - emailDeliveryAlertKvKey, - checkEmailDeliveryBurstAndNotify, - shouldRunEmailDeliveryAlertCron, -} = await import('#app/email-delivery-alerts.ts') + emailDeliveryAlertKvKey, + checkEmailDeliveryBurstAndNotify, + shouldRunEmailDeliveryAlertCron, +} = await import("#app/email-delivery-alerts.ts"); function createDb(count: number) { - return { - prepare(query: string) { - const normalized = query.replace(/\s+/g, ' ').trim().toLowerCase() - return { - bind(..._params: Array) { - return this - }, - async first() { - if (normalized.includes('from email_delivery_events')) { - return { count } as T - } - return null - }, - async all() { - if (normalized.includes('from users u')) { - return { - results: [{ email: 'admin@example.com' }], - } as { results: Array } - } - return { results: [] } - }, - } - }, - } as unknown as D1Database + return { + prepare(query: string) { + const normalized = query.replace(/\s+/g, " ").trim().toLowerCase(); + return { + bind(..._params: Array) { + return this; + }, + async first() { + if (normalized.includes("from email_delivery_events")) { + return { count } as T; + } + return null; + }, + async all() { + if (normalized.includes("from users u")) { + return { + results: [{ email: "admin@example.com" }], + } as { results: Array }; + } + return { results: [] }; + }, + }; + }, + } as unknown as D1Database; } -test('shouldRunEmailDeliveryAlertCron runs on the hour', () => { - expect( - shouldRunEmailDeliveryAlertCron(new Date('2026-07-25T12:00:00.000Z')), - ).toBe(true) - expect( - shouldRunEmailDeliveryAlertCron(new Date('2026-07-25T12:05:00.000Z')), - ).toBe(false) -}) +test("email delivery burst alerts notify, cool down, and skip cooldown on notify failure", async () => { + expect( + shouldRunEmailDeliveryAlertCron(new Date("2026-07-25T12:00:00.000Z")), + ).toBe(true); + expect( + shouldRunEmailDeliveryAlertCron(new Date("2026-07-25T12:05:00.000Z")), + ).toBe(false); -test('checkEmailDeliveryBurstAndNotify stays quiet below threshold', async () => { - sendCloudflareEmail.mockClear() - const result = await checkEmailDeliveryBurstAndNotify({ - env: { - APP_DB: createDb(10), - APP_BASE_URL: 'https://heykody.dev', - CLOUDFLARE_ACCOUNT_ID: 'acct', - CLOUDFLARE_API_TOKEN: 'token', - }, - threshold: 20, - }) - expect(result).toEqual({ status: 'below_threshold', count: 10 }) - expect(sendCloudflareEmail).not.toHaveBeenCalled() -}) + sendCloudflareEmail.mockClear(); + const quiet = await checkEmailDeliveryBurstAndNotify({ + env: { + APP_DB: createDb(10), + APP_BASE_URL: "https://heykody.dev", + CLOUDFLARE_ACCOUNT_ID: "acct", + CLOUDFLARE_API_TOKEN: "token", + }, + threshold: 20, + }); + expect(quiet).toEqual({ status: "below_threshold", count: 10 }); + expect(sendCloudflareEmail).not.toHaveBeenCalled(); -test('checkEmailDeliveryBurstAndNotify emails admins and respects cooldown', async () => { - sendCloudflareEmail.mockClear() - consoleWarn.mockImplementation(() => {}) - const kvStore = new Map() - const kv = { - async get(key: string) { - return kvStore.get(key) ?? null - }, - async put(key: string, value: string) { - kvStore.set(key, value) - }, - } as unknown as KVNamespace + consoleWarn.mockImplementation(() => {}); + const kvStore = new Map(); + const kv = { + async get(key: string) { + return kvStore.get(key) ?? null; + }, + async put(key: string, value: string) { + kvStore.set(key, value); + }, + } as unknown as KVNamespace; - const env = { - APP_DB: createDb(35), - APP_BASE_URL: 'https://heykody.dev', - CLOUDFLARE_ACCOUNT_ID: 'acct', - CLOUDFLARE_API_TOKEN: 'token', - BUNDLE_ARTIFACTS_KV: kv, - } - const now = new Date('2026-07-25T12:00:00.000Z') - try { - const first = await checkEmailDeliveryBurstAndNotify({ - env, - now, - threshold: 20, - }) - expect(first).toEqual({ status: 'notified', count: 35, recipients: 1 }) - expect(sendCloudflareEmail).toHaveBeenCalledTimes(1) - expect(kvStore.get(emailDeliveryAlertKvKey)).toBe(String(now.getTime())) - expect(consoleWarn).toHaveBeenCalledWith( - 'email-delivery-burst-alerted', - expect.objectContaining({ count: 35 }), - ) + const env = { + APP_DB: createDb(35), + APP_BASE_URL: "https://heykody.dev", + CLOUDFLARE_ACCOUNT_ID: "acct", + CLOUDFLARE_API_TOKEN: "token", + BUNDLE_ARTIFACTS_KV: kv, + }; + const now = new Date("2026-07-25T12:00:00.000Z"); + try { + const first = await checkEmailDeliveryBurstAndNotify({ + env, + now, + threshold: 20, + }); + expect(first).toEqual({ status: "notified", count: 35, recipients: 1 }); + expect(sendCloudflareEmail).toHaveBeenCalledTimes(1); + expect(kvStore.get(emailDeliveryAlertKvKey)).toBe(String(now.getTime())); + expect(consoleWarn).toHaveBeenCalledWith( + "email-delivery-burst-alerted", + expect.objectContaining({ count: 35 }), + ); - const second = await checkEmailDeliveryBurstAndNotify({ - env, - now: new Date(now.getTime() + 60_000), - threshold: 20, - }) - expect(second).toEqual({ status: 'cooldown', count: 35 }) - expect(sendCloudflareEmail).toHaveBeenCalledTimes(1) - } finally { - consoleWarn.mockReset() - } -}) + const second = await checkEmailDeliveryBurstAndNotify({ + env, + now: new Date(now.getTime() + 60_000), + threshold: 20, + }); + expect(second).toEqual({ status: "cooldown", count: 35 }); + expect(sendCloudflareEmail).toHaveBeenCalledTimes(1); -test('checkEmailDeliveryBurstAndNotify does not write cooldown when notify fails', async () => { - sendCloudflareEmail.mockClear() - sendCloudflareEmail.mockRejectedValueOnce(new Error('cf email down')) - consoleWarn.mockImplementation(() => {}) - const kvStore = new Map() - const kv = { - async get(key: string) { - return kvStore.get(key) ?? null - }, - async put(key: string, value: string) { - kvStore.set(key, value) - }, - } as unknown as KVNamespace - - const env = { - APP_DB: createDb(35), - APP_BASE_URL: 'https://heykody.dev', - CLOUDFLARE_ACCOUNT_ID: 'acct', - CLOUDFLARE_API_TOKEN: 'token', - BUNDLE_ARTIFACTS_KV: kv, - } - try { - await expect( - checkEmailDeliveryBurstAndNotify({ - env, - now: new Date('2026-07-25T12:00:00.000Z'), - threshold: 20, - }), - ).rejects.toThrow('cf email down') - expect(kvStore.has(emailDeliveryAlertKvKey)).toBe(false) - expect(consoleWarn).toHaveBeenCalledWith( - 'email-delivery-alert-notification-failed', - expect.any(Error), - ) - } finally { - consoleWarn.mockReset() - sendCloudflareEmail.mockResolvedValue({ ok: true }) - } -}) + kvStore.clear(); + sendCloudflareEmail.mockRejectedValueOnce(new Error("cf email down")); + await expect( + checkEmailDeliveryBurstAndNotify({ + env, + now: new Date("2026-07-25T13:00:00.000Z"), + threshold: 20, + }), + ).rejects.toThrow("cf email down"); + expect(kvStore.has(emailDeliveryAlertKvKey)).toBe(false); + expect(consoleWarn).toHaveBeenCalledWith( + "email-delivery-alert-notification-failed", + expect.any(Error), + ); + } finally { + consoleWarn.mockReset(); + sendCloudflareEmail.mockResolvedValue({ ok: true }); + } +}); diff --git a/packages/worker/src/app/handlers/account-memories.node.test.ts b/packages/worker/src/app/handlers/account-memories.node.test.ts index 3d8b3c9313..d487d06390 100644 --- a/packages/worker/src/app/handlers/account-memories.node.test.ts +++ b/packages/worker/src/app/handlers/account-memories.node.test.ts @@ -1,266 +1,258 @@ -import { expect, test, vi } from 'vitest' +import { expect, test, vi } from "vitest"; const mockModule = vi.hoisted(() => { - const memoryRow = { - id: '11111111-1111-4111-8111-111111111111', - category: 'preferences', - status: 'active' as const, - subject: 'Favorite editor', - summary: 'Prefers VS Code for TypeScript work.', - details: 'Uses the Remix extension and oxlint.', - tags: ['editor', 'typescript'], - sourceUris: ['https://example.com/notes/editor'], - dedupeKey: 'prefs:editor', - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - lastAccessedAt: null as string | null, - deletedAt: null as string | null, - } - return { - memoryRow, - readAuthenticatedAppUser: vi.fn(async () => ({ - sessionUserId: '42', - userId: 42, - username: 'test-user', - email: 'user@example.com', - displayName: 'user', - artifactOwnerIds: [], - mcpUser: { - userId: 'stable-user-1', - email: 'user@example.com', - username: 'test-user', - displayName: 'user', - }, - })), - readAuthSessionResult: async () => ({ session: null, setCookie: null }), - listMemoriesByUserId: vi.fn(async () => [ - { - id: memoryRow.id, - user_id: 'stable-user-1', - category: memoryRow.category, - status: memoryRow.status, - subject: memoryRow.subject, - summary: memoryRow.summary, - details: memoryRow.details, - tags_json: JSON.stringify(memoryRow.tags), - source_uris_json: JSON.stringify(memoryRow.sourceUris), - dedupe_key: memoryRow.dedupeKey, - created_at: memoryRow.createdAt, - updated_at: memoryRow.updatedAt, - last_accessed_at: memoryRow.lastAccessedAt, - deleted_at: memoryRow.deletedAt, - }, - ]), - getMemory: vi.fn(async () => memoryRow), - deleteMemory: vi.fn(async () => ({ - ...memoryRow, - status: 'deleted' as const, - })), - } -}) + const memoryRow = { + id: "11111111-1111-4111-8111-111111111111", + category: "preferences", + status: "active" as const, + subject: "Favorite editor", + summary: "Prefers VS Code for TypeScript work.", + details: "Uses the Remix extension and oxlint.", + tags: ["editor", "typescript"], + sourceUris: ["https://example.com/notes/editor"], + dedupeKey: "prefs:editor", + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + lastAccessedAt: null as string | null, + deletedAt: null as string | null, + }; + return { + memoryRow, + readAuthenticatedAppUser: vi.fn(async () => ({ + sessionUserId: "42", + userId: 42, + username: "test-user", + email: "user@example.com", + displayName: "user", + artifactOwnerIds: [], + mcpUser: { + userId: "stable-user-1", + email: "user@example.com", + username: "test-user", + displayName: "user", + }, + })), + readAuthSessionResult: async () => ({ session: null, setCookie: null }), + listMemoriesByUserId: vi.fn(async () => [ + { + id: memoryRow.id, + user_id: "stable-user-1", + category: memoryRow.category, + status: memoryRow.status, + subject: memoryRow.subject, + summary: memoryRow.summary, + details: memoryRow.details, + tags_json: JSON.stringify(memoryRow.tags), + source_uris_json: JSON.stringify(memoryRow.sourceUris), + dedupe_key: memoryRow.dedupeKey, + created_at: memoryRow.createdAt, + updated_at: memoryRow.updatedAt, + last_accessed_at: memoryRow.lastAccessedAt, + deleted_at: memoryRow.deletedAt, + }, + ]), + getMemory: vi.fn(async () => memoryRow), + deleteMemory: vi.fn(async () => ({ + ...memoryRow, + status: "deleted" as const, + })), + }; +}); -const { memoryRow } = mockModule +const { memoryRow } = mockModule; -vi.mock('#app/authenticated-user.ts', () => ({ - readAuthenticatedAppUser: (...args: Array) => - mockModule.readAuthenticatedAppUser(...args), -})) +vi.mock("#app/authenticated-user.ts", () => ({ + readAuthenticatedAppUser: (...args: Array) => + mockModule.readAuthenticatedAppUser(...args), +})); -vi.mock('#app/auth-session.ts', () => ({ - readAuthSessionResult: (...args: Array) => - mockModule.readAuthSessionResult(...args), -})) +vi.mock("#app/auth-session.ts", () => ({ + readAuthSessionResult: (...args: Array) => + mockModule.readAuthSessionResult(...args), +})); -vi.mock('#app/auth-redirect.ts', () => ({ - redirectToLogin: () => new Response(null, { status: 302 }), - redirectToLoginWhenUnauthenticated: () => new Response(null, { status: 302 }), -})) +vi.mock("#app/auth-redirect.ts", () => ({ + redirectToLogin: () => new Response(null, { status: 302 }), + redirectToLoginWhenUnauthenticated: () => new Response(null, { status: 302 }), +})); -vi.mock('#app/ssr-render.tsx', () => ({ - renderAppPage: async () => new Response('ok'), -})) +vi.mock("#app/ssr-render.tsx", () => ({ + renderAppPage: async () => new Response("ok"), +})); -vi.mock('#mcp/memory/repo.ts', () => ({ - listMemoriesByUserId: (...args: Array) => - mockModule.listMemoriesByUserId(...args), -})) +vi.mock("#mcp/memory/repo.ts", () => ({ + listMemoriesByUserId: (...args: Array) => + mockModule.listMemoriesByUserId(...args), +})); -vi.mock('#mcp/memory/service.ts', () => ({ - getMemory: (...args: Array) => mockModule.getMemory(...args), - deleteMemory: (...args: Array) => mockModule.deleteMemory(...args), -})) +vi.mock("#mcp/memory/service.ts", () => ({ + getMemory: (...args: Array) => mockModule.getMemory(...args), + deleteMemory: (...args: Array) => mockModule.deleteMemory(...args), +})); const { createAccountMemoriesApiHandler } = - await import('./account-memories.ts') + await import("./account-memories.ts"); function createEnv() { - return { - APP_DB: {} as D1Database, - } as Env + return { + APP_DB: {} as D1Database, + } as Env; } -test('memories API lists user-scoped memories', async () => { - const handler = createAccountMemoriesApiHandler(createEnv()) +test("memories API lists, filters, and selects user-scoped memories", async () => { + const handler = createAccountMemoriesApiHandler(createEnv()); - const listResponse = await handler.handler({ - request: new Request('https://example.com/account/memories.json'), - params: {}, - } as never) + const listResponse = await handler.handler({ + request: new Request("https://example.com/account/memories.json"), + params: {}, + } as never); - expect(listResponse.status).toBe(200) - expect(listResponse.headers.get('Cache-Control')).toBe('no-store') - expect(mockModule.listMemoriesByUserId).toHaveBeenCalledWith( - expect.anything(), - 'stable-user-1', - expect.objectContaining({ - statuses: ['active', 'archived'], - limit: 100, - }), - ) - await expect(listResponse.json()).resolves.toEqual({ - ok: true, - email: 'user@example.com', - username: 'test-user', - memories: [ - { - id: memoryRow.id, - subject: memoryRow.subject, - category: memoryRow.category, - status: memoryRow.status, - tags: memoryRow.tags, - summary: memoryRow.summary, - updatedAt: memoryRow.updatedAt, - }, - ], - selectedMemory: null, - query: '', - includeDeleted: false, - }) -}) + expect(listResponse.status).toBe(200); + expect(listResponse.headers.get("Cache-Control")).toBe("no-store"); + expect(mockModule.listMemoriesByUserId).toHaveBeenCalledWith( + expect.anything(), + "stable-user-1", + expect.objectContaining({ + statuses: ["active", "archived"], + limit: 100, + }), + ); + await expect(listResponse.json()).resolves.toEqual({ + ok: true, + email: "user@example.com", + username: "test-user", + memories: [ + { + id: memoryRow.id, + subject: memoryRow.subject, + category: memoryRow.category, + status: memoryRow.status, + tags: memoryRow.tags, + summary: memoryRow.summary, + updatedAt: memoryRow.updatedAt, + }, + ], + selectedMemory: null, + query: "", + includeDeleted: false, + }); -test('memories API supports q and includeDeleted filters', async () => { - const handler = createAccountMemoriesApiHandler(createEnv()) + const filtered = await handler.handler({ + request: new Request( + "https://example.com/account/memories.json?q=editor&includeDeleted=true&selected=11111111-1111-4111-8111-111111111111", + ), + params: {}, + } as never); - const response = await handler.handler({ - request: new Request( - 'https://example.com/account/memories.json?q=editor&includeDeleted=true&selected=11111111-1111-4111-8111-111111111111', - ), - params: {}, - } as never) + expect(filtered.status).toBe(200); + expect(mockModule.listMemoriesByUserId).toHaveBeenCalledWith( + expect.anything(), + "stable-user-1", + expect.objectContaining({ + statuses: ["active", "archived", "deleted"], + }), + ); + expect(mockModule.getMemory).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "stable-user-1", + memoryId: memoryRow.id, + }), + ); + const payload = (await filtered.json()) as { + ok: boolean; + query: string; + includeDeleted: boolean; + selectedMemory: { id: string; details: string } | null; + }; + expect(payload.ok).toBe(true); + expect(payload.query).toBe("editor"); + expect(payload.includeDeleted).toBe(true); + expect(payload.selectedMemory).toEqual( + expect.objectContaining({ + id: memoryRow.id, + details: memoryRow.details, + sourceUris: memoryRow.sourceUris, + dedupeKey: memoryRow.dedupeKey, + }), + ); +}); - expect(response.status).toBe(200) - expect(mockModule.listMemoriesByUserId).toHaveBeenCalledWith( - expect.anything(), - 'stable-user-1', - expect.objectContaining({ - statuses: ['active', 'archived', 'deleted'], - }), - ) - expect(mockModule.getMemory).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'stable-user-1', - memoryId: memoryRow.id, - }), - ) - const payload = (await response.json()) as { - ok: boolean - query: string - includeDeleted: boolean - selectedMemory: { id: string; details: string } | null - } - expect(payload.ok).toBe(true) - expect(payload.query).toBe('editor') - expect(payload.includeDeleted).toBe(true) - expect(payload.selectedMemory).toEqual( - expect.objectContaining({ - id: memoryRow.id, - details: memoryRow.details, - sourceUris: memoryRow.sourceUris, - dedupeKey: memoryRow.dedupeKey, - }), - ) -}) +test("memories API soft/force deletes and rejects invalid delete requests", async () => { + const handler = createAccountMemoriesApiHandler(createEnv()); -test('memories API delete action soft-deletes by default and supports force', async () => { - const handler = createAccountMemoriesApiHandler(createEnv()) + const softResponse = await handler.handler({ + request: new Request("https://example.com/account/memories.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "delete", + memoryId: memoryRow.id, + }), + }), + params: {}, + } as never); + expect(softResponse.status).toBe(200); + expect(mockModule.deleteMemory).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "stable-user-1", + memoryId: memoryRow.id, + force: false, + }), + ); - const softResponse = await handler.handler({ - request: new Request('https://example.com/account/memories.json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'delete', - memoryId: memoryRow.id, - }), - }), - params: {}, - } as never) - expect(softResponse.status).toBe(200) - expect(mockModule.deleteMemory).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'stable-user-1', - memoryId: memoryRow.id, - force: false, - }), - ) + mockModule.deleteMemory.mockClear(); + const hardResponse = await handler.handler({ + request: new Request("https://example.com/account/memories.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "delete", + memoryId: memoryRow.id, + force: true, + }), + }), + params: {}, + } as never); + expect(hardResponse.status).toBe(200); + expect(mockModule.deleteMemory).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "stable-user-1", + memoryId: memoryRow.id, + force: true, + }), + ); - mockModule.deleteMemory.mockClear() - const hardResponse = await handler.handler({ - request: new Request('https://example.com/account/memories.json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'delete', - memoryId: memoryRow.id, - force: true, - }), - }), - params: {}, - } as never) - expect(hardResponse.status).toBe(200) - expect(mockModule.deleteMemory).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'stable-user-1', - memoryId: memoryRow.id, - force: true, - }), - ) -}) + const missingId = await handler.handler({ + request: new Request("https://example.com/account/memories.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "delete" }), + }), + params: {}, + } as never); + expect(missingId.status).toBe(400); -test('memories API reject missing memoryId and unknown actions', async () => { - const handler = createAccountMemoriesApiHandler(createEnv()) + const invalidAction = await handler.handler({ + request: new Request("https://example.com/account/memories.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "upsert" }), + }), + params: {}, + } as never); + expect(invalidAction.status).toBe(400); - const missingId = await handler.handler({ - request: new Request('https://example.com/account/memories.json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'delete' }), - }), - params: {}, - } as never) - expect(missingId.status).toBe(400) - - const invalidAction = await handler.handler({ - request: new Request('https://example.com/account/memories.json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'upsert' }), - }), - params: {}, - } as never) - expect(invalidAction.status).toBe(400) - - mockModule.deleteMemory.mockResolvedValueOnce(null) - const notFound = await handler.handler({ - request: new Request('https://example.com/account/memories.json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'delete', - memoryId: 'missing', - }), - }), - params: {}, - } as never) - expect(notFound.status).toBe(404) -}) + mockModule.deleteMemory.mockResolvedValueOnce(null); + const notFound = await handler.handler({ + request: new Request("https://example.com/account/memories.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "delete", + memoryId: "missing", + }), + }), + params: {}, + } as never); + expect(notFound.status).toBe(404); +}); diff --git a/packages/worker/src/app/handlers/account-values.node.test.ts b/packages/worker/src/app/handlers/account-values.node.test.ts index 45959d0e1e..130ff0fb1e 100644 --- a/packages/worker/src/app/handlers/account-values.node.test.ts +++ b/packages/worker/src/app/handlers/account-values.node.test.ts @@ -1,350 +1,281 @@ -import { expect, test, vi } from 'vitest' +import { expect, test, vi } from "vitest"; const mockModule = vi.hoisted(() => ({ - readAuthenticatedAppUser: vi.fn(async () => ({ - sessionUserId: '42', - userId: 42, - username: 'test-user', - email: 'user@example.com', - displayName: 'user', - artifactOwnerIds: [], - mcpUser: { - userId: 'stable-user-1', - email: 'user@example.com', - username: 'test-user', - displayName: 'user', - }, - })), - readAuthSessionResult: async () => ({ session: null, setCookie: null }), - listValues: vi.fn(async () => [ - { - name: 'theme', - scope: 'user' as const, - value: 'dark', - description: 'UI theme preference', - appId: null, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - { - name: '_integration:github', - scope: 'user' as const, - value: '{"clientId":"x"}', - description: 'reserved', - appId: null, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - { - name: '_openapi:widgets', - scope: 'user' as const, - value: '{"ops":[]}', - description: 'reserved', - appId: null, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - ]), - getValue: vi.fn(async () => null), - saveValue: vi.fn(async () => ({ - name: 'locale', - scope: 'user' as const, - value: 'en-US', - description: 'Preferred locale', - appId: null, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - ttlMs: null, - })), - deleteValue: vi.fn(async () => true), -})) + readAuthenticatedAppUser: vi.fn(async () => ({ + sessionUserId: "42", + userId: 42, + username: "test-user", + email: "user@example.com", + displayName: "user", + artifactOwnerIds: [], + mcpUser: { + userId: "stable-user-1", + email: "user@example.com", + username: "test-user", + displayName: "user", + }, + })), + readAuthSessionResult: async () => ({ session: null, setCookie: null }), + listValues: vi.fn(async () => [ + { + name: "theme", + scope: "user" as const, + value: "dark", + description: "UI theme preference", + appId: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + ttlMs: null, + }, + { + name: "_integration:github", + scope: "user" as const, + value: '{"clientId":"x"}', + description: "reserved", + appId: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + ttlMs: null, + }, + { + name: "_openapi:widgets", + scope: "user" as const, + value: '{"ops":[]}', + description: "reserved", + appId: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + ttlMs: null, + }, + ]), + getValue: vi.fn(async () => null), + saveValue: vi.fn(async () => ({ + name: "locale", + scope: "user" as const, + value: "en-US", + description: "Preferred locale", + appId: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + ttlMs: null, + })), + deleteValue: vi.fn(async () => true), +})); -vi.mock('#app/authenticated-user.ts', () => ({ - readAuthenticatedAppUser: (...args: Array) => - mockModule.readAuthenticatedAppUser(...args), -})) +vi.mock("#app/authenticated-user.ts", () => ({ + readAuthenticatedAppUser: (...args: Array) => + mockModule.readAuthenticatedAppUser(...args), +})); -vi.mock('#app/auth-session.ts', () => ({ - readAuthSessionResult: (...args: Array) => - mockModule.readAuthSessionResult(...args), -})) +vi.mock("#app/auth-session.ts", () => ({ + readAuthSessionResult: (...args: Array) => + mockModule.readAuthSessionResult(...args), +})); -vi.mock('#app/auth-redirect.ts', () => ({ - redirectToLogin: () => new Response(null, { status: 302 }), -})) +vi.mock("#app/auth-redirect.ts", () => ({ + redirectToLogin: () => new Response(null, { status: 302 }), +})); -vi.mock('#app/ssr-render.tsx', () => ({ - renderAppPage: async () => new Response('ok'), -})) +vi.mock("#app/ssr-render.tsx", () => ({ + renderAppPage: async () => new Response("ok"), +})); -vi.mock('#mcp/values/service.ts', () => ({ - listValues: (...args: Array) => mockModule.listValues(...args), - getValue: (...args: Array) => mockModule.getValue(...args), - saveValue: (...args: Array) => mockModule.saveValue(...args), - deleteValue: (...args: Array) => mockModule.deleteValue(...args), -})) +vi.mock("#mcp/values/service.ts", () => ({ + listValues: (...args: Array) => mockModule.listValues(...args), + getValue: (...args: Array) => mockModule.getValue(...args), + saveValue: (...args: Array) => mockModule.saveValue(...args), + deleteValue: (...args: Array) => mockModule.deleteValue(...args), +})); -const { createAccountValuesApiHandler } = await import('./account-values.ts') +const { createAccountValuesApiHandler } = await import("./account-values.ts"); function createEnv() { - return { - APP_DB: {} as D1Database, - } as Env + return { + APP_DB: {} as D1Database, + } as Env; } -test('values API lists user-scoped values and hides reserved prefixes', async () => { - const handler = createAccountValuesApiHandler(createEnv()) +test("values API lists, selects, saves, and deletes user-scoped values", async () => { + const handler = createAccountValuesApiHandler(createEnv()); - const listResponse = await handler.handler({ - request: new Request('https://example.com/account/values.json'), - }) + const listResponse = await handler.handler({ + request: new Request("https://example.com/account/values.json"), + }); + expect(listResponse.status).toBe(200); + expect(listResponse.headers.get("Cache-Control")).toBe("no-store"); + await expect(listResponse.json()).resolves.toEqual({ + ok: true, + values: [ + { + id: "theme", + name: "theme", + description: "UI theme preference", + valuePreview: "dark", + updatedAt: new Date(0).toISOString(), + ttlMs: null, + }, + ], + selectedValue: null, + selectedValueId: null, + }); + expect(mockModule.listValues).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "stable-user-1", + scope: "user", + storageContext: { sessionId: null, appId: null }, + }), + ); - expect(listResponse.status).toBe(200) - expect(listResponse.headers.get('Cache-Control')).toBe('no-store') - await expect(listResponse.json()).resolves.toEqual({ - ok: true, - values: [ - { - id: 'theme', - name: 'theme', - description: 'UI theme preference', - valuePreview: 'dark', - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - ], - selectedValue: null, - selectedValueId: null, - }) - expect(mockModule.listValues).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'stable-user-1', - scope: 'user', - storageContext: { sessionId: null, appId: null }, - }), - ) -}) + const selectedResponse = await handler.handler({ + request: new Request( + "https://example.com/account/values.json?selected=theme", + ), + }); + expect(selectedResponse.status).toBe(200); + await expect(selectedResponse.json()).resolves.toMatchObject({ + ok: true, + selectedValueId: "theme", + selectedValue: expect.objectContaining({ + id: "theme", + name: "theme", + value: "dark", + }), + }); -test('values API resolves selected value from selected query param', async () => { - const handler = createAccountValuesApiHandler(createEnv()) + mockModule.listValues.mockResolvedValueOnce([ + { + name: "locale", + scope: "user" as const, + value: "en-US", + description: "Preferred locale", + appId: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + ttlMs: null, + }, + ]); + const saveResponse = await handler.handler({ + request: new Request("https://example.com/account/values.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "save", + name: "locale", + value: "en-US", + description: "Preferred locale", + }), + }), + }); + expect(saveResponse.status).toBe(200); + expect(mockModule.saveValue).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "stable-user-1", + scope: "user", + storageContext: { sessionId: null, appId: null }, + name: "locale", + value: "en-US", + description: "Preferred locale", + userEmail: "user@example.com", + }), + ); + expect(await saveResponse.json()).toMatchObject({ + ok: true, + selectedValueId: "locale", + selectedValue: expect.objectContaining({ id: "locale", value: "en-US" }), + }); - const response = await handler.handler({ - request: new Request( - 'https://example.com/account/values.json?selected=theme', - ), - }) + mockModule.listValues.mockResolvedValueOnce([]); + const deleteResponse = await handler.handler({ + request: new Request("https://example.com/account/values.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "delete", + name: "theme", + }), + }), + }); + expect(deleteResponse.status).toBe(200); + expect(mockModule.deleteValue).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "stable-user-1", + scope: "user", + name: "theme", + }), + ); + await expect(deleteResponse.json()).resolves.toEqual({ + ok: true, + values: [], + selectedValue: null, + selectedValueId: null, + }); +}); - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - ok: true, - values: [ - { - id: 'theme', - name: 'theme', - description: 'UI theme preference', - valuePreview: 'dark', - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - ], - selectedValue: { - id: 'theme', - name: 'theme', - description: 'UI theme preference', - value: 'dark', - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - ttlMs: null, - scope: 'user', - }, - selectedValueId: 'theme', - }) -}) +test("values API rejects reserved names, missing deletes, invalid actions, and unauthenticated requests", async () => { + const handler = createAccountValuesApiHandler(createEnv()); + mockModule.saveValue.mockClear(); + mockModule.deleteValue.mockClear(); -test('values API saves a value and returns selectedValueId', async () => { - mockModule.listValues.mockResolvedValueOnce([ - { - name: 'locale', - scope: 'user' as const, - value: 'en-US', - description: 'Preferred locale', - appId: null, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - ]) - const handler = createAccountValuesApiHandler(createEnv()) + const reservedSave = await handler.handler({ + request: new Request("https://example.com/account/values.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "save", + name: "_integration:github", + value: '{"clientId":"x"}', + description: "should not save", + }), + }), + }); + expect(reservedSave.status).toBe(400); + await expect(reservedSave.json()).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining("reserved"), + }); + expect(mockModule.saveValue).not.toHaveBeenCalled(); - const saveResponse = await handler.handler({ - request: new Request('https://example.com/account/values.json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'save', - name: 'locale', - value: 'en-US', - description: 'Preferred locale', - }), - }), - }) + const reservedDelete = await handler.handler({ + request: new Request("https://example.com/account/values.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "delete", + name: "_integration:github", + }), + }), + }); + expect(reservedDelete.status).toBe(400); + await expect(reservedDelete.json()).resolves.toMatchObject({ ok: false }); + expect(mockModule.deleteValue).not.toHaveBeenCalled(); - expect(saveResponse.status).toBe(200) - expect(mockModule.saveValue).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'stable-user-1', - scope: 'user', - storageContext: { sessionId: null, appId: null }, - name: 'locale', - value: 'en-US', - description: 'Preferred locale', - userEmail: 'user@example.com', - }), - ) - expect(await saveResponse.json()).toEqual({ - ok: true, - selectedValueId: 'locale', - selectedValue: { - id: 'locale', - name: 'locale', - description: 'Preferred locale', - value: 'en-US', - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - ttlMs: null, - scope: 'user', - }, - values: [ - { - id: 'locale', - name: 'locale', - description: 'Preferred locale', - valuePreview: 'en-US', - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - ], - }) -}) + mockModule.deleteValue.mockResolvedValueOnce(false); + const missingDelete = await handler.handler({ + request: new Request("https://example.com/account/values.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "delete", + name: "missing", + }), + }), + }); + expect(missingDelete.status).toBe(404); + await expect(missingDelete.json()).resolves.toMatchObject({ ok: false }); -test('values API deletes a value by name', async () => { - mockModule.listValues.mockResolvedValueOnce([]) - const handler = createAccountValuesApiHandler(createEnv()) + const invalid = await handler.handler({ + request: new Request("https://example.com/account/values.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "nope" }), + }), + }); + expect(invalid.status).toBe(400); + await expect(invalid.json()).resolves.toMatchObject({ ok: false }); - const deleteResponse = await handler.handler({ - request: new Request('https://example.com/account/values.json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'delete', - name: 'theme', - }), - }), - }) - - expect(deleteResponse.status).toBe(200) - expect(mockModule.deleteValue).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'stable-user-1', - scope: 'user', - storageContext: { sessionId: null, appId: null }, - name: 'theme', - }), - ) - await expect(deleteResponse.json()).resolves.toEqual({ - ok: true, - values: [], - selectedValue: null, - selectedValueId: null, - }) -}) - -test('values API rejects save and delete for reserved names without mutating', async () => { - const handler = createAccountValuesApiHandler(createEnv()) - mockModule.saveValue.mockClear() - mockModule.deleteValue.mockClear() - - const saveResponse = await handler.handler({ - request: new Request('https://example.com/account/values.json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'save', - name: '_integration:github', - value: '{"clientId":"x"}', - description: 'should not save', - }), - }), - }) - expect(saveResponse.status).toBe(400) - await expect(saveResponse.json()).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining('reserved'), - }) - expect(mockModule.saveValue).not.toHaveBeenCalled() - - const deleteResponse = await handler.handler({ - request: new Request('https://example.com/account/values.json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'delete', - name: '_integration:github', - }), - }), - }) - expect(deleteResponse.status).toBe(400) - await expect(deleteResponse.json()).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining('reserved'), - }) - expect(mockModule.deleteValue).not.toHaveBeenCalled() -}) - -test('values API returns 404 when deleting a missing value', async () => { - mockModule.deleteValue.mockResolvedValueOnce(false) - const handler = createAccountValuesApiHandler(createEnv()) - - const deleteResponse = await handler.handler({ - request: new Request('https://example.com/account/values.json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'delete', - name: 'missing', - }), - }), - }) - - expect(deleteResponse.status).toBe(404) - await expect(deleteResponse.json()).resolves.toEqual({ - ok: false, - error: 'Value not found.', - }) -}) - -test('values API rejects invalid actions and unauthenticated requests', async () => { - const handler = createAccountValuesApiHandler(createEnv()) - - const invalid = await handler.handler({ - request: new Request('https://example.com/account/values.json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'nope' }), - }), - }) - expect(invalid.status).toBe(400) - await expect(invalid.json()).resolves.toEqual({ - ok: false, - error: 'Invalid action.', - }) - - mockModule.readAuthenticatedAppUser.mockResolvedValueOnce(null) - const unauthorized = await handler.handler({ - request: new Request('https://example.com/account/values.json'), - }) - expect(unauthorized.status).toBe(401) -}) + mockModule.readAuthenticatedAppUser.mockResolvedValueOnce(null); + const unauthorized = await handler.handler({ + request: new Request("https://example.com/account/values.json"), + }); + expect(unauthorized.status).toBe(401); +}); From 151ad12dacc05e43f0d88c4880e0287198981493 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 09:28:28 +0000 Subject: [PATCH 4/4] style: apply oxfmt after mistaken prettier pass CI format:check uses oxfmt (tabs/single quotes). A Prettier pass had rewritten touched test files to spaces/double quotes. Co-authored-by: Kent C. Dodds --- e2e/account-activity.spec.ts | 38 +- e2e/account-jobs.spec.ts | 86 +-- .../app/account-activity-data.node.test.ts | 418 +++++++------- .../src/app/account-billing-data.node.test.ts | 230 ++++---- .../src/app/auth-denial-alerts.node.test.ts | 192 +++---- .../worker/src/app/auth-session.node.test.ts | 186 +++---- .../app/email-delivery-alerts.node.test.ts | 222 ++++---- .../handlers/account-memories.node.test.ts | 466 ++++++++-------- .../app/handlers/account-values.node.test.ts | 518 +++++++++--------- .../stripe-webhook-signature.node.test.ts | 152 ++--- packages/worker/src/d1-retry.node.test.ts | 148 +++-- .../package-service-states.workers.test.ts | 348 ++++++------ .../capabilities/runs/runs.workers.test.ts | 6 +- .../worker/src/mcp/observability.node.test.ts | 448 +++++++-------- .../package-invocations/common.node.test.ts | 78 +-- .../package-workflows-sentry.node.test.ts | 170 +++--- .../run-records/run-records.workers.test.ts | 206 ++++--- .../worker/src/sentry-options.node.test.ts | 342 ++++++------ .../worker/src/usage/activation.node.test.ts | 332 +++++------ 19 files changed, 2292 insertions(+), 2294 deletions(-) diff --git a/e2e/account-activity.spec.ts b/e2e/account-activity.spec.ts index 17484755f7..8d33fda361 100644 --- a/e2e/account-activity.spec.ts +++ b/e2e/account-activity.spec.ts @@ -1,22 +1,22 @@ -import { expect, test } from "./playwright-utils.ts"; -import { ensurePrimaryUserExists, primaryTestUser } from "./auth-test-user.ts"; +import { expect, test } from './playwright-utils.ts' +import { ensurePrimaryUserExists, primaryTestUser } from './auth-test-user.ts' -test("account activity shows filters for a seeded account", async ({ - page, - login, +test('account activity shows filters for a seeded account', async ({ + page, + login, }) => { - await ensurePrimaryUserExists(); - await login({ - email: primaryTestUser.email, - password: primaryTestUser.password, - mode: "login", - }); + await ensurePrimaryUserExists() + await login({ + email: primaryTestUser.email, + password: primaryTestUser.password, + mode: 'login', + }) - await page.goto("/account/activity"); - await expect( - page.getByRole("heading", { name: "Activity", exact: true }), - ).toBeVisible(); - await expect(page.getByLabel("Status filter")).toHaveValue("error"); - await expect(page.getByLabel("Surface filter")).toHaveValue("all"); - await expect(page.getByRole("link", { name: "Activity" })).toBeVisible(); -}); + await page.goto('/account/activity') + await expect( + page.getByRole('heading', { name: 'Activity', exact: true }), + ).toBeVisible() + await expect(page.getByLabel('Status filter')).toHaveValue('error') + await expect(page.getByLabel('Surface filter')).toHaveValue('all') + await expect(page.getByRole('link', { name: 'Activity' })).toBeVisible() +}) diff --git a/e2e/account-jobs.spec.ts b/e2e/account-jobs.spec.ts index a93fe3e445..899aa5affd 100644 --- a/e2e/account-jobs.spec.ts +++ b/e2e/account-jobs.spec.ts @@ -1,27 +1,27 @@ -import { expect, test } from "./playwright-utils.ts"; -import { quoteSqlString } from "@kody-internal/shared/sql-literals.ts"; -import { createStableUserIdFromEmail } from "../packages/worker/src/user-id.ts"; -import { executeE2eD1Command } from "./d1-utils.ts"; +import { expect, test } from './playwright-utils.ts' +import { quoteSqlString } from '@kody-internal/shared/sql-literals.ts' +import { createStableUserIdFromEmail } from '../packages/worker/src/user-id.ts' +import { executeE2eD1Command } from './d1-utils.ts' -test("account jobs detail URL persists across reload for a seeded job", async ({ - page, - seedE2eUser, - login, +test('account jobs detail URL persists across reload for a seeded job', async ({ + page, + seedE2eUser, + login, }) => { - const runId = Date.now(); - const email = `account-jobs-detail-${runId}@example.com`; - const user = await seedE2eUser({ - email, - username: `account-jobs-detail-${runId}`, - password: "account-jobs-password", - }); - const userId = await createStableUserIdFromEmail(email); - const jobId = crypto.randomUUID(); - const jobName = `Seeded job ${runId}`; - const now = new Date().toISOString(); - const nextRunAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); - executeE2eD1Command( - `INSERT INTO jobs ( + const runId = Date.now() + const email = `account-jobs-detail-${runId}@example.com` + const user = await seedE2eUser({ + email, + username: `account-jobs-detail-${runId}`, + password: 'account-jobs-password', + }) + const userId = await createStableUserIdFromEmail(email) + const jobId = crypto.randomUUID() + const jobName = `Seeded job ${runId}` + const now = new Date().toISOString() + const nextRunAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() + executeE2eD1Command( + `INSERT INTO jobs ( id, user_id, name, source_id, published_commit, storage_id, params_json, schedule_json, timezone, enabled, kill_switch_enabled, caller_context_json, created_at, updated_at, next_run_at, @@ -34,7 +34,7 @@ test("account jobs detail URL persists across reload for a seeded job", async ({ NULL, ${quoteSqlString(`job:${jobId}`)}, NULL, - ${quoteSqlString(JSON.stringify({ type: "interval", every: "1h" }))}, + ${quoteSqlString(JSON.stringify({ type: 'interval', every: '1h' }))}, 'UTC', 1, 0, @@ -47,26 +47,26 @@ test("account jobs detail URL persists across reload for a seeded job", async ({ 0, '[]' )`, - ); + ) - await login({ - email: user.email, - password: user.password, - mode: "login", - }); + await login({ + email: user.email, + password: user.password, + mode: 'login', + }) - await page.goto(`/account/jobs/${encodeURIComponent(jobId)}`); - await expect( - page.getByRole("heading", { name: "Jobs", exact: true }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: jobName, exact: true }), - ).toBeVisible(); + await page.goto(`/account/jobs/${encodeURIComponent(jobId)}`) + await expect( + page.getByRole('heading', { name: 'Jobs', exact: true }), + ).toBeVisible() + await expect( + page.getByRole('heading', { name: jobName, exact: true }), + ).toBeVisible() - const detailUrl = page.url(); - await page.reload(); - await expect(page).toHaveURL(detailUrl); - await expect( - page.getByRole("heading", { name: jobName, exact: true }), - ).toBeVisible(); -}); + const detailUrl = page.url() + await page.reload() + await expect(page).toHaveURL(detailUrl) + await expect( + page.getByRole('heading', { name: jobName, exact: true }), + ).toBeVisible() +}) diff --git a/packages/worker/src/app/account-activity-data.node.test.ts b/packages/worker/src/app/account-activity-data.node.test.ts index 11ab29d080..6de99e1c23 100644 --- a/packages/worker/src/app/account-activity-data.node.test.ts +++ b/packages/worker/src/app/account-activity-data.node.test.ts @@ -1,226 +1,226 @@ -import { expect, test, vi } from "vitest"; +import { expect, test, vi } from 'vitest' import { - loadAccountActivityData, - readAccountActivityFilters, - readAccountActivitySelectedRunId, - statusFilterToRunStatus, - surfaceFilterToRunSurface, -} from "#app/account-activity-data.ts"; -import { type RunRecord } from "#worker/run-records/types.ts"; + loadAccountActivityData, + readAccountActivityFilters, + readAccountActivitySelectedRunId, + statusFilterToRunStatus, + surfaceFilterToRunSurface, +} from '#app/account-activity-data.ts' +import { type RunRecord } from '#worker/run-records/types.ts' const mockModule = vi.hoisted(() => ({ - listRunRecords: vi.fn(), - getRunRecord: vi.fn(), - summarizeRunRecords: vi.fn(), -})); + listRunRecords: vi.fn(), + getRunRecord: vi.fn(), + summarizeRunRecords: vi.fn(), +})) -vi.mock("#worker/run-records/service.ts", () => ({ - listRunRecords: (...args: Array) => - mockModule.listRunRecords(...args), - getRunRecord: (...args: Array) => mockModule.getRunRecord(...args), - summarizeRunRecords: (...args: Array) => - mockModule.summarizeRunRecords(...args), -})); +vi.mock('#worker/run-records/service.ts', () => ({ + listRunRecords: (...args: Array) => + mockModule.listRunRecords(...args), + getRunRecord: (...args: Array) => mockModule.getRunRecord(...args), + summarizeRunRecords: (...args: Array) => + mockModule.summarizeRunRecords(...args), +})) const user = { - sessionUserId: "42", - userId: 42, - username: "test-user", - email: "user@example.com", - displayName: "user", - artifactOwnerIds: [], - mcpUser: { - userId: "stable-user-1", - email: "user@example.com", - username: "test-user", - displayName: "user", - }, -}; + sessionUserId: '42', + userId: 42, + username: 'test-user', + email: 'user@example.com', + displayName: 'user', + artifactOwnerIds: [], + mcpUser: { + userId: 'stable-user-1', + email: 'user@example.com', + username: 'test-user', + displayName: 'user', + }, +} function makeRun(overrides: Partial = {}): RunRecord { - return { - id: "run-1", - surface: "job", - status: "error", - name: "Morning digest", - packageId: null, - kodyId: null, - sourceId: "source-1", - publishedCommit: null, - storageId: "storage-1", - jobId: "job-1", - workflowId: null, - invocationId: null, - sessionId: null, - idempotencyKey: null, - parentRunId: null, - startedAt: "2026-07-25T09:00:00.000Z", - finishedAt: "2026-07-25T09:00:01.000Z", - durationMs: 1000, - errorName: "Error", - errorMessage: "boom", - metadata: {}, - logCount: 2, - ...overrides, - }; + return { + id: 'run-1', + surface: 'job', + status: 'error', + name: 'Morning digest', + packageId: null, + kodyId: null, + sourceId: 'source-1', + publishedCommit: null, + storageId: 'storage-1', + jobId: 'job-1', + workflowId: null, + invocationId: null, + sessionId: null, + idempotencyKey: null, + parentRunId: null, + startedAt: '2026-07-25T09:00:00.000Z', + finishedAt: '2026-07-25T09:00:01.000Z', + durationMs: 1000, + errorName: 'Error', + errorMessage: 'boom', + metadata: {}, + logCount: 2, + ...overrides, + } } -test("activity helpers parse filters and prefer path selected run ids", () => { - expect( - readAccountActivityFilters("https://example.com/account/activity"), - ).toEqual({ - statusFilter: "error", - surfaceFilter: "all", - cursor: null, - }); - expect( - readAccountActivityFilters( - "https://example.com/account/activity?status=all&surface=job&cursor=abc", - ), - ).toEqual({ - statusFilter: "all", - surfaceFilter: "job", - cursor: "abc", - }); - expect(statusFilterToRunStatus("error")).toBe("error"); - expect(statusFilterToRunStatus("all")).toBeNull(); - expect(surfaceFilterToRunSurface("all")).toBeNull(); - expect(surfaceFilterToRunSurface("webhook")).toBe("webhook"); +test('activity helpers parse filters and prefer path selected run ids', () => { + expect( + readAccountActivityFilters('https://example.com/account/activity'), + ).toEqual({ + statusFilter: 'error', + surfaceFilter: 'all', + cursor: null, + }) + expect( + readAccountActivityFilters( + 'https://example.com/account/activity?status=all&surface=job&cursor=abc', + ), + ).toEqual({ + statusFilter: 'all', + surfaceFilter: 'job', + cursor: 'abc', + }) + expect(statusFilterToRunStatus('error')).toBe('error') + expect(statusFilterToRunStatus('all')).toBeNull() + expect(surfaceFilterToRunSurface('all')).toBeNull() + expect(surfaceFilterToRunSurface('webhook')).toBe('webhook') - expect( - readAccountActivitySelectedRunId( - "https://example.com/account/activity/run-path?selected=run-query", - "run-path-param", - ), - ).toBe("run-path-param"); - expect( - readAccountActivitySelectedRunId( - "https://example.com/account/activity/run-path", - ), - ).toBe("run-path"); - expect( - readAccountActivitySelectedRunId( - "https://example.com/account/activity?selected=run-query", - ), - ).toBe("run-query"); -}); + expect( + readAccountActivitySelectedRunId( + 'https://example.com/account/activity/run-path?selected=run-query', + 'run-path-param', + ), + ).toBe('run-path-param') + expect( + readAccountActivitySelectedRunId( + 'https://example.com/account/activity/run-path', + ), + ).toBe('run-path') + expect( + readAccountActivitySelectedRunId( + 'https://example.com/account/activity?selected=run-query', + ), + ).toBe('run-query') +}) -test("loadAccountActivityData maps filters, summary, pagination, detail, and cursors", async () => { - const run = makeRun(); - mockModule.summarizeRunRecords.mockResolvedValue({ - since: "2026-07-19T12:00:00.000Z", - total: 4, - errors: 1, - running: 0, - bySurface: [{ surface: "job", total: 4, errors: 1 }], - }); - mockModule.listRunRecords.mockResolvedValue({ - runs: [run], - nextCursor: "cursor-2", - }); - mockModule.getRunRecord.mockResolvedValue({ - run, - logs: [ - { - runId: run.id, - sequence: 1, - level: "error", - message: "second", - fields: null, - }, - { - runId: run.id, - sequence: 0, - level: "log", - message: "first", - fields: null, - }, - ], - }); +test('loadAccountActivityData maps filters, summary, pagination, detail, and cursors', async () => { + const run = makeRun() + mockModule.summarizeRunRecords.mockResolvedValue({ + since: '2026-07-19T12:00:00.000Z', + total: 4, + errors: 1, + running: 0, + bySurface: [{ surface: 'job', total: 4, errors: 1 }], + }) + mockModule.listRunRecords.mockResolvedValue({ + runs: [run], + nextCursor: 'cursor-2', + }) + mockModule.getRunRecord.mockResolvedValue({ + run, + logs: [ + { + runId: run.id, + sequence: 1, + level: 'error', + message: 'second', + fields: null, + }, + { + runId: run.id, + sequence: 0, + level: 'log', + message: 'first', + fields: null, + }, + ], + }) - const env = {} as Env; - const data = await loadAccountActivityData({ - env, - request: new Request( - "https://example.com/account/activity/run-1?status=error&surface=job", - ), - user, - now: new Date("2026-07-26T12:00:00.000Z"), - }); + const env = {} as Env + const data = await loadAccountActivityData({ + env, + request: new Request( + 'https://example.com/account/activity/run-1?status=error&surface=job', + ), + user, + now: new Date('2026-07-26T12:00:00.000Z'), + }) - expect(mockModule.summarizeRunRecords).toHaveBeenCalledWith({ - env, - userId: "stable-user-1", - since: "2026-07-19T12:00:00.000Z", - }); - expect(mockModule.listRunRecords).toHaveBeenCalledWith({ - env, - userId: "stable-user-1", - filter: { - status: "error", - surface: "job", - since: "2026-07-19T12:00:00.000Z", - }, - limit: 25, - cursor: null, - }); - expect(mockModule.getRunRecord).toHaveBeenCalledWith({ - env, - userId: "stable-user-1", - runId: "run-1", - }); - expect(data).toMatchObject({ - ok: true, - statusFilter: "error", - surfaceFilter: "job", - summary: { - total: 4, - errors: 1, - running: 0, - }, - nextCursor: "cursor-2", - selectedRunId: "run-1", - retentionDays: 30, - runs: [ - expect.objectContaining({ - id: "run-1", - surface: "job", - status: "error", - errorMessage: "boom", - }), - ], - selectedRun: expect.objectContaining({ - id: "run-1", - logs: [ - expect.objectContaining({ sequence: 0, message: "first" }), - expect.objectContaining({ sequence: 1, message: "second" }), - ], - }), - }); + expect(mockModule.summarizeRunRecords).toHaveBeenCalledWith({ + env, + userId: 'stable-user-1', + since: '2026-07-19T12:00:00.000Z', + }) + expect(mockModule.listRunRecords).toHaveBeenCalledWith({ + env, + userId: 'stable-user-1', + filter: { + status: 'error', + surface: 'job', + since: '2026-07-19T12:00:00.000Z', + }, + limit: 25, + cursor: null, + }) + expect(mockModule.getRunRecord).toHaveBeenCalledWith({ + env, + userId: 'stable-user-1', + runId: 'run-1', + }) + expect(data).toMatchObject({ + ok: true, + statusFilter: 'error', + surfaceFilter: 'job', + summary: { + total: 4, + errors: 1, + running: 0, + }, + nextCursor: 'cursor-2', + selectedRunId: 'run-1', + retentionDays: 30, + runs: [ + expect.objectContaining({ + id: 'run-1', + surface: 'job', + status: 'error', + errorMessage: 'boom', + }), + ], + selectedRun: expect.objectContaining({ + id: 'run-1', + logs: [ + expect.objectContaining({ sequence: 0, message: 'first' }), + expect.objectContaining({ sequence: 1, message: 'second' }), + ], + }), + }) - mockModule.listRunRecords.mockResolvedValue({ - runs: [], - nextCursor: null, - }); - mockModule.getRunRecord.mockResolvedValue(null); + mockModule.listRunRecords.mockResolvedValue({ + runs: [], + nextCursor: null, + }) + mockModule.getRunRecord.mockResolvedValue(null) - await loadAccountActivityData({ - env: {} as Env, - request: new Request( - "https://example.com/account/activity.json?status=all&cursor=page-2", - ), - user, - now: new Date("2026-07-26T12:00:00.000Z"), - }); + await loadAccountActivityData({ + env: {} as Env, + request: new Request( + 'https://example.com/account/activity.json?status=all&cursor=page-2', + ), + user, + now: new Date('2026-07-26T12:00:00.000Z'), + }) - expect(mockModule.listRunRecords).toHaveBeenCalledWith( - expect.objectContaining({ - filter: { - status: null, - surface: null, - since: "2026-07-19T12:00:00.000Z", - }, - cursor: "page-2", - }), - ); -}); + expect(mockModule.listRunRecords).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { + status: null, + surface: null, + since: '2026-07-19T12:00:00.000Z', + }, + cursor: 'page-2', + }), + ) +}) diff --git a/packages/worker/src/app/account-billing-data.node.test.ts b/packages/worker/src/app/account-billing-data.node.test.ts index e24de50f55..254af009c1 100644 --- a/packages/worker/src/app/account-billing-data.node.test.ts +++ b/packages/worker/src/app/account-billing-data.node.test.ts @@ -1,131 +1,131 @@ -import { expect, test, vi } from "vitest"; -import { consoleError } from "#worker/test-support/console-spies.ts"; +import { expect, test, vi } from 'vitest' +import { consoleError } from '#worker/test-support/console-spies.ts' const refreshStripePlanForUser = vi.hoisted(() => - vi.fn(async () => ({ - stripePlan: "pro" as const, - cancelAt: null as string | null, - subscriptionStatus: "active" as string | null, - })), -); + vi.fn(async () => ({ + stripePlan: 'pro' as const, + cancelAt: null as string | null, + subscriptionStatus: 'active' as string | null, + })), +) -vi.mock("#worker/billing/subscription-sync.ts", () => ({ - refreshStripePlanForUser, -})); +vi.mock('#worker/billing/subscription-sync.ts', () => ({ + refreshStripePlanForUser, +})) import { - loadAccountBillingData, - resolveBillingErrorMessage, -} from "#app/account-billing-data.ts"; + loadAccountBillingData, + resolveBillingErrorMessage, +} from '#app/account-billing-data.ts' function createBillingTestDb(input: { - plan: string; - stripePlan?: string | null; - stripeCustomerId?: string | null; + plan: string + stripePlan?: string | null + stripeCustomerId?: string | null }) { - return { - prepare(query: string) { - const normalized = query.replace(/\s+/g, " ").trim().toLowerCase(); - return { - bind(...params: Array) { - return { - async first() { - if ( - normalized.includes("from users") && - normalized.includes("where id") - ) { - void params; - return { - plan: input.plan, - stripe_plan: input.stripePlan ?? null, - stripe_customer_id: input.stripeCustomerId ?? null, - stripe_plan_refreshed_at: null, - } as T; - } - return null; - }, - async run() { - return { success: true }; - }, - }; - }, - }; - }, - } as unknown as D1Database; + return { + prepare(query: string) { + const normalized = query.replace(/\s+/g, ' ').trim().toLowerCase() + return { + bind(...params: Array) { + return { + async first() { + if ( + normalized.includes('from users') && + normalized.includes('where id') + ) { + void params + return { + plan: input.plan, + stripe_plan: input.stripePlan ?? null, + stripe_customer_id: input.stripeCustomerId ?? null, + stripe_plan_refreshed_at: null, + } as T + } + return null + }, + async run() { + return { success: true } + }, + } + }, + } + }, + } as unknown as D1Database } -test("loadAccountBillingData refreshes Stripe status and degrades when refresh is unavailable", async () => { - expect(resolveBillingErrorMessage("totally_new_code")).toBe( - "totally_new_code", - ); - expect(resolveBillingErrorMessage(null)).toBeUndefined(); - expect(typeof resolveBillingErrorMessage("no_customer")).toBe("string"); +test('loadAccountBillingData refreshes Stripe status and degrades when refresh is unavailable', async () => { + expect(resolveBillingErrorMessage('totally_new_code')).toBe( + 'totally_new_code', + ) + expect(resolveBillingErrorMessage(null)).toBeUndefined() + expect(typeof resolveBillingErrorMessage('no_customer')).toBe('string') - refreshStripePlanForUser.mockResolvedValueOnce({ - stripePlan: "pro", - cancelAt: "2026-08-01T00:00:00.000Z", - subscriptionStatus: "past_due", - }); + refreshStripePlanForUser.mockResolvedValueOnce({ + stripePlan: 'pro', + cancelAt: '2026-08-01T00:00:00.000Z', + subscriptionStatus: 'past_due', + }) - const env = { - APP_DB: createBillingTestDb({ - plan: "free", - stripePlan: "pro", - stripeCustomerId: "cus_test", - }), - STRIPE_SECRET_KEY: "sk_test", - STRIPE_PRO_PRICE_ID: "price_pro", - } as Env; + const env = { + APP_DB: createBillingTestDb({ + plan: 'free', + stripePlan: 'pro', + stripeCustomerId: 'cus_test', + }), + STRIPE_SECRET_KEY: 'sk_test', + STRIPE_PRO_PRICE_ID: 'price_pro', + } as Env - const data = await loadAccountBillingData({ - env, - userId: 9, - now: new Date("2026-07-25T12:00:00.000Z"), - }); + const data = await loadAccountBillingData({ + env, + userId: 9, + now: new Date('2026-07-25T12:00:00.000Z'), + }) - expect(data.ok).toBe(true); - expect(data.configured).toBe(true); - expect(data.hasStripeCustomer).toBe(true); - expect(data.stripePlan).toBe("pro"); - expect(data.effectivePlan).toBe("pro"); - expect(data.subscriptionStatus).toBe("past_due"); - expect(data.cancelAt).toBe("2026-08-01T00:00:00.000Z"); - expect(data.usageHref).toBe("/account/usage"); - expect(data.checkoutAvailable).toBe(true); - expect(refreshStripePlanForUser).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 9, - customerId: "cus_test", - }), - ); + expect(data.ok).toBe(true) + expect(data.configured).toBe(true) + expect(data.hasStripeCustomer).toBe(true) + expect(data.stripePlan).toBe('pro') + expect(data.effectivePlan).toBe('pro') + expect(data.subscriptionStatus).toBe('past_due') + expect(data.cancelAt).toBe('2026-08-01T00:00:00.000Z') + expect(data.usageHref).toBe('/account/usage') + expect(data.checkoutAvailable).toBe(true) + expect(refreshStripePlanForUser).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 9, + customerId: 'cus_test', + }), + ) - consoleError.mockImplementation(() => {}); - refreshStripePlanForUser.mockRejectedValueOnce(new Error("stripe down")); - const failed = await loadAccountBillingData({ env, userId: 3 }); - expect(failed.stripePlan).toBe("pro"); - expect(failed.subscriptionStatus).toBeNull(); - expect(failed.cancelAt).toBeNull(); - expect(consoleError).toHaveBeenCalledWith( - "account_billing_refresh_failed", - expect.objectContaining({ userId: 3, error: "stripe down" }), - ); + consoleError.mockImplementation(() => {}) + refreshStripePlanForUser.mockRejectedValueOnce(new Error('stripe down')) + const failed = await loadAccountBillingData({ env, userId: 3 }) + expect(failed.stripePlan).toBe('pro') + expect(failed.subscriptionStatus).toBeNull() + expect(failed.cancelAt).toBeNull() + expect(consoleError).toHaveBeenCalledWith( + 'account_billing_refresh_failed', + expect.objectContaining({ userId: 3, error: 'stripe down' }), + ) - refreshStripePlanForUser.mockClear(); - const noCustomerEnv = { - APP_DB: createBillingTestDb({ - plan: "free", - stripePlan: null, - stripeCustomerId: null, - }), - STRIPE_SECRET_KEY: "sk_test", - STRIPE_PRO_PRICE_ID: "price_pro", - } as Env; - const noCustomer = await loadAccountBillingData({ - env: noCustomerEnv, - userId: 4, - }); - expect(noCustomer.hasStripeCustomer).toBe(false); - expect(noCustomer.subscriptionStatus).toBeNull(); - expect(noCustomer.configured).toBe(true); - expect(refreshStripePlanForUser).not.toHaveBeenCalled(); -}); + refreshStripePlanForUser.mockClear() + const noCustomerEnv = { + APP_DB: createBillingTestDb({ + plan: 'free', + stripePlan: null, + stripeCustomerId: null, + }), + STRIPE_SECRET_KEY: 'sk_test', + STRIPE_PRO_PRICE_ID: 'price_pro', + } as Env + const noCustomer = await loadAccountBillingData({ + env: noCustomerEnv, + userId: 4, + }) + expect(noCustomer.hasStripeCustomer).toBe(false) + expect(noCustomer.subscriptionStatus).toBeNull() + expect(noCustomer.configured).toBe(true) + expect(refreshStripePlanForUser).not.toHaveBeenCalled() +}) diff --git a/packages/worker/src/app/auth-denial-alerts.node.test.ts b/packages/worker/src/app/auth-denial-alerts.node.test.ts index 8e26da617e..562475c1f2 100644 --- a/packages/worker/src/app/auth-denial-alerts.node.test.ts +++ b/packages/worker/src/app/auth-denial-alerts.node.test.ts @@ -1,108 +1,108 @@ -import { expect, test, vi } from "vitest"; -import { consoleWarn } from "#worker/test-support/console-spies.ts"; +import { expect, test, vi } from 'vitest' +import { consoleWarn } from '#worker/test-support/console-spies.ts' -const sendCloudflareEmail = vi.fn(async () => ({ ok: true })); +const sendCloudflareEmail = vi.fn(async () => ({ ok: true })) -vi.mock("#app/email/cloudflare-email.ts", () => ({ - sendCloudflareEmail: (...args: Array) => - sendCloudflareEmail(...args), -})); +vi.mock('#app/email/cloudflare-email.ts', () => ({ + sendCloudflareEmail: (...args: Array) => + sendCloudflareEmail(...args), +})) const { - authDenialAlertKvKey, - checkAuthDenialBurstAndNotify, - shouldRunAuthDenialAlertCron, -} = await import("#app/auth-denial-alerts.ts"); + authDenialAlertKvKey, + checkAuthDenialBurstAndNotify, + shouldRunAuthDenialAlertCron, +} = await import('#app/auth-denial-alerts.ts') function createDb(count: number) { - return { - prepare(query: string) { - const normalized = query.replace(/\s+/g, " ").trim().toLowerCase(); - return { - bind(..._params: Array) { - return this; - }, - async first() { - if (normalized.includes("from audit_events")) { - return { count } as T; - } - return null; - }, - async all() { - if (normalized.includes("from users u")) { - return { - results: [{ email: "admin@example.com" }], - } as { results: Array }; - } - return { results: [] }; - }, - }; - }, - } as unknown as D1Database; + return { + prepare(query: string) { + const normalized = query.replace(/\s+/g, ' ').trim().toLowerCase() + return { + bind(..._params: Array) { + return this + }, + async first() { + if (normalized.includes('from audit_events')) { + return { count } as T + } + return null + }, + async all() { + if (normalized.includes('from users u')) { + return { + results: [{ email: 'admin@example.com' }], + } as { results: Array } + } + return { results: [] } + }, + } + }, + } as unknown as D1Database } -test("auth denial burst alerts stay quiet below threshold, then notify and cool down", async () => { - expect( - shouldRunAuthDenialAlertCron(new Date("2026-07-25T12:00:00.000Z")), - ).toBe(true); - expect( - shouldRunAuthDenialAlertCron(new Date("2026-07-25T12:05:00.000Z")), - ).toBe(false); +test('auth denial burst alerts stay quiet below threshold, then notify and cool down', async () => { + expect( + shouldRunAuthDenialAlertCron(new Date('2026-07-25T12:00:00.000Z')), + ).toBe(true) + expect( + shouldRunAuthDenialAlertCron(new Date('2026-07-25T12:05:00.000Z')), + ).toBe(false) - sendCloudflareEmail.mockClear(); - const quiet = await checkAuthDenialBurstAndNotify({ - env: { - APP_DB: createDb(10), - APP_BASE_URL: "https://heykody.dev", - CLOUDFLARE_ACCOUNT_ID: "acct", - CLOUDFLARE_API_TOKEN: "token", - }, - threshold: 50, - }); - expect(quiet).toEqual({ status: "below_threshold", count: 10 }); - expect(sendCloudflareEmail).not.toHaveBeenCalled(); + sendCloudflareEmail.mockClear() + const quiet = await checkAuthDenialBurstAndNotify({ + env: { + APP_DB: createDb(10), + APP_BASE_URL: 'https://heykody.dev', + CLOUDFLARE_ACCOUNT_ID: 'acct', + CLOUDFLARE_API_TOKEN: 'token', + }, + threshold: 50, + }) + expect(quiet).toEqual({ status: 'below_threshold', count: 10 }) + expect(sendCloudflareEmail).not.toHaveBeenCalled() - consoleWarn.mockImplementation(() => {}); - const kvStore = new Map(); - const kv = { - async get(key: string) { - return kvStore.get(key) ?? null; - }, - async put(key: string, value: string) { - kvStore.set(key, value); - }, - } as unknown as KVNamespace; + consoleWarn.mockImplementation(() => {}) + const kvStore = new Map() + const kv = { + async get(key: string) { + return kvStore.get(key) ?? null + }, + async put(key: string, value: string) { + kvStore.set(key, value) + }, + } as unknown as KVNamespace - const env = { - APP_DB: createDb(80), - APP_BASE_URL: "https://heykody.dev", - CLOUDFLARE_ACCOUNT_ID: "acct", - CLOUDFLARE_API_TOKEN: "token", - BUNDLE_ARTIFACTS_KV: kv, - }; - const now = new Date("2026-07-25T12:00:00.000Z"); - try { - const first = await checkAuthDenialBurstAndNotify({ - env, - now, - threshold: 50, - }); - expect(first).toEqual({ status: "notified", count: 80, recipients: 1 }); - expect(sendCloudflareEmail).toHaveBeenCalledTimes(1); - expect(kvStore.get(authDenialAlertKvKey)).toBe(String(now.getTime())); - expect(consoleWarn).toHaveBeenCalledWith( - "auth-denial-burst-alerted", - expect.objectContaining({ count: 80 }), - ); + const env = { + APP_DB: createDb(80), + APP_BASE_URL: 'https://heykody.dev', + CLOUDFLARE_ACCOUNT_ID: 'acct', + CLOUDFLARE_API_TOKEN: 'token', + BUNDLE_ARTIFACTS_KV: kv, + } + const now = new Date('2026-07-25T12:00:00.000Z') + try { + const first = await checkAuthDenialBurstAndNotify({ + env, + now, + threshold: 50, + }) + expect(first).toEqual({ status: 'notified', count: 80, recipients: 1 }) + expect(sendCloudflareEmail).toHaveBeenCalledTimes(1) + expect(kvStore.get(authDenialAlertKvKey)).toBe(String(now.getTime())) + expect(consoleWarn).toHaveBeenCalledWith( + 'auth-denial-burst-alerted', + expect.objectContaining({ count: 80 }), + ) - const second = await checkAuthDenialBurstAndNotify({ - env, - now: new Date(now.getTime() + 60_000), - threshold: 50, - }); - expect(second).toEqual({ status: "cooldown", count: 80 }); - expect(sendCloudflareEmail).toHaveBeenCalledTimes(1); - } finally { - consoleWarn.mockReset(); - } -}); + const second = await checkAuthDenialBurstAndNotify({ + env, + now: new Date(now.getTime() + 60_000), + threshold: 50, + }) + expect(second).toEqual({ status: 'cooldown', count: 80 }) + expect(sendCloudflareEmail).toHaveBeenCalledTimes(1) + } finally { + consoleWarn.mockReset() + } +}) diff --git a/packages/worker/src/app/auth-session.node.test.ts b/packages/worker/src/app/auth-session.node.test.ts index 29e01eee98..19377ff7a4 100644 --- a/packages/worker/src/app/auth-session.node.test.ts +++ b/packages/worker/src/app/auth-session.node.test.ts @@ -1,100 +1,100 @@ -import { expect, test } from "vitest"; +import { expect, test } from 'vitest' import { - createAuthCookie, - isAuthSessionInvalidatedByPasswordChange, - readParsedAuthSession, - setAuthSessionSecret, -} from "#app/auth-session.ts"; -import { parsePasswordChangedAtMs } from "#app/request-auth-cache.ts"; + createAuthCookie, + isAuthSessionInvalidatedByPasswordChange, + readParsedAuthSession, + setAuthSessionSecret, +} from '#app/auth-session.ts' +import { parsePasswordChangedAtMs } from '#app/request-auth-cache.ts' -const testCookieSecret = "test-cookie-secret-0123456789abcdef0123456789"; +const testCookieSecret = 'test-cookie-secret-0123456789abcdef0123456789' -test("createAuthCookie stamps issuedAt for password-change invalidation", async () => { - setAuthSessionSecret(testCookieSecret); - const now = 1_700_000_000_000; - const cookie = await createAuthCookie( - { id: "1", email: "user@example.com", rememberMe: false }, - false, - now, - ); - const request = new Request("https://example.com/", { - headers: { Cookie: cookie.split(";")[0]! }, - }); - const parsed = await readParsedAuthSession(request, now); - expect(parsed?.issuedAt).toBe(now); - expect(parsed?.session.rememberMe).toBe(false); -}); +test('createAuthCookie stamps issuedAt for password-change invalidation', async () => { + setAuthSessionSecret(testCookieSecret) + const now = 1_700_000_000_000 + const cookie = await createAuthCookie( + { id: '1', email: 'user@example.com', rememberMe: false }, + false, + now, + ) + const request = new Request('https://example.com/', { + headers: { Cookie: cookie.split(';')[0]! }, + }) + const parsed = await readParsedAuthSession(request, now) + expect(parsed?.issuedAt).toBe(now) + expect(parsed?.session.rememberMe).toBe(false) +}) -test("password-change invalidation covers legacy cookies, second-precision SQLite, and re-login", () => { - expect(parsePasswordChangedAtMs(null)).toBeNull(); - expect(parsePasswordChangedAtMs("")).toBeNull(); - expect(parsePasswordChangedAtMs(" ")).toBeNull(); - expect(parsePasswordChangedAtMs("not-a-timestamp")).toBeNull(); +test('password-change invalidation covers legacy cookies, second-precision SQLite, and re-login', () => { + expect(parsePasswordChangedAtMs(null)).toBeNull() + expect(parsePasswordChangedAtMs('')).toBeNull() + expect(parsePasswordChangedAtMs(' ')).toBeNull() + expect(parsePasswordChangedAtMs('not-a-timestamp')).toBeNull() - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: undefined, - passwordChangedAtMs: null, - }), - ).toBe(false); - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: undefined, - passwordChangedAtMs: 100, - }), - ).toBe(true); - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: 50, - passwordChangedAtMs: 100, - }), - ).toBe(true); - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: 100, - passwordChangedAtMs: 100, - }), - ).toBe(true); - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: 101, - passwordChangedAtMs: 100, - }), - ).toBe(false); + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: undefined, + passwordChangedAtMs: null, + }), + ).toBe(false) + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: undefined, + passwordChangedAtMs: 100, + }), + ).toBe(true) + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: 50, + passwordChangedAtMs: 100, + }), + ).toBe(true) + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: 100, + passwordChangedAtMs: 100, + }), + ).toBe(true) + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: 101, + passwordChangedAtMs: 100, + }), + ).toBe(false) - const secondPrecisionChangedAt = parsePasswordChangedAtMs( - "2026-07-25 12:00:00", - ); - expect(secondPrecisionChangedAt).toBe( - Date.parse("2026-07-25T12:00:00.000Z") + 999, - ); - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: Date.parse("2026-07-25T12:00:00.500Z"), - passwordChangedAtMs: secondPrecisionChangedAt, - }), - ).toBe(true); - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: Date.parse("2026-07-25T12:00:01.000Z"), - passwordChangedAtMs: secondPrecisionChangedAt, - }), - ).toBe(false); + const secondPrecisionChangedAt = parsePasswordChangedAtMs( + '2026-07-25 12:00:00', + ) + expect(secondPrecisionChangedAt).toBe( + Date.parse('2026-07-25T12:00:00.000Z') + 999, + ) + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: Date.parse('2026-07-25T12:00:00.500Z'), + passwordChangedAtMs: secondPrecisionChangedAt, + }), + ).toBe(true) + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: Date.parse('2026-07-25T12:00:01.000Z'), + passwordChangedAtMs: secondPrecisionChangedAt, + }), + ).toBe(false) - const msPrecisionChangedAt = parsePasswordChangedAtMs( - "2026-07-25T12:00:00.400Z", - ); - expect(msPrecisionChangedAt).toBe(Date.parse("2026-07-25T12:00:00.400Z")); - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: Date.parse("2026-07-25T12:00:00.300Z"), - passwordChangedAtMs: msPrecisionChangedAt, - }), - ).toBe(true); - expect( - isAuthSessionInvalidatedByPasswordChange({ - issuedAt: Date.parse("2026-07-25T12:00:00.500Z"), - passwordChangedAtMs: msPrecisionChangedAt, - }), - ).toBe(false); -}); + const msPrecisionChangedAt = parsePasswordChangedAtMs( + '2026-07-25T12:00:00.400Z', + ) + expect(msPrecisionChangedAt).toBe(Date.parse('2026-07-25T12:00:00.400Z')) + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: Date.parse('2026-07-25T12:00:00.300Z'), + passwordChangedAtMs: msPrecisionChangedAt, + }), + ).toBe(true) + expect( + isAuthSessionInvalidatedByPasswordChange({ + issuedAt: Date.parse('2026-07-25T12:00:00.500Z'), + passwordChangedAtMs: msPrecisionChangedAt, + }), + ).toBe(false) +}) diff --git a/packages/worker/src/app/email-delivery-alerts.node.test.ts b/packages/worker/src/app/email-delivery-alerts.node.test.ts index 2de87d2275..c5d321a653 100644 --- a/packages/worker/src/app/email-delivery-alerts.node.test.ts +++ b/packages/worker/src/app/email-delivery-alerts.node.test.ts @@ -1,124 +1,124 @@ -import { expect, test, vi } from "vitest"; -import { consoleWarn } from "#worker/test-support/console-spies.ts"; +import { expect, test, vi } from 'vitest' +import { consoleWarn } from '#worker/test-support/console-spies.ts' -const sendCloudflareEmail = vi.fn(async () => ({ ok: true })); +const sendCloudflareEmail = vi.fn(async () => ({ ok: true })) -vi.mock("#app/email/cloudflare-email.ts", () => ({ - sendCloudflareEmail: (...args: Array) => - sendCloudflareEmail(...args), -})); +vi.mock('#app/email/cloudflare-email.ts', () => ({ + sendCloudflareEmail: (...args: Array) => + sendCloudflareEmail(...args), +})) const { - emailDeliveryAlertKvKey, - checkEmailDeliveryBurstAndNotify, - shouldRunEmailDeliveryAlertCron, -} = await import("#app/email-delivery-alerts.ts"); + emailDeliveryAlertKvKey, + checkEmailDeliveryBurstAndNotify, + shouldRunEmailDeliveryAlertCron, +} = await import('#app/email-delivery-alerts.ts') function createDb(count: number) { - return { - prepare(query: string) { - const normalized = query.replace(/\s+/g, " ").trim().toLowerCase(); - return { - bind(..._params: Array) { - return this; - }, - async first() { - if (normalized.includes("from email_delivery_events")) { - return { count } as T; - } - return null; - }, - async all() { - if (normalized.includes("from users u")) { - return { - results: [{ email: "admin@example.com" }], - } as { results: Array }; - } - return { results: [] }; - }, - }; - }, - } as unknown as D1Database; + return { + prepare(query: string) { + const normalized = query.replace(/\s+/g, ' ').trim().toLowerCase() + return { + bind(..._params: Array) { + return this + }, + async first() { + if (normalized.includes('from email_delivery_events')) { + return { count } as T + } + return null + }, + async all() { + if (normalized.includes('from users u')) { + return { + results: [{ email: 'admin@example.com' }], + } as { results: Array } + } + return { results: [] } + }, + } + }, + } as unknown as D1Database } -test("email delivery burst alerts notify, cool down, and skip cooldown on notify failure", async () => { - expect( - shouldRunEmailDeliveryAlertCron(new Date("2026-07-25T12:00:00.000Z")), - ).toBe(true); - expect( - shouldRunEmailDeliveryAlertCron(new Date("2026-07-25T12:05:00.000Z")), - ).toBe(false); +test('email delivery burst alerts notify, cool down, and skip cooldown on notify failure', async () => { + expect( + shouldRunEmailDeliveryAlertCron(new Date('2026-07-25T12:00:00.000Z')), + ).toBe(true) + expect( + shouldRunEmailDeliveryAlertCron(new Date('2026-07-25T12:05:00.000Z')), + ).toBe(false) - sendCloudflareEmail.mockClear(); - const quiet = await checkEmailDeliveryBurstAndNotify({ - env: { - APP_DB: createDb(10), - APP_BASE_URL: "https://heykody.dev", - CLOUDFLARE_ACCOUNT_ID: "acct", - CLOUDFLARE_API_TOKEN: "token", - }, - threshold: 20, - }); - expect(quiet).toEqual({ status: "below_threshold", count: 10 }); - expect(sendCloudflareEmail).not.toHaveBeenCalled(); + sendCloudflareEmail.mockClear() + const quiet = await checkEmailDeliveryBurstAndNotify({ + env: { + APP_DB: createDb(10), + APP_BASE_URL: 'https://heykody.dev', + CLOUDFLARE_ACCOUNT_ID: 'acct', + CLOUDFLARE_API_TOKEN: 'token', + }, + threshold: 20, + }) + expect(quiet).toEqual({ status: 'below_threshold', count: 10 }) + expect(sendCloudflareEmail).not.toHaveBeenCalled() - consoleWarn.mockImplementation(() => {}); - const kvStore = new Map(); - const kv = { - async get(key: string) { - return kvStore.get(key) ?? null; - }, - async put(key: string, value: string) { - kvStore.set(key, value); - }, - } as unknown as KVNamespace; + consoleWarn.mockImplementation(() => {}) + const kvStore = new Map() + const kv = { + async get(key: string) { + return kvStore.get(key) ?? null + }, + async put(key: string, value: string) { + kvStore.set(key, value) + }, + } as unknown as KVNamespace - const env = { - APP_DB: createDb(35), - APP_BASE_URL: "https://heykody.dev", - CLOUDFLARE_ACCOUNT_ID: "acct", - CLOUDFLARE_API_TOKEN: "token", - BUNDLE_ARTIFACTS_KV: kv, - }; - const now = new Date("2026-07-25T12:00:00.000Z"); - try { - const first = await checkEmailDeliveryBurstAndNotify({ - env, - now, - threshold: 20, - }); - expect(first).toEqual({ status: "notified", count: 35, recipients: 1 }); - expect(sendCloudflareEmail).toHaveBeenCalledTimes(1); - expect(kvStore.get(emailDeliveryAlertKvKey)).toBe(String(now.getTime())); - expect(consoleWarn).toHaveBeenCalledWith( - "email-delivery-burst-alerted", - expect.objectContaining({ count: 35 }), - ); + const env = { + APP_DB: createDb(35), + APP_BASE_URL: 'https://heykody.dev', + CLOUDFLARE_ACCOUNT_ID: 'acct', + CLOUDFLARE_API_TOKEN: 'token', + BUNDLE_ARTIFACTS_KV: kv, + } + const now = new Date('2026-07-25T12:00:00.000Z') + try { + const first = await checkEmailDeliveryBurstAndNotify({ + env, + now, + threshold: 20, + }) + expect(first).toEqual({ status: 'notified', count: 35, recipients: 1 }) + expect(sendCloudflareEmail).toHaveBeenCalledTimes(1) + expect(kvStore.get(emailDeliveryAlertKvKey)).toBe(String(now.getTime())) + expect(consoleWarn).toHaveBeenCalledWith( + 'email-delivery-burst-alerted', + expect.objectContaining({ count: 35 }), + ) - const second = await checkEmailDeliveryBurstAndNotify({ - env, - now: new Date(now.getTime() + 60_000), - threshold: 20, - }); - expect(second).toEqual({ status: "cooldown", count: 35 }); - expect(sendCloudflareEmail).toHaveBeenCalledTimes(1); + const second = await checkEmailDeliveryBurstAndNotify({ + env, + now: new Date(now.getTime() + 60_000), + threshold: 20, + }) + expect(second).toEqual({ status: 'cooldown', count: 35 }) + expect(sendCloudflareEmail).toHaveBeenCalledTimes(1) - kvStore.clear(); - sendCloudflareEmail.mockRejectedValueOnce(new Error("cf email down")); - await expect( - checkEmailDeliveryBurstAndNotify({ - env, - now: new Date("2026-07-25T13:00:00.000Z"), - threshold: 20, - }), - ).rejects.toThrow("cf email down"); - expect(kvStore.has(emailDeliveryAlertKvKey)).toBe(false); - expect(consoleWarn).toHaveBeenCalledWith( - "email-delivery-alert-notification-failed", - expect.any(Error), - ); - } finally { - consoleWarn.mockReset(); - sendCloudflareEmail.mockResolvedValue({ ok: true }); - } -}); + kvStore.clear() + sendCloudflareEmail.mockRejectedValueOnce(new Error('cf email down')) + await expect( + checkEmailDeliveryBurstAndNotify({ + env, + now: new Date('2026-07-25T13:00:00.000Z'), + threshold: 20, + }), + ).rejects.toThrow('cf email down') + expect(kvStore.has(emailDeliveryAlertKvKey)).toBe(false) + expect(consoleWarn).toHaveBeenCalledWith( + 'email-delivery-alert-notification-failed', + expect.any(Error), + ) + } finally { + consoleWarn.mockReset() + sendCloudflareEmail.mockResolvedValue({ ok: true }) + } +}) diff --git a/packages/worker/src/app/handlers/account-memories.node.test.ts b/packages/worker/src/app/handlers/account-memories.node.test.ts index d487d06390..6921886f90 100644 --- a/packages/worker/src/app/handlers/account-memories.node.test.ts +++ b/packages/worker/src/app/handlers/account-memories.node.test.ts @@ -1,258 +1,258 @@ -import { expect, test, vi } from "vitest"; +import { expect, test, vi } from 'vitest' const mockModule = vi.hoisted(() => { - const memoryRow = { - id: "11111111-1111-4111-8111-111111111111", - category: "preferences", - status: "active" as const, - subject: "Favorite editor", - summary: "Prefers VS Code for TypeScript work.", - details: "Uses the Remix extension and oxlint.", - tags: ["editor", "typescript"], - sourceUris: ["https://example.com/notes/editor"], - dedupeKey: "prefs:editor", - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - lastAccessedAt: null as string | null, - deletedAt: null as string | null, - }; - return { - memoryRow, - readAuthenticatedAppUser: vi.fn(async () => ({ - sessionUserId: "42", - userId: 42, - username: "test-user", - email: "user@example.com", - displayName: "user", - artifactOwnerIds: [], - mcpUser: { - userId: "stable-user-1", - email: "user@example.com", - username: "test-user", - displayName: "user", - }, - })), - readAuthSessionResult: async () => ({ session: null, setCookie: null }), - listMemoriesByUserId: vi.fn(async () => [ - { - id: memoryRow.id, - user_id: "stable-user-1", - category: memoryRow.category, - status: memoryRow.status, - subject: memoryRow.subject, - summary: memoryRow.summary, - details: memoryRow.details, - tags_json: JSON.stringify(memoryRow.tags), - source_uris_json: JSON.stringify(memoryRow.sourceUris), - dedupe_key: memoryRow.dedupeKey, - created_at: memoryRow.createdAt, - updated_at: memoryRow.updatedAt, - last_accessed_at: memoryRow.lastAccessedAt, - deleted_at: memoryRow.deletedAt, - }, - ]), - getMemory: vi.fn(async () => memoryRow), - deleteMemory: vi.fn(async () => ({ - ...memoryRow, - status: "deleted" as const, - })), - }; -}); + const memoryRow = { + id: '11111111-1111-4111-8111-111111111111', + category: 'preferences', + status: 'active' as const, + subject: 'Favorite editor', + summary: 'Prefers VS Code for TypeScript work.', + details: 'Uses the Remix extension and oxlint.', + tags: ['editor', 'typescript'], + sourceUris: ['https://example.com/notes/editor'], + dedupeKey: 'prefs:editor', + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + lastAccessedAt: null as string | null, + deletedAt: null as string | null, + } + return { + memoryRow, + readAuthenticatedAppUser: vi.fn(async () => ({ + sessionUserId: '42', + userId: 42, + username: 'test-user', + email: 'user@example.com', + displayName: 'user', + artifactOwnerIds: [], + mcpUser: { + userId: 'stable-user-1', + email: 'user@example.com', + username: 'test-user', + displayName: 'user', + }, + })), + readAuthSessionResult: async () => ({ session: null, setCookie: null }), + listMemoriesByUserId: vi.fn(async () => [ + { + id: memoryRow.id, + user_id: 'stable-user-1', + category: memoryRow.category, + status: memoryRow.status, + subject: memoryRow.subject, + summary: memoryRow.summary, + details: memoryRow.details, + tags_json: JSON.stringify(memoryRow.tags), + source_uris_json: JSON.stringify(memoryRow.sourceUris), + dedupe_key: memoryRow.dedupeKey, + created_at: memoryRow.createdAt, + updated_at: memoryRow.updatedAt, + last_accessed_at: memoryRow.lastAccessedAt, + deleted_at: memoryRow.deletedAt, + }, + ]), + getMemory: vi.fn(async () => memoryRow), + deleteMemory: vi.fn(async () => ({ + ...memoryRow, + status: 'deleted' as const, + })), + } +}) -const { memoryRow } = mockModule; +const { memoryRow } = mockModule -vi.mock("#app/authenticated-user.ts", () => ({ - readAuthenticatedAppUser: (...args: Array) => - mockModule.readAuthenticatedAppUser(...args), -})); +vi.mock('#app/authenticated-user.ts', () => ({ + readAuthenticatedAppUser: (...args: Array) => + mockModule.readAuthenticatedAppUser(...args), +})) -vi.mock("#app/auth-session.ts", () => ({ - readAuthSessionResult: (...args: Array) => - mockModule.readAuthSessionResult(...args), -})); +vi.mock('#app/auth-session.ts', () => ({ + readAuthSessionResult: (...args: Array) => + mockModule.readAuthSessionResult(...args), +})) -vi.mock("#app/auth-redirect.ts", () => ({ - redirectToLogin: () => new Response(null, { status: 302 }), - redirectToLoginWhenUnauthenticated: () => new Response(null, { status: 302 }), -})); +vi.mock('#app/auth-redirect.ts', () => ({ + redirectToLogin: () => new Response(null, { status: 302 }), + redirectToLoginWhenUnauthenticated: () => new Response(null, { status: 302 }), +})) -vi.mock("#app/ssr-render.tsx", () => ({ - renderAppPage: async () => new Response("ok"), -})); +vi.mock('#app/ssr-render.tsx', () => ({ + renderAppPage: async () => new Response('ok'), +})) -vi.mock("#mcp/memory/repo.ts", () => ({ - listMemoriesByUserId: (...args: Array) => - mockModule.listMemoriesByUserId(...args), -})); +vi.mock('#mcp/memory/repo.ts', () => ({ + listMemoriesByUserId: (...args: Array) => + mockModule.listMemoriesByUserId(...args), +})) -vi.mock("#mcp/memory/service.ts", () => ({ - getMemory: (...args: Array) => mockModule.getMemory(...args), - deleteMemory: (...args: Array) => mockModule.deleteMemory(...args), -})); +vi.mock('#mcp/memory/service.ts', () => ({ + getMemory: (...args: Array) => mockModule.getMemory(...args), + deleteMemory: (...args: Array) => mockModule.deleteMemory(...args), +})) const { createAccountMemoriesApiHandler } = - await import("./account-memories.ts"); + await import('./account-memories.ts') function createEnv() { - return { - APP_DB: {} as D1Database, - } as Env; + return { + APP_DB: {} as D1Database, + } as Env } -test("memories API lists, filters, and selects user-scoped memories", async () => { - const handler = createAccountMemoriesApiHandler(createEnv()); +test('memories API lists, filters, and selects user-scoped memories', async () => { + const handler = createAccountMemoriesApiHandler(createEnv()) - const listResponse = await handler.handler({ - request: new Request("https://example.com/account/memories.json"), - params: {}, - } as never); + const listResponse = await handler.handler({ + request: new Request('https://example.com/account/memories.json'), + params: {}, + } as never) - expect(listResponse.status).toBe(200); - expect(listResponse.headers.get("Cache-Control")).toBe("no-store"); - expect(mockModule.listMemoriesByUserId).toHaveBeenCalledWith( - expect.anything(), - "stable-user-1", - expect.objectContaining({ - statuses: ["active", "archived"], - limit: 100, - }), - ); - await expect(listResponse.json()).resolves.toEqual({ - ok: true, - email: "user@example.com", - username: "test-user", - memories: [ - { - id: memoryRow.id, - subject: memoryRow.subject, - category: memoryRow.category, - status: memoryRow.status, - tags: memoryRow.tags, - summary: memoryRow.summary, - updatedAt: memoryRow.updatedAt, - }, - ], - selectedMemory: null, - query: "", - includeDeleted: false, - }); + expect(listResponse.status).toBe(200) + expect(listResponse.headers.get('Cache-Control')).toBe('no-store') + expect(mockModule.listMemoriesByUserId).toHaveBeenCalledWith( + expect.anything(), + 'stable-user-1', + expect.objectContaining({ + statuses: ['active', 'archived'], + limit: 100, + }), + ) + await expect(listResponse.json()).resolves.toEqual({ + ok: true, + email: 'user@example.com', + username: 'test-user', + memories: [ + { + id: memoryRow.id, + subject: memoryRow.subject, + category: memoryRow.category, + status: memoryRow.status, + tags: memoryRow.tags, + summary: memoryRow.summary, + updatedAt: memoryRow.updatedAt, + }, + ], + selectedMemory: null, + query: '', + includeDeleted: false, + }) - const filtered = await handler.handler({ - request: new Request( - "https://example.com/account/memories.json?q=editor&includeDeleted=true&selected=11111111-1111-4111-8111-111111111111", - ), - params: {}, - } as never); + const filtered = await handler.handler({ + request: new Request( + 'https://example.com/account/memories.json?q=editor&includeDeleted=true&selected=11111111-1111-4111-8111-111111111111', + ), + params: {}, + } as never) - expect(filtered.status).toBe(200); - expect(mockModule.listMemoriesByUserId).toHaveBeenCalledWith( - expect.anything(), - "stable-user-1", - expect.objectContaining({ - statuses: ["active", "archived", "deleted"], - }), - ); - expect(mockModule.getMemory).toHaveBeenCalledWith( - expect.objectContaining({ - userId: "stable-user-1", - memoryId: memoryRow.id, - }), - ); - const payload = (await filtered.json()) as { - ok: boolean; - query: string; - includeDeleted: boolean; - selectedMemory: { id: string; details: string } | null; - }; - expect(payload.ok).toBe(true); - expect(payload.query).toBe("editor"); - expect(payload.includeDeleted).toBe(true); - expect(payload.selectedMemory).toEqual( - expect.objectContaining({ - id: memoryRow.id, - details: memoryRow.details, - sourceUris: memoryRow.sourceUris, - dedupeKey: memoryRow.dedupeKey, - }), - ); -}); + expect(filtered.status).toBe(200) + expect(mockModule.listMemoriesByUserId).toHaveBeenCalledWith( + expect.anything(), + 'stable-user-1', + expect.objectContaining({ + statuses: ['active', 'archived', 'deleted'], + }), + ) + expect(mockModule.getMemory).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'stable-user-1', + memoryId: memoryRow.id, + }), + ) + const payload = (await filtered.json()) as { + ok: boolean + query: string + includeDeleted: boolean + selectedMemory: { id: string; details: string } | null + } + expect(payload.ok).toBe(true) + expect(payload.query).toBe('editor') + expect(payload.includeDeleted).toBe(true) + expect(payload.selectedMemory).toEqual( + expect.objectContaining({ + id: memoryRow.id, + details: memoryRow.details, + sourceUris: memoryRow.sourceUris, + dedupeKey: memoryRow.dedupeKey, + }), + ) +}) -test("memories API soft/force deletes and rejects invalid delete requests", async () => { - const handler = createAccountMemoriesApiHandler(createEnv()); +test('memories API soft/force deletes and rejects invalid delete requests', async () => { + const handler = createAccountMemoriesApiHandler(createEnv()) - const softResponse = await handler.handler({ - request: new Request("https://example.com/account/memories.json", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - memoryId: memoryRow.id, - }), - }), - params: {}, - } as never); - expect(softResponse.status).toBe(200); - expect(mockModule.deleteMemory).toHaveBeenCalledWith( - expect.objectContaining({ - userId: "stable-user-1", - memoryId: memoryRow.id, - force: false, - }), - ); + const softResponse = await handler.handler({ + request: new Request('https://example.com/account/memories.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'delete', + memoryId: memoryRow.id, + }), + }), + params: {}, + } as never) + expect(softResponse.status).toBe(200) + expect(mockModule.deleteMemory).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'stable-user-1', + memoryId: memoryRow.id, + force: false, + }), + ) - mockModule.deleteMemory.mockClear(); - const hardResponse = await handler.handler({ - request: new Request("https://example.com/account/memories.json", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - memoryId: memoryRow.id, - force: true, - }), - }), - params: {}, - } as never); - expect(hardResponse.status).toBe(200); - expect(mockModule.deleteMemory).toHaveBeenCalledWith( - expect.objectContaining({ - userId: "stable-user-1", - memoryId: memoryRow.id, - force: true, - }), - ); + mockModule.deleteMemory.mockClear() + const hardResponse = await handler.handler({ + request: new Request('https://example.com/account/memories.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'delete', + memoryId: memoryRow.id, + force: true, + }), + }), + params: {}, + } as never) + expect(hardResponse.status).toBe(200) + expect(mockModule.deleteMemory).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'stable-user-1', + memoryId: memoryRow.id, + force: true, + }), + ) - const missingId = await handler.handler({ - request: new Request("https://example.com/account/memories.json", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "delete" }), - }), - params: {}, - } as never); - expect(missingId.status).toBe(400); + const missingId = await handler.handler({ + request: new Request('https://example.com/account/memories.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'delete' }), + }), + params: {}, + } as never) + expect(missingId.status).toBe(400) - const invalidAction = await handler.handler({ - request: new Request("https://example.com/account/memories.json", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "upsert" }), - }), - params: {}, - } as never); - expect(invalidAction.status).toBe(400); + const invalidAction = await handler.handler({ + request: new Request('https://example.com/account/memories.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'upsert' }), + }), + params: {}, + } as never) + expect(invalidAction.status).toBe(400) - mockModule.deleteMemory.mockResolvedValueOnce(null); - const notFound = await handler.handler({ - request: new Request("https://example.com/account/memories.json", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - memoryId: "missing", - }), - }), - params: {}, - } as never); - expect(notFound.status).toBe(404); -}); + mockModule.deleteMemory.mockResolvedValueOnce(null) + const notFound = await handler.handler({ + request: new Request('https://example.com/account/memories.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'delete', + memoryId: 'missing', + }), + }), + params: {}, + } as never) + expect(notFound.status).toBe(404) +}) diff --git a/packages/worker/src/app/handlers/account-values.node.test.ts b/packages/worker/src/app/handlers/account-values.node.test.ts index 130ff0fb1e..f79a9cfabc 100644 --- a/packages/worker/src/app/handlers/account-values.node.test.ts +++ b/packages/worker/src/app/handlers/account-values.node.test.ts @@ -1,281 +1,281 @@ -import { expect, test, vi } from "vitest"; +import { expect, test, vi } from 'vitest' const mockModule = vi.hoisted(() => ({ - readAuthenticatedAppUser: vi.fn(async () => ({ - sessionUserId: "42", - userId: 42, - username: "test-user", - email: "user@example.com", - displayName: "user", - artifactOwnerIds: [], - mcpUser: { - userId: "stable-user-1", - email: "user@example.com", - username: "test-user", - displayName: "user", - }, - })), - readAuthSessionResult: async () => ({ session: null, setCookie: null }), - listValues: vi.fn(async () => [ - { - name: "theme", - scope: "user" as const, - value: "dark", - description: "UI theme preference", - appId: null, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - { - name: "_integration:github", - scope: "user" as const, - value: '{"clientId":"x"}', - description: "reserved", - appId: null, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - { - name: "_openapi:widgets", - scope: "user" as const, - value: '{"ops":[]}', - description: "reserved", - appId: null, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - ]), - getValue: vi.fn(async () => null), - saveValue: vi.fn(async () => ({ - name: "locale", - scope: "user" as const, - value: "en-US", - description: "Preferred locale", - appId: null, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - ttlMs: null, - })), - deleteValue: vi.fn(async () => true), -})); + readAuthenticatedAppUser: vi.fn(async () => ({ + sessionUserId: '42', + userId: 42, + username: 'test-user', + email: 'user@example.com', + displayName: 'user', + artifactOwnerIds: [], + mcpUser: { + userId: 'stable-user-1', + email: 'user@example.com', + username: 'test-user', + displayName: 'user', + }, + })), + readAuthSessionResult: async () => ({ session: null, setCookie: null }), + listValues: vi.fn(async () => [ + { + name: 'theme', + scope: 'user' as const, + value: 'dark', + description: 'UI theme preference', + appId: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + ttlMs: null, + }, + { + name: '_integration:github', + scope: 'user' as const, + value: '{"clientId":"x"}', + description: 'reserved', + appId: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + ttlMs: null, + }, + { + name: '_openapi:widgets', + scope: 'user' as const, + value: '{"ops":[]}', + description: 'reserved', + appId: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + ttlMs: null, + }, + ]), + getValue: vi.fn(async () => null), + saveValue: vi.fn(async () => ({ + name: 'locale', + scope: 'user' as const, + value: 'en-US', + description: 'Preferred locale', + appId: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + ttlMs: null, + })), + deleteValue: vi.fn(async () => true), +})) -vi.mock("#app/authenticated-user.ts", () => ({ - readAuthenticatedAppUser: (...args: Array) => - mockModule.readAuthenticatedAppUser(...args), -})); +vi.mock('#app/authenticated-user.ts', () => ({ + readAuthenticatedAppUser: (...args: Array) => + mockModule.readAuthenticatedAppUser(...args), +})) -vi.mock("#app/auth-session.ts", () => ({ - readAuthSessionResult: (...args: Array) => - mockModule.readAuthSessionResult(...args), -})); +vi.mock('#app/auth-session.ts', () => ({ + readAuthSessionResult: (...args: Array) => + mockModule.readAuthSessionResult(...args), +})) -vi.mock("#app/auth-redirect.ts", () => ({ - redirectToLogin: () => new Response(null, { status: 302 }), -})); +vi.mock('#app/auth-redirect.ts', () => ({ + redirectToLogin: () => new Response(null, { status: 302 }), +})) -vi.mock("#app/ssr-render.tsx", () => ({ - renderAppPage: async () => new Response("ok"), -})); +vi.mock('#app/ssr-render.tsx', () => ({ + renderAppPage: async () => new Response('ok'), +})) -vi.mock("#mcp/values/service.ts", () => ({ - listValues: (...args: Array) => mockModule.listValues(...args), - getValue: (...args: Array) => mockModule.getValue(...args), - saveValue: (...args: Array) => mockModule.saveValue(...args), - deleteValue: (...args: Array) => mockModule.deleteValue(...args), -})); +vi.mock('#mcp/values/service.ts', () => ({ + listValues: (...args: Array) => mockModule.listValues(...args), + getValue: (...args: Array) => mockModule.getValue(...args), + saveValue: (...args: Array) => mockModule.saveValue(...args), + deleteValue: (...args: Array) => mockModule.deleteValue(...args), +})) -const { createAccountValuesApiHandler } = await import("./account-values.ts"); +const { createAccountValuesApiHandler } = await import('./account-values.ts') function createEnv() { - return { - APP_DB: {} as D1Database, - } as Env; + return { + APP_DB: {} as D1Database, + } as Env } -test("values API lists, selects, saves, and deletes user-scoped values", async () => { - const handler = createAccountValuesApiHandler(createEnv()); +test('values API lists, selects, saves, and deletes user-scoped values', async () => { + const handler = createAccountValuesApiHandler(createEnv()) - const listResponse = await handler.handler({ - request: new Request("https://example.com/account/values.json"), - }); - expect(listResponse.status).toBe(200); - expect(listResponse.headers.get("Cache-Control")).toBe("no-store"); - await expect(listResponse.json()).resolves.toEqual({ - ok: true, - values: [ - { - id: "theme", - name: "theme", - description: "UI theme preference", - valuePreview: "dark", - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - ], - selectedValue: null, - selectedValueId: null, - }); - expect(mockModule.listValues).toHaveBeenCalledWith( - expect.objectContaining({ - userId: "stable-user-1", - scope: "user", - storageContext: { sessionId: null, appId: null }, - }), - ); + const listResponse = await handler.handler({ + request: new Request('https://example.com/account/values.json'), + }) + expect(listResponse.status).toBe(200) + expect(listResponse.headers.get('Cache-Control')).toBe('no-store') + await expect(listResponse.json()).resolves.toEqual({ + ok: true, + values: [ + { + id: 'theme', + name: 'theme', + description: 'UI theme preference', + valuePreview: 'dark', + updatedAt: new Date(0).toISOString(), + ttlMs: null, + }, + ], + selectedValue: null, + selectedValueId: null, + }) + expect(mockModule.listValues).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'stable-user-1', + scope: 'user', + storageContext: { sessionId: null, appId: null }, + }), + ) - const selectedResponse = await handler.handler({ - request: new Request( - "https://example.com/account/values.json?selected=theme", - ), - }); - expect(selectedResponse.status).toBe(200); - await expect(selectedResponse.json()).resolves.toMatchObject({ - ok: true, - selectedValueId: "theme", - selectedValue: expect.objectContaining({ - id: "theme", - name: "theme", - value: "dark", - }), - }); + const selectedResponse = await handler.handler({ + request: new Request( + 'https://example.com/account/values.json?selected=theme', + ), + }) + expect(selectedResponse.status).toBe(200) + await expect(selectedResponse.json()).resolves.toMatchObject({ + ok: true, + selectedValueId: 'theme', + selectedValue: expect.objectContaining({ + id: 'theme', + name: 'theme', + value: 'dark', + }), + }) - mockModule.listValues.mockResolvedValueOnce([ - { - name: "locale", - scope: "user" as const, - value: "en-US", - description: "Preferred locale", - appId: null, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - ]); - const saveResponse = await handler.handler({ - request: new Request("https://example.com/account/values.json", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "save", - name: "locale", - value: "en-US", - description: "Preferred locale", - }), - }), - }); - expect(saveResponse.status).toBe(200); - expect(mockModule.saveValue).toHaveBeenCalledWith( - expect.objectContaining({ - userId: "stable-user-1", - scope: "user", - storageContext: { sessionId: null, appId: null }, - name: "locale", - value: "en-US", - description: "Preferred locale", - userEmail: "user@example.com", - }), - ); - expect(await saveResponse.json()).toMatchObject({ - ok: true, - selectedValueId: "locale", - selectedValue: expect.objectContaining({ id: "locale", value: "en-US" }), - }); + mockModule.listValues.mockResolvedValueOnce([ + { + name: 'locale', + scope: 'user' as const, + value: 'en-US', + description: 'Preferred locale', + appId: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + ttlMs: null, + }, + ]) + const saveResponse = await handler.handler({ + request: new Request('https://example.com/account/values.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save', + name: 'locale', + value: 'en-US', + description: 'Preferred locale', + }), + }), + }) + expect(saveResponse.status).toBe(200) + expect(mockModule.saveValue).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'stable-user-1', + scope: 'user', + storageContext: { sessionId: null, appId: null }, + name: 'locale', + value: 'en-US', + description: 'Preferred locale', + userEmail: 'user@example.com', + }), + ) + expect(await saveResponse.json()).toMatchObject({ + ok: true, + selectedValueId: 'locale', + selectedValue: expect.objectContaining({ id: 'locale', value: 'en-US' }), + }) - mockModule.listValues.mockResolvedValueOnce([]); - const deleteResponse = await handler.handler({ - request: new Request("https://example.com/account/values.json", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - name: "theme", - }), - }), - }); - expect(deleteResponse.status).toBe(200); - expect(mockModule.deleteValue).toHaveBeenCalledWith( - expect.objectContaining({ - userId: "stable-user-1", - scope: "user", - name: "theme", - }), - ); - await expect(deleteResponse.json()).resolves.toEqual({ - ok: true, - values: [], - selectedValue: null, - selectedValueId: null, - }); -}); + mockModule.listValues.mockResolvedValueOnce([]) + const deleteResponse = await handler.handler({ + request: new Request('https://example.com/account/values.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'delete', + name: 'theme', + }), + }), + }) + expect(deleteResponse.status).toBe(200) + expect(mockModule.deleteValue).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'stable-user-1', + scope: 'user', + name: 'theme', + }), + ) + await expect(deleteResponse.json()).resolves.toEqual({ + ok: true, + values: [], + selectedValue: null, + selectedValueId: null, + }) +}) -test("values API rejects reserved names, missing deletes, invalid actions, and unauthenticated requests", async () => { - const handler = createAccountValuesApiHandler(createEnv()); - mockModule.saveValue.mockClear(); - mockModule.deleteValue.mockClear(); +test('values API rejects reserved names, missing deletes, invalid actions, and unauthenticated requests', async () => { + const handler = createAccountValuesApiHandler(createEnv()) + mockModule.saveValue.mockClear() + mockModule.deleteValue.mockClear() - const reservedSave = await handler.handler({ - request: new Request("https://example.com/account/values.json", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "save", - name: "_integration:github", - value: '{"clientId":"x"}', - description: "should not save", - }), - }), - }); - expect(reservedSave.status).toBe(400); - await expect(reservedSave.json()).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining("reserved"), - }); - expect(mockModule.saveValue).not.toHaveBeenCalled(); + const reservedSave = await handler.handler({ + request: new Request('https://example.com/account/values.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save', + name: '_integration:github', + value: '{"clientId":"x"}', + description: 'should not save', + }), + }), + }) + expect(reservedSave.status).toBe(400) + await expect(reservedSave.json()).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('reserved'), + }) + expect(mockModule.saveValue).not.toHaveBeenCalled() - const reservedDelete = await handler.handler({ - request: new Request("https://example.com/account/values.json", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - name: "_integration:github", - }), - }), - }); - expect(reservedDelete.status).toBe(400); - await expect(reservedDelete.json()).resolves.toMatchObject({ ok: false }); - expect(mockModule.deleteValue).not.toHaveBeenCalled(); + const reservedDelete = await handler.handler({ + request: new Request('https://example.com/account/values.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'delete', + name: '_integration:github', + }), + }), + }) + expect(reservedDelete.status).toBe(400) + await expect(reservedDelete.json()).resolves.toMatchObject({ ok: false }) + expect(mockModule.deleteValue).not.toHaveBeenCalled() - mockModule.deleteValue.mockResolvedValueOnce(false); - const missingDelete = await handler.handler({ - request: new Request("https://example.com/account/values.json", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - name: "missing", - }), - }), - }); - expect(missingDelete.status).toBe(404); - await expect(missingDelete.json()).resolves.toMatchObject({ ok: false }); + mockModule.deleteValue.mockResolvedValueOnce(false) + const missingDelete = await handler.handler({ + request: new Request('https://example.com/account/values.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'delete', + name: 'missing', + }), + }), + }) + expect(missingDelete.status).toBe(404) + await expect(missingDelete.json()).resolves.toMatchObject({ ok: false }) - const invalid = await handler.handler({ - request: new Request("https://example.com/account/values.json", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "nope" }), - }), - }); - expect(invalid.status).toBe(400); - await expect(invalid.json()).resolves.toMatchObject({ ok: false }); + const invalid = await handler.handler({ + request: new Request('https://example.com/account/values.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'nope' }), + }), + }) + expect(invalid.status).toBe(400) + await expect(invalid.json()).resolves.toMatchObject({ ok: false }) - mockModule.readAuthenticatedAppUser.mockResolvedValueOnce(null); - const unauthorized = await handler.handler({ - request: new Request("https://example.com/account/values.json"), - }); - expect(unauthorized.status).toBe(401); -}); + mockModule.readAuthenticatedAppUser.mockResolvedValueOnce(null) + const unauthorized = await handler.handler({ + request: new Request('https://example.com/account/values.json'), + }) + expect(unauthorized.status).toBe(401) +}) diff --git a/packages/worker/src/billing/stripe-webhook-signature.node.test.ts b/packages/worker/src/billing/stripe-webhook-signature.node.test.ts index 9fc356f3f1..7fcfebf084 100644 --- a/packages/worker/src/billing/stripe-webhook-signature.node.test.ts +++ b/packages/worker/src/billing/stripe-webhook-signature.node.test.ts @@ -1,89 +1,89 @@ -import { expect, test } from "vitest"; +import { expect, test } from 'vitest' import { - buildStripeWebhookSignatureHeader, - computeStripeWebhookSignature, - verifyStripeWebhookSignature, -} from "./stripe-webhook-signature.ts"; + buildStripeWebhookSignatureHeader, + computeStripeWebhookSignature, + verifyStripeWebhookSignature, +} from './stripe-webhook-signature.ts' -const fixtureSecret = "whsec_test_fixture_secret"; +const fixtureSecret = 'whsec_test_fixture_secret' const fixtureBody = - '{"id":"evt_test_fixture","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_test"}}}'; -const fixtureTimestamp = 1_700_000_000; + '{"id":"evt_test_fixture","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_test"}}}' +const fixtureTimestamp = 1_700_000_000 /** HMAC-SHA256 hex of `${t}.${rawBody}` keyed by UTF-8(whsec_...) via Node crypto. */ const fixtureSignature = - "850a8305dd40d37b2ad74f83d4a32d429fe9a017a6db9ac082e7ea2c3b430140"; + '850a8305dd40d37b2ad74f83d4a32d429fe9a017a6db9ac082e7ea2c3b430140' -test("Stripe webhook signatures match the HMAC fixture and reject bad headers", async () => { - const signature = await computeStripeWebhookSignature({ - secret: fixtureSecret, - timestamp: fixtureTimestamp, - rawBody: fixtureBody, - }); - expect(signature).toBe(fixtureSignature); +test('Stripe webhook signatures match the HMAC fixture and reject bad headers', async () => { + const signature = await computeStripeWebhookSignature({ + secret: fixtureSecret, + timestamp: fixtureTimestamp, + rawBody: fixtureBody, + }) + expect(signature).toBe(fixtureSignature) - const header = `t=${fixtureTimestamp},v1=${fixtureSignature}`; - await expect( - verifyStripeWebhookSignature({ - secret: fixtureSecret, - signatureHeader: header, - rawBody: fixtureBody, - nowSeconds: fixtureTimestamp, - }), - ).resolves.toEqual({ timestamp: fixtureTimestamp }); + const header = `t=${fixtureTimestamp},v1=${fixtureSignature}` + await expect( + verifyStripeWebhookSignature({ + secret: fixtureSecret, + signatureHeader: header, + rawBody: fixtureBody, + nowSeconds: fixtureTimestamp, + }), + ).resolves.toEqual({ timestamp: fixtureTimestamp }) - const built = await buildStripeWebhookSignatureHeader({ - secret: fixtureSecret, - rawBody: fixtureBody, - timestamp: fixtureTimestamp, - }); - expect(built).toBe(header); + const built = await buildStripeWebhookSignatureHeader({ + secret: fixtureSecret, + rawBody: fixtureBody, + timestamp: fixtureTimestamp, + }) + expect(built).toBe(header) - await expect( - verifyStripeWebhookSignature({ - secret: fixtureSecret, - signatureHeader: null, - rawBody: fixtureBody, - nowSeconds: fixtureTimestamp, - }), - ).rejects.toMatchObject({ - name: "StripeWebhookSignatureError", - code: "missing_header", - }); + await expect( + verifyStripeWebhookSignature({ + secret: fixtureSecret, + signatureHeader: null, + rawBody: fixtureBody, + nowSeconds: fixtureTimestamp, + }), + ).rejects.toMatchObject({ + name: 'StripeWebhookSignatureError', + code: 'missing_header', + }) - await expect( - verifyStripeWebhookSignature({ - secret: fixtureSecret, - signatureHeader: `t=${fixtureTimestamp},v1=${"0".repeat(64)}`, - rawBody: fixtureBody, - nowSeconds: fixtureTimestamp, - }), - ).rejects.toMatchObject({ code: "signature_mismatch" }); + await expect( + verifyStripeWebhookSignature({ + secret: fixtureSecret, + signatureHeader: `t=${fixtureTimestamp},v1=${'0'.repeat(64)}`, + rawBody: fixtureBody, + nowSeconds: fixtureTimestamp, + }), + ).rejects.toMatchObject({ code: 'signature_mismatch' }) - await expect( - verifyStripeWebhookSignature({ - secret: fixtureSecret, - signatureHeader: header, - rawBody: fixtureBody.replace("evt_test_fixture", "evt_tampered"), - nowSeconds: fixtureTimestamp, - }), - ).rejects.toMatchObject({ code: "signature_mismatch" }); + await expect( + verifyStripeWebhookSignature({ + secret: fixtureSecret, + signatureHeader: header, + rawBody: fixtureBody.replace('evt_test_fixture', 'evt_tampered'), + nowSeconds: fixtureTimestamp, + }), + ).rejects.toMatchObject({ code: 'signature_mismatch' }) - await expect( - verifyStripeWebhookSignature({ - secret: fixtureSecret, - signatureHeader: header, - rawBody: fixtureBody, - toleranceSeconds: 300, - nowSeconds: fixtureTimestamp + 301, - }), - ).rejects.toMatchObject({ code: "timestamp_tolerance" }); + await expect( + verifyStripeWebhookSignature({ + secret: fixtureSecret, + signatureHeader: header, + rawBody: fixtureBody, + toleranceSeconds: 300, + nowSeconds: fixtureTimestamp + 301, + }), + ).rejects.toMatchObject({ code: 'timestamp_tolerance' }) - await expect( - verifyStripeWebhookSignature({ - secret: fixtureSecret, - signatureHeader: `t=${fixtureTimestamp},v0=deadbeef`, - rawBody: fixtureBody, - nowSeconds: fixtureTimestamp, - }), - ).rejects.toMatchObject({ code: "no_v1_signatures" }); -}); + await expect( + verifyStripeWebhookSignature({ + secret: fixtureSecret, + signatureHeader: `t=${fixtureTimestamp},v0=deadbeef`, + rawBody: fixtureBody, + nowSeconds: fixtureTimestamp, + }), + ).rejects.toMatchObject({ code: 'no_v1_signatures' }) +}) diff --git a/packages/worker/src/d1-retry.node.test.ts b/packages/worker/src/d1-retry.node.test.ts index b27bfaf76a..4fe770d034 100644 --- a/packages/worker/src/d1-retry.node.test.ts +++ b/packages/worker/src/d1-retry.node.test.ts @@ -1,80 +1,78 @@ -import { expect, test, vi } from "vitest"; +import { expect, test, vi } from 'vitest' import { - d1LockRetryBaseDelayMs, - isRetryableD1LockError, - runD1WithRetry, -} from "./d1-retry.ts"; + d1LockRetryBaseDelayMs, + isRetryableD1LockError, + runD1WithRetry, +} from './d1-retry.ts' -test("runD1WithRetry matches lock errors, retries them, and rethrows other failures immediately", async () => { - expect( - isRetryableD1LockError( - new Error("D1_ERROR: NOSENTRY database is locked: SQLITE_BUSY"), - ), - ).toBe(true); - expect( - isRetryableD1LockError( - new Error("Currently processing a long-running export."), - ), - ).toBe(true); - expect(isRetryableD1LockError(new Error("Network connection lost."))).toBe( - true, - ); - expect( - isRetryableD1LockError( - new Error( - "D1_ERROR: internal error; reference = 0u3odos5iotccpol68ppc0eg", - ), - ), - ).toBe(true); - expect( - isRetryableD1LockError( - new Error( - "Internal error in D1 DB storage caused object to be reset; reference = 8t4dqqpoq1ctvjr8kca8fl4c", - ), - ), - ).toBe(true); - expect( - isRetryableD1LockError( - new Error("Network connection lost while uploading..."), - ), - ).toBe(false); - expect(isRetryableD1LockError(new Error("internal error"))).toBe(false); - expect( - isRetryableD1LockError( - new Error( - "D1_ERROR: Internal error in D1 DB storage caused object to be reset", - ), - ), - ).toBe(false); - expect(isRetryableD1LockError(new Error("syntax error near SELECT"))).toBe( - false, - ); +test('runD1WithRetry matches lock errors, retries them, and rethrows other failures immediately', async () => { + expect( + isRetryableD1LockError( + new Error('D1_ERROR: NOSENTRY database is locked: SQLITE_BUSY'), + ), + ).toBe(true) + expect( + isRetryableD1LockError( + new Error('Currently processing a long-running export.'), + ), + ).toBe(true) + expect(isRetryableD1LockError(new Error('Network connection lost.'))).toBe( + true, + ) + expect( + isRetryableD1LockError( + new Error( + 'D1_ERROR: internal error; reference = 0u3odos5iotccpol68ppc0eg', + ), + ), + ).toBe(true) + expect( + isRetryableD1LockError( + new Error( + 'Internal error in D1 DB storage caused object to be reset; reference = 8t4dqqpoq1ctvjr8kca8fl4c', + ), + ), + ).toBe(true) + expect( + isRetryableD1LockError( + new Error('Network connection lost while uploading...'), + ), + ).toBe(false) + expect(isRetryableD1LockError(new Error('internal error'))).toBe(false) + expect( + isRetryableD1LockError( + new Error( + 'D1_ERROR: Internal error in D1 DB storage caused object to be reset', + ), + ), + ).toBe(false) + expect(isRetryableD1LockError(new Error('syntax error near SELECT'))).toBe( + false, + ) - const successOperation = vi.fn(async () => "ok"); - await expect(runD1WithRetry(successOperation)).resolves.toBe("ok"); - expect(successOperation).toHaveBeenCalledTimes(1); + const successOperation = vi.fn(async () => 'ok') + await expect(runD1WithRetry(successOperation)).resolves.toBe('ok') + expect(successOperation).toHaveBeenCalledTimes(1) - vi.useFakeTimers(); - const retryOperation = vi - .fn() - .mockRejectedValueOnce( - new Error("D1_ERROR: NOSENTRY database is locked: SQLITE_BUSY"), - ) - .mockResolvedValueOnce("ok"); - try { - const resultPromise = runD1WithRetry(retryOperation); - await vi.advanceTimersByTimeAsync(d1LockRetryBaseDelayMs); - await expect(resultPromise).resolves.toBe("ok"); - expect(retryOperation).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } + vi.useFakeTimers() + const retryOperation = vi + .fn() + .mockRejectedValueOnce( + new Error('D1_ERROR: NOSENTRY database is locked: SQLITE_BUSY'), + ) + .mockResolvedValueOnce('ok') + try { + const resultPromise = runD1WithRetry(retryOperation) + await vi.advanceTimersByTimeAsync(d1LockRetryBaseDelayMs) + await expect(resultPromise).resolves.toBe('ok') + expect(retryOperation).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } - const failingOperation = vi - .fn() - .mockRejectedValue(new Error("D1_ERROR: syntax error near INSERTZ")); - await expect(runD1WithRetry(failingOperation)).rejects.toThrow( - "syntax error", - ); - expect(failingOperation).toHaveBeenCalledTimes(1); -}); + const failingOperation = vi + .fn() + .mockRejectedValue(new Error('D1_ERROR: syntax error near INSERTZ')) + await expect(runD1WithRetry(failingOperation)).rejects.toThrow('syntax error') + expect(failingOperation).toHaveBeenCalledTimes(1) +}) diff --git a/packages/worker/src/entitlements/package-service-states.workers.test.ts b/packages/worker/src/entitlements/package-service-states.workers.test.ts index e9d5339aba..b011d76cba 100644 --- a/packages/worker/src/entitlements/package-service-states.workers.test.ts +++ b/packages/worker/src/entitlements/package-service-states.workers.test.ts @@ -1,192 +1,192 @@ -import { env } from "cloudflare:workers"; -import { expect, test } from "vitest"; +import { env } from 'cloudflare:workers' +import { expect, test } from 'vitest' import { - assertWithinEntitlement, - countRunningPackageServices, - packageServiceStateStaleMs, -} from "#worker/entitlements/service.ts"; -import { EntitlementLimitError } from "#worker/entitlements/errors.ts"; -import { planLimits } from "#worker/entitlements/plans.ts"; -import { ensureEntitlementTestSchema } from "#worker/entitlements/test-schema.ts"; -import { seedAccount } from "#worker/test-support/workers-seed.ts"; -import { createStableUserIdFromEmail } from "#worker/user-id.ts"; + assertWithinEntitlement, + countRunningPackageServices, + packageServiceStateStaleMs, +} from '#worker/entitlements/service.ts' +import { EntitlementLimitError } from '#worker/entitlements/errors.ts' +import { planLimits } from '#worker/entitlements/plans.ts' +import { ensureEntitlementTestSchema } from '#worker/entitlements/test-schema.ts' +import { seedAccount } from '#worker/test-support/workers-seed.ts' +import { createStableUserIdFromEmail } from '#worker/user-id.ts' async function upsertServiceState(input: { - userId: string; - packageId: string; - serviceName: string; - status: "running" | "idle" | "stopped" | "error"; - updatedAt: string; - startedAt?: string | null; + userId: string + packageId: string + serviceName: string + status: 'running' | 'idle' | 'stopped' | 'error' + updatedAt: string + startedAt?: string | null }) { - await env.APP_DB.prepare( - `INSERT INTO package_service_states ( + await env.APP_DB.prepare( + `INSERT INTO package_service_states ( user_id, package_id, service_name, status, started_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(user_id, package_id, service_name) DO UPDATE SET status = excluded.status, started_at = excluded.started_at, updated_at = excluded.updated_at`, - ) - .bind( - input.userId, - input.packageId, - input.serviceName, - input.status, - input.startedAt ?? null, - input.updatedAt, - ) - .run(); + ) + .bind( + input.userId, + input.packageId, + input.serviceName, + input.status, + input.startedAt ?? null, + input.updatedAt, + ) + .run() } -test("package_service_states counts fresh running rows and enforces the plan boundary", async () => { - await ensureEntitlementTestSchema(env.APP_DB); - const now = new Date("2026-07-26T12:00:00.000Z"); - const userId = await createStableUserIdFromEmail( - `svc-count-${crypto.randomUUID()}@example.com`, - ); - const otherUserId = await createStableUserIdFromEmail( - `svc-count-other-${crypto.randomUUID()}@example.com`, - ); - const fresh = now.toISOString(); - const stale = new Date( - now.valueOf() - packageServiceStateStaleMs - 1, - ).toISOString(); +test('package_service_states counts fresh running rows and enforces the plan boundary', async () => { + await ensureEntitlementTestSchema(env.APP_DB) + const now = new Date('2026-07-26T12:00:00.000Z') + const userId = await createStableUserIdFromEmail( + `svc-count-${crypto.randomUUID()}@example.com`, + ) + const otherUserId = await createStableUserIdFromEmail( + `svc-count-other-${crypto.randomUUID()}@example.com`, + ) + const fresh = now.toISOString() + const stale = new Date( + now.valueOf() - packageServiceStateStaleMs - 1, + ).toISOString() - await upsertServiceState({ - userId, - packageId: "pkg-a", - serviceName: "alpha", - status: "running", - startedAt: fresh, - updatedAt: fresh, - }); - await upsertServiceState({ - userId, - packageId: "pkg-b", - serviceName: "beta", - status: "stopped", - updatedAt: fresh, - }); - await upsertServiceState({ - userId, - packageId: "pkg-c", - serviceName: "gamma", - status: "error", - updatedAt: fresh, - }); - await upsertServiceState({ - userId, - packageId: "pkg-d", - serviceName: "delta", - status: "idle", - updatedAt: fresh, - }); - await upsertServiceState({ - userId, - packageId: "pkg-e", - serviceName: "stale", - status: "running", - startedAt: stale, - updatedAt: stale, - }); - await upsertServiceState({ - userId: otherUserId, - packageId: "pkg-a", - serviceName: "alpha", - status: "running", - startedAt: fresh, - updatedAt: fresh, - }); + await upsertServiceState({ + userId, + packageId: 'pkg-a', + serviceName: 'alpha', + status: 'running', + startedAt: fresh, + updatedAt: fresh, + }) + await upsertServiceState({ + userId, + packageId: 'pkg-b', + serviceName: 'beta', + status: 'stopped', + updatedAt: fresh, + }) + await upsertServiceState({ + userId, + packageId: 'pkg-c', + serviceName: 'gamma', + status: 'error', + updatedAt: fresh, + }) + await upsertServiceState({ + userId, + packageId: 'pkg-d', + serviceName: 'delta', + status: 'idle', + updatedAt: fresh, + }) + await upsertServiceState({ + userId, + packageId: 'pkg-e', + serviceName: 'stale', + status: 'running', + startedAt: stale, + updatedAt: stale, + }) + await upsertServiceState({ + userId: otherUserId, + packageId: 'pkg-a', + serviceName: 'alpha', + status: 'running', + startedAt: fresh, + updatedAt: fresh, + }) - expect( - await countRunningPackageServices({ - db: env.APP_DB, - userId, - now, - }), - ).toBe(1); - expect( - await countRunningPackageServices({ - db: env.APP_DB, - userId, - now, - excludeService: { packageId: "pkg-a", serviceName: "alpha" }, - }), - ).toBe(0); + expect( + await countRunningPackageServices({ + db: env.APP_DB, + userId, + now, + }), + ).toBe(1) + expect( + await countRunningPackageServices({ + db: env.APP_DB, + userId, + now, + excludeService: { packageId: 'pkg-a', serviceName: 'alpha' }, + }), + ).toBe(0) - await upsertServiceState({ - userId, - packageId: "pkg-f", - serviceName: "epsilon", - status: "running", - startedAt: fresh, - updatedAt: fresh, - }); - expect( - await countRunningPackageServices({ - db: env.APP_DB, - userId, - now, - excludeService: { packageId: "pkg-a", serviceName: "alpha" }, - }), - ).toBe(1); + await upsertServiceState({ + userId, + packageId: 'pkg-f', + serviceName: 'epsilon', + status: 'running', + startedAt: fresh, + updatedAt: fresh, + }) + expect( + await countRunningPackageServices({ + db: env.APP_DB, + userId, + now, + excludeService: { packageId: 'pkg-a', serviceName: 'alpha' }, + }), + ).toBe(1) - const email = `svc-limit-${crypto.randomUUID()}@example.com`; - const limitUserId = await createStableUserIdFromEmail(email); - await seedAccount({ - db: env.APP_DB, - email, - username: `svc-limit-${crypto.randomUUID().slice(0, 8)}`, - plan: "pro", - stableUserId: limitUserId, - }); - const limit = planLimits.pro.maxPackageServices; - for (let index = 0; index < limit; index += 1) { - await upsertServiceState({ - userId: limitUserId, - packageId: `pkg-${index}`, - serviceName: "worker", - status: "running", - startedAt: fresh, - updatedAt: fresh, - }); - } + const email = `svc-limit-${crypto.randomUUID()}@example.com` + const limitUserId = await createStableUserIdFromEmail(email) + await seedAccount({ + db: env.APP_DB, + email, + username: `svc-limit-${crypto.randomUUID().slice(0, 8)}`, + plan: 'pro', + stableUserId: limitUserId, + }) + const limit = planLimits.pro.maxPackageServices + for (let index = 0; index < limit; index += 1) { + await upsertServiceState({ + userId: limitUserId, + packageId: `pkg-${index}`, + serviceName: 'worker', + status: 'running', + startedAt: fresh, + updatedAt: fresh, + }) + } - const denied = await assertWithinEntitlement({ - db: env.APP_DB, - userId: limitUserId, - email, - resource: "package_services", - now, - }).then( - () => null, - (thrown: unknown) => thrown, - ); - if (!(denied instanceof EntitlementLimitError)) { - throw new Error( - "Expected EntitlementLimitError at the package_services boundary.", - ); - } - expect(denied.details).toMatchObject({ - resource: "package_services", - plan: "pro", - limit, - current: limit, - }); + const denied = await assertWithinEntitlement({ + db: env.APP_DB, + userId: limitUserId, + email, + resource: 'package_services', + now, + }).then( + () => null, + (thrown: unknown) => thrown, + ) + if (!(denied instanceof EntitlementLimitError)) { + throw new Error( + 'Expected EntitlementLimitError at the package_services boundary.', + ) + } + expect(denied.details).toMatchObject({ + resource: 'package_services', + plan: 'pro', + limit, + current: limit, + }) - await assertWithinEntitlement({ - db: env.APP_DB, - userId: limitUserId, - email, - resource: "package_services", - now, - getCurrent: async () => - await countRunningPackageServices({ - db: env.APP_DB, - userId: limitUserId, - now, - excludeService: { packageId: "pkg-0", serviceName: "worker" }, - }), - }); -}); + await assertWithinEntitlement({ + db: env.APP_DB, + userId: limitUserId, + email, + resource: 'package_services', + now, + getCurrent: async () => + await countRunningPackageServices({ + db: env.APP_DB, + userId: limitUserId, + now, + excludeService: { packageId: 'pkg-0', serviceName: 'worker' }, + }), + }) +}) diff --git a/packages/worker/src/mcp/capabilities/runs/runs.workers.test.ts b/packages/worker/src/mcp/capabilities/runs/runs.workers.test.ts index 8cc12a0ad1..17086bcc99 100644 --- a/packages/worker/src/mcp/capabilities/runs/runs.workers.test.ts +++ b/packages/worker/src/mcp/capabilities/runs/runs.workers.test.ts @@ -107,7 +107,11 @@ test( userId: ownerId, id: 'run-job-err', startedAt: new Date(startedAtBase).toISOString(), - context: baseContext({ surface: 'job', jobId: 'job-a', name: 'with-logs' }), + context: baseContext({ + surface: 'job', + jobId: 'job-a', + name: 'with-logs', + }), status: 'error', error: new Error('boom'), logs: ['line-one', 'line-two'], diff --git a/packages/worker/src/mcp/observability.node.test.ts b/packages/worker/src/mcp/observability.node.test.ts index 8ad45b8ef1..35b6f101e9 100644 --- a/packages/worker/src/mcp/observability.node.test.ts +++ b/packages/worker/src/mcp/observability.node.test.ts @@ -1,231 +1,231 @@ -import { expect, test, vi } from "vitest"; +import { expect, test, vi } from 'vitest' const sentryMock = vi.hoisted(() => ({ - isInitialized: vi.fn(() => true), - getClient: vi.fn(() => ({ getOptions: () => ({ dsn: "https://example" }) })), - withScope: vi.fn((callback: (scope: ScopeStub) => void) => { - callback(sentryMock.scope); - }), - captureException: vi.fn(), - captureMessage: vi.fn(), - scope: { - setLevel: vi.fn(), - setTag: vi.fn(), - setContext: vi.fn(), - setUser: vi.fn(), - }, -})); - -type ScopeStub = typeof sentryMock.scope; - -vi.mock("@sentry/cloudflare", () => ({ - isInitialized: (...args: Array) => sentryMock.isInitialized(...args), - getClient: (...args: Array) => sentryMock.getClient(...args), - withScope: (...args: Array) => sentryMock.withScope(...args), - captureException: (...args: Array) => - sentryMock.captureException(...args), - captureMessage: (...args: Array) => - sentryMock.captureMessage(...args), -})); - -const { logMcpEvent } = await import("./observability.ts"); -const { McpCallerError } = await import("./caller-error.ts"); -const { UserCodeError } = await import("#worker/user-code-error.ts"); + isInitialized: vi.fn(() => true), + getClient: vi.fn(() => ({ getOptions: () => ({ dsn: 'https://example' }) })), + withScope: vi.fn((callback: (scope: ScopeStub) => void) => { + callback(sentryMock.scope) + }), + captureException: vi.fn(), + captureMessage: vi.fn(), + scope: { + setLevel: vi.fn(), + setTag: vi.fn(), + setContext: vi.fn(), + setUser: vi.fn(), + }, +})) + +type ScopeStub = typeof sentryMock.scope + +vi.mock('@sentry/cloudflare', () => ({ + isInitialized: (...args: Array) => sentryMock.isInitialized(...args), + getClient: (...args: Array) => sentryMock.getClient(...args), + withScope: (...args: Array) => sentryMock.withScope(...args), + captureException: (...args: Array) => + sentryMock.captureException(...args), + captureMessage: (...args: Array) => + sentryMock.captureMessage(...args), +})) + +const { logMcpEvent } = await import('./observability.ts') +const { McpCallerError } = await import('./caller-error.ts') +const { UserCodeError } = await import('#worker/user-code-error.ts') function captureMcpEvents(run: () => void) { - sentryMock.captureException.mockClear(); - sentryMock.captureMessage.mockClear(); - sentryMock.withScope.mockClear(); - sentryMock.scope.setLevel.mockClear(); - sentryMock.scope.setUser.mockClear(); - - const originalInfo = console.info; - const payloads: Array = []; - console.info = ((tag: unknown, json?: unknown) => { - if (tag === "mcp-event" && typeof json === "string") { - payloads.push(json); - } - }) as typeof console.info; - try { - run(); - } finally { - console.info = originalInfo; - } - return payloads; + sentryMock.captureException.mockClear() + sentryMock.captureMessage.mockClear() + sentryMock.withScope.mockClear() + sentryMock.scope.setLevel.mockClear() + sentryMock.scope.setUser.mockClear() + + const originalInfo = console.info + const payloads: Array = [] + console.info = ((tag: unknown, json?: unknown) => { + if (tag === 'mcp-event' && typeof json === 'string') { + payloads.push(json) + } + }) as typeof console.info + try { + run() + } finally { + console.info = originalInfo + } + return payloads } const callerFailureBase = { - category: "mcp", - tool: "capability", - outcome: "failure", - durationMs: 3, - baseUrl: "https://example.com", - hasUser: true, - userId: "user-1", -} as const; - -test("logMcpEvent keeps sandbox and caller failures off Sentry and still reports platform bugs", () => { - const payloads = captureMcpEvents(() => { - logMcpEvent({ - category: "mcp", - tool: "execute", - toolName: "execute", - outcome: "failure", - durationMs: 12, - baseUrl: "https://example.com", - hasUser: true, - userId: "user-1", - sandboxError: true, - errorName: "Unknown", - errorMessage: - "Notion API /data_sources/39977ef0-f2db-81c6-9147-000bd579e312/query failed: validation_error", - cause: "Notion API /data_sources/.../query failed: validation_error", - }); - - logMcpEvent({ - ...callerFailureBase, - capabilityName: "search", - failurePhase: "handler", - errorName: "McpCallerError", - errorMessage: 'Provide "query" or "domain".', - cause: new McpCallerError('Provide "query" or "domain".'), - }); - - logMcpEvent({ - ...callerFailureBase, - tool: "search", - toolName: "search", - errorName: "McpCallerError", - errorMessage: - 'Unknown domain "skills". Available domains: account, packages.', - cause: new McpCallerError( - 'Unknown domain "skills". Available domains: account, packages.', - ), - }); - - logMcpEvent({ - ...callerFailureBase, - capabilityName: "repo_open_session", - failurePhase: "handler", - errorName: "Error", - errorMessage: "Opening the session failed.", - cause: new Error("Opening the session failed.", { - cause: new McpCallerError("Discard the current session first."), - }), - }); - - logMcpEvent({ - ...callerFailureBase, - capabilityName: "value_get", - failurePhase: "parse_input", - errorName: "ZodError", - errorMessage: "name: Required", - cause: new Error("name: Required"), - }); - - logMcpEvent({ - ...callerFailureBase, - tool: "search", - toolName: "search", - callerError: true, - errorName: "EntityBatchError", - errorMessage: "All entity lookups failed.", - }); - - logMcpEvent({ - ...callerFailureBase, - capabilityName: "user_module_run", - failurePhase: "handler", - errorName: "UserCodeError", - errorMessage: "boom from user code", - cause: new UserCodeError("boom from user code"), - }); - }); - - expect(payloads).toHaveLength(7); - expect(JSON.parse(payloads[0]!)).toMatchObject({ - tool: "execute", - outcome: "failure", - sandboxError: true, - }); - expect(sentryMock.captureException).not.toHaveBeenCalled(); - expect(sentryMock.captureMessage).not.toHaveBeenCalled(); - - captureMcpEvents(() => { - logMcpEvent({ - ...callerFailureBase, - capabilityName: "value_get", - capabilitySource: "builtin", - sandboxError: false, - failurePhase: "handler", - errorName: "Error", - errorMessage: "platform handler blew up", - cause: new Error("platform handler blew up"), - }); - - logMcpEvent({ - ...callerFailureBase, - capabilityName: "package_get", - failurePhase: "handler", - errorName: "Error", - errorMessage: "D1 write failed.", - cause: new Error("D1 write failed.", { - cause: new Error("storage unavailable"), - }), - }); - - logMcpEvent({ - ...callerFailureBase, - capabilityName: "storage_query", - domain: "storage", - capabilitySource: "builtin", - conversationId: "conv-storage-1", - storageId: "storage-notes-1", - failurePhase: "handler", - errorName: "Error", - errorMessage: "no such table: notes: SQLITE_ERROR", - context: { - sqlPreview: "SELECT * FROM notes LIMIT 1", - }, - cause: new Error("no such table: notes: SQLITE_ERROR"), - }); - }); - - expect(sentryMock.captureException).toHaveBeenCalledTimes(3); - expect(sentryMock.captureException).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ message: "platform handler blew up" }), - ); - expect(sentryMock.captureException).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ message: "D1 write failed." }), - ); - expect(sentryMock.captureException).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - message: "no such table: notes: SQLITE_ERROR", - }), - ); - expect(sentryMock.scope.setLevel).toHaveBeenCalledWith("error"); - expect(sentryMock.scope.setUser).toHaveBeenCalledWith({ id: "user-1" }); - expect(sentryMock.scope.setContext).toHaveBeenCalledWith( - "mcp", - expect.objectContaining({ - baseUrl: "https://example.com", - hasUser: true, - errorMessage: "platform handler blew up", - detail: undefined, - }), - ); - expect(sentryMock.scope.setContext).toHaveBeenCalledWith( - "mcp", - expect.objectContaining({ - conversationId: "conv-storage-1", - storageId: "storage-notes-1", - detail: { - sqlPreview: "SELECT * FROM notes LIMIT 1", - }, - }), - ); - expect(sentryMock.captureMessage).not.toHaveBeenCalled(); -}); + category: 'mcp', + tool: 'capability', + outcome: 'failure', + durationMs: 3, + baseUrl: 'https://example.com', + hasUser: true, + userId: 'user-1', +} as const + +test('logMcpEvent keeps sandbox and caller failures off Sentry and still reports platform bugs', () => { + const payloads = captureMcpEvents(() => { + logMcpEvent({ + category: 'mcp', + tool: 'execute', + toolName: 'execute', + outcome: 'failure', + durationMs: 12, + baseUrl: 'https://example.com', + hasUser: true, + userId: 'user-1', + sandboxError: true, + errorName: 'Unknown', + errorMessage: + 'Notion API /data_sources/39977ef0-f2db-81c6-9147-000bd579e312/query failed: validation_error', + cause: 'Notion API /data_sources/.../query failed: validation_error', + }) + + logMcpEvent({ + ...callerFailureBase, + capabilityName: 'search', + failurePhase: 'handler', + errorName: 'McpCallerError', + errorMessage: 'Provide "query" or "domain".', + cause: new McpCallerError('Provide "query" or "domain".'), + }) + + logMcpEvent({ + ...callerFailureBase, + tool: 'search', + toolName: 'search', + errorName: 'McpCallerError', + errorMessage: + 'Unknown domain "skills". Available domains: account, packages.', + cause: new McpCallerError( + 'Unknown domain "skills". Available domains: account, packages.', + ), + }) + + logMcpEvent({ + ...callerFailureBase, + capabilityName: 'repo_open_session', + failurePhase: 'handler', + errorName: 'Error', + errorMessage: 'Opening the session failed.', + cause: new Error('Opening the session failed.', { + cause: new McpCallerError('Discard the current session first.'), + }), + }) + + logMcpEvent({ + ...callerFailureBase, + capabilityName: 'value_get', + failurePhase: 'parse_input', + errorName: 'ZodError', + errorMessage: 'name: Required', + cause: new Error('name: Required'), + }) + + logMcpEvent({ + ...callerFailureBase, + tool: 'search', + toolName: 'search', + callerError: true, + errorName: 'EntityBatchError', + errorMessage: 'All entity lookups failed.', + }) + + logMcpEvent({ + ...callerFailureBase, + capabilityName: 'user_module_run', + failurePhase: 'handler', + errorName: 'UserCodeError', + errorMessage: 'boom from user code', + cause: new UserCodeError('boom from user code'), + }) + }) + + expect(payloads).toHaveLength(7) + expect(JSON.parse(payloads[0]!)).toMatchObject({ + tool: 'execute', + outcome: 'failure', + sandboxError: true, + }) + expect(sentryMock.captureException).not.toHaveBeenCalled() + expect(sentryMock.captureMessage).not.toHaveBeenCalled() + + captureMcpEvents(() => { + logMcpEvent({ + ...callerFailureBase, + capabilityName: 'value_get', + capabilitySource: 'builtin', + sandboxError: false, + failurePhase: 'handler', + errorName: 'Error', + errorMessage: 'platform handler blew up', + cause: new Error('platform handler blew up'), + }) + + logMcpEvent({ + ...callerFailureBase, + capabilityName: 'package_get', + failurePhase: 'handler', + errorName: 'Error', + errorMessage: 'D1 write failed.', + cause: new Error('D1 write failed.', { + cause: new Error('storage unavailable'), + }), + }) + + logMcpEvent({ + ...callerFailureBase, + capabilityName: 'storage_query', + domain: 'storage', + capabilitySource: 'builtin', + conversationId: 'conv-storage-1', + storageId: 'storage-notes-1', + failurePhase: 'handler', + errorName: 'Error', + errorMessage: 'no such table: notes: SQLITE_ERROR', + context: { + sqlPreview: 'SELECT * FROM notes LIMIT 1', + }, + cause: new Error('no such table: notes: SQLITE_ERROR'), + }) + }) + + expect(sentryMock.captureException).toHaveBeenCalledTimes(3) + expect(sentryMock.captureException).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ message: 'platform handler blew up' }), + ) + expect(sentryMock.captureException).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ message: 'D1 write failed.' }), + ) + expect(sentryMock.captureException).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + message: 'no such table: notes: SQLITE_ERROR', + }), + ) + expect(sentryMock.scope.setLevel).toHaveBeenCalledWith('error') + expect(sentryMock.scope.setUser).toHaveBeenCalledWith({ id: 'user-1' }) + expect(sentryMock.scope.setContext).toHaveBeenCalledWith( + 'mcp', + expect.objectContaining({ + baseUrl: 'https://example.com', + hasUser: true, + errorMessage: 'platform handler blew up', + detail: undefined, + }), + ) + expect(sentryMock.scope.setContext).toHaveBeenCalledWith( + 'mcp', + expect.objectContaining({ + conversationId: 'conv-storage-1', + storageId: 'storage-notes-1', + detail: { + sqlPreview: 'SELECT * FROM notes LIMIT 1', + }, + }), + ) + expect(sentryMock.captureMessage).not.toHaveBeenCalled() +}) diff --git a/packages/worker/src/package-invocations/common.node.test.ts b/packages/worker/src/package-invocations/common.node.test.ts index 4cf6071cef..00c0e89ac2 100644 --- a/packages/worker/src/package-invocations/common.node.test.ts +++ b/packages/worker/src/package-invocations/common.node.test.ts @@ -1,42 +1,42 @@ -import { expect, test } from "vitest"; -import { packageWorkflowInvocationSource } from "#worker/package-runtime/package-invocation-sources.ts"; +import { expect, test } from 'vitest' +import { packageWorkflowInvocationSource } from '#worker/package-runtime/package-invocation-sources.ts' import { - resolveInvocationRuntimeName, - resolveInvocationRuntimeSurface, -} from "./common.ts"; + resolveInvocationRuntimeName, + resolveInvocationRuntimeSurface, +} from './common.ts' -test("invocation runtime surface and name map selectors without double-counting package workflows", () => { - expect( - resolveInvocationRuntimeSurface({ - selector: { kind: "export", exportName: "./run" }, - source: packageWorkflowInvocationSource, - }), - ).toBeNull(); - expect( - resolveInvocationRuntimeSurface({ - selector: { kind: "export", exportName: "./run" }, - source: "discord-gateway", - }), - ).toBe("export"); - expect( - resolveInvocationRuntimeSurface({ - selector: { kind: "subscription", topic: "email.inbound" }, - source: "email", - }), - ).toBe("subscription"); +test('invocation runtime surface and name map selectors without double-counting package workflows', () => { + expect( + resolveInvocationRuntimeSurface({ + selector: { kind: 'export', exportName: './run' }, + source: packageWorkflowInvocationSource, + }), + ).toBeNull() + expect( + resolveInvocationRuntimeSurface({ + selector: { kind: 'export', exportName: './run' }, + source: 'discord-gateway', + }), + ).toBe('export') + expect( + resolveInvocationRuntimeSurface({ + selector: { kind: 'subscription', topic: 'email.inbound' }, + source: 'email', + }), + ).toBe('subscription') - expect( - resolveInvocationRuntimeName({ - surface: "subscription", - invocationName: "./on-email", - topic: "email.inbound", - }), - ).toBe("email.inbound"); - expect( - resolveInvocationRuntimeName({ - surface: "export", - invocationName: "./run", - topic: "ignored", - }), - ).toBe("./run"); -}); + expect( + resolveInvocationRuntimeName({ + surface: 'subscription', + invocationName: './on-email', + topic: 'email.inbound', + }), + ).toBe('email.inbound') + expect( + resolveInvocationRuntimeName({ + surface: 'export', + invocationName: './run', + topic: 'ignored', + }), + ).toBe('./run') +}) diff --git a/packages/worker/src/package-runtime/package-workflows-sentry.node.test.ts b/packages/worker/src/package-runtime/package-workflows-sentry.node.test.ts index 04722f8d89..8e0e85ac1c 100644 --- a/packages/worker/src/package-runtime/package-workflows-sentry.node.test.ts +++ b/packages/worker/src/package-runtime/package-workflows-sentry.node.test.ts @@ -1,95 +1,95 @@ -import { expect, test, vi } from "vitest"; +import { expect, test, vi } from 'vitest' const sentryMock = vi.hoisted(() => ({ - isInitialized: vi.fn(() => true), - setUser: vi.fn(), - setTag: vi.fn(), - setContext: vi.fn(), -})); + isInitialized: vi.fn(() => true), + setUser: vi.fn(), + setTag: vi.fn(), + setContext: vi.fn(), +})) -vi.mock("@sentry/cloudflare", () => ({ - isInitialized: (...args: Array) => sentryMock.isInitialized(...args), - setUser: (...args: Array) => sentryMock.setUser(...args), - setTag: (...args: Array) => sentryMock.setTag(...args), - setContext: (...args: Array) => sentryMock.setContext(...args), -})); +vi.mock('@sentry/cloudflare', () => ({ + isInitialized: (...args: Array) => sentryMock.isInitialized(...args), + setUser: (...args: Array) => sentryMock.setUser(...args), + setTag: (...args: Array) => sentryMock.setTag(...args), + setContext: (...args: Array) => sentryMock.setContext(...args), +})) const { applyDynamicWorkflowSentryScope } = - await import("./package-workflows-sentry.ts"); + await import('./package-workflows-sentry.ts') -test("applyDynamicWorkflowSentryScope tags package and inline workflows", () => { - applyDynamicWorkflowSentryScope({ - instanceId: "wf-instance-1", - payload: { - sourceType: "package", - userId: "user-stable-1", - packageId: "pkg-1", - kodyId: "demo-package", - sourceId: "source-1", - workflowName: "nightly", - exportName: "run", - idempotencyKey: "idem-1", - runAt: "2026-07-25T00:00:00.000Z", - planDate: null, - }, - }); +test('applyDynamicWorkflowSentryScope tags package and inline workflows', () => { + applyDynamicWorkflowSentryScope({ + instanceId: 'wf-instance-1', + payload: { + sourceType: 'package', + userId: 'user-stable-1', + packageId: 'pkg-1', + kodyId: 'demo-package', + sourceId: 'source-1', + workflowName: 'nightly', + exportName: 'run', + idempotencyKey: 'idem-1', + runAt: '2026-07-25T00:00:00.000Z', + planDate: null, + }, + }) - expect(sentryMock.setUser).toHaveBeenCalledWith({ id: "user-stable-1" }); - expect(sentryMock.setTag).toHaveBeenCalledWith( - "workflow.source_type", - "package", - ); - expect(sentryMock.setTag).toHaveBeenCalledWith( - "workflow.kody_id", - "demo-package", - ); - expect(sentryMock.setContext).toHaveBeenCalledWith( - "workflow", - expect.objectContaining({ - instanceId: "wf-instance-1", - packageId: "pkg-1", - kodyId: "demo-package", - exportName: "run", - workflowName: "nightly", - idempotencyKey: "idem-1", - }), - ); + expect(sentryMock.setUser).toHaveBeenCalledWith({ id: 'user-stable-1' }) + expect(sentryMock.setTag).toHaveBeenCalledWith( + 'workflow.source_type', + 'package', + ) + expect(sentryMock.setTag).toHaveBeenCalledWith( + 'workflow.kody_id', + 'demo-package', + ) + expect(sentryMock.setContext).toHaveBeenCalledWith( + 'workflow', + expect.objectContaining({ + instanceId: 'wf-instance-1', + packageId: 'pkg-1', + kodyId: 'demo-package', + exportName: 'run', + workflowName: 'nightly', + idempotencyKey: 'idem-1', + }), + ) - sentryMock.setUser.mockClear(); - sentryMock.setTag.mockClear(); - sentryMock.setContext.mockClear(); + sentryMock.setUser.mockClear() + sentryMock.setTag.mockClear() + sentryMock.setContext.mockClear() - applyDynamicWorkflowSentryScope({ - instanceId: "wf-inline-1", - payload: { - sourceType: "inline", - userId: "user-stable-2", - packageContext: null, - workflowName: "ad-hoc", - code: "export default async () => 1", - idempotencyKey: "idem-2", - runAt: "2026-07-25T01:00:00.000Z", - planDate: null, - }, - }); + applyDynamicWorkflowSentryScope({ + instanceId: 'wf-inline-1', + payload: { + sourceType: 'inline', + userId: 'user-stable-2', + packageContext: null, + workflowName: 'ad-hoc', + code: 'export default async () => 1', + idempotencyKey: 'idem-2', + runAt: '2026-07-25T01:00:00.000Z', + planDate: null, + }, + }) - expect(sentryMock.setUser).toHaveBeenCalledWith({ id: "user-stable-2" }); - expect(sentryMock.setTag).toHaveBeenCalledWith( - "workflow.source_type", - "inline", - ); - expect(sentryMock.setTag).not.toHaveBeenCalledWith( - "workflow.kody_id", - expect.anything(), - ); - expect(sentryMock.setContext).toHaveBeenCalledWith( - "workflow", - expect.objectContaining({ - instanceId: "wf-inline-1", - sourceType: "inline", - packageId: null, - kodyId: null, - codeBytes: "export default async () => 1".length, - }), - ); -}); + expect(sentryMock.setUser).toHaveBeenCalledWith({ id: 'user-stable-2' }) + expect(sentryMock.setTag).toHaveBeenCalledWith( + 'workflow.source_type', + 'inline', + ) + expect(sentryMock.setTag).not.toHaveBeenCalledWith( + 'workflow.kody_id', + expect.anything(), + ) + expect(sentryMock.setContext).toHaveBeenCalledWith( + 'workflow', + expect.objectContaining({ + instanceId: 'wf-inline-1', + sourceType: 'inline', + packageId: null, + kodyId: null, + codeBytes: 'export default async () => 1'.length, + }), + ) +}) diff --git a/packages/worker/src/run-records/run-records.workers.test.ts b/packages/worker/src/run-records/run-records.workers.test.ts index 7bbaf4f22e..9bd9611347 100644 --- a/packages/worker/src/run-records/run-records.workers.test.ts +++ b/packages/worker/src/run-records/run-records.workers.test.ts @@ -438,7 +438,9 @@ test('cap and stale retention journey', async () => { handle: { id: 'retention-trigger', userId, - startedAt: new Date(baseMs + runRecordMaxRunsPerUser + 20).toISOString(), + startedAt: new Date( + baseMs + runRecordMaxRunsPerUser + 20, + ).toISOString(), persistence: 'eager', context: baseContext({ surface: 'job', name: 'trigger' }), }, @@ -722,116 +724,112 @@ test('cap and stale retention journey', async () => { } }) -test( - 'alarm lifecycle: fresh arm, self-termination when idle, re-arm after idle, and age-prune', - async () => { - const userId = uniqueUserId('alarm-lifecycle') - const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) - const startedAtMs = Date.now() +test('alarm lifecycle: fresh arm, self-termination when idle, re-arm after idle, and age-prune', async () => { + const userId = uniqueUserId('alarm-lifecycle') + const stub = env.RUN_LOG.get(env.RUN_LOG.idFromName(userId)) + const startedAtMs = Date.now() - // Fresh finish arms a far-future retention alarm at the row's age deadline. - await finishRunRecord({ - env, - handle: { - id: 'first-run', - userId, - startedAt: new Date(startedAtMs).toISOString(), - persistence: 'eager', - context: baseContext({ surface: 'job', name: 'fresh' }), - }, - status: 'success', - }) - const initialAlarm = await runInDurableObject( - stub, - async (_instance: RunLog, state) => state.storage.getAlarm(), - ) - expect(initialAlarm).toBeTypeOf('number') - // One-shot at the row's age deadline — not an immediate/hourly wake. - expect(initialAlarm).toBeGreaterThan( - startedAtMs + runRecordRetentionDays * 24 * 60 * 60 * 1000 - 5_000, - ) + // Fresh finish arms a far-future retention alarm at the row's age deadline. + await finishRunRecord({ + env, + handle: { + id: 'first-run', + userId, + startedAt: new Date(startedAtMs).toISOString(), + persistence: 'eager', + context: baseContext({ surface: 'job', name: 'fresh' }), + }, + status: 'success', + }) + const initialAlarm = await runInDurableObject( + stub, + async (_instance: RunLog, state) => state.storage.getAlarm(), + ) + expect(initialAlarm).toBeTypeOf('number') + // One-shot at the row's age deadline — not an immediate/hourly wake. + expect(initialAlarm).toBeGreaterThan( + startedAtMs + runRecordRetentionDays * 24 * 60 * 60 * 1000 - 5_000, + ) - // Age the row past the retention cutoff and fire the alarm directly; - // with nothing left to keep, the alarm self-terminates (no re-arm). - const expiredStartedAt = new Date( - Date.now() - (runRecordRetentionDays + 3) * 24 * 60 * 60 * 1000, - ).toISOString() - await runInDurableObject(stub, async (instance: RunLog, state) => { - state.storage.sql.exec( - `UPDATE runs SET started_at = ?, finished_at = ?, updated_at = ? WHERE id = ?`, - expiredStartedAt, - expiredStartedAt, - expiredStartedAt, - 'first-run', - ) - state.storage.sql.exec( - `INSERT INTO run_log_meta (key, value) VALUES (?, ?) + // Age the row past the retention cutoff and fire the alarm directly; + // with nothing left to keep, the alarm self-terminates (no re-arm). + const expiredStartedAt = new Date( + Date.now() - (runRecordRetentionDays + 3) * 24 * 60 * 60 * 1000, + ).toISOString() + await runInDurableObject(stub, async (instance: RunLog, state) => { + state.storage.sql.exec( + `UPDATE runs SET started_at = ?, finished_at = ?, updated_at = ? WHERE id = ?`, + expiredStartedAt, + expiredStartedAt, + expiredStartedAt, + 'first-run', + ) + state.storage.sql.exec( + `INSERT INTO run_log_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, - 'finishes_since_retention', - 0, - ) - await state.storage.deleteAlarm() - await instance.alarm() - }) - const idleState = await runInDurableObject( - stub, - async (_instance: RunLog, state) => ({ - alarmAt: await state.storage.getAlarm(), - remaining: state.storage.sql - .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM runs`) - .one().n, - }), + 'finishes_since_retention', + 0, ) - expect(idleState.remaining).toBe(0) - expect(idleState.alarmAt).toBeNull() + await state.storage.deleteAlarm() + await instance.alarm() + }) + const idleState = await runInDurableObject( + stub, + async (_instance: RunLog, state) => ({ + alarmAt: await state.storage.getAlarm(), + remaining: state.storage.sql + .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM runs`) + .one().n, + }), + ) + expect(idleState.remaining).toBe(0) + expect(idleState.alarmAt).toBeNull() - // Write after idle re-arms the alarm for the new row. - const runId = 'post-idle-run' - await finishRunRecord({ - env, - handle: { - id: runId, - userId, - startedAt: new Date().toISOString(), - persistence: 'eager', - context: baseContext({ surface: 'job', name: 'post-idle' }), - }, - status: 'success', - }) - const reArmedAlarm = await runInDurableObject( - stub, - async (_instance: RunLog, state) => state.storage.getAlarm(), - ) - expect(reArmedAlarm).toBeTypeOf('number') + // Write after idle re-arms the alarm for the new row. + const runId = 'post-idle-run' + await finishRunRecord({ + env, + handle: { + id: runId, + userId, + startedAt: new Date().toISOString(), + persistence: 'eager', + context: baseContext({ surface: 'job', name: 'post-idle' }), + }, + status: 'success', + }) + const reArmedAlarm = await runInDurableObject( + stub, + async (_instance: RunLog, state) => state.storage.getAlarm(), + ) + expect(reArmedAlarm).toBeTypeOf('number') - // Age the post-idle run past the cutoff without bumping the amortized finish - // counter; alarm fires, prunes it, then self-terminates again. - await runInDurableObject(stub, async (instance: RunLog, state) => { - state.storage.sql.exec( - `UPDATE runs SET started_at = ?, finished_at = ?, updated_at = ? WHERE id = ?`, - expiredStartedAt, - expiredStartedAt, - expiredStartedAt, - runId, - ) - state.storage.sql.exec( - `INSERT INTO run_log_meta (key, value) VALUES (?, ?) + // Age the post-idle run past the cutoff without bumping the amortized finish + // counter; alarm fires, prunes it, then self-terminates again. + await runInDurableObject(stub, async (instance: RunLog, state) => { + state.storage.sql.exec( + `UPDATE runs SET started_at = ?, finished_at = ?, updated_at = ? WHERE id = ?`, + expiredStartedAt, + expiredStartedAt, + expiredStartedAt, + runId, + ) + state.storage.sql.exec( + `INSERT INTO run_log_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, - 'finishes_since_retention', - 0, - ) - await state.storage.deleteAlarm() - await instance.alarm() - }) - expect(await getRunRecord({ env, userId, runId })).toBeNull() - expect( - await runInDurableObject( - stub, - async (_instance: RunLog, state) => state.storage.getAlarm(), - ), - ).toBeNull() - }, -) + 'finishes_since_retention', + 0, + ) + await state.storage.deleteAlarm() + await instance.alarm() + }) + expect(await getRunRecord({ env, userId, runId })).toBeNull() + expect( + await runInDurableObject(stub, async (_instance: RunLog, state) => + state.storage.getAlarm(), + ), + ).toBeNull() +}) test('run recording degrades to a warning instead of failing the observed run', async () => { silenceIncidentalRuntimeWarnings() diff --git a/packages/worker/src/sentry-options.node.test.ts b/packages/worker/src/sentry-options.node.test.ts index 3b317d86fb..22a61af412 100644 --- a/packages/worker/src/sentry-options.node.test.ts +++ b/packages/worker/src/sentry-options.node.test.ts @@ -1,188 +1,186 @@ -import { expect, test } from "vitest"; -import { isUserCodeError, UserCodeError } from "./user-code-error.ts"; +import { expect, test } from 'vitest' +import { isUserCodeError, UserCodeError } from './user-code-error.ts' import { - executorSandboxTimeoutMessage, - filterSentryEvent, -} from "./sentry-options.ts"; + executorSandboxTimeoutMessage, + filterSentryEvent, +} from './sentry-options.ts' -test("filterSentryEvent drops expected platform and caller noise and keeps real errors", () => { - expect( - filterSentryEvent({ - exception: { - values: [ - { - value: "D1_ERROR: NOSENTRY database is locked: SQLITE_BUSY", - }, - ], - }, - }), - ).toBeNull(); +test('filterSentryEvent drops expected platform and caller noise and keeps real errors', () => { + expect( + filterSentryEvent({ + exception: { + values: [ + { + value: 'D1_ERROR: NOSENTRY database is locked: SQLITE_BUSY', + }, + ], + }, + }), + ).toBeNull() - expect( - filterSentryEvent({ - exception: { - values: [ - { value: "Currently processing a long-running export." }, - { value: "D1_ERROR: Currently processing a long-running export." }, - ], - }, - }), - ).toBeNull(); + expect( + filterSentryEvent({ + exception: { + values: [ + { value: 'Currently processing a long-running export.' }, + { value: 'D1_ERROR: Currently processing a long-running export.' }, + ], + }, + }), + ).toBeNull() - // One representative form per D1 blip family (with and without D1_ERROR: prefix). - expect( - filterSentryEvent({ - exception: { values: [{ value: "Network connection lost." }] }, - }), - ).toBeNull(); - expect( - filterSentryEvent({ - exception: { - values: [ - { - value: - "D1_ERROR: internal error; reference = 0u3odos5iotccpol68ppc0eg", - }, - ], - }, - }), - ).toBeNull(); - expect( - filterSentryEvent({ - exception: { - values: [ - { - value: - "Internal error in D1 DB storage caused object to be reset; reference = 8t4dqqpoq1ctvjr8kca8fl4c", - }, - ], - }, - }), - ).toBeNull(); + // One representative form per D1 blip family (with and without D1_ERROR: prefix). + expect( + filterSentryEvent({ + exception: { values: [{ value: 'Network connection lost.' }] }, + }), + ).toBeNull() + expect( + filterSentryEvent({ + exception: { + values: [ + { + value: + 'D1_ERROR: internal error; reference = 0u3odos5iotccpol68ppc0eg', + }, + ], + }, + }), + ).toBeNull() + expect( + filterSentryEvent({ + exception: { + values: [ + { + value: + 'Internal error in D1 DB storage caused object to be reset; reference = 8t4dqqpoq1ctvjr8kca8fl4c', + }, + ], + }, + }), + ).toBeNull() - const unrelatedNetworkLoss = { - exception: { - values: [{ value: "Network connection lost while uploading..." }], - }, - }; - expect(filterSentryEvent(unrelatedNetworkLoss)).toBe(unrelatedNetworkLoss); + const unrelatedNetworkLoss = { + exception: { + values: [{ value: 'Network connection lost while uploading...' }], + }, + } + expect(filterSentryEvent(unrelatedNetworkLoss)).toBe(unrelatedNetworkLoss) - const bareInternalError = { - exception: { values: [{ value: "internal error" }] }, - }; - expect(filterSentryEvent(bareInternalError)).toBe(bareInternalError); + const bareInternalError = { + exception: { values: [{ value: 'internal error' }] }, + } + expect(filterSentryEvent(bareInternalError)).toBe(bareInternalError) - const bareObjectReset = { - exception: { - values: [ - { - value: - "D1_ERROR: Internal error in D1 DB storage caused object to be reset", - }, - ], - }, - }; - expect(filterSentryEvent(bareObjectReset)).toBe(bareObjectReset); + const bareObjectReset = { + exception: { + values: [ + { + value: + 'D1_ERROR: Internal error in D1 DB storage caused object to be reset', + }, + ], + }, + } + expect(filterSentryEvent(bareObjectReset)).toBe(bareObjectReset) - const userModuleBuildFailure = { - exception: { - values: [ - { - value: - 'Build failed with 1 error:\nvirtual:.__kody_root__/entry.ts:11:49: ERROR: Unexpected "^"', - }, - ], - }, - }; - expect(filterSentryEvent(userModuleBuildFailure)).toBeNull(); - expect( - filterSentryEvent({ - message: - 'Build failed with 1 error:\nvirtual:.__kody_root__/entry.ts:11:49: ERROR: Unexpected "^"', - }), - ).toBeNull(); + const userModuleBuildFailure = { + exception: { + values: [ + { + value: + 'Build failed with 1 error:\nvirtual:.__kody_root__/entry.ts:11:49: ERROR: Unexpected "^"', + }, + ], + }, + } + expect(filterSentryEvent(userModuleBuildFailure)).toBeNull() + expect( + filterSentryEvent({ + message: + 'Build failed with 1 error:\nvirtual:.__kody_root__/entry.ts:11:49: ERROR: Unexpected "^"', + }), + ).toBeNull() - const sandboxTimeout = { - exception: { - values: [{ value: executorSandboxTimeoutMessage }], - }, - }; - expect(filterSentryEvent(sandboxTimeout)).toBeNull(); - expect( - filterSentryEvent({ message: executorSandboxTimeoutMessage }), - ).toBeNull(); + const sandboxTimeout = { + exception: { + values: [{ value: executorSandboxTimeoutMessage }], + }, + } + expect(filterSentryEvent(sandboxTimeout)).toBeNull() + expect( + filterSentryEvent({ message: executorSandboxTimeoutMessage }), + ).toBeNull() - const platformBuildFailure = { - exception: { - values: [ - { - value: - 'Build failed with 1 error:\npackages/worker/src/index.ts:1:0: ERROR: Unexpected "{"', - }, - ], - }, - }; - expect(filterSentryEvent(platformBuildFailure)).toBe(platformBuildFailure); + const platformBuildFailure = { + exception: { + values: [ + { + value: + 'Build failed with 1 error:\npackages/worker/src/index.ts:1:0: ERROR: Unexpected "{"', + }, + ], + }, + } + expect(filterSentryEvent(platformBuildFailure)).toBe(platformBuildFailure) - const syntaxError = { - exception: { - values: [{ value: "D1_ERROR: syntax error near INSERTZ" }], - }, - }; - expect(filterSentryEvent(syntaxError)).toBe(syntaxError); + const syntaxError = { + exception: { + values: [{ value: 'D1_ERROR: syntax error near INSERTZ' }], + }, + } + expect(filterSentryEvent(syntaxError)).toBe(syntaxError) - const webhookTimeout = { - exception: { - values: [{ value: "Webhook sync invocation timed out." }], - }, - }; - expect(filterSentryEvent(webhookTimeout)).toBe(webhookTimeout); + const webhookTimeout = { + exception: { + values: [{ value: 'Webhook sync invocation timed out.' }], + }, + } + expect(filterSentryEvent(webhookTimeout)).toBe(webhookTimeout) - const prefixedSandboxTimeout = { - exception: { - values: [{ value: `Error: ${executorSandboxTimeoutMessage}` }], - }, - }; - expect(filterSentryEvent(prefixedSandboxTimeout)).toBe( - prefixedSandboxTimeout, - ); + const prefixedSandboxTimeout = { + exception: { + values: [{ value: `Error: ${executorSandboxTimeoutMessage}` }], + }, + } + expect(filterSentryEvent(prefixedSandboxTimeout)).toBe(prefixedSandboxTimeout) - const userCodeEvent = { - exception: { - values: [{ type: "UserCodeError", value: "boom" }], - }, - }; - expect( - filterSentryEvent(userCodeEvent, { - originalException: new UserCodeError("boom"), - }), - ).toBeNull(); - expect( - filterSentryEvent(userCodeEvent, { - originalException: new Error("wrapper", { - cause: new UserCodeError("boom"), - }), - }), - ).toBeNull(); + const userCodeEvent = { + exception: { + values: [{ type: 'UserCodeError', value: 'boom' }], + }, + } + expect( + filterSentryEvent(userCodeEvent, { + originalException: new UserCodeError('boom'), + }), + ).toBeNull() + expect( + filterSentryEvent(userCodeEvent, { + originalException: new Error('wrapper', { + cause: new UserCodeError('boom'), + }), + }), + ).toBeNull() - const nestedUserCode = new Error("handler failed", { - cause: new Error("step failed", { cause: new UserCodeError("boom") }), - }); - expect(isUserCodeError(new UserCodeError("boom"))).toBe(true); - expect(isUserCodeError(nestedUserCode)).toBe(true); - expect(isUserCodeError(new Error("platform blew up"))).toBe(false); - expect(isUserCodeError("boom")).toBe(false); - expect(isUserCodeError(null)).toBe(false); + const nestedUserCode = new Error('handler failed', { + cause: new Error('step failed', { cause: new UserCodeError('boom') }), + }) + expect(isUserCodeError(new UserCodeError('boom'))).toBe(true) + expect(isUserCodeError(nestedUserCode)).toBe(true) + expect(isUserCodeError(new Error('platform blew up'))).toBe(false) + expect(isUserCodeError('boom')).toBe(false) + expect(isUserCodeError(null)).toBe(false) - const platformEvent = { - exception: { - values: [{ type: "Error", value: "Durable Object storage failed" }], - }, - }; - expect( - filterSentryEvent(platformEvent, { - originalException: new Error("Durable Object storage failed"), - }), - ).toBe(platformEvent); - expect(filterSentryEvent(platformEvent)).toBe(platformEvent); -}); + const platformEvent = { + exception: { + values: [{ type: 'Error', value: 'Durable Object storage failed' }], + }, + } + expect( + filterSentryEvent(platformEvent, { + originalException: new Error('Durable Object storage failed'), + }), + ).toBe(platformEvent) + expect(filterSentryEvent(platformEvent)).toBe(platformEvent) +}) diff --git a/packages/worker/src/usage/activation.node.test.ts b/packages/worker/src/usage/activation.node.test.ts index d894e45ffc..1c17669916 100644 --- a/packages/worker/src/usage/activation.node.test.ts +++ b/packages/worker/src/usage/activation.node.test.ts @@ -1,26 +1,26 @@ -import { readFileSync } from "node:fs"; -import { DatabaseSync } from "node:sqlite"; -import { expect, test } from "vitest"; -import { createD1FromSqlite } from "#worker/test-support/create-d1-from-sqlite.ts"; -import { consoleWarn } from "#worker/test-support/console-spies.ts"; +import { readFileSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { expect, test } from 'vitest' +import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts' +import { consoleWarn } from '#worker/test-support/console-spies.ts' import { - countsTowardPackageActivation, - recordActivationMilestone, - recordSuccessfulPackageRun, -} from "./activation.ts"; + countsTowardPackageActivation, + recordActivationMilestone, + recordSuccessfulPackageRun, +} from './activation.ts' function createActivationDb() { - const sqlite = new DatabaseSync(":memory:"); - sqlite.exec( - readFileSync( - new URL( - "../../migrations/0037-package-runtime-debug.sql", - import.meta.url, - ), - "utf8", - ), - ); - sqlite.exec(` + const sqlite = new DatabaseSync(':memory:') + sqlite.exec( + readFileSync( + new URL( + '../../migrations/0037-package-runtime-debug.sql', + import.meta.url, + ), + 'utf8', + ), + ) + sqlite.exec(` CREATE TABLE user_activation_milestones ( user_id TEXT NOT NULL, milestone TEXT NOT NULL CHECK ( @@ -37,169 +37,169 @@ function createActivationDb() { updated_at TEXT NOT NULL, PRIMARY KEY (user_id, package_id) ); - `); - return { sqlite, db: createD1FromSqlite(sqlite) }; + `) + return { sqlite, db: createD1FromSqlite(sqlite) } } function listMilestones(sqlite: DatabaseSync, userId: string) { - return sqlite - .prepare( - `SELECT milestone, package_id FROM user_activation_milestones + return sqlite + .prepare( + `SELECT milestone, package_id FROM user_activation_milestones WHERE user_id = ? ORDER BY milestone`, - ) - .all(userId) as Array<{ milestone: string; package_id: string | null }>; + ) + .all(userId) as Array<{ milestone: string; package_id: string | null }> } function listSuccessCounts(sqlite: DatabaseSync, userId: string) { - return sqlite - .prepare( - `SELECT package_id, success_count FROM user_package_run_successes + return sqlite + .prepare( + `SELECT package_id, success_count FROM user_package_run_successes WHERE user_id = ? ORDER BY package_id`, - ) - .all(userId) as Array<{ package_id: string; success_count: number }>; + ) + .all(userId) as Array<{ package_id: string; success_count: number }> } -test("activation requires the same package to succeed twice and ignores HTTP surfaces", async () => { - const { sqlite, db } = createActivationDb(); - const env = { APP_DB: db }; +test('activation requires the same package to succeed twice and ignores HTTP surfaces', async () => { + const { sqlite, db } = createActivationDb() + const env = { APP_DB: db } - expect(countsTowardPackageActivation("webhook")).toBe(false); - expect(countsTowardPackageActivation("app_fetch")).toBe(false); - expect(countsTowardPackageActivation("job")).toBe(true); - expect(countsTowardPackageActivation(undefined)).toBe(true); + expect(countsTowardPackageActivation('webhook')).toBe(false) + expect(countsTowardPackageActivation('app_fetch')).toBe(false) + expect(countsTowardPackageActivation('job')).toBe(true) + expect(countsTowardPackageActivation(undefined)).toBe(true) - await recordSuccessfulPackageRun(env, { - userId: "user-1", - packageId: "pkg-a", - surface: "job", - }); - expect(listMilestones(sqlite, "user-1").map((row) => row.milestone)).toEqual([ - "package_run_succeeded", - ]); - expect(listSuccessCounts(sqlite, "user-1")).toEqual([ - { package_id: "pkg-a", success_count: 1 }, - ]); + await recordSuccessfulPackageRun(env, { + userId: 'user-1', + packageId: 'pkg-a', + surface: 'job', + }) + expect(listMilestones(sqlite, 'user-1').map((row) => row.milestone)).toEqual([ + 'package_run_succeeded', + ]) + expect(listSuccessCounts(sqlite, 'user-1')).toEqual([ + { package_id: 'pkg-a', success_count: 1 }, + ]) - await recordSuccessfulPackageRun(env, { - userId: "user-1", - packageId: "pkg-b", - surface: "service", - }); - expect(listMilestones(sqlite, "user-1").map((row) => row.milestone)).toEqual([ - "package_run_succeeded", - ]); - expect(listSuccessCounts(sqlite, "user-1")).toEqual([ - { package_id: "pkg-a", success_count: 1 }, - { package_id: "pkg-b", success_count: 1 }, - ]); + await recordSuccessfulPackageRun(env, { + userId: 'user-1', + packageId: 'pkg-b', + surface: 'service', + }) + expect(listMilestones(sqlite, 'user-1').map((row) => row.milestone)).toEqual([ + 'package_run_succeeded', + ]) + expect(listSuccessCounts(sqlite, 'user-1')).toEqual([ + { package_id: 'pkg-a', success_count: 1 }, + { package_id: 'pkg-b', success_count: 1 }, + ]) - await recordSuccessfulPackageRun(env, { - userId: "user-http", - packageId: "pkg-a", - surface: "webhook", - }); - await recordSuccessfulPackageRun(env, { - userId: "user-http", - packageId: "pkg-a", - surface: "app_fetch", - }); - expect(listMilestones(sqlite, "user-http")).toEqual([]); - expect(listSuccessCounts(sqlite, "user-http")).toEqual([]); + await recordSuccessfulPackageRun(env, { + userId: 'user-http', + packageId: 'pkg-a', + surface: 'webhook', + }) + await recordSuccessfulPackageRun(env, { + userId: 'user-http', + packageId: 'pkg-a', + surface: 'app_fetch', + }) + expect(listMilestones(sqlite, 'user-http')).toEqual([]) + expect(listSuccessCounts(sqlite, 'user-http')).toEqual([]) - await recordSuccessfulPackageRun(env, { - userId: "user-1", - packageId: "pkg-a", - surface: "job", - }); - expect(listMilestones(sqlite, "user-1")).toEqual([ - { milestone: "package_activated", package_id: "pkg-a" }, - { milestone: "package_run_succeeded", package_id: "pkg-a" }, - ]); - expect(listSuccessCounts(sqlite, "user-1")).toEqual([ - { package_id: "pkg-a", success_count: 2 }, - { package_id: "pkg-b", success_count: 1 }, - ]); + await recordSuccessfulPackageRun(env, { + userId: 'user-1', + packageId: 'pkg-a', + surface: 'job', + }) + expect(listMilestones(sqlite, 'user-1')).toEqual([ + { milestone: 'package_activated', package_id: 'pkg-a' }, + { milestone: 'package_run_succeeded', package_id: 'pkg-a' }, + ]) + expect(listSuccessCounts(sqlite, 'user-1')).toEqual([ + { package_id: 'pkg-a', success_count: 2 }, + { package_id: 'pkg-b', success_count: 1 }, + ]) - await recordActivationMilestone(env, { - userId: "user-2", - milestone: "package_run_succeeded", - packageId: "pkg-a", - reachedAt: "2026-07-01T00:00:00.000Z", - }); - await recordActivationMilestone(env, { - userId: "user-2", - milestone: "package_run_succeeded", - packageId: "pkg-z", - reachedAt: "2026-07-09T00:00:00.000Z", - }); - const sticky = sqlite - .prepare( - `SELECT reached_at, package_id FROM user_activation_milestones + await recordActivationMilestone(env, { + userId: 'user-2', + milestone: 'package_run_succeeded', + packageId: 'pkg-a', + reachedAt: '2026-07-01T00:00:00.000Z', + }) + await recordActivationMilestone(env, { + userId: 'user-2', + milestone: 'package_run_succeeded', + packageId: 'pkg-z', + reachedAt: '2026-07-09T00:00:00.000Z', + }) + const sticky = sqlite + .prepare( + `SELECT reached_at, package_id FROM user_activation_milestones WHERE user_id = 'user-2' AND milestone = 'package_run_succeeded'`, - ) - .get() as { reached_at: string; package_id: string }; - expect(sticky).toEqual({ - reached_at: "2026-07-01T00:00:00.000Z", - package_id: "pkg-a", - }); + ) + .get() as { reached_at: string; package_id: string } + expect(sticky).toEqual({ + reached_at: '2026-07-01T00:00:00.000Z', + package_id: 'pkg-a', + }) - await recordActivationMilestone(env, { - userId: "user-fast", - milestone: "package_activated", - packageId: "pkg-a", - reachedAt: "2026-07-01T00:00:00.000Z", - }); - const prepareCalls: Array = []; - const originalPrepare = db.prepare.bind(db); - db.prepare = ((query: string) => { - prepareCalls.push(query.replace(/\s+/g, " ").trim()); - return originalPrepare(query); - }) as typeof db.prepare; - await recordSuccessfulPackageRun(env, { - userId: "user-fast", - packageId: "pkg-a", - surface: "job", - }); - await recordSuccessfulPackageRun(env, { - userId: "user-fast", - packageId: "pkg-b", - surface: "workflow", - }); - expect(prepareCalls).toEqual([ - "SELECT 1 AS n FROM user_activation_milestones WHERE user_id = ?1 AND milestone = ?2", - "SELECT 1 AS n FROM user_activation_milestones WHERE user_id = ?1 AND milestone = ?2", - ]); - expect(listSuccessCounts(sqlite, "user-fast")).toEqual([]); -}); + await recordActivationMilestone(env, { + userId: 'user-fast', + milestone: 'package_activated', + packageId: 'pkg-a', + reachedAt: '2026-07-01T00:00:00.000Z', + }) + const prepareCalls: Array = [] + const originalPrepare = db.prepare.bind(db) + db.prepare = ((query: string) => { + prepareCalls.push(query.replace(/\s+/g, ' ').trim()) + return originalPrepare(query) + }) as typeof db.prepare + await recordSuccessfulPackageRun(env, { + userId: 'user-fast', + packageId: 'pkg-a', + surface: 'job', + }) + await recordSuccessfulPackageRun(env, { + userId: 'user-fast', + packageId: 'pkg-b', + surface: 'workflow', + }) + expect(prepareCalls).toEqual([ + 'SELECT 1 AS n FROM user_activation_milestones WHERE user_id = ?1 AND milestone = ?2', + 'SELECT 1 AS n FROM user_activation_milestones WHERE user_id = ?1 AND milestone = ?2', + ]) + expect(listSuccessCounts(sqlite, 'user-fast')).toEqual([]) +}) -test("activation instrumentation never throws without a database or on write failure", async () => { - await expect( - recordSuccessfulPackageRun({}, { userId: "user-1", packageId: "pkg-a" }), - ).resolves.toBeUndefined(); - await expect( - recordActivationMilestone( - {}, - { userId: "user-1", milestone: "package_activated" }, - ), - ).resolves.toBeUndefined(); +test('activation instrumentation never throws without a database or on write failure', async () => { + await expect( + recordSuccessfulPackageRun({}, { userId: 'user-1', packageId: 'pkg-a' }), + ).resolves.toBeUndefined() + await expect( + recordActivationMilestone( + {}, + { userId: 'user-1', milestone: 'package_activated' }, + ), + ).resolves.toBeUndefined() - consoleWarn.mockImplementation(() => {}); - const env = { - APP_DB: { - prepare() { - throw new Error("d1 unavailable"); - }, - } as unknown as D1Database, - }; - await expect( - recordSuccessfulPackageRun(env, { - userId: "user-1", - packageId: "pkg-a", - surface: "job", - }), - ).resolves.toBeUndefined(); - expect(consoleWarn).toHaveBeenCalledWith( - "activation-run-record-failed", - expect.any(Error), - ); -}); + consoleWarn.mockImplementation(() => {}) + const env = { + APP_DB: { + prepare() { + throw new Error('d1 unavailable') + }, + } as unknown as D1Database, + } + await expect( + recordSuccessfulPackageRun(env, { + userId: 'user-1', + packageId: 'pkg-a', + surface: 'job', + }), + ).resolves.toBeUndefined() + expect(consoleWarn).toHaveBeenCalledWith( + 'activation-run-record-failed', + expect.any(Error), + ) +})