diff --git a/e2e/account-activity.spec.ts b/e2e/account-activity.spec.ts index 87bffddcdb..8d33fda361 100644 --- a/e2e/account-activity.spec.ts +++ b/e2e/account-activity.spec.ts @@ -1,7 +1,7 @@ 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 ({ +test('account activity shows filters for a seeded account', async ({ page, login, }) => { @@ -16,19 +16,7 @@ test('account activity shows empty state and filters for a seeded account', asyn 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() }) diff --git a/e2e/account-jobs.spec.ts b/e2e/account-jobs.spec.ts index 8e90daa66a..899aa5affd 100644 --- a/e2e/account-jobs.spec.ts +++ b/e2e/account-jobs.spec.ts @@ -69,7 +69,4 @@ test('account jobs detail URL persists across reload for a seeded job', async ({ await expect( page.getByRole('heading', { name: jobName, exact: true }), ).toBeVisible() - await expect( - page.getByRole('definition').filter({ hasText: 'Runs every 1h' }), - ).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..6de99e1c23 100644 --- a/packages/worker/src/app/account-activity-data.node.test.ts +++ b/packages/worker/src/app/account-activity-data.node.test.ts @@ -65,7 +65,7 @@ function makeRun(overrides: Partial = {}): RunRecord { } } -test('readAccountActivityFilters defaults to errors across all surfaces', () => { +test('activity helpers parse filters and prefer path selected run ids', () => { expect( readAccountActivityFilters('https://example.com/account/activity'), ).toEqual({ @@ -86,9 +86,7 @@ test('readAccountActivityFilters defaults to errors across all surfaces', () => 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', @@ -107,7 +105,7 @@ test('readAccountActivitySelectedRunId prefers path params over query', () => { ).toBe('run-query') }) -test('loadAccountActivityData maps filters, summary, pagination, and detail', async () => { +test('loadAccountActivityData maps filters, summary, pagination, detail, and cursors', async () => { const run = makeRun() mockModule.summarizeRunRecords.mockResolvedValue({ since: '2026-07-19T12:00:00.000Z', @@ -199,16 +197,7 @@ test('loadAccountActivityData maps filters, summary, pagination, and detail', as ], }), }) -}) -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, 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..254af009c1 100644 --- a/packages/worker/src/app/account-billing-data.node.test.ts +++ b/packages/worker/src/app/account-billing-data.node.test.ts @@ -54,15 +54,13 @@ function createBillingTestDb(input: { } as unknown as D1Database } -test('resolveBillingErrorMessage maps known codes and passes through unknown', () => { - expect(resolveBillingErrorMessage('no_customer')).toContain('Subscribe first') +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', @@ -100,37 +98,20 @@ test('loadAccountBillingData passes subscriptionStatus from Stripe refresh', asy customerId: 'cus_test', }), ) -}) -test('loadAccountBillingData leaves subscriptionStatus null when refresh fails', async () => { consoleError.mockImplementation(() => {}) refreshStripePlanForUser.mockRejectedValueOnce(new 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') + 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' }), ) -}) -test('loadAccountBillingData skips refresh when there is no Stripe customer', async () => { refreshStripePlanForUser.mockClear() - - const env = { + const noCustomerEnv = { APP_DB: createBillingTestDb({ plan: 'free', stripePlan: null, @@ -139,10 +120,12 @@ test('loadAccountBillingData skips refresh when there is no Stripe customer', as 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) + 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..562475c1f2 100644 --- a/packages/worker/src/app/auth-denial-alerts.node.test.ts +++ b/packages/worker/src/app/auth-denial-alerts.node.test.ts @@ -41,18 +41,16 @@ function createDb(count: number) { } as unknown as D1Database } -test('shouldRunAuthDenialAlertCron runs on the hour', () => { +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({ + const quiet = await checkAuthDenialBurstAndNotify({ env: { APP_DB: createDb(10), APP_BASE_URL: 'https://heykody.dev', @@ -61,12 +59,9 @@ test('checkAuthDenialBurstAndNotify stays quiet below threshold', async () => { }, threshold: 50, }) - expect(result).toEqual({ status: 'below_threshold', count: 10 }) + 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 = { diff --git a/packages/worker/src/app/auth-session.node.test.ts b/packages/worker/src/app/auth-session.node.test.ts index 966375b6ad..19377ff7a4 100644 --- a/packages/worker/src/app/auth-session.node.test.ts +++ b/packages/worker/src/app/auth-session.node.test.ts @@ -9,7 +9,7 @@ import { parsePasswordChangedAtMs } from '#app/request-auth-cache.ts' const testCookieSecret = 'test-cookie-secret-0123456789abcdef0123456789' -test('createAuthCookie always stamps issuedAt for password-change checks', async () => { +test('createAuthCookie stamps issuedAt for password-change invalidation', async () => { setAuthSessionSecret(testCookieSecret) const now = 1_700_000_000_000 const cookie = await createAuthCookie( @@ -25,7 +25,12 @@ test('createAuthCookie always stamps issuedAt for password-change checks', async expect(parsed?.session.rememberMe).toBe(false) }) -test('isAuthSessionInvalidatedByPasswordChange fails closed for legacy cookies', () => { +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, @@ -56,45 +61,40 @@ test('isAuthSessionInvalidatedByPasswordChange fails closed for legacy cookies', passwordChangedAtMs: 100, }), ).toBe(false) -}) -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) + 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: changedAtMs, + passwordChangedAtMs: secondPrecisionChangedAt, }), ).toBe(true) expect( isAuthSessionInvalidatedByPasswordChange({ issuedAt: Date.parse('2026-07-25T12:00:01.000Z'), - passwordChangedAtMs: changedAtMs, + passwordChangedAtMs: secondPrecisionChangedAt, }), ).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')) + 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: changedAtMs, + passwordChangedAtMs: msPrecisionChangedAt, }), ).toBe(true) expect( isAuthSessionInvalidatedByPasswordChange({ issuedAt: Date.parse('2026-07-25T12:00:00.500Z'), - passwordChangedAtMs: changedAtMs, + passwordChangedAtMs: msPrecisionChangedAt, }), ).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() -}) 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..c5d321a653 100644 --- a/packages/worker/src/app/email-delivery-alerts.node.test.ts +++ b/packages/worker/src/app/email-delivery-alerts.node.test.ts @@ -41,18 +41,16 @@ function createDb(count: number) { } as unknown as D1Database } -test('shouldRunEmailDeliveryAlertCron runs on the hour', () => { +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({ + const quiet = await checkEmailDeliveryBurstAndNotify({ env: { APP_DB: createDb(10), APP_BASE_URL: 'https://heykody.dev', @@ -61,12 +59,9 @@ test('checkEmailDeliveryBurstAndNotify stays quiet below threshold', async () => }, threshold: 20, }) - expect(result).toEqual({ status: 'below_threshold', count: 10 }) + 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 = { @@ -107,37 +102,13 @@ test('checkEmailDeliveryBurstAndNotify emails admins and respects cooldown', asy }) expect(second).toEqual({ status: 'cooldown', count: 35 }) expect(sendCloudflareEmail).toHaveBeenCalledTimes(1) - } finally { - consoleWarn.mockReset() - } -}) -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 { + kvStore.clear() + sendCloudflareEmail.mockRejectedValueOnce(new Error('cf email down')) await expect( checkEmailDeliveryBurstAndNotify({ env, - now: new Date('2026-07-25T12:00:00.000Z'), + now: new Date('2026-07-25T13:00:00.000Z'), threshold: 20, }), ).rejects.toThrow('cf email down') 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..6921886f90 100644 --- a/packages/worker/src/app/handlers/account-memories.node.test.ts +++ b/packages/worker/src/app/handlers/account-memories.node.test.ts @@ -99,7 +99,7 @@ function createEnv() { } as Env } -test('memories API lists user-scoped memories', async () => { +test('memories API lists, filters, and selects user-scoped memories', async () => { const handler = createAccountMemoriesApiHandler(createEnv()) const listResponse = await handler.handler({ @@ -136,19 +136,15 @@ test('memories API lists user-scoped memories', async () => { query: '', includeDeleted: false, }) -}) - -test('memories API supports q and includeDeleted filters', async () => { - const handler = createAccountMemoriesApiHandler(createEnv()) - const response = await handler.handler({ + 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(response.status).toBe(200) + expect(filtered.status).toBe(200) expect(mockModule.listMemoriesByUserId).toHaveBeenCalledWith( expect.anything(), 'stable-user-1', @@ -162,7 +158,7 @@ test('memories API supports q and includeDeleted filters', async () => { memoryId: memoryRow.id, }), ) - const payload = (await response.json()) as { + const payload = (await filtered.json()) as { ok: boolean query: string includeDeleted: boolean @@ -181,7 +177,7 @@ test('memories API supports q and includeDeleted filters', async () => { ) }) -test('memories API delete action soft-deletes by default and supports force', async () => { +test('memories API soft/force deletes and rejects invalid delete requests', async () => { const handler = createAccountMemoriesApiHandler(createEnv()) const softResponse = await handler.handler({ @@ -225,10 +221,6 @@ test('memories API delete action soft-deletes by default and supports force', as force: true, }), ) -}) - -test('memories API reject missing memoryId and unknown actions', async () => { - const handler = createAccountMemoriesApiHandler(createEnv()) const missingId = await handler.handler({ request: new Request('https://example.com/account/memories.json', { 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..f79a9cfabc 100644 --- a/packages/worker/src/app/handlers/account-values.node.test.ts +++ b/packages/worker/src/app/handlers/account-values.node.test.ts @@ -95,13 +95,12 @@ function createEnv() { } as Env } -test('values API lists user-scoped values and hides reserved prefixes', async () => { +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({ @@ -126,45 +125,23 @@ test('values API lists user-scoped values and hides reserved prefixes', async () storageContext: { sessionId: null, appId: null }, }), ) -}) -test('values API resolves selected value from selected query param', async () => { - const handler = createAccountValuesApiHandler(createEnv()) - - const response = await handler.handler({ + const selectedResponse = await handler.handler({ request: new Request( 'https://example.com/account/values.json?selected=theme', ), }) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ + expect(selectedResponse.status).toBe(200) + await expect(selectedResponse.json()).resolves.toMatchObject({ ok: true, - values: [ - { - id: 'theme', - name: 'theme', - description: 'UI theme preference', - valuePreview: 'dark', - updatedAt: new Date(0).toISOString(), - ttlMs: null, - }, - ], - selectedValue: { + selectedValueId: 'theme', + selectedValue: expect.objectContaining({ 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 saves a value and returns selectedValueId', async () => { mockModule.listValues.mockResolvedValueOnce([ { name: 'locale', @@ -177,8 +154,6 @@ test('values API saves a value and returns selectedValueId', async () => { ttlMs: null, }, ]) - const handler = createAccountValuesApiHandler(createEnv()) - const saveResponse = await handler.handler({ request: new Request('https://example.com/account/values.json', { method: 'POST', @@ -191,7 +166,6 @@ test('values API saves a value and returns selectedValueId', async () => { }), }), }) - expect(saveResponse.status).toBe(200) expect(mockModule.saveValue).toHaveBeenCalledWith( expect.objectContaining({ @@ -204,36 +178,13 @@ test('values API saves a value and returns selectedValueId', async () => { userEmail: 'user@example.com', }), ) - expect(await saveResponse.json()).toEqual({ + expect(await saveResponse.json()).toMatchObject({ 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, - }, - ], + selectedValue: expect.objectContaining({ id: 'locale', value: 'en-US' }), }) -}) -test('values API deletes a value by name', async () => { mockModule.listValues.mockResolvedValueOnce([]) - const handler = createAccountValuesApiHandler(createEnv()) - const deleteResponse = await handler.handler({ request: new Request('https://example.com/account/values.json', { method: 'POST', @@ -244,13 +195,11 @@ test('values API deletes a value by name', async () => { }), }), }) - expect(deleteResponse.status).toBe(200) expect(mockModule.deleteValue).toHaveBeenCalledWith( expect.objectContaining({ userId: 'stable-user-1', scope: 'user', - storageContext: { sessionId: null, appId: null }, name: 'theme', }), ) @@ -262,12 +211,12 @@ test('values API deletes a value by name', async () => { }) }) -test('values API rejects save and delete for reserved names without mutating', async () => { +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 saveResponse = await handler.handler({ + const reservedSave = await handler.handler({ request: new Request('https://example.com/account/values.json', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -279,14 +228,14 @@ test('values API rejects save and delete for reserved names without mutating', a }), }), }) - expect(saveResponse.status).toBe(400) - await expect(saveResponse.json()).resolves.toMatchObject({ + expect(reservedSave.status).toBe(400) + await expect(reservedSave.json()).resolves.toMatchObject({ ok: false, error: expect.stringContaining('reserved'), }) expect(mockModule.saveValue).not.toHaveBeenCalled() - const deleteResponse = await handler.handler({ + const reservedDelete = await handler.handler({ request: new Request('https://example.com/account/values.json', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -296,19 +245,12 @@ test('values API rejects save and delete for reserved names without mutating', a }), }), }) - expect(deleteResponse.status).toBe(400) - await expect(deleteResponse.json()).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining('reserved'), - }) + expect(reservedDelete.status).toBe(400) + await expect(reservedDelete.json()).resolves.toMatchObject({ ok: false }) 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({ + const missingDelete = await handler.handler({ request: new Request('https://example.com/account/values.json', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -318,16 +260,8 @@ test('values API returns 404 when deleting a missing value', async () => { }), }), }) - - 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()) + 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', { @@ -337,10 +271,7 @@ test('values API rejects invalid actions and unauthenticated requests', async () }), }) expect(invalid.status).toBe(400) - await expect(invalid.json()).resolves.toEqual({ - ok: false, - error: 'Invalid action.', - }) + await expect(invalid.json()).resolves.toMatchObject({ ok: false }) mockModule.readAuthenticatedAppUser.mockResolvedValueOnce(null) const unauthorized = await handler.handler({ 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..7fcfebf084 100644 --- a/packages/worker/src/billing/stripe-webhook-signature.node.test.ts +++ b/packages/worker/src/billing/stripe-webhook-signature.node.test.ts @@ -13,18 +13,15 @@ const fixtureTimestamp = 1_700_000_000 const fixtureSignature = '850a8305dd40d37b2ad74f83d4a32d429fe9a017a6db9ac082e7ea2c3b430140' -test('computeStripeWebhookSignature matches known HMAC fixture', async () => { +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}` - await expect( verifyStripeWebhookSignature({ secret: fixtureSecret, 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/d1-retry.node.test.ts b/packages/worker/src/d1-retry.node.test.ts index 6409082169..4fe770d034 100644 --- a/packages/worker/src/d1-retry.node.test.ts +++ b/packages/worker/src/d1-retry.node.test.ts @@ -11,31 +11,14 @@ test('runD1WithRetry matches lock errors, retries them, and rethrows other failu 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( @@ -43,18 +26,6 @@ test('runD1WithRetry matches lock errors, retries them, and rethrows other failu ), ), ).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( @@ -62,23 +33,16 @@ test('runD1WithRetry matches lock errors, retries them, and rethrows other failu ), ), ).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'), + 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 while applying migration; reference = abc', + 'D1_ERROR: Internal error in D1 DB storage caused object to be reset', ), ), ).toBe(false) @@ -106,72 +70,6 @@ test('runD1WithRetry matches lock errors, retries them, and rethrows other failu 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')) 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..b011d76cba 100644 --- a/packages/worker/src/entitlements/package-service-states.workers.test.ts +++ b/packages/worker/src/entitlements/package-service-states.workers.test.ts @@ -39,7 +39,7 @@ async function upsertServiceState(input: { .run() } -test('countRunningPackageServices counts fresh running rows and ignores idle/stopped/error/stale', async () => { +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( @@ -106,7 +106,6 @@ test('countRunningPackageServices counts fresh running rows and ignores idle/sto now, }), ).toBe(1) - expect( await countRunningPackageServices({ db: env.APP_DB, @@ -132,25 +131,20 @@ test('countRunningPackageServices counts fresh running rows and ignores idle/sto excludeService: { packageId: 'pkg-a', serviceName: 'alpha' }, }), ).toBe(1) -}) -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) + const limitUserId = await createStableUserIdFromEmail(email) await seedAccount({ db: env.APP_DB, email, username: `svc-limit-${crypto.randomUUID().slice(0, 8)}`, plan: 'pro', - stableUserId: userId, + stableUserId: limitUserId, }) - 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, + userId: limitUserId, packageId: `pkg-${index}`, serviceName: 'worker', status: 'running', @@ -161,7 +155,7 @@ test('package_services entitlement enforces at the plan boundary from package_se const denied = await assertWithinEntitlement({ db: env.APP_DB, - userId, + userId: limitUserId, email, resource: 'package_services', now, @@ -183,14 +177,14 @@ test('package_services entitlement enforces at the plan boundary from package_se await assertWithinEntitlement({ db: env.APP_DB, - userId, + userId: limitUserId, email, resource: 'package_services', now, getCurrent: async () => await countRunningPackageServices({ db: env.APP_DB, - userId, + 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 0289cb4458..17086bcc99 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,56 @@ 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, + id: 'run-job-err', + startedAt: new Date(startedAtBase).toISOString(), context: baseContext({ surface: 'job', - jobId: 'job-logs', + 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 +161,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, + (entry) => entry.surface === 'job' && entry.total >= 1, ), ).toBe(true) - expect( - summary.by_surface.some( - (entry) => entry.surface === 'webhook' && 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/mcp/observability.node.test.ts b/packages/worker/src/mcp/observability.node.test.ts index 144bfc0fad..35b6f101e9 100644 --- a/packages/worker/src/mcp/observability.node.test.ts +++ b/packages/worker/src/mcp/observability.node.test.ts @@ -173,9 +173,25 @@ test('logMcpEvent keeps sandbox and caller failures off Sentry and still reports 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(2) + expect(sentryMock.captureException).toHaveBeenCalledTimes(3) expect(sentryMock.captureException).toHaveBeenNthCalledWith( 1, expect.objectContaining({ message: 'platform handler blew up' }), @@ -184,6 +200,12 @@ test('logMcpEvent keeps sandbox and caller failures off Sentry and still reports 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( @@ -195,32 +217,6 @@ test('logMcpEvent keeps sandbox and caller failures off Sentry and still reports 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({ @@ -231,4 +227,5 @@ test('logMcpEvent copies conversationId, storageId, and detail into Sentry mcp c }, }), ) + 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..00c0e89ac2 100644 --- a/packages/worker/src/package-invocations/common.node.test.ts +++ b/packages/worker/src/package-invocations/common.node.test.ts @@ -5,16 +5,13 @@ import { resolveInvocationRuntimeSurface, } from './common.ts' -test('resolveInvocationRuntimeSurface omits package-workflow so the outer workflow owns the run record', () => { +test('invocation runtime surface and name map selectors without double-counting package workflows', () => { expect( resolveInvocationRuntimeSurface({ selector: { kind: 'export', exportName: './run' }, source: packageWorkflowInvocationSource, }), ).toBeNull() -}) - -test('resolveInvocationRuntimeSurface maps export and subscription selectors', () => { expect( resolveInvocationRuntimeSurface({ selector: { kind: 'export', exportName: './run' }, @@ -27,9 +24,7 @@ test('resolveInvocationRuntimeSurface maps export and subscription selectors', ( source: 'email', }), ).toBe('subscription') -}) -test('resolveInvocationRuntimeName prefers topic for subscription surfaces', () => { expect( resolveInvocationRuntimeName({ surface: 'subscription', 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..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 @@ -17,11 +17,7 @@ vi.mock('@sentry/cloudflare', () => ({ const { applyDynamicWorkflowSentryScope } = 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: { @@ -58,9 +54,7 @@ test('applyDynamicWorkflowSentryScope tags package workflows with user and coord idempotencyKey: 'idem-1', }), ) -}) -test('applyDynamicWorkflowSentryScope records inline code size without package tags', () => { sentryMock.setUser.mockClear() sentryMock.setTag.mockClear() sentryMock.setContext.mockClear() 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..9bd9611347 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,377 +403,337 @@ 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() - insertRunRow(state, { - id: `error-${index}`, - status: 'error', - startedAt, - finishedAt: startedAt, - }) - } - }) - 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' }), +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) } - 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() + // --- 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: `success-${index}`, - status: 'success', - startedAt, - finishedAt: startedAt, + id: 'still-running', + status: 'running', + startedAt: new Date(baseMs + successCount).toISOString(), + 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, + }) + } }) - 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', - }) - - const running = await getRunRecord({ env, userId, runId: 'still-running' }) - expect(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) -}) + 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) + } -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() + // --- 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, - }) - } - for (let index = 0; index < staleCount; index += 1) { - insertRunRow(state, { - id: `stale-${index}`, + id: 'stale-running', status: 'running', - startedAt: new Date(Date.parse(staleStartedAt) + index).toISOString(), + startedAt: staleStartedAt, 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, + id: 'fresh-running', + status: 'running', + startedAt: freshStartedAt, + finishedAt: null, }) - } - }) - 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: 'stale-trigger', + userId, + startedAt: new Date().toISOString(), + persistence: 'eager', + context: baseContext({ surface: 'job', name: 'stale-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 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() + } -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, + // --- 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, + }) + } }) - insertRunRow(state, { - id: 'fresh-running', - status: 'running', - startedAt: freshStartedAt, - finishedAt: null, + 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', }) - }) - 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() -}) + // 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('run recording degrades to a warning instead of failing the observed run', async () => { - silenceIncidentalRuntimeWarnings() - const userId = uniqueUserId('never-throws') - expect( - beginRunRecord({ + // --- 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: 'old-success', + status: 'success', + startedAt: oldStartedAt, + finishedAt: oldStartedAt, + }) + insertRunRow(state, { + id: 'recent-success', + status: 'success', + startedAt: recentStartedAt, + finishedAt: recentStartedAt, + }) + }) + await armRetentionOnNextFinish(userId, 2) + await finishRunRecord({ env, - userId, - context: baseContext({ surface: 'not-a-surface' as RunSurface }), - }), - ).toBeNull() - - // `user_activation_milestones` is absent from this suite's D1 schema, so the - // activation milestone write fans out into a failure the run must survive. - await finishRunRecord({ - env, - handle: { - id: crypto.randomUUID(), - userId, - startedAt: new Date().toISOString(), - persistence: 'eager', - context: baseContext({ - surface: 'subscription', - name: 'email.message.received', - packageId: 'package-1', - }), - }, - status: 'success', - }) - - expect(consoleWarn.mock.calls.map(([message]) => message)).toEqual([ - 'run-record-begin-failed', - 'activation-run-record-failed', - ]) - const page = await listRunRecords({ env, userId }) - expect(page.runs).toHaveLength(1) - expect(page.runs[0]?.status).toBe('success') + 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') + } }) -test('fresh finishes arm a far-future retention alarm for the new row', async () => { - const userId = uniqueUserId('far-alarm') +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: crypto.randomUUID(), + id: 'first-run', userId, startedAt: new Date(startedAtMs).toISOString(), persistence: 'eager', @@ -811,81 +741,39 @@ test('fresh finishes arm a far-future retention alarm for the new row', async () }, status: 'success', }) - const alarmAt = await runInDurableObject( + const initialAlarm = await runInDurableObject( stub, async (_instance: RunLog, state) => state.storage.getAlarm(), ) - expect(alarmAt).toBeTypeOf('number') + expect(initialAlarm).toBeTypeOf('number') // One-shot at the row's age deadline — not an immediate/hourly wake. - expect(alarmAt).toBeGreaterThan( + expect(initialAlarm).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, + // 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) => { - 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, + `UPDATE runs SET started_at = ?, finished_at = ?, updated_at = ? WHERE id = ?`, + expiredStartedAt, + expiredStartedAt, + expiredStartedAt, + 'first-run', ) - // 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, + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + 'finishes_since_retention', + 0, ) await state.storage.deleteAlarm() await instance.alarm() }) - const idle = await runInDurableObject( + const idleState = await runInDurableObject( stub, async (_instance: RunLog, state) => ({ alarmAt: await state.storage.getAlarm(), @@ -894,9 +782,10 @@ test('write after idle re-arms alarm and age-prunes without the finish counter', .one().n, }), ) - expect(idle.remaining).toBe(0) - expect(idle.alarmAt).toBeNull() + 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, @@ -909,17 +798,14 @@ test('write after idle re-arms alarm and age-prunes without the finish counter', }, status: 'success', }) - - const armed = await runInDurableObject( + const reArmedAlarm = await runInDurableObject( stub, async (_instance: RunLog, state) => state.storage.getAlarm(), ) - expect(armed).toBeTypeOf('number') + expect(reArmedAlarm).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() + // 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 = ?`, @@ -930,21 +816,57 @@ test('write after idle re-arms alarm and age-prunes without the finish counter', ) state.storage.sql.exec( `INSERT INTO run_log_meta (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + 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() +}) - 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('run recording degrades to a warning instead of failing the observed run', async () => { + silenceIncidentalRuntimeWarnings() + const userId = uniqueUserId('never-throws') + expect( + beginRunRecord({ + env, + userId, + context: baseContext({ surface: 'not-a-surface' as RunSurface }), + }), + ).toBeNull() + + // `user_activation_milestones` is absent from this suite's D1 schema, so the + // activation milestone write fans out into a failure the run must survive. + await finishRunRecord({ + env, + handle: { + id: crypto.randomUUID(), + userId, + startedAt: new Date().toISOString(), + persistence: 'eager', + context: baseContext({ + surface: 'subscription', + name: 'email.message.received', + packageId: 'package-1', + }), + }, + status: 'success', + }) + + expect(consoleWarn.mock.calls.map(([message]) => message)).toEqual([ + 'run-record-begin-failed', + 'activation-run-record-failed', + ]) + const page = await listRunRecords({ env, userId }) + expect(page.runs).toHaveLength(1) + expect(page.runs[0]?.status).toBe('success') }) test('clearRunRecords empties the Durable Object', async () => { diff --git a/packages/worker/src/sentry-options.node.test.ts b/packages/worker/src/sentry-options.node.test.ts index bd1bd09edb..22a61af412 100644 --- a/packages/worker/src/sentry-options.node.test.ts +++ b/packages/worker/src/sentry-options.node.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest' -import { UserCodeError } from './user-code-error.ts' +import { isUserCodeError, UserCodeError } from './user-code-error.ts' import { executorSandboxTimeoutMessage, filterSentryEvent, @@ -22,32 +22,19 @@ test('filterSentryEvent drops expected platform and caller noise and keeps real filterSentryEvent({ exception: { values: [ - { - value: 'Currently processing a long-running export.', - }, - { - value: 'D1_ERROR: Currently processing a long-running export.', - }, + { 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.' }], - }, + exception: { values: [{ value: 'Network connection lost.' }] }, }), ).toBeNull() - expect( - filterSentryEvent({ - exception: { - values: [{ value: 'D1_ERROR: Network connection lost.' }], - }, - }), - ).toBeNull() - expect( filterSentryEvent({ exception: { @@ -60,27 +47,6 @@ test('filterSentryEvent drops expected platform and caller noise and keeps real }, }), ).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: { @@ -102,9 +68,7 @@ test('filterSentryEvent drops expected platform and caller noise and keeps real expect(filterSentryEvent(unrelatedNetworkLoss)).toBe(unrelatedNetworkLoss) const bareInternalError = { - exception: { - values: [{ value: 'internal error' }], - }, + exception: { values: [{ value: 'internal error' }] }, } expect(filterSentryEvent(bareInternalError)).toBe(bareInternalError) @@ -180,38 +144,43 @@ test('filterSentryEvent drops expected platform and caller noise and keeps real }, } expect(filterSentryEvent(prefixedSandboxTimeout)).toBe(prefixedSandboxTimeout) -}) -test('filterSentryEvent drops UserCodeError via originalException hint', () => { - const event = { + const userCodeEvent = { exception: { values: [{ type: 'UserCodeError', value: 'boom' }], }, } expect( - filterSentryEvent(event, { + filterSentryEvent(userCodeEvent, { originalException: new UserCodeError('boom'), }), ).toBeNull() expect( - filterSentryEvent(event, { + filterSentryEvent(userCodeEvent, { originalException: new Error('wrapper', { cause: new UserCodeError('boom'), }), }), ).toBeNull() -}) -test('filterSentryEvent keeps genuine platform errors', () => { - const event = { + 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(event, { + filterSentryEvent(platformEvent, { originalException: new Error('Durable Object storage failed'), }), - ).toBe(event) - expect(filterSentryEvent(event)).toBe(event) + ).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..1c17669916 100644 --- a/packages/worker/src/usage/activation.node.test.ts +++ b/packages/worker/src/usage/activation.node.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from 'node:fs' import { DatabaseSync } from 'node:sqlite' -import { expect, test, vi } from 'vitest' +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, @@ -58,10 +59,15 @@ function listSuccessCounts(sqlite: DatabaseSync, userId: string) { .all(userId) as Array<{ package_id: string; success_count: number }> } -test('activation requires the same package to succeed twice on the durable counter', async () => { +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', @@ -74,8 +80,6 @@ test('activation requires the same package to succeed twice on the durable count { 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', @@ -89,6 +93,19 @@ test('activation requires the same package to succeed twice on the durable count { 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', @@ -102,11 +119,29 @@ test('activation requires the same package to succeed twice on the durable count { 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-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', + }) await recordActivationMilestone(env, { userId: 'user-fast', @@ -114,14 +149,12 @@ test('terminal package_activated fast path does not re-query or rewrite counters 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', @@ -132,75 +165,14 @@ test('terminal package_activated fast path does not re-query or rewrite counters 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 - 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 () => { +test('activation instrumentation never throws without a database or on write failure', async () => { await expect( recordSuccessfulPackageRun({}, { userId: 'user-1', packageId: 'pkg-a' }), ).resolves.toBeUndefined() @@ -210,10 +182,8 @@ test('activation instrumentation never throws without a database', async () => { { 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(() => {}) + consoleWarn.mockImplementation(() => {}) const env = { APP_DB: { prepare() { @@ -228,9 +198,8 @@ test('activation write failures never throw to the caller', async () => { surface: 'job', }), ).resolves.toBeUndefined() - expect(warnSpy).toHaveBeenCalledWith( + expect(consoleWarn).toHaveBeenCalledWith( 'activation-run-record-failed', expect.any(Error), ) - warnSpy.mockRestore() }) 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) -})