Skip to content

Commit 859f8b6

Browse files
committed
add tests for the postgres sql client
1 parent 6231972 commit 859f8b6

File tree

3 files changed

+290
-2
lines changed

3 files changed

+290
-2
lines changed

packages/domain/shared/src/sql-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { OrganizationId } from "./id.ts"
66
/**
77
* SqlClient provides database access and transaction management.
88
*
9-
* This is a domain-level service that abstracts the database client,
9+
* This is a domain-level service that abstracts the Postgres database client,
1010
* allowing it to be mocked in tests. The generic parameter X allows
1111
* platforms to specify their concrete database/transaction type.
1212
*/

packages/platform/db-postgres/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@types/pg": "catalog:",
4242
"dotenv": "catalog:",
4343
"drizzle-kit": "catalog:",
44-
"tsx": "catalog:"
44+
"tsx": "catalog:",
45+
"vitest": "catalog:"
4546
}
4647
}
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { OrganizationId, SqlClient } from "@domain/shared"
2+
import { Effect } from "effect"
3+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
4+
import type { Operator, PostgresClient } from "./client.ts"
5+
import { SqlClientLive } from "./sql-client.ts"
6+
7+
interface MockTx {
8+
readonly id: symbol
9+
execute: (stmt: unknown) => Promise<unknown>
10+
}
11+
12+
interface MockClientState {
13+
transactionCallCount: number
14+
executedStatements: unknown[]
15+
txInstances: Operator[]
16+
}
17+
18+
function createMockPostgresClient(state: MockClientState): PostgresClient {
19+
const mockTx: MockTx = {
20+
id: Symbol("tx"),
21+
execute: async (stmt: unknown) => {
22+
state.executedStatements.push(stmt)
23+
return undefined
24+
},
25+
}
26+
27+
const txAsOperator = mockTx as unknown as Operator
28+
const client: PostgresClient = {
29+
pool: {} as PostgresClient["pool"],
30+
db: {} as PostgresClient["db"],
31+
transaction: async (fn) => {
32+
state.transactionCallCount += 1
33+
state.txInstances.push(txAsOperator)
34+
return fn(txAsOperator as Parameters<Parameters<PostgresClient["transaction"]>[0]>[0])
35+
},
36+
}
37+
return client
38+
}
39+
40+
function extractSetConfigOrgId(stmt: unknown): string | null {
41+
if (
42+
stmt !== null &&
43+
typeof stmt === "object" &&
44+
"params" in stmt &&
45+
Array.isArray((stmt as { params: unknown }).params)
46+
) {
47+
const params = (stmt as { params: unknown[] }).params
48+
if (params.length >= 2 && typeof params[1] === "string") return params[1]
49+
}
50+
return null
51+
}
52+
53+
async function runWithSqlClient<R, E>(
54+
client: PostgresClient,
55+
organizationId: OrganizationId,
56+
f: (sqlClient: import("@domain/shared").SqlClientShape<Operator>) => Effect.Effect<R, E, SqlClient>,
57+
): Promise<R> {
58+
const layer = SqlClientLive(client, organizationId)
59+
const effect = Effect.gen(function* () {
60+
const sqlClient = yield* SqlClient
61+
return yield* f(sqlClient as import("@domain/shared").SqlClientShape<Operator>)
62+
}).pipe(Effect.provide(layer))
63+
return Effect.runPromise(effect)
64+
}
65+
66+
describe("SqlClientLive", () => {
67+
let state: MockClientState
68+
69+
beforeEach(() => {
70+
state = {
71+
transactionCallCount: 0,
72+
executedStatements: [],
73+
txInstances: [],
74+
}
75+
})
76+
77+
afterEach(() => {
78+
expect(state.transactionCallCount).toBeDefined()
79+
})
80+
81+
describe("single transaction", () => {
82+
it("starts one DB transaction and returns the inner effect value", async () => {
83+
const client = createMockPostgresClient(state)
84+
const orgId = OrganizationId("org-single")
85+
86+
const result = await runWithSqlClient(client, orgId, (sql) => sql.transaction(Effect.succeed(42)))
87+
88+
expect(result).toBe(42)
89+
expect(state.transactionCallCount).toBe(1)
90+
expect(state.executedStatements.length).toBe(1)
91+
})
92+
93+
it("invokes set_config (RLS context) once per transaction", async () => {
94+
const client = createMockPostgresClient(state)
95+
const orgId = OrganizationId("tenant-abc")
96+
97+
await runWithSqlClient(client, orgId, (sql) => sql.transaction(Effect.succeed(null)))
98+
99+
expect(state.transactionCallCount).toBe(1)
100+
expect(state.executedStatements.length).toBe(1)
101+
const orgFromStmt = extractSetConfigOrgId(state.executedStatements[0])
102+
if (orgFromStmt !== null) expect(orgFromStmt).toBe("tenant-abc")
103+
})
104+
})
105+
106+
describe("nested transactions (single DB transaction)", () => {
107+
it("does not start a second DB transaction when transaction() is called inside an open transaction", async () => {
108+
const client = createMockPostgresClient(state)
109+
const orgId = OrganizationId("org-nested")
110+
111+
const result = await runWithSqlClient(client, orgId, (sql) =>
112+
sql.transaction(
113+
Effect.gen(function* () {
114+
const inner = yield* SqlClient
115+
return yield* (inner as import("@domain/shared").SqlClientShape<Operator>).transaction(Effect.succeed(99))
116+
}),
117+
),
118+
)
119+
120+
expect(result).toBe(99)
121+
expect(state.transactionCallCount).toBe(1)
122+
})
123+
124+
it("reuses the same tx for multiple nested transaction() calls", async () => {
125+
const client = createMockPostgresClient(state)
126+
const orgId = OrganizationId("org-multi-nested")
127+
128+
const result = await runWithSqlClient(client, orgId, (sql) =>
129+
sql.transaction(
130+
Effect.gen(function* () {
131+
const sc = yield* SqlClient
132+
const shape = sc as import("@domain/shared").SqlClientShape<Operator>
133+
const a = yield* shape.transaction(Effect.succeed(1))
134+
const b = yield* shape.transaction(Effect.succeed(2))
135+
const c = yield* shape.transaction(Effect.succeed(3))
136+
return a + b + c
137+
}),
138+
),
139+
)
140+
141+
expect(result).toBe(6)
142+
expect(state.transactionCallCount).toBe(1)
143+
})
144+
})
145+
146+
describe("query inside transaction (same connection)", () => {
147+
it("uses the same tx for query() when called inside an open transaction", async () => {
148+
const client = createMockPostgresClient(state)
149+
const orgId = OrganizationId("org-query-in-tx")
150+
let capturedTx: Operator | null = null
151+
let capturedOrgId: OrganizationId | null = null
152+
153+
await runWithSqlClient(client, orgId, (sql) =>
154+
sql.transaction(
155+
Effect.gen(function* () {
156+
const result = yield* sql.query(async (tx, oid) => {
157+
capturedTx = tx
158+
capturedOrgId = oid
159+
return "done"
160+
})
161+
return result
162+
}),
163+
),
164+
)
165+
166+
expect(state.transactionCallCount).toBe(1)
167+
expect(capturedTx).not.toBeNull()
168+
expect(capturedOrgId).toBe(orgId)
169+
expect(state.txInstances[0]).toBe(capturedTx)
170+
})
171+
172+
it("does not start a new transaction for query() when already in a transaction", async () => {
173+
const client = createMockPostgresClient(state)
174+
const orgId = OrganizationId("org-no-double-tx")
175+
176+
await runWithSqlClient(client, orgId, (sql) =>
177+
sql.transaction(
178+
Effect.gen(function* () {
179+
yield* sql.query(async () => "first")
180+
yield* sql.query(async () => "second")
181+
return "ok"
182+
}),
183+
),
184+
)
185+
186+
expect(state.transactionCallCount).toBe(1)
187+
})
188+
})
189+
190+
describe("query outside transaction (own transaction)", () => {
191+
it("starts a single transaction and sets RLS when query() is called without an open transaction", async () => {
192+
const client = createMockPostgresClient(state)
193+
const orgId = OrganizationId("org-query-alone")
194+
195+
const result = await runWithSqlClient(client, orgId, (sql) =>
196+
sql.query(async (_tx, oid) => {
197+
expect(oid).toBe(orgId)
198+
return "result"
199+
}),
200+
)
201+
202+
expect(result).toBe("result")
203+
expect(state.transactionCallCount).toBe(1)
204+
expect(state.executedStatements.length).toBe(1)
205+
const orgFromStmt = extractSetConfigOrgId(state.executedStatements[0])
206+
if (orgFromStmt !== null) expect(orgFromStmt).toBe("org-query-alone")
207+
})
208+
})
209+
210+
describe("scoped tenancy", () => {
211+
it("passes the same organizationId to query callbacks inside and outside transaction", async () => {
212+
const client = createMockPostgresClient(state)
213+
const orgId = OrganizationId("scoped-tenant-1")
214+
const seenOrgIds: OrganizationId[] = []
215+
216+
await runWithSqlClient(client, orgId, (sql) =>
217+
Effect.gen(function* () {
218+
yield* sql.query(async (_tx, oid) => {
219+
seenOrgIds.push(oid)
220+
return null
221+
})
222+
return yield* sql.transaction(
223+
Effect.gen(function* () {
224+
return yield* sql.query(async (_tx, oid) => {
225+
seenOrgIds.push(oid)
226+
return null
227+
})
228+
}),
229+
)
230+
}),
231+
)
232+
233+
expect(seenOrgIds).toHaveLength(2)
234+
expect(seenOrgIds[0]).toBe(orgId)
235+
expect(seenOrgIds[1]).toBe(orgId)
236+
})
237+
238+
it("uses default organizationId when not provided", async () => {
239+
const client = createMockPostgresClient(state)
240+
const defaultOrgId = OrganizationId("system")
241+
const layer = SqlClientLive(client)
242+
const effect = Effect.gen(function* () {
243+
const sql = yield* SqlClient
244+
return yield* (sql as import("@domain/shared").SqlClientShape<Operator>).transaction(
245+
Effect.succeed((sql as import("@domain/shared").SqlClientShape<Operator>).organizationId),
246+
)
247+
}).pipe(Effect.provide(layer))
248+
const result = await Effect.runPromise(effect)
249+
250+
expect(result).toBe(defaultOrgId)
251+
expect(state.executedStatements.length).toBe(1)
252+
const orgFromStmt = extractSetConfigOrgId(state.executedStatements[0])
253+
if (orgFromStmt !== null) expect(orgFromStmt).toBe("system")
254+
})
255+
})
256+
257+
describe("failure and rollback", () => {
258+
it("propagates failure from inner effect and does not commit", async () => {
259+
const client = createMockPostgresClient(state)
260+
const orgId = OrganizationId("org-fail")
261+
262+
const effect = runWithSqlClient(client, orgId, (sql) =>
263+
sql.transaction(Effect.fail(new Error("intentional failure"))),
264+
)
265+
266+
await expect(effect).rejects.toThrow("intentional failure")
267+
expect(state.transactionCallCount).toBe(1)
268+
})
269+
270+
it("resets activeTx after failure so subsequent query() starts its own transaction", async () => {
271+
const client = createMockPostgresClient(state)
272+
const orgId = OrganizationId("org-reset")
273+
274+
const failOnce = runWithSqlClient(client, orgId, (sql) => sql.transaction(Effect.fail(new Error("fail"))))
275+
await expect(failOnce).rejects.toThrow("fail")
276+
277+
state.transactionCallCount = 0
278+
state.executedStatements = []
279+
state.txInstances = []
280+
281+
const result = await runWithSqlClient(client, orgId, (sql) => sql.query(async () => "after-fail"))
282+
283+
expect(result).toBe("after-fail")
284+
expect(state.transactionCallCount).toBe(1)
285+
})
286+
})
287+
})

0 commit comments

Comments
 (0)