From b7aeed84ce9b8dbec22fc5fc97995b75d8063d9c Mon Sep 17 00:00:00 2001 From: "starslingdev[bot]" <248995740+starslingdev[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:24:29 +0000 Subject: [PATCH] =?UTF-8?q?perf(test-utils):=20stop=20re-deleting=20cleane?= =?UTF-8?q?d=20rows=20=E2=80=94=20up=20to=205.4=C3=97=20faster=20(#10762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: starslingdev[bot] <248995740+starslingdev[bot]@users.noreply.github.com> Co-authored-by: Taesu --- .changeset/olive-pumas-repeat.md | 5 ++ docs/content/docs/integrations/hono.mdx | 2 +- packages/test-utils/package.json | 3 +- .../test-utils/src/adapter/cleanup.test.ts | 75 +++++++++++++++++++ packages/test-utils/src/adapter/cleanup.ts | 29 +++++++ .../src/adapter/create-test-suite.ts | 58 +++++++------- packages/test-utils/vitest.config.ts | 3 + 7 files changed, 144 insertions(+), 31 deletions(-) create mode 100644 .changeset/olive-pumas-repeat.md create mode 100644 packages/test-utils/src/adapter/cleanup.test.ts create mode 100644 packages/test-utils/src/adapter/cleanup.ts create mode 100644 packages/test-utils/vitest.config.ts diff --git a/.changeset/olive-pumas-repeat.md b/.changeset/olive-pumas-repeat.md new file mode 100644 index 0000000000..6ecb2a9256 --- /dev/null +++ b/.changeset/olive-pumas-repeat.md @@ -0,0 +1,5 @@ +--- +"@better-auth/test-utils": patch +--- + +Adapter test suites now run significantly faster. Cleanup between tests no longer re-deletes rows it has already removed, so suites spend far less time waiting on redundant database round trips. diff --git a/docs/content/docs/integrations/hono.mdx b/docs/content/docs/integrations/hono.mdx index 6f6b0cdb0f..b5609d3efd 100644 --- a/docs/content/docs/integrations/hono.mdx +++ b/docs/content/docs/integrations/hono.mdx @@ -45,7 +45,7 @@ If you're using another runtime, keep your existing entry point and add the same The resulting Hono route must match your Better Auth `basePath`, which defaults to `/api/auth`. If your app already uses `new - Hono().basePath("/api")`, mount Better Auth at `/auth/*`: + Hono().basePath("/api")`, mount Better Auth at `/auth/*`: ```ts const app = new Hono().basePath("/api"); diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 26655d251a..ea7951426b 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -22,7 +22,8 @@ "sideEffects": false, "scripts": { "build": "tsdown", - "dev": "tsdown --watch" + "dev": "tsdown --watch", + "test": "vitest" }, "files": [ "dist" diff --git a/packages/test-utils/src/adapter/cleanup.test.ts b/packages/test-utils/src/adapter/cleanup.test.ts new file mode 100644 index 0000000000..d540f0e587 --- /dev/null +++ b/packages/test-utils/src/adapter/cleanup.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import type { CleanupResult } from "./cleanup"; +import { cleanupRows } from "./cleanup"; + +type TestRow = { + id: string; +}; + +describe("cleanupRows", () => { + it("does not retry rows that were deleted successfully", async () => { + const deletedRowIds: string[] = []; + let trackedRows: Record = { + user: [{ id: "user-1" }, { id: "user-2" }], + }; + const cleanupRow = async ( + _model: string, + row: TestRow, + ): Promise => { + deletedRowIds.push(row.id); + return "complete"; + }; + + trackedRows = await cleanupRows(trackedRows, cleanupRow); + trackedRows = await cleanupRows(trackedRows, cleanupRow); + + expect(deletedRowIds).toEqual(["user-1", "user-2"]); + expect(trackedRows).toEqual({}); + }); + + it("retries only rows whose deletion failed", async () => { + const attemptedRowIds: string[] = []; + let secondRowFails = true; + let trackedRows: Record = { + user: [{ id: "user-1" }, { id: "user-2" }, { id: "user-3" }], + }; + const cleanupRow = async ( + _model: string, + row: TestRow, + ): Promise => { + attemptedRowIds.push(row.id); + if (row.id === "user-2" && secondRowFails) { + secondRowFails = false; + throw new Error("Temporary delete failure"); + } + return "complete"; + }; + + trackedRows = await cleanupRows(trackedRows, cleanupRow); + + expect(trackedRows).toEqual({ user: [{ id: "user-2" }] }); + + trackedRows = await cleanupRows(trackedRows, cleanupRow); + + expect(attemptedRowIds).toEqual(["user-1", "user-2", "user-3", "user-2"]); + expect(trackedRows).toEqual({}); + }); + + it("retains rows explicitly marked for retry", async () => { + let modelAvailable = false; + let trackedRows: Record = { + customModel: [{ id: "custom-1" }], + }; + const cleanupRow = async (): Promise => + modelAvailable ? "complete" : "retry"; + + trackedRows = await cleanupRows(trackedRows, cleanupRow); + + expect(trackedRows).toEqual({ customModel: [{ id: "custom-1" }] }); + + modelAvailable = true; + trackedRows = await cleanupRows(trackedRows, cleanupRow); + + expect(trackedRows).toEqual({}); + }); +}); diff --git a/packages/test-utils/src/adapter/cleanup.ts b/packages/test-utils/src/adapter/cleanup.ts new file mode 100644 index 0000000000..0f00f41762 --- /dev/null +++ b/packages/test-utils/src/adapter/cleanup.ts @@ -0,0 +1,29 @@ +export type RowsByModel = Record; + +export type CleanupResult = "complete" | "retry"; + +export async function cleanupRows( + trackedRows: Readonly>, + cleanupRow: (model: string, row: Row) => Promise, +): Promise> { + const rowsToRetry: RowsByModel = {}; + + for (const [model, rows] of Object.entries(trackedRows)) { + const failedRows: Row[] = []; + for (const row of rows) { + try { + const result = await cleanupRow(model, row); + if (result === "retry") { + failedRows.push(row); + } + } catch { + failedRows.push(row); + } + } + if (failedRows.length > 0) { + rowsToRetry[model] = failedRows; + } + } + + return rowsToRetry; +} diff --git a/packages/test-utils/src/adapter/create-test-suite.ts b/packages/test-utils/src/adapter/create-test-suite.ts index 2ef2bcc679..0972470057 100644 --- a/packages/test-utils/src/adapter/create-test-suite.ts +++ b/packages/test-utils/src/adapter/create-test-suite.ts @@ -6,7 +6,7 @@ import type { Verification, } from "@better-auth/core/db"; import { createLocalAccountIssuer, getAuthTables } from "@better-auth/core/db"; -import type { DBAdapter } from "@better-auth/core/db/adapter"; +import type { DBAdapter, Where } from "@better-auth/core/db/adapter"; import { createAdapterFactory, deepmerge, @@ -17,6 +17,8 @@ import { generateId } from "@better-auth/core/utils/id"; import type { Auth } from "better-auth"; import { betterAuth } from "better-auth"; import { test } from "vitest"; +import type { RowsByModel } from "./cleanup"; +import { cleanupRows } from "./cleanup"; import type { Logger } from "./test-adapter"; /** @@ -122,6 +124,10 @@ type Failure = { type Result = Success | Failure; +type CreatedRow = { + id: Where["value"]; +}; + async function tryCatch( promise: Promise, ): Promise> { @@ -251,7 +257,7 @@ export const createTestSuite = < customIdGenerator?: () => any | Promise | undefined; transformIdOutput?: (id: any) => string | undefined; }) => { - const createdRows: Record = {}; + let createdRows: RowsByModel = {}; let adapter = await helpers.adapter(); const wrapperAdapter = ( @@ -352,7 +358,7 @@ export const createTestSuite = < select, forceAllowId: true, }); - createdRows[model] = [...(createdRows[model] || []), res]; + (createdRows[model] ??= []).push({ id: res.id }); return res as any; }, options: adapter.options, @@ -374,34 +380,28 @@ export const createTestSuite = < }; const cleanupCreatedRows = async () => { + if (Object.keys(createdRows).length === 0) return; + adapter = await helpers.adapter(); - for (const model of Object.keys(createdRows)) { - for (const row of createdRows[model]!) { - const schema = getAuthTables(helpers.getBetterAuthOptions()); - const getDefaultModelName = initGetDefaultModelName({ - schema, - usePlural: adapter.options?.adapterConfig.usePlural, - }); - let defaultModelName: string; - try { - defaultModelName = getDefaultModelName(model); - } catch { - continue; - } - if (!schema[defaultModelName]) continue; // model doesn't exist in the schema anymore, so we skip it - try { - await adapter.delete({ - model, - where: [{ field: "id", value: row.id }], - }); - } catch { - // We ignore any failed attempts to delete the created rows. - } - if (createdRows[model]!.length === 1) { - delete createdRows[model]; - } + const schema = getAuthTables(helpers.getBetterAuthOptions()); + const getDefaultModelName = initGetDefaultModelName({ + schema, + usePlural: adapter.options?.adapterConfig.usePlural, + }); + createdRows = await cleanupRows(createdRows, async (model, row) => { + let defaultModelName: string; + try { + defaultModelName = getDefaultModelName(model); + } catch { + return "retry"; } - } + if (!schema[defaultModelName]) return "retry"; + await adapter.delete({ + model, + where: [{ field: "id", value: row.id }], + }); + return "complete"; + }); }; // Track current applied BetterAuth options state diff --git a/packages/test-utils/vitest.config.ts b/packages/test-utils/vitest.config.ts new file mode 100644 index 0000000000..d7b613c2a4 --- /dev/null +++ b/packages/test-utils/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({});