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
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user