diff --git a/scripts/mutation/equivalent-mutants.txt b/scripts/mutation/equivalent-mutants.txt index 892e20967..a23253b50 100644 --- a/scripts/mutation/equivalent-mutants.txt +++ b/scripts/mutation/equivalent-mutants.txt @@ -1040,8 +1040,8 @@ src/shared/cache-registry.ts:126:47 ?? → || # get(): Set|undefined — a Se # Stripe request null checks: both values are typed as string|null, never undefined, # so strict and loose null equality agree for every possible input. -src/shared/stripe/request.ts:89:17 === → == # retryAfter is string|null; both comparisons are true only for null -src/shared/stripe/request.ts:151:15 === → == # Headers.get returns string|null; both comparisons are true only for null +src/shared/stripe/request.ts:90:17 === → == # retryAfter is string|null; both comparisons are true only for null +src/shared/stripe/request.ts:152:15 === → == # Headers.get returns string|null; both comparisons are true only for null # A header part without "=" has no value. Its key cannot affect timestamp or # signature state: a timestamp assignment remains undefined and a v1 signature diff --git a/src/shared/db/activity-log-backfill.ts b/src/shared/db/activity-log-backfill.ts index a126cd3d4..ad11b2b30 100644 --- a/src/shared/db/activity-log-backfill.ts +++ b/src/shared/db/activity-log-backfill.ts @@ -7,21 +7,16 @@ * with the env key, re-encrypt under the owner's public key, write it back. * Only the public key is needed (no password), so it runs unattended. * - * It is resumable without a cursor: a converted row no longer matches the - * `enc:` prefix, so each batch shrinks the remaining set. A batch that finds - * nothing leaves the task disabled until more legacy data exists. + * It is resumable without a row cursor: a converted row no longer matches the + * `enc:` prefix, so each batch shrinks the remaining set. The maintenance task + * stores a completion checkpoint after the final batch, avoiding future scans. */ /* jscpd:ignore-start */ import { decrypt, ENCRYPTION_PREFIX } from "#shared/crypto/encryption.ts"; import { encryptWithOwnerKey } from "#shared/crypto/keys.ts"; import type { EnvKeyEncrypted } from "#shared/crypto/sealed.ts"; -import { - executeBatch, - queryAll, - rowExists, - update, -} from "#shared/db/client.ts"; +import { executeBatch, queryAll, update } from "#shared/db/client.ts"; import { ACTIVITY_LOG_BACKFILL_BATCH } from "#shared/limits.ts"; import { logDebug } from "#shared/logger.ts"; @@ -32,6 +27,8 @@ import { logDebug } from "#shared/logger.ts"; // legacy env-key ciphertext. type LegacyRow = { id: number; message: EnvKeyEncrypted }; +export const ACTIVITY_LOG_BACKFILL_COMPLETE = "complete"; + /** * Re-encrypt one batch of legacy env-key rows to the owner key. Returns the * number of rows converted (0 when none remain). All the rewrites land in a @@ -64,16 +61,12 @@ export const backfillActivityLogBatch = async ( return rows.length; }; -export const hasLegacyActivityLog = (): Promise => - rowExists("SELECT 1 FROM activity_log WHERE message LIKE ? LIMIT 1", [ - `${ENCRYPTION_PREFIX}%`, - ]); - export const runActivityLogBackfill = async ( publicKey: string, -): Promise => { +): Promise => { const converted = await backfillActivityLogBatch(publicKey); if (converted > 0) { logDebug("Backfill", `activity_log: re-encrypted ${converted} rows`); } + return converted; }; diff --git a/src/shared/db/migrations/2026-07-21_activity_backfill_complete.ts b/src/shared/db/migrations/2026-07-21_activity_backfill_complete.ts new file mode 100644 index 000000000..003f16625 --- /dev/null +++ b/src/shared/db/migrations/2026-07-21_activity_backfill_complete.ts @@ -0,0 +1,15 @@ +import { ACTIVITY_LOG_BACKFILL_COMPLETE } from "#shared/db/activity-log-backfill.ts"; +import { bareSchemaMigration } from "./define.ts"; + +export default bareSchemaMigration( + "2026-07-21_activity_backfill_complete", + "Keep completed activity log backfills complete after moving to checkpoints.", + async ({ getDb }) => { + await getDb().execute({ + args: ["activity_log_backfill", ACTIVITY_LOG_BACKFILL_COMPLETE], + sql: `INSERT OR IGNORE INTO maintenance_tasks + (name, next_run_at, checkpoint) + VALUES (?, 0, ?)`, + }); + }, +); diff --git a/src/shared/db/migrations/registry.ts b/src/shared/db/migrations/registry.ts index 2765da409..fe224ed70 100644 --- a/src/shared/db/migrations/registry.ts +++ b/src/shared/db/migrations/registry.ts @@ -372,6 +372,11 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [ "2026-07-19_maintenance_checkpoint", () => import("./2026-07-19_maintenance_checkpoint.ts"), ), + // Preserve the old migration's missing-row signal as a completed checkpoint. + entry( + "2026-07-21_activity_backfill_complete", + () => import("./2026-07-21_activity_backfill_complete.ts"), + ), ]; /* jscpd:ignore-end */ diff --git a/src/shared/db/migrations/schema/version.ts b/src/shared/db/migrations/schema/version.ts index e9999fd09..e17c4fc2f 100644 --- a/src/shared/db/migrations/schema/version.ts +++ b/src/shared/db/migrations/schema/version.ts @@ -1,7 +1,7 @@ /** Schema version label and the migrations bookkeeping table name. */ export const LATEST_UPDATE = - "Add secure local scheduled maintenance and durable task claims."; + "Preserve completed activity log backfills without rescanning."; export const SCHEMA_MIGRATIONS_TABLE = "schema_migrations"; export const LATEST_DB_UPDATE_KEY = "latest_db_update"; diff --git a/src/shared/maintenance/registry.ts b/src/shared/maintenance/registry.ts index 3a0dcac56..f753dfb1b 100644 --- a/src/shared/maintenance/registry.ts +++ b/src/shared/maintenance/registry.ts @@ -1,25 +1,33 @@ -import { hasLegacyActivityLog } from "#shared/db/activity-log-backfill.ts"; +import { ACTIVITY_LOG_BACKFILL_COMPLETE } from "#shared/db/activity-log-backfill.ts"; import { settings } from "#shared/db/settings.ts"; import { + ACTIVITY_LOG_BACKFILL_BATCH, ACTIVITY_LOG_BACKFILL_INTERVAL_MS, PRUNE_INTERVAL_MS, } from "#shared/limits.ts"; -import { defineMaintenanceTasks } from "#shared/maintenance/definition.ts"; +import { + defineMaintenanceTasks, + type MaintenanceTaskCheck, +} from "#shared/maintenance/definition.ts"; import { CONFIG_KEYS } from "#shared/settings/keys.ts"; const FAILURE_RETRY_MS = 5 * 60 * 1000; +const alwaysEnabled = ( + settingsKeys: readonly string[], +): MaintenanceTaskCheck => ({ + enabled: () => true, + maxDatabaseCalls: 0, + maxExternalCalls: 0, + settingsKeys, +}); + export const MAINTENANCE_TASKS = defineMaintenanceTasks([ { - check: { - enabled: () => true, - maxDatabaseCalls: 0, - maxExternalCalls: 0, - settingsKeys: [ - CONFIG_KEYS.AUTO_PURGE_ORPHANS, - CONFIG_KEYS.ORPHAN_PURGE_RETENTION, - ], - }, + check: alwaysEnabled([ + CONFIG_KEYS.AUTO_PURGE_ORPHANS, + CONFIG_KEYS.ORPHAN_PURGE_RETENTION, + ]), deadlineMs: 15_000, failureRetryIntervalMs: FAILURE_RETRY_MS, intervalMs: PRUNE_INTERVAL_MS, @@ -35,24 +43,24 @@ export const MAINTENANCE_TASKS = defineMaintenanceTasks([ wakePolicy: "organic_safe", }, { - check: { - enabled: async () => - Boolean(settings.publicKey) && hasLegacyActivityLog(), - maxDatabaseCalls: 1, - maxExternalCalls: 0, - settingsKeys: [CONFIG_KEYS.PUBLIC_KEY], - }, + check: alwaysEnabled([CONFIG_KEYS.PUBLIC_KEY]), deadlineMs: 10_000, failureRetryIntervalMs: ACTIVITY_LOG_BACKFILL_INTERVAL_MS, intervalMs: ACTIVITY_LOG_BACKFILL_INTERVAL_MS, maxDatabaseCalls: 2, maxExternalCalls: 0, name: "activity_log_backfill", - run: async () => { + run: async ({ checkpoint, requestFollowUp, setCheckpoint }) => { + if (checkpoint === ACTIVITY_LOG_BACKFILL_COMPLETE) return; const { runActivityLogBackfill } = await import( "#shared/db/activity-log-backfill.ts" ); - await runActivityLogBackfill(settings.publicKey); + const converted = await runActivityLogBackfill(settings.publicKey); + if (converted < ACTIVITY_LOG_BACKFILL_BATCH) { + setCheckpoint(ACTIVITY_LOG_BACKFILL_COMPLETE); + } else { + requestFollowUp(); + } }, wakePolicy: "organic_safe", }, diff --git a/src/shared/stripe/request.ts b/src/shared/stripe/request.ts index c821c01ae..ae4ea7fd9 100644 --- a/src/shared/stripe/request.ts +++ b/src/shared/stripe/request.ts @@ -1,6 +1,7 @@ import * as v from "valibot"; import { namedError } from "#shared/named-error.ts"; import { delay } from "#shared/now.ts"; +import { countExternalSubrequest } from "#shared/subrequest-budget.ts"; import { encodeStripeForm, type StripeFormValue } from "./form.ts"; import { parseStripeErrorBody } from "./schemas.ts"; @@ -288,6 +289,7 @@ export const createStripeRequest = ( async function attempt(retry: number): Promise { let response: Response; + countExternalSubrequest("Stripe API request"); try { response = await config.fetch(url, { ...(method === "POST" ? { body: encoded } : {}), diff --git a/test/integration/migration-restore-verify.test.ts b/test/integration/migration-restore-verify.test.ts index 7603bc79c..6b574e7a4 100644 --- a/test/integration/migration-restore-verify.test.ts +++ b/test/integration/migration-restore-verify.test.ts @@ -67,8 +67,9 @@ describeWithEnv( // covered by its own data test), and removal-only migrations whose // absent-table checks cannot be rebuilt by a restore case. enabled_features // now owns the triggers that keep saved feature data and visibility in step. - // The built-site marker drop is also removal-only. - expect(additiveMigrations.length).toBe(MIGRATIONS.length - 21); + // The built-site marker drop is also removal-only. The activity backfill + // completion migration is data-only and covered by its direct tests. + expect(additiveMigrations.length).toBe(MIGRATIONS.length - 22); }); test("restores triggers attached to a dropped table", async () => { diff --git a/test/shared/db/activity-log-backfill.test.ts b/test/shared/db/activity-log-backfill.test.ts index 959203179..2e06ffa30 100644 --- a/test/shared/db/activity-log-backfill.test.ts +++ b/test/shared/db/activity-log-backfill.test.ts @@ -5,11 +5,10 @@ import { ENCRYPTION_PREFIX } from "#shared/crypto/encryption.ts"; import { HYBRID_PREFIX } from "#shared/crypto/keys.ts"; import { backfillActivityLogBatch, - hasLegacyActivityLog, runActivityLogBackfill, } from "#shared/db/activity-log-backfill.ts"; import { getAllActivityLog, logActivity } from "#shared/db/activityLog.ts"; -import { execute } from "#shared/db/client.ts"; +import { execute, queryOne } from "#shared/db/client.ts"; import { settings } from "#shared/db/settings.ts"; import { setSuppressDebugLogs } from "#shared/log-settings.ts"; import { MAINTENANCE_TASKS } from "#shared/maintenance/registry.ts"; @@ -20,10 +19,11 @@ import { rawActivityMessage, } from "#test-utils/activity-log.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +import { recordQueries } from "#test-utils/record-queries.ts"; import { withTestSession } from "#test-utils/session.ts"; const captureBackfillLogs = async ( - operation: () => Promise, + operation: () => Promise, ): Promise => { setSuppressDebugLogs(false); const debugStub = stub(console, "debug"); @@ -116,12 +116,11 @@ describeWithEnv("db > activity log backfill", { db: true }, () => { expect(logs).toEqual([]); }); - test("reports whether legacy work remains", async () => { - expect(await hasLegacyActivityLog()).toBe(false); + test("returns the size of each remaining batch", async () => { + expect(await runActivityLogBackfill(settings.publicKey)).toBe(0); await insertLegacyActivity("legacy"); - expect(await hasLegacyActivityLog()).toBe(true); - await runActivityLogBackfill(settings.publicKey); - expect(await hasLegacyActivityLog()).toBe(false); + expect(await runActivityLogBackfill(settings.publicKey)).toBe(1); + expect(await runActivityLogBackfill(settings.publicKey)).toBe(0); }); test("runs the legacy backfill through the maintenance registry", async () => { @@ -132,6 +131,31 @@ describeWithEnv("db > activity log backfill", { db: true }, () => { expect((await rawActivityMessage(id)).startsWith(HYBRID_PREFIX)).toBe(true); }); + test("a completed task does not scan the activity log again", async () => { + await maintenance.run(MAINTENANCE_TASKS); + expect( + await queryOne<{ checkpoint: string }>( + "SELECT checkpoint FROM maintenance_tasks WHERE name = 'activity_log_backfill'", + ), + ).toEqual({ checkpoint: "complete" }); + await execute( + "UPDATE maintenance_tasks SET next_run_at = 0 WHERE name = 'activity_log_backfill'", + ); + const queries: string[] = []; + const restore = recordQueries(queries); + try { + await maintenance.run(MAINTENANCE_TASKS); + } finally { + restore(); + } + + expect( + queries.filter((sql) => + sql.includes("FROM activity_log WHERE message LIKE"), + ), + ).toEqual([]); + }); + test("surfaces a corrupt legacy message to the task runner", async () => { await execute( "INSERT INTO activity_log (message, created, listing_id, attendee_id) VALUES (?, ?, NULL, NULL)", diff --git a/test/shared/db/migrations.test.ts b/test/shared/db/migrations.test.ts index 5c70a0e12..93d5eaf6d 100644 --- a/test/shared/db/migrations.test.ts +++ b/test/shared/db/migrations.test.ts @@ -7,6 +7,7 @@ import { import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { stub } from "@std/testing/mock"; +import { ACTIVITY_LOG_BACKFILL_COMPLETE } from "#shared/db/activity-log-backfill.ts"; import { getDb, insert, setDb } from "#shared/db/client.ts"; import { getAllListings } from "#shared/db/listings/records.ts"; import { MIGRATION_IDS } from "#shared/db/migrations/registry.ts"; @@ -284,6 +285,29 @@ describeWithEnv("db > migrations", { db: true }, () => { expect(await settingValue("latest_db_update")).toBe(LATEST_UPDATE); }); + test("runs the completed activity backfill migration on an old completed site", async () => { + await getDb().execute( + "DELETE FROM maintenance_tasks WHERE name = 'activity_log_backfill'", + ); + await simulateUpgradeFromRelease( + "Add secure local scheduled maintenance and durable task claims.", + "2026-07-21_activity_backfill_complete", + ); + + await initDb(); + + const task = await getDb().execute( + "SELECT checkpoint FROM maintenance_tasks WHERE name = 'activity_log_backfill'", + ); + expect(task.rows).toEqual([ + { checkpoint: ACTIVITY_LOG_BACKFILL_COMPLETE }, + ]); + expect(await appliedMigrationIds()).toContain( + "2026-07-21_activity_backfill_complete", + ); + expect(await settingValue("latest_db_update")).toBe(LATEST_UPDATE); + }); + test("moves feature visibility on a site upgrading from the previous release", async () => { await getDb().execute( "INSERT OR REPLACE INTO settings (key, value) VALUES ('has_logistics', 'true')", diff --git a/test/shared/db/migrations/2026-07-21_activity_backfill_complete.test.ts b/test/shared/db/migrations/2026-07-21_activity_backfill_complete.test.ts new file mode 100644 index 000000000..760321cda --- /dev/null +++ b/test/shared/db/migrations/2026-07-21_activity_backfill_complete.test.ts @@ -0,0 +1,79 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { ACTIVITY_LOG_BACKFILL_COMPLETE } from "#shared/db/activity-log-backfill.ts"; +import { getDb } from "#shared/db/client.ts"; +import activityBackfillCompleteMigration from "#shared/db/migrations/2026-07-21_activity_backfill_complete.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { buildMigrationContext } from "#test-utils/migrations.ts"; + +const context = buildMigrationContext({}); +const runMigration = () => activityBackfillCompleteMigration(context).up(); +const activityTask = () => + getDb().execute( + "SELECT checkpoint, next_run_at FROM maintenance_tasks WHERE name = 'activity_log_backfill'", + ); +const replaceActivityTask = (checkpoint: string | null, nextRunAt: number) => + getDb().execute({ + args: ["activity_log_backfill", nextRunAt, checkpoint], + sql: `INSERT OR REPLACE INTO maintenance_tasks + (name, next_run_at, checkpoint) + VALUES (?, ?, ?)`, + }); + +describeWithEnv( + "db > migrations > completed activity backfill", + { db: true }, + () => { + test("declares its identity", () => { + const migration = activityBackfillCompleteMigration(context); + expect({ + description: migration.description, + id: migration.id, + requires: migration.requires, + }).toEqual({ + description: + "Keep completed activity log backfills complete after moving to checkpoints.", + id: "2026-07-21_activity_backfill_complete", + requires: {}, + }); + }); + + test("restores the completed checkpoint for an absent old task", async () => { + await getDb().execute( + "DELETE FROM maintenance_tasks WHERE name = 'activity_log_backfill'", + ); + + await runMigration(); + + expect((await activityTask()).rows).toEqual([ + { + checkpoint: ACTIVITY_LOG_BACKFILL_COMPLETE, + next_run_at: 0, + }, + ]); + }); + + test("keeps an existing unfinished task", async () => { + await replaceActivityTask(null, 123); + + await runMigration(); + + expect((await activityTask()).rows).toEqual([ + { checkpoint: null, next_run_at: 123 }, + ]); + }); + + test("keeps one completed task unchanged when run again", async () => { + await replaceActivityTask(ACTIVITY_LOG_BACKFILL_COMPLETE, 456); + + await runMigration(); + + expect((await activityTask()).rows).toEqual([ + { + checkpoint: ACTIVITY_LOG_BACKFILL_COMPLETE, + next_run_at: 456, + }, + ]); + }); + }, +); diff --git a/test/shared/db/migrations/registry.test.ts b/test/shared/db/migrations/registry.test.ts index 36e8d6f47..5b4ad8905 100644 --- a/test/shared/db/migrations/registry.test.ts +++ b/test/shared/db/migrations/registry.test.ts @@ -31,10 +31,11 @@ describe("db > migration registry", () => { }); test("orders every scheduled-maintenance schema change", () => { - expect(MIGRATION_IDS.slice(-3)).toEqual([ + expect(MIGRATION_IDS.slice(-4)).toEqual([ "2026-07-18_maintenance_tasks", "2026-07-18_drop_built_sites_last_pruned", "2026-07-19_maintenance_checkpoint", + "2026-07-21_activity_backfill_complete", ]); }); }); diff --git a/test/shared/db/migrations/schema/version/guard.test.ts b/test/shared/db/migrations/schema/version/guard.test.ts index 4d6e9ed87..3d80c3eab 100644 --- a/test/shared/db/migrations/schema/version/guard.test.ts +++ b/test/shared/db/migrations/schema/version/guard.test.ts @@ -98,6 +98,7 @@ describe("db > migrations > schema change guard", () => { "2026-07-18_maintenance_tasks", "2026-07-18_drop_built_sites_last_pruned", "2026-07-19_maintenance_checkpoint", + "2026-07-21_activity_backfill_complete", ], schemaHash: "dhpq62", }); @@ -114,7 +115,7 @@ describe("db > migrations > schema change guard", () => { dbSchemaHash: "db_schema_hash", latestDbUpdate: "latest_db_update", latestUpdate: - "Add secure local scheduled maintenance and durable task claims.", + "Preserve completed activity log backfills without rescanning.", migrationLock: "migration_lock", schemaMigrations: "schema_migrations", }); diff --git a/test/shared/maintenance/registry.test.ts b/test/shared/maintenance/registry.test.ts index 003b1f37e..6ae9c6599 100644 --- a/test/shared/maintenance/registry.test.ts +++ b/test/shared/maintenance/registry.test.ts @@ -1,20 +1,25 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; -import { hasLegacyActivityLog } from "#shared/db/activity-log-backfill.ts"; -import { settings } from "#shared/db/settings.ts"; +import { ENCRYPTION_PREFIX } from "#shared/crypto/encryption.ts"; +import { HYBRID_PREFIX } from "#shared/crypto/keys.ts"; +import { executeBatch } from "#shared/db/client.ts"; import { + ACTIVITY_LOG_BACKFILL_BATCH, MAINTENANCE_PRUNE_BATCH, PRUNE_UNUSED_STRINGS_RETENTION_MS, } from "#shared/limits.ts"; import type { MaintenanceTaskDeclaration } from "#shared/maintenance/definition.ts"; import { MAINTENANCE_TASKS } from "#shared/maintenance/registry.ts"; -import { nowMs } from "#shared/now.ts"; +import { nowIso, nowMs } from "#shared/now.ts"; import { insertLoginAttempt, insertStrings, loginAttemptExists, } from "#test/shared/db/prune/helpers.ts"; -import { insertLegacyActivity } from "#test-utils/activity-log.ts"; +import { + insertLegacyActivity, + rawActivityMessage, +} from "#test-utils/activity-log.ts"; import { describeWithEnv } from "#test-utils/db.ts"; const taskNamed = (name: string): MaintenanceTaskDeclaration => { @@ -26,6 +31,7 @@ const taskNamed = (name: string): MaintenanceTaskDeclaration => { const runTask = ( task: MaintenanceTaskDeclaration, requestFollowUp: () => void = () => {}, + setCheckpoint: (checkpoint: string | null) => void = () => {}, ): void | Promise => task.run({ budget: { @@ -34,7 +40,7 @@ const runTask = ( checkpoint: null, deadline: Date.now() + 10_000, requestFollowUp, - setCheckpoint: () => {}, + setCheckpoint, }); describeWithEnv("maintenance registry", { db: true }, () => { @@ -63,7 +69,7 @@ describeWithEnv("maintenance registry", { db: true }, () => { { check: { enabled: undefined, - maxDatabaseCalls: 1, + maxDatabaseCalls: 0, maxExternalCalls: 0, settingsKeys: ["public_key"], }, @@ -104,27 +110,41 @@ describeWithEnv("maintenance registry", { db: true }, () => { }); test("the activity task enables and drains legacy rows", async () => { - await insertLegacyActivity("registry legacy"); + const id = await insertLegacyActivity("registry legacy"); const task = taskNamed("activity_log_backfill"); expect(await task.check.enabled()).toBe(true); await runTask(task); - expect(await hasLegacyActivityLog()).toBe(false); + expect((await rawActivityMessage(id)).startsWith(HYBRID_PREFIX)).toBe(true); }); - test("the activity task stays disabled when no legacy rows remain", async () => { - expect(await taskNamed("activity_log_backfill").check.enabled()).toBe( - false, - ); + test("the activity task stays available to preserve its checkpoint", async () => { + expect(await taskNamed("activity_log_backfill").check.enabled()).toBe(true); }); - test("the activity task stays disabled without an owner public key", async () => { - await insertLegacyActivity("legacy without owner key"); - settings.setForTest({ public_key: "" }); + test("the activity task requests a follow-up after a full batch", async () => { + const firstId = await insertLegacyActivity("full registry batch"); + const message = await rawActivityMessage(firstId); + expect(message.startsWith(ENCRYPTION_PREFIX)).toBe(true); + await executeBatch( + Array.from({ length: ACTIVITY_LOG_BACKFILL_BATCH - 1 }, () => ({ + args: [message, nowIso()], + sql: "INSERT INTO activity_log (message, created, listing_id, attendee_id) VALUES (?, ?, NULL, NULL)", + })), + ); + let followUps = 0; + const checkpoints: (string | null)[] = []; - expect(await taskNamed("activity_log_backfill").check.enabled()).toBe( - false, + await runTask( + taskNamed("activity_log_backfill"), + () => { + followUps += 1; + }, + (checkpoint) => checkpoints.push(checkpoint), ); + + expect(followUps).toBe(1); + expect(checkpoints).toEqual([]); }); }); diff --git a/test/shared/maintenance/runner.test.ts b/test/shared/maintenance/runner.test.ts index e572e6fb3..61c43f42f 100644 --- a/test/shared/maintenance/runner.test.ts +++ b/test/shared/maintenance/runner.test.ts @@ -168,6 +168,31 @@ describeWithEnv("maintenance runner", { db: true }, () => { expect(calls).toEqual([]); }); + test("a disabled task is removed without running", async () => { + await execute( + "INSERT INTO maintenance_tasks (name, next_run_at) VALUES ('disabled', 0)", + ); + const tasks = defineMaintenanceTasks([ + declaration( + "disabled", + () => { + throw new Error("disabled task ran"); + }, + { + check: { enabled: () => false }, + }, + ), + ]); + + await runWithSubrequestBudget(() => maintenance.run(tasks)); + + expect( + await queryOne( + "SELECT name FROM maintenance_tasks WHERE name = 'disabled'", + ), + ).toBeNull(); + }); + test("the default scheduled wake includes scheduled-only work", async () => { const calls: string[] = []; const tasks = defineMaintenanceTasks([ diff --git a/test/shared/stripe/request.test.ts b/test/shared/stripe/request.test.ts index d9ad4de01..4d8517743 100644 --- a/test/shared/stripe/request.test.ts +++ b/test/shared/stripe/request.test.ts @@ -12,6 +12,11 @@ import { StripeConnectionError, StripeProtocolError, } from "#shared/stripe/request.ts"; +import { + getSubrequestUsage, + runWithSubrequestBudget, + withSubrequestAllowance, +} from "#shared/subrequest-budget.ts"; import { stripeCheckoutSession } from "#test/lib/stripe/fixtures.ts"; const checkoutParams = (): StripeCheckoutSessionCreateParams => ({ @@ -73,7 +78,7 @@ const unreadableResponse = (error: unknown, status = 200): Response => ); const recordingCheckout = ( - responses: Response[], + responses: (Error | Response)[], maxNetworkRetries: number, ) => { const requests: RequestInit[] = []; @@ -83,6 +88,7 @@ const recordingCheckout = ( requests.push(init); const response = responses.shift(); if (!response) throw new Error("No Stripe response left"); + if (response instanceof Error) return Promise.reject(response); return Promise.resolve(response); }, maxNetworkRetries, @@ -95,6 +101,24 @@ const recordingCheckout = ( return { client, requests, waits }; }; +const expectTwoCountedCheckoutAttempts = async ( + firstAttempt: Error | Response, +): Promise => { + const { client } = recordingCheckout( + [firstAttempt, Response.json(stripeCheckoutSession())], + 1, + ); + + await runWithSubrequestBudget(async () => { + await client.checkout.sessions.create(checkoutParams()); + expect(getSubrequestUsage()).toEqual({ + database: 0, + external: 2, + total: 2, + }); + }); +}; + const oneCallErrorClient = (response: Response) => { let calls = 0; const client = createStripeClient("sk_test_secret", { @@ -189,6 +213,37 @@ describe("Stripe request transport", () => { expect(waits).toEqual([500]); }); + test("counts every Stripe retry against the shared request budget", async () => { + await expectTwoCountedCheckoutAttempts( + new Response("busy", { status: 500 }), + ); + }); + + test("counts a rejected Stripe transport before retrying", async () => { + await expectTwoCountedCheckoutAttempts( + new TypeError("network unavailable"), + ); + }); + + test("blocks Stripe before transport when no external allowance remains", async () => { + let fetches = 0; + const client = createStripeClient("sk_test_secret", { + fetch: () => { + fetches += 1; + return Promise.resolve(Response.json({ livemode: false })); + }, + }); + + await expect( + runWithSubrequestBudget(() => + withSubrequestAllowance({ database: 0, external: 0, total: 0 }, () => + client.balance.retrieve(), + ), + ), + ).rejects.toThrow("Blocked external operation: Stripe API request"); + expect(fetches).toBe(0); + }); + test("reuses the POST body and idempotency key after a response body read failure", async () => { const { client, requests, waits } = recordingCheckout( [