Skip to content

Commit 46e056c

Browse files
committed
fix(testing): lazy queue consumption at resolution and wrapper restore on reset
- each chain holds one lazy rows supplier: the queued set is dequeued only when a default thenable actually resolves, so a chain answered by a per-test terminal override leaves its queued rows for the next chain - resetDbChainMock also mockReset()s the stable db entry-point wrappers so direct overrides on databaseMock.db.* cannot outlive a suite
1 parent 39463dc commit 46e056c

2 files changed

Lines changed: 83 additions & 21 deletions

File tree

packages/testing/src/mocks/database.mock.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,24 @@ describe('database mock', () => {
120120
])
121121
})
122122

123+
it('preserves a queued set when a terminal override resolves the chain', async () => {
124+
queueTableRows(workflowTable, [{ id: 'queued' }])
125+
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'override' }])
126+
await expect(db.select().from(workflowTable).where({}).limit(1)).resolves.toEqual([
127+
{ id: 'override' },
128+
])
129+
await expect(db.select().from(workflowTable).where({})).resolves.toEqual([{ id: 'queued' }])
130+
})
131+
132+
it('restores directly-overridden db entry points on resetDbChainMock', async () => {
133+
;(db.select as ReturnType<typeof vi.fn>).mockImplementation(() => {
134+
throw new Error('broken')
135+
})
136+
expect(() => db.select()).toThrow('broken')
137+
resetDbChainMock()
138+
await expect(db.select().from(workflowTable).where({})).resolves.toEqual([])
139+
})
140+
123141
it('clears queues and rewires defaults on resetDbChainMock', async () => {
124142
queueTableRows(workflowTable, [{ id: 'stale' }])
125143
dbChainMockFns.where.mockReturnValue('broken' as never)

packages/testing/src/mocks/database.mock.ts

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -188,40 +188,71 @@ const transaction: ReturnType<typeof vi.fn> = vi.fn(
188188
async (cb: (tx: any) => unknown): Promise<unknown> => cb(dbChainMock.db)
189189
)
190190

191-
const rowsPromise = (rows: unknown[] | null) => Promise.resolve((rows ?? []) as unknown[])
191+
/**
192+
* Lazy per-chain rows supplier: dequeues once, at the moment the FIRST default
193+
* thenable actually resolves. A chain whose result comes from a per-test
194+
* override never reaches a default resolution, so its queued set stays
195+
* available for the next chain on that table.
196+
*/
197+
type RowsSupplier = () => unknown[] | null
198+
199+
const chainRowsSupplier = (tables: unknown[]): RowsSupplier => {
200+
let consumed = false
201+
let rows: unknown[] | null = null
202+
return () => {
203+
if (!consumed) {
204+
consumed = true
205+
rows = dequeueChainRows(tables)
206+
}
207+
return rows
208+
}
209+
}
210+
211+
const noRows: RowsSupplier = () => null
212+
213+
/** An awaitable chain step that resolves `getRows()` only when actually awaited. */
214+
const lazyRowsThenable = (getRows: RowsSupplier): any => ({
215+
then: (onFulfilled?: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
216+
Promise.resolve((getRows() ?? []) as unknown[]).then(onFulfilled, onRejected),
217+
catch: (onRejected?: (reason: unknown) => unknown) =>
218+
Promise.resolve((getRows() ?? []) as unknown[]).catch(onRejected),
219+
finally: (onFinally?: () => void) =>
220+
Promise.resolve((getRows() ?? []) as unknown[]).finally(onFinally),
221+
})
192222

193223
// `.limit()` returns a builder that is awaitable and also exposes `.offset()`
194224
// for keyset/OFFSET paging (`.limit(n).offset(m)`).
195-
const limitBuilder = (rows: unknown[] | null) => {
196-
const thenable: any = rowsPromise(rows)
197-
thenable.offset = spyOrDefault(offset, () => rowsPromise(rows))
225+
const limitBuilder = (getRows: RowsSupplier) => {
226+
const thenable = lazyRowsThenable(getRows)
227+
thenable.offset = spyOrDefault(offset, () => lazyRowsThenable(getRows))
198228
return thenable
199229
}
200230

201-
const terminalBuilder = (rows: unknown[] | null): any => {
202-
const thenable: any = rowsPromise(rows)
203-
thenable.limit = spyOrDefault(limit, () => limitBuilder(rows))
204-
thenable.orderBy = spyOrDefault(orderBy, () => terminalBuilder(rows))
231+
const terminalBuilder = (getRows: RowsSupplier): any => {
232+
const thenable = lazyRowsThenable(getRows)
233+
thenable.limit = spyOrDefault(limit, () => limitBuilder(getRows))
234+
thenable.orderBy = spyOrDefault(orderBy, () => terminalBuilder(getRows))
205235
thenable.returning = returning
206236
thenable.groupBy = spyOrDefault(groupBy, () => {
207-
const builder = terminalBuilder(rows)
208-
builder.having = spyOrDefault(having, () => terminalBuilder(rows))
237+
const builder = terminalBuilder(getRows)
238+
builder.having = spyOrDefault(having, () => terminalBuilder(getRows))
209239
return builder
210240
})
211-
thenable.for = spyOrDefault(forClause, () => terminalBuilder(rows))
241+
thenable.for = spyOrDefault(forClause, () => terminalBuilder(getRows))
212242
return thenable
213243
}
214244

215245
// The from/join builder is itself a thenable so `await db.select().from(t)`
216-
// (no where clause) also resolves table-routed rows; dequeue happens lazily at
217-
// await (or where()) time, so a chain never double-consumes.
218-
const joinBuilder = (tables: unknown[]): any => ({
219-
where: spyOrDefault(where, () => terminalBuilder(dequeueChainRows(tables))),
220-
innerJoin: spyOrDefault(innerJoin, (table: unknown) => joinBuilder([...tables, table])),
221-
leftJoin: spyOrDefault(leftJoin, (table: unknown) => joinBuilder([...tables, table])),
222-
then: (onFulfilled?: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
223-
rowsPromise(dequeueChainRows(tables)).then(onFulfilled, onRejected),
224-
})
246+
// (no where clause) also resolves table-routed rows; the chain's single lazy
247+
// supplier means it never double-consumes no matter which step is awaited.
248+
const joinBuilder = (tables: unknown[]): any => {
249+
const getRows = chainRowsSupplier(tables)
250+
const builder = lazyRowsThenable(getRows)
251+
builder.where = spyOrDefault(where, () => terminalBuilder(getRows))
252+
builder.innerJoin = spyOrDefault(innerJoin, (table: unknown) => joinBuilder([...tables, table]))
253+
builder.leftJoin = spyOrDefault(leftJoin, (table: unknown) => joinBuilder([...tables, table]))
254+
return builder
255+
}
225256

226257
const selectBuilder = () => ({
227258
from: spyOrDefault(from, (table: unknown) => joinBuilder([table])),
@@ -230,7 +261,7 @@ const selectBuilder = () => ({
230261
// Mutation chains route nothing: their where() resolves the plain default so a
231262
// mutation can never consume rows queued for a select.
232263
const mutationWhere = () => ({
233-
where: spyOrDefault(where, () => terminalBuilder(null)),
264+
where: spyOrDefault(where, () => terminalBuilder(noRows)),
234265
})
235266

236267
export const dbChainMockFns = {
@@ -301,6 +332,19 @@ export function resetDbChainMock(): void {
301332
transaction.mockImplementation(async (cb: (tx: typeof dbChainMock.db) => unknown) =>
302333
cb(dbChainMock.db)
303334
)
335+
// The stable db-instance entry points are wrappers around the spies above; a
336+
// suite may have overridden them directly, so restore their original
337+
// implementations too (mockReset restores the fn passed to vi.fn()).
338+
for (const key of [
339+
'select',
340+
'selectDistinct',
341+
'selectDistinctOn',
342+
'insert',
343+
'update',
344+
'delete',
345+
] as const) {
346+
;(dbInstance[key] as ChainSpy).mockReset()
347+
}
304348
}
305349

306350
/**

0 commit comments

Comments
 (0)