test(db): cover the tenant-scope pinning (coreci-chat#14)
ci / test-pglite (PGlite, default) (push) Failing after 1m48s
ci / test-postgres (Postgres 16, G-022 RLS) (push) Failing after 1m33s
ci / test-pglite (PGlite, default) (pull_request) Failing after 1m48s
ci / test-postgres (Postgres 16, G-022 RLS) (pull_request) Failing after 1m32s

This commit is contained in:
2026-10-27 08:32:51 +00:00
parent cf098f2b9d
commit 063cf85248
+127
View File
@@ -0,0 +1,127 @@
/**
* withTenant — the tenant scope must live on ONE connection (coreci-chat#14).
*
* In pg mode the client is a POOL: a pool hands out a different connection
* per statement, so `SET LOCAL app.tenant_id` (transaction-local) only holds
* if the scope and the statements share a connection. A client that can pin
* one connection exposes `transaction()`; withTenant uses it when it is there
* and keeps its BEGIN/COMMIT path as the fallback for clients that cannot.
*/
import { describe, it, expect } from "vitest";
import { setDbClient, withTenant, TenantContextError, type DbClient } from "../src/withTenant.js";
type Recorder = {
client: DbClient;
/** statements that went through the pooled client (where a scope gets lost) */
pooled: string[];
/** statements that ran inside the pinned transaction */
pinned: string[];
};
function recorder(withTransaction: boolean): Recorder {
const pooled: string[] = [];
const pinned: string[] = [];
const pooledClient: DbClient = {
async query<T = Record<string, unknown>>(text: string) {
pooled.push(text);
return { rows: [] as T[], rowCount: 0 };
},
async exec() {
/* not used here */
},
};
const client: DbClient = withTransaction
? {
...pooledClient,
async transaction<T>(fn: (c: DbClient) => Promise<T>): Promise<T> {
pinned.push("BEGIN");
const tx: DbClient = {
async query<R = Record<string, unknown>>(text: string) {
pinned.push(text);
return { rows: [] as R[], rowCount: 0 };
},
async exec() {
/* not used here */
},
};
try {
const out = await fn(tx);
pinned.push("COMMIT");
return out;
} catch (err) {
pinned.push("ROLLBACK");
throw err;
}
},
}
: pooledClient;
return { client, pooled, pinned };
}
describe("withTenant — tenant scope pinning (coreci-chat#14)", () => {
it("runs the scope and the statements on the transaction's connection", async () => {
const r = recorder(true);
setDbClient(r.client);
const out = await withTenant("tenant-a", async (c) => {
await c.query("SELECT 1");
return "ok";
});
expect(out).toBe("ok");
expect(r.pinned).toEqual([
"BEGIN",
"SELECT set_config('app.tenant_id', $1, true)",
"SELECT 1",
"COMMIT",
]);
// Nothing touched the pooled client: a pool statement would have taken a
// different connection and lost the scope.
expect(r.pooled).toEqual([]);
});
it("falls back to BEGIN/COMMIT when the client cannot pin a connection", async () => {
const r = recorder(false);
setDbClient(r.client);
await withTenant("tenant-a", async (c) => {
await c.query("SELECT 1");
});
expect(r.pooled).toEqual([
"BEGIN",
"SELECT set_config('app.tenant_id', $1, true)",
"SELECT 1",
"COMMIT",
]);
expect(r.pinned).toEqual([]);
});
it("rolls the transaction back and wraps the failure in TenantContextError", async () => {
const r = recorder(true);
setDbClient(r.client);
await expect(
withTenant("tenant-a", async () => {
throw new Error("boom");
}),
).rejects.toBeInstanceOf(TenantContextError);
expect(r.pinned).toContain("ROLLBACK");
});
it("rolls the fallback path back too", async () => {
const r = recorder(false);
setDbClient(r.client);
await expect(
withTenant("tenant-a", async () => {
throw new Error("boom");
}),
).rejects.toBeInstanceOf(TenantContextError);
expect(r.pooled).toContain("ROLLBACK");
});
});