Skip to content

Commit c029210

Browse files
committed
refactor(testing): share the drizzle condition-tree helpers
Asserting on WHERE clauses is the only way to pin a filter the row-queue mocks cannot enforce — a mock returns whatever was queued regardless of the predicate — so this pattern spreads to every test that guards a query's scoping. It had reached five local copies of the same flatten/has pair, four of them added by the tests in this branch. Moved to @sim/testing beside createMockSqlOperators, whose output shape they parse, so the helper and the node types it depends on live together.
1 parent a910b36 commit c029210

7 files changed

Lines changed: 78 additions & 100 deletions

File tree

apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.test.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import {
55
createMockRequest,
66
dbChainMockFns,
7+
flattenMockConditions,
78
queueTableRows,
89
resetDbChainMock,
910
schemaMock,
@@ -32,16 +33,6 @@ function listRequest() {
3233
)
3334
}
3435

35-
/** Flattens the nested `and(...)` objects the drizzle operator mocks produce. */
36-
function flattenConditions(condition: unknown): Array<Record<string, unknown>> {
37-
if (!condition || typeof condition !== 'object') return []
38-
const node = condition as Record<string, unknown>
39-
if (node.type === 'and' && Array.isArray(node.conditions)) {
40-
return node.conditions.flatMap(flattenConditions)
41-
}
42-
return [node]
43-
}
44-
4536
describe('admin workspace folders GET', () => {
4637
beforeEach(() => {
4738
vi.clearAllMocks()
@@ -67,7 +58,7 @@ describe('admin workspace folders GET', () => {
6758
// Asserted on the COLUMN: `resourceType`/`workspaceId` are eq nodes, so a bare
6859
// "some isNull exists" check could pass on an unrelated clause.
6960
expect(
70-
flattenConditions(where).some(
61+
flattenMockConditions(where).some(
7162
(node) => node.type === 'isNull' && node.column === schemaMock.folder.deletedAt
7263
)
7364
).toBe(true)
@@ -82,7 +73,7 @@ describe('admin workspace folders GET', () => {
8273
await GET(listRequest(), routeContext)
8374

8475
const where = dbChainMockFns.where.mock.calls[1]?.[0]
85-
const nodes = flattenConditions(where)
76+
const nodes = flattenMockConditions(where)
8677
expect(nodes.some((n) => n.type === 'eq' && n.right === WORKSPACE_ID)).toBe(true)
8778
expect(nodes.some((n) => n.type === 'eq' && n.right === 'workflow')).toBe(true)
8879
})

apps/sim/lib/folders/cascade.test.ts

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { flattenMockConditions, hasMockCondition } from '@sim/testing'
45
import { beforeEach, describe, expect, it, vi } from 'vitest'
56
import {
67
archiveFolderCascade,
@@ -81,23 +82,6 @@ function makeConfig(overrides: Partial<FolderResourceConfig> = {}): FolderResour
8182
}
8283
}
8384

84-
/** Flattens the nested `and(...)` objects the drizzle operator mocks produce. */
85-
function flattenConditions(condition: unknown): Array<Record<string, unknown>> {
86-
if (!condition || typeof condition !== 'object') return []
87-
const node = condition as Record<string, unknown>
88-
if (node.type === 'and' && Array.isArray(node.conditions)) {
89-
return node.conditions.flatMap(flattenConditions)
90-
}
91-
return [node]
92-
}
93-
94-
function hasCondition(
95-
condition: unknown,
96-
predicate: (node: Record<string, unknown>) => boolean
97-
): boolean {
98-
return flattenConditions(condition).some(predicate)
99-
}
100-
10185
const TIMESTAMP = new Date('2026-01-01T00:00:00.000Z')
10286
const NOW = new Date('2026-02-02T00:00:00.000Z')
10387

@@ -138,7 +122,7 @@ describe('collectCascadeSubtreeIds', () => {
138122

139123
expect(ids).toEqual(['root', 'child', 'grandchild'])
140124
// Either still active, or carrying this cascade's own stamp — never another snapshot's.
141-
const clause = flattenConditions(selectCalls[0].where).find((node) => node.type === 'or')
125+
const clause = flattenMockConditions(selectCalls[0].where).find((node) => node.type === 'or')
142126
expect(clause).toBeDefined()
143127
const branches = (clause?.conditions ?? []) as Array<Record<string, unknown>>
144128
expect(branches.some((node) => node.type === 'isNull')).toBe(true)
@@ -150,8 +134,10 @@ describe('collectCascadeSubtreeIds', () => {
150134

151135
await collectCascadeSubtreeIds(tx, 'ws-1', 'knowledge_base', 'root', TIMESTAMP)
152136

153-
expect(hasCondition(selectCalls[0].where, (node) => node.right === 'knowledge_base')).toBe(true)
154-
expect(hasCondition(selectCalls[0].where, (node) => node.right === 'ws-1')).toBe(true)
137+
expect(hasMockCondition(selectCalls[0].where, (node) => node.right === 'knowledge_base')).toBe(
138+
true
139+
)
140+
expect(hasMockCondition(selectCalls[0].where, (node) => node.right === 'ws-1')).toBe(true)
155141
})
156142
})
157143

@@ -169,7 +155,7 @@ describe('collectArchivedSubtreeIds', () => {
169155
const ids = await collectArchivedSubtreeIds(tx, 'ws-1', 'table', 'root', TIMESTAMP)
170156

171157
expect(ids).toEqual(['root', 'child'])
172-
expect(hasCondition(selectCalls[0].where, (node) => node.right === TIMESTAMP)).toBe(true)
158+
expect(hasMockCondition(selectCalls[0].where, (node) => node.right === TIMESTAMP)).toBe(true)
173159
})
174160

175161
it('terminates on a parent cycle instead of recursing forever', async () => {
@@ -230,7 +216,7 @@ describe('archiveFolderCascade', () => {
230216
await archiveFolderCascade(tx, makeConfig(), 'ws-1', ['root'], TIMESTAMP)
231217

232218
for (const call of updateCalls) {
233-
expect(hasCondition(call.where, (node) => node.type === 'isNull')).toBe(true)
219+
expect(hasMockCondition(call.where, (node) => node.type === 'isNull')).toBe(true)
234220
}
235221
})
236222

@@ -299,7 +285,7 @@ describe('restoreFolderCascade', () => {
299285
expect(updateCalls[1].set).toEqual({ archivedAt: null, updatedAt: NOW })
300286
expect(updateCalls[2].table).toBe(DEPENDENT_TABLE)
301287
expect(
302-
hasCondition(updateCalls[2].where, (node) => {
288+
hasMockCondition(updateCalls[2].where, (node) => {
303289
return node.type === 'inArray' && Array.isArray(node.values) && node.values.length === 2
304290
})
305291
).toBe(true)
@@ -353,7 +339,7 @@ describe('restoreFolderCascade', () => {
353339
)
354340

355341
for (const call of updateCalls) {
356-
expect(hasCondition(call.where, (node) => node.right === TIMESTAMP)).toBe(true)
342+
expect(hasMockCondition(call.where, (node) => node.right === TIMESTAMP)).toBe(true)
357343
}
358344
})
359345
})
@@ -373,7 +359,7 @@ describe('restoreFolderRows', () => {
373359

374360
expect(folders).toBe(2)
375361
expect(updateCalls).toHaveLength(1)
376-
expect(hasCondition(updateCalls[0].where, (node) => node.right === TIMESTAMP)).toBe(true)
362+
expect(hasMockCondition(updateCalls[0].where, (node) => node.right === TIMESTAMP)).toBe(true)
377363
})
378364
})
379365

apps/sim/lib/folders/lifecycle.test.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
auditMock,
66
dbChainMock,
77
dbChainMockFns,
8+
flattenMockConditions,
89
queueTableRows,
910
resetDbChainMock,
1011
schemaMock,
@@ -67,16 +68,6 @@ import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/f
6768

6869
const CHILD_TABLE = { name: 'child_table' }
6970

70-
/** Flattens the nested `and(...)` objects the drizzle operator mocks produce. */
71-
function flattenConditions(condition: unknown): Array<Record<string, unknown>> {
72-
if (!condition || typeof condition !== 'object') return []
73-
const node = condition as Record<string, unknown>
74-
if (node.type === 'and' && Array.isArray(node.conditions)) {
75-
return node.conditions.flatMap(flattenConditions)
76-
}
77-
return [node]
78-
}
79-
8071
/** Stand-in for the per-resource config; each test declares only the deltas it exercises. */
8172
function setConfig(overrides: Record<string, unknown> = {}) {
8273
resourceConfig.current = {
@@ -266,12 +257,12 @@ describe('createFolder', () => {
266257
// parent condition is itself `isNull(parentId)`, so a presence-only check passes with the
267258
// soft-delete filter deleted. That made the first version of this test vacuous.
268259
expect(
269-
flattenConditions(folderWhere).some(
260+
flattenMockConditions(folderWhere).some(
270261
(node) => node.type === 'isNull' && node.column === schemaMock.folder.deletedAt
271262
)
272263
).toBe(true)
273264
expect(
274-
flattenConditions(childWhere).some(
265+
flattenMockConditions(childWhere).some(
275266
(node) => node.type === 'isNull' && node.column === 'child.archivedAt'
276267
)
277268
).toBe(true)

apps/sim/lib/folders/naming.test.ts

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { flattenMockConditions, hasMockCondition } from '@sim/testing'
45
import { describe, expect, it } from 'vitest'
56
import { deduplicateFolderName } from '@/lib/folders/naming'
67

@@ -27,23 +28,6 @@ function makeTx(siblingNames: string[]) {
2728
return { tx: tx as never, selectCalls }
2829
}
2930

30-
/** Flattens the nested `and(...)` objects the drizzle operator mocks produce. */
31-
function flattenConditions(condition: unknown): Array<Record<string, unknown>> {
32-
if (!condition || typeof condition !== 'object') return []
33-
const node = condition as Record<string, unknown>
34-
if (node.type === 'and' && Array.isArray(node.conditions)) {
35-
return node.conditions.flatMap(flattenConditions)
36-
}
37-
return [node]
38-
}
39-
40-
function hasCondition(
41-
condition: unknown,
42-
predicate: (node: Record<string, unknown>) => boolean
43-
): boolean {
44-
return flattenConditions(condition).some(predicate)
45-
}
46-
4731
/**
4832
* The suffix shape is a cross-surface contract: the client's `nextUntitledFolderName` and
4933
* migration 0272's backfill both produce `"<name> (N)"` starting at (1). A server-side drift
@@ -101,11 +85,13 @@ describe('deduplicateFolderName', () => {
10185

10286
expect(selectCalls).toHaveLength(1)
10387
const { where } = selectCalls[0]
104-
expect(hasCondition(where, (n) => n.type === 'eq' && n.right === 'ws-1')).toBe(true)
88+
expect(hasMockCondition(where, (n) => n.type === 'eq' && n.right === 'ws-1')).toBe(true)
10589
// Without this a knowledge-base folder would count table folders as siblings.
106-
expect(hasCondition(where, (n) => n.type === 'eq' && n.right === 'knowledge_base')).toBe(true)
90+
expect(hasMockCondition(where, (n) => n.type === 'eq' && n.right === 'knowledge_base')).toBe(
91+
true
92+
)
10793
// Root scope must be IS NULL, not eq(null), which matches nothing in SQL.
108-
expect(hasCondition(where, (n) => n.type === 'isNull')).toBe(true)
94+
expect(hasMockCondition(where, (n) => n.type === 'isNull')).toBe(true)
10995
})
11096

11197
it('scopes to the given parent when nested', async () => {
@@ -114,7 +100,7 @@ describe('deduplicateFolderName', () => {
114100
await deduplicateFolderName(tx, 'ws-1', 'parent-1', 'Reports', 'workflow')
115101

116102
expect(
117-
hasCondition(selectCalls[0].where, (n) => n.type === 'eq' && n.right === 'parent-1')
103+
hasMockCondition(selectCalls[0].where, (n) => n.type === 'eq' && n.right === 'parent-1')
118104
).toBe(true)
119105
})
120106

@@ -126,7 +112,7 @@ describe('deduplicateFolderName', () => {
126112
await deduplicateFolderName(tx, 'ws-1', null, 'Reports', 'workflow')
127113

128114
expect(
129-
flattenConditions(selectCalls[0].where).filter((n) => n.type === 'isNull')
115+
flattenMockConditions(selectCalls[0].where).filter((n) => n.type === 'isNull')
130116
).toHaveLength(2)
131117
})
132118
})

apps/sim/lib/folders/queries.test.ts

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
4+
import {
5+
dbChainMockFns,
6+
hasMockCondition,
7+
queueTableRows,
8+
resetDbChainMock,
9+
schemaMock,
10+
} from '@sim/testing'
511
import { beforeEach, describe, expect, it, vi } from 'vitest'
612
import {
713
findActiveFolder,
@@ -11,23 +17,6 @@ import {
1117
wouldCreateFolderCycle,
1218
} from '@/lib/folders/queries'
1319

14-
/** Flattens the nested `and(...)` objects the drizzle operator mocks produce. */
15-
function flattenConditions(condition: unknown): Array<Record<string, unknown>> {
16-
if (!condition || typeof condition !== 'object') return []
17-
const node = condition as Record<string, unknown>
18-
if (node.type === 'and' && Array.isArray(node.conditions)) {
19-
return node.conditions.flatMap(flattenConditions)
20-
}
21-
return [node]
22-
}
23-
24-
function hasCondition(
25-
condition: unknown,
26-
predicate: (node: Record<string, unknown>) => boolean
27-
): boolean {
28-
return flattenConditions(condition).some(predicate)
29-
}
30-
3120
/** The condition passed to the Nth `.where()` of this test. */
3221
function whereAt(index: number): unknown {
3322
return dbChainMockFns.where.mock.calls[index]?.[0]
@@ -68,11 +57,13 @@ describe('folder queries', () => {
6857
await findActiveFolder('f-1', 'ws-1', 'knowledge_base')
6958

7059
const where = whereAt(0)
71-
expect(hasCondition(where, (n) => n.type === 'eq' && n.right === 'f-1')).toBe(true)
72-
expect(hasCondition(where, (n) => n.type === 'eq' && n.right === 'ws-1')).toBe(true)
73-
expect(hasCondition(where, (n) => n.type === 'eq' && n.right === 'knowledge_base')).toBe(true)
60+
expect(hasMockCondition(where, (n) => n.type === 'eq' && n.right === 'f-1')).toBe(true)
61+
expect(hasMockCondition(where, (n) => n.type === 'eq' && n.right === 'ws-1')).toBe(true)
62+
expect(hasMockCondition(where, (n) => n.type === 'eq' && n.right === 'knowledge_base')).toBe(
63+
true
64+
)
7465
// Archived folders are not valid destinations — a row filed under one is unreachable.
75-
expect(hasCondition(where, (n) => n.type === 'isNull')).toBe(true)
66+
expect(hasMockCondition(where, (n) => n.type === 'isNull')).toBe(true)
7667
})
7768

7869
it('returns null when no row matches', async () => {
@@ -98,7 +89,7 @@ describe('folder queries', () => {
9889

9990
expect(dbChainMockFns.where.mock.calls.length).toBeGreaterThanOrEqual(2)
10091
for (const [where] of dbChainMockFns.where.mock.calls) {
101-
expect(hasCondition(where, (n) => n.type === 'eq' && n.right === 'table')).toBe(true)
92+
expect(hasMockCondition(where, (n) => n.type === 'eq' && n.right === 'table')).toBe(true)
10293
}
10394
})
10495

@@ -162,10 +153,10 @@ describe('folder queries', () => {
162153
await listFoldersForWorkspace('ws-1', 'active', 'table')
163154

164155
const where = whereAt(0)
165-
expect(hasCondition(where, (n) => n.type === 'eq' && n.right === 'ws-1')).toBe(true)
166-
expect(hasCondition(where, (n) => n.type === 'eq' && n.right === 'table')).toBe(true)
167-
expect(hasCondition(where, (n) => n.type === 'isNull')).toBe(true)
168-
expect(hasCondition(where, (n) => n.type === 'isNotNull')).toBe(false)
156+
expect(hasMockCondition(where, (n) => n.type === 'eq' && n.right === 'ws-1')).toBe(true)
157+
expect(hasMockCondition(where, (n) => n.type === 'eq' && n.right === 'table')).toBe(true)
158+
expect(hasMockCondition(where, (n) => n.type === 'isNull')).toBe(true)
159+
expect(hasMockCondition(where, (n) => n.type === 'isNotNull')).toBe(false)
169160
})
170161

171162
it('inverts the soft-delete filter for the archived scope', async () => {
@@ -174,8 +165,8 @@ describe('folder queries', () => {
174165
await listFoldersForWorkspace('ws-1', 'archived', 'workflow')
175166

176167
const where = whereAt(0)
177-
expect(hasCondition(where, (n) => n.type === 'isNotNull')).toBe(true)
178-
expect(hasCondition(where, (n) => n.type === 'isNull')).toBe(false)
168+
expect(hasMockCondition(where, (n) => n.type === 'isNotNull')).toBe(true)
169+
expect(hasMockCondition(where, (n) => n.type === 'isNull')).toBe(false)
179170
})
180171
})
181172

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,33 @@ export const drizzleOrmMock = {
421421
getTableColumns: vi.fn((table: Record<string, unknown>) => ({ ...table })),
422422
...createMockSqlOperators(),
423423
}
424+
425+
/**
426+
* Condition nodes produced by `createMockSqlOperators` — `{ type: 'eq', left, right }`,
427+
* `{ type: 'isNull', column }`, and so on.
428+
*/
429+
export type MockCondition = Record<string, unknown>
430+
431+
/**
432+
* Flattens the nested `and(...)` trees `createMockSqlOperators` builds into a flat node list.
433+
*
434+
* Tests assert on WHERE clauses to pin filters the row-queue mocks cannot enforce — a mock
435+
* returns whatever was queued regardless of the predicate, so "the query filters on X" is only
436+
* testable by inspecting the condition tree. `and()` nests arbitrarily, hence the flatten.
437+
*/
438+
export function flattenMockConditions(condition: unknown): MockCondition[] {
439+
if (!condition || typeof condition !== 'object') return []
440+
const node = condition as MockCondition
441+
if (node.type === 'and' && Array.isArray(node.conditions)) {
442+
return node.conditions.flatMap(flattenMockConditions)
443+
}
444+
return [node]
445+
}
446+
447+
/** True when any node in `condition` satisfies `predicate`. */
448+
export function hasMockCondition(
449+
condition: unknown,
450+
predicate: (node: MockCondition) => boolean
451+
): boolean {
452+
return flattenMockConditions(condition).some(predicate)
453+
}

packages/testing/src/mocks/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export {
4545
dbChainMock,
4646
dbChainMockFns,
4747
drizzleOrmMock,
48+
flattenMockConditions,
49+
hasMockCondition,
50+
type MockCondition,
4851
queueTableRows,
4952
resetDbChainMock,
5053
} from './database.mock'

0 commit comments

Comments
 (0)