diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index 141c166dc3b..6ea563bcec9 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -1,7 +1,9 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { permissionGroup } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { DEFAULT_PERMISSION_GROUP_CONFIG, @@ -10,8 +12,6 @@ const { mockGetWorkspaceWithOwner, mockGetProviderFromModel, mockGetBlock, - mockWorkspaceGroups, - mockDefaultGroup, } = vi.hoisted(() => ({ DEFAULT_PERMISSION_GROUP_CONFIG: { allowedIntegrations: null, @@ -44,59 +44,6 @@ const { mockGetWorkspaceWithOwner: vi.fn<() => Promise<{ organizationId: string | null } | null>>(), mockGetProviderFromModel: vi.fn<(model: string) => string>(), mockGetBlock: vi.fn<(type: string) => { hideFromToolbar?: boolean } | undefined>(), - // resolveWorkspaceGroup selects non-default groups targeting the workspace - // (FROM permissionGroup INNER JOIN permissionGroupWorkspace), awaiting the - // builder directly; each row carries `isMember`/`hasMembers` booleans. A row - // with neither flag set reads as an all-members group (hasMembers falsy). - // resolveDefaultGroup selects the org default directly with limit(1), no join. - // The db mock branches on whether an inner join was used. - mockWorkspaceGroups: { - value: [] as Array<{ - id?: string - name?: string - config: Record - isMember?: boolean - hasMembers?: boolean - }>, - }, - mockDefaultGroup: { value: [] as Array<{ config: Record }> }, -})) - -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn().mockImplementation(() => { - let usedInnerJoin = false - const resolveRows = () => (usedInnerJoin ? mockWorkspaceGroups.value : mockDefaultGroup.value) - const chain: Record = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.innerJoin = vi.fn().mockImplementation(() => { - usedInnerJoin = true - return chain - }) - chain.leftJoin = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockReturnValue(chain) - chain.orderBy = vi.fn().mockReturnValue(chain) - chain.limit = vi.fn().mockImplementation(() => Promise.resolve(resolveRows())) - // resolveWorkspaceGroup awaits the builder directly after `orderBy` (no - // limit), so the chain must be thenable. - chain.then = (onFulfilled: (rows: unknown) => unknown) => - Promise.resolve(resolveRows()).then(onFulfilled) - return chain - }), - }, -})) - -vi.mock('@sim/db/schema', () => ({ - permissionGroup: {}, - permissionGroupMember: {}, - permissionGroupWorkspace: {}, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - asc: vi.fn(), - sql: vi.fn(), })) vi.mock('@/lib/billing', () => ({ @@ -157,6 +104,34 @@ function setEnterpriseOrgWorkspace() { mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) } +interface WorkspaceGroupRow { + id?: string + name?: string + config: Record + isMember?: boolean + hasMembers?: boolean +} + +/** + * Queue one group-resolution pass. resolveWorkspaceGroup selects non-default + * groups targeting the workspace first (FROM permissionGroup INNER JOIN + * permissionGroupWorkspace, awaited at `orderBy`); each row carries + * `isMember`/`hasMembers` booleans, and a row with neither flag set reads as + * an all-members group. Only when no workspace group wins does + * resolveDefaultGroup select the org default (also FROM permissionGroup, with + * `limit(1)`). Both selects read the same table, so the queue holds the + * workspace-group set first and the default-group set second. + */ +function queueGroupResolution( + workspaceGroups: WorkspaceGroupRow[] = [], + defaultGroup: Array<{ config: Record }> = [] +) { + queueTableRows(permissionGroup, workspaceGroups) + queueTableRows(permissionGroup, defaultGroup) +} + +afterAll(resetDbChainMock) + /** * Default every block to non-legacy. `vi.clearAllMocks()` (used by the * describe-level hooks) keeps implementations, so reset here to stop a legacy @@ -185,8 +160,7 @@ describe('IntegrationNotAllowedError', () => { describe('getUserPermissionConfig (org + entitlement gating)', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) }) @@ -219,8 +193,7 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { it('falls back to the org default group when no workspace group governs the user', async () => { setEnterpriseOrgWorkspace() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [{ config: { disableSkills: true } }] + queueGroupResolution([], [{ config: { disableSkills: true } }]) const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -229,8 +202,7 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { it('governs an external member via the org default group', async () => { setEnterpriseOrgWorkspace() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [{ config: { disableCustomTools: true } }] + queueGroupResolution([], [{ config: { disableCustomTools: true } }]) const config = await getUserPermissionConfig('external-user', 'workspace-1') @@ -239,9 +211,6 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { it('returns null when no workspace group and no default group apply', async () => { setEnterpriseOrgWorkspace() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] - const config = await getUserPermissionConfig('user-123', 'workspace-1') expect(config).toBeNull() @@ -251,16 +220,15 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { describe('getUserPermissionConfig (workspace-group precedence)', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() }) it('governs an explicit member via their workspace group', async () => { - mockWorkspaceGroups.value = [ + queueGroupResolution([ { id: 'g', config: { disableMcpTools: true }, isMember: true, hasMembers: true }, - ] + ]) const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -268,9 +236,9 @@ describe('getUserPermissionConfig (workspace-group precedence)', () => { }) it('governs all members (including non-listed) via an all-members group', async () => { - mockWorkspaceGroups.value = [ + queueGroupResolution([ { id: 'g', config: { disableSkills: true }, isMember: false, hasMembers: false }, - ] + ]) const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -278,9 +246,9 @@ describe('getUserPermissionConfig (workspace-group precedence)', () => { }) it('governs an external member via an all-members group', async () => { - mockWorkspaceGroups.value = [ + queueGroupResolution([ { id: 'g', config: { disableCustomTools: true }, isMember: false, hasMembers: false }, - ] + ]) const config = await getUserPermissionConfig('external-user', 'workspace-1') @@ -288,10 +256,10 @@ describe('getUserPermissionConfig (workspace-group precedence)', () => { }) it('prefers an explicit-member group over an all-members group on the same workspace', async () => { - mockWorkspaceGroups.value = [ + queueGroupResolution([ { id: 'all', config: { disableMcpTools: true }, isMember: false, hasMembers: false }, { id: 'explicit', config: { disableSkills: true }, isMember: true, hasMembers: true }, - ] + ]) const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -300,10 +268,10 @@ describe('getUserPermissionConfig (workspace-group precedence)', () => { }) it('a narrowed group (has members) does not govern a non-member; falls back to default', async () => { - mockWorkspaceGroups.value = [ - { id: 'narrowed', config: { disableSkills: true }, isMember: false, hasMembers: true }, - ] - mockDefaultGroup.value = [{ config: { disableCustomTools: true } }] + queueGroupResolution( + [{ id: 'narrowed', config: { disableSkills: true }, isMember: false, hasMembers: true }], + [{ config: { disableCustomTools: true } }] + ) const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -312,10 +280,9 @@ describe('getUserPermissionConfig (workspace-group precedence)', () => { }) it('a narrowed group does not govern a non-member; unrestricted when no default', async () => { - mockWorkspaceGroups.value = [ + queueGroupResolution([ { id: 'narrowed', config: { disableSkills: true }, isMember: false, hasMembers: true }, - ] - mockDefaultGroup.value = [] + ]) const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -326,8 +293,7 @@ describe('getUserPermissionConfig (workspace-group precedence)', () => { describe('validateBlockType', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() }) describe('when no env allowlist is configured', () => { @@ -416,8 +382,7 @@ describe('validateBlockType', () => { describe('validateModelProvider', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() }) @@ -428,7 +393,7 @@ describe('validateModelProvider', () => { }) it('throws ProviderNotAllowedError when provider is not in allowlist', async () => { - mockWorkspaceGroups.value = [{ config: { allowedModelProviders: ['anthropic'] } }] + queueGroupResolution([{ config: { allowedModelProviders: ['anthropic'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf( @@ -437,14 +402,14 @@ describe('validateModelProvider', () => { }) it('allows when provider is on the allowlist', async () => { - mockWorkspaceGroups.value = [{ config: { allowedModelProviders: ['anthropic', 'openai'] } }] + queueGroupResolution([{ config: { allowedModelProviders: ['anthropic', 'openai'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await validateModelProvider('user-123', 'workspace-1', 'gpt-4') }) it('throws ModelNotAllowedError when the model is on the denylist', async () => { - mockWorkspaceGroups.value = [{ config: { deniedModels: ['gpt-4'] } }] + queueGroupResolution([{ config: { deniedModels: ['gpt-4'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf( @@ -453,7 +418,7 @@ describe('validateModelProvider', () => { }) it('denylist match is case-insensitive', async () => { - mockWorkspaceGroups.value = [{ config: { deniedModels: ['Ollama/Llama3'] } }] + queueGroupResolution([{ config: { deniedModels: ['Ollama/Llama3'] } }]) mockGetProviderFromModel.mockReturnValue('ollama') await expect( @@ -462,9 +427,7 @@ describe('validateModelProvider', () => { }) it('enforces the denylist even when no provider allowlist is set', async () => { - mockWorkspaceGroups.value = [ - { config: { allowedModelProviders: null, deniedModels: ['gpt-4'] } }, - ] + queueGroupResolution([{ config: { allowedModelProviders: null, deniedModels: ['gpt-4'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf( @@ -473,15 +436,14 @@ describe('validateModelProvider', () => { }) it('allows a model that is not on the denylist', async () => { - mockWorkspaceGroups.value = [{ config: { deniedModels: ['gpt-4'] } }] + queueGroupResolution([{ config: { deniedModels: ['gpt-4'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await validateModelProvider('user-123', 'workspace-1', 'gpt-4o') }) it('applies the org default group when no workspace group governs the user', async () => { - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [{ config: { allowedModelProviders: ['anthropic'] } }] + queueGroupResolution([], [{ config: { allowedModelProviders: ['anthropic'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf( @@ -493,14 +455,13 @@ describe('validateModelProvider', () => { describe('validateMcpToolsAllowed', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() }) it('throws McpToolsNotAllowedError when disableMcpTools is set', async () => { - mockWorkspaceGroups.value = [{ config: { disableMcpTools: true } }] + queueGroupResolution([{ config: { disableMcpTools: true } }]) await expect(validateMcpToolsAllowed('user-123', 'workspace-1')).rejects.toBeInstanceOf( McpToolsNotAllowedError @@ -508,7 +469,7 @@ describe('validateMcpToolsAllowed', () => { }) it('no-ops when disableMcpTools is false', async () => { - mockWorkspaceGroups.value = [{ config: {} }] + queueGroupResolution([{ config: {} }]) await validateMcpToolsAllowed('user-123', 'workspace-1') }) @@ -517,38 +478,37 @@ describe('validateMcpToolsAllowed', () => { describe('validatePublicFileSharing', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() }) it('throws when public file sharing is fully disabled', async () => { - mockWorkspaceGroups.value = [{ config: { disablePublicFileSharing: true } }] + queueGroupResolution([{ config: { disablePublicFileSharing: true } }]) await expect( validatePublicFileSharing('user-123', 'workspace-1', 'password') ).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError) }) it('throws when the auth type is not in the allow-list', async () => { - mockWorkspaceGroups.value = [{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }] + queueGroupResolution([{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }]) await expect( validatePublicFileSharing('user-123', 'workspace-1', 'public') ).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError) }) it('allows an auth type that is in the allow-list', async () => { - mockWorkspaceGroups.value = [{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }] + queueGroupResolution([{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }]) await validatePublicFileSharing('user-123', 'workspace-1', 'password') }) it('allows any auth type when the allow-list is null', async () => { - mockWorkspaceGroups.value = [{ config: { allowedFileShareAuthTypes: null } }] + queueGroupResolution([{ config: { allowedFileShareAuthTypes: null } }]) await validatePublicFileSharing('user-123', 'workspace-1', 'email') }) it('no-ops when no auth type is provided (master switch only)', async () => { - mockWorkspaceGroups.value = [{ config: { allowedFileShareAuthTypes: ['password'] } }] + queueGroupResolution([{ config: { allowedFileShareAuthTypes: ['password'] } }]) await validatePublicFileSharing('user-123', 'workspace-1') }) }) @@ -556,26 +516,25 @@ describe('validatePublicFileSharing', () => { describe('validateChatDeployAuth', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() }) it('throws when the auth type is not in the allow-list', async () => { - mockWorkspaceGroups.value = [{ config: { allowedChatDeployAuthTypes: ['password', 'sso'] } }] + queueGroupResolution([{ config: { allowedChatDeployAuthTypes: ['password', 'sso'] } }]) await expect( validateChatDeployAuth('user-123', 'workspace-1', 'public') ).rejects.toBeInstanceOf(ChatDeployAuthNotAllowedError) }) it('allows an auth type that is in the allow-list', async () => { - mockWorkspaceGroups.value = [{ config: { allowedChatDeployAuthTypes: ['password', 'sso'] } }] + queueGroupResolution([{ config: { allowedChatDeployAuthTypes: ['password', 'sso'] } }]) await validateChatDeployAuth('user-123', 'workspace-1', 'password') }) it('allows any auth type when the allow-list is null', async () => { - mockWorkspaceGroups.value = [{ config: { allowedChatDeployAuthTypes: null } }] + queueGroupResolution([{ config: { allowedChatDeployAuthTypes: null } }]) await validateChatDeployAuth('user-123', 'workspace-1', 'email') }) @@ -588,14 +547,13 @@ describe('validateChatDeployAuth', () => { describe('assertPermissionsAllowed', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() }) it('throws ProviderNotAllowedError when model provider is blocked', async () => { - mockWorkspaceGroups.value = [{ config: { allowedModelProviders: ['anthropic'] } }] + queueGroupResolution([{ config: { allowedModelProviders: ['anthropic'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await expect( @@ -608,7 +566,7 @@ describe('assertPermissionsAllowed', () => { }) it('throws ModelNotAllowedError when the model is on the denylist', async () => { - mockWorkspaceGroups.value = [{ config: { deniedModels: ['gpt-4'] } }] + queueGroupResolution([{ config: { deniedModels: ['gpt-4'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await expect( @@ -621,7 +579,7 @@ describe('assertPermissionsAllowed', () => { }) it('throws IntegrationNotAllowedError when block type is blocked', async () => { - mockWorkspaceGroups.value = [{ config: { allowedIntegrations: ['slack'] } }] + queueGroupResolution([{ config: { allowedIntegrations: ['slack'] } }]) await expect( assertPermissionsAllowed({ @@ -633,7 +591,7 @@ describe('assertPermissionsAllowed', () => { }) it('exempts legacy blocks from the integration allowlist', async () => { - mockWorkspaceGroups.value = [{ config: { allowedIntegrations: ['slack'] } }] + queueGroupResolution([{ config: { allowedIntegrations: ['slack'] } }]) mockGetBlock.mockImplementation((type) => type === 'notion' ? { hideFromToolbar: true } : undefined ) @@ -646,7 +604,7 @@ describe('assertPermissionsAllowed', () => { }) it('throws ToolNotAllowedError when the tool is on the denylist', async () => { - mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }] + queueGroupResolution([{ config: { deniedTools: ['slack_canvas'] } }]) await expect( assertPermissionsAllowed({ @@ -658,7 +616,7 @@ describe('assertPermissionsAllowed', () => { }) it('allows a tool that is not on the denylist', async () => { - mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }] + queueGroupResolution([{ config: { deniedTools: ['slack_canvas'] } }]) await assertPermissionsAllowed({ userId: 'user-123', @@ -668,7 +626,7 @@ describe('assertPermissionsAllowed', () => { }) it('allows every tool when the denylist is empty', async () => { - mockWorkspaceGroups.value = [{ config: { deniedTools: [] } }] + queueGroupResolution([{ config: { deniedTools: [] } }]) await assertPermissionsAllowed({ userId: 'user-123', @@ -678,9 +636,9 @@ describe('assertPermissionsAllowed', () => { }) it('denies a tool even when its block is allowed by the integration allowlist', async () => { - mockWorkspaceGroups.value = [ + queueGroupResolution([ { config: { allowedIntegrations: ['slack'], deniedTools: ['slack_canvas'] } }, - ] + ]) await expect( assertPermissionsAllowed({ @@ -693,7 +651,7 @@ describe('assertPermissionsAllowed', () => { }) it('still enforces the tool denylist for an exempt block type', async () => { - mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }] + queueGroupResolution([{ config: { deniedTools: ['slack_canvas'] } }]) mockGetBlock.mockImplementation((type) => type === 'slack' ? { hideFromToolbar: true } : undefined ) @@ -709,7 +667,7 @@ describe('assertPermissionsAllowed', () => { }) it('throws CustomToolsNotAllowedError when custom tools are disabled', async () => { - mockWorkspaceGroups.value = [{ config: { disableCustomTools: true } }] + queueGroupResolution([{ config: { disableCustomTools: true } }]) await expect( assertPermissionsAllowed({ @@ -721,7 +679,7 @@ describe('assertPermissionsAllowed', () => { }) it('throws SkillsNotAllowedError when skills are disabled', async () => { - mockWorkspaceGroups.value = [{ config: { disableSkills: true } }] + queueGroupResolution([{ config: { disableSkills: true } }]) await expect( assertPermissionsAllowed({ @@ -733,9 +691,6 @@ describe('assertPermissionsAllowed', () => { }) it('passes when the workspace has no blocking config', async () => { - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] - await assertPermissionsAllowed({ userId: 'user-123', workspaceId: 'workspace-1', diff --git a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts index 0139b9c738c..907f08c9cf1 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts @@ -2,72 +2,14 @@ * @vitest-environment node */ import { knowledgeBase, workflow, workflowBlocks, workflowDeploymentVersion } from '@sim/db/schema' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const dbMock = vi.hoisted(() => { - const reads = new Map() - const updates: Array<{ table: unknown; values: Record }> = [] - const deletes: Array<{ table: unknown }> = [] - - const nextPage = (table: unknown): unknown[] => { - const pages = reads.get(table) - return pages && pages.length > 0 ? (pages.shift() as unknown[]) : [] - } - - // A drizzle-style read builder bound to one table: `.where`/`.orderBy`/`.limit` chain back to - // the same builder, and awaiting it (at `.where()` or `.limit()`) shifts that table's next page. - const makeReadBuilder = (table: unknown) => { - const builder = { - where: () => builder, - orderBy: () => builder, - limit: () => builder, - then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (error: unknown) => unknown) => - Promise.resolve(nextPage(table)).then(onFulfilled, onRejected), - } - return builder - } - - const db = { - select: () => ({ from: (table: unknown) => makeReadBuilder(table) }), - update: (table: unknown) => ({ - set: (values: Record) => ({ - where: () => { - updates.push({ table, values }) - return Promise.resolve([]) - }, - }), - }), - delete: (table: unknown) => ({ - where: () => { - deletes.push({ table }) - return Promise.resolve([]) - }, - }), - } - - return { - db, - updates, - deletes, - queueRead: (table: unknown, ...pages: unknown[][]) => reads.set(table, pages), - reset: () => { - reads.clear() - updates.length = 0 - deletes.length = 0 - }, - } -}) +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockInvalidateDeployedStateCache } = vi.hoisted(() => ({ mockInvalidateDeployedStateCache: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: dbMock.db, - dbReplica: dbMock.db, - runOutsideTransactionContext: (fn: () => T): T => fn(), - instrumentPoolClient: (client: T): T => client, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/workflows/persistence/utils', () => ({ invalidateDeployedStateCache: mockInvalidateDeployedStateCache, @@ -197,10 +139,22 @@ const kbValue = (state: unknown) => const docValue = (state: unknown) => (state as StateBlocks).blocks['block-1'].subBlocks.documentId.value +/** Every `update(table).set(values)` pair, in call order (one `set` per `update`). */ +const updates = () => + dbChainMockFns.update.mock.calls.map((call, index) => ({ + table: call[0], + values: dbChainMockFns.set.mock.calls[index]?.[0] as Record, + })) + +/** The table of every `delete(table)` call, in call order. */ +const deletes = () => dbChainMockFns.delete.mock.calls.map((call) => ({ table: call[0] })) + +afterAll(resetDbChainMock) + describe('cleanup-failed', () => { beforeEach(() => { vi.clearAllMocks() - dbMock.reset() + resetDbChainMock() vi.mocked(getBlock).mockReturnValue(kbBlockConfig()) }) @@ -248,82 +202,82 @@ describe('cleanup-failed', () => { describe('clearFailedReferencesInWorkflows', () => { it('sweeps the draft blocks and returns the affected workflow ids', async () => { - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')]) + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('failed-kb')]) const affected = await clearFailedReferencesInWorkflows('child-ws', failedByKind(), 'test') expect([...affected]).toEqual(['wf-1']) - expect(dbMock.updates).toHaveLength(1) - expect(dbMock.updates[0].table).toBe(workflowBlocks) - const cleared = dbMock.updates[0].values.subBlocks as Record + expect(updates()).toHaveLength(1) + expect(updates()[0].table).toBe(workflowBlocks) + const cleared = updates()[0].values.subBlocks as Record expect(cleared.knowledgeBaseId.value).toBe('') expect(cleared.documentId.value).toBe('') }) it('returns an empty set and writes nothing when no block references a failed id', async () => { - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')]) + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('other-kb')]) const affected = await clearFailedReferencesInWorkflows('child-ws', failedByKind(), 'test') expect(affected.size).toBe(0) - expect(dbMock.updates).toHaveLength(0) + expect(updates()).toHaveLength(0) }) }) describe('clearFailedReferencesInDeploymentVersions', () => { it('rewrites a version referencing a failed id and invalidates its deployed-state cache', async () => { - dbMock.queueRead(workflowDeploymentVersion, [ + queueTableRows(workflowDeploymentVersion, [ { id: 'dv-1', version: 5, state: versionState('failed-kb') }, ]) await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test') - expect(dbMock.updates).toHaveLength(1) - expect(dbMock.updates[0].table).toBe(workflowDeploymentVersion) - expect(kbValue(dbMock.updates[0].values.state)).toBe('') - expect(docValue(dbMock.updates[0].values.state)).toBe('') + expect(updates()).toHaveLength(1) + expect(updates()[0].table).toBe(workflowDeploymentVersion) + expect(kbValue(updates()[0].values.state)).toBe('') + expect(docValue(updates()[0].values.state)).toBe('') expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1) expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-1') }) it('leaves a version that does not reference a failed id unwritten and uncached', async () => { - dbMock.queueRead(workflowDeploymentVersion, [ + queueTableRows(workflowDeploymentVersion, [ { id: 'dv-old', version: 3, state: versionState('other-kb') }, ]) await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test') - expect(dbMock.updates).toHaveLength(0) + expect(updates()).toHaveLength(0) expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled() }) it('writes only the changed version when a workflow mixes referencing and non-referencing versions', async () => { - dbMock.queueRead(workflowDeploymentVersion, [ + queueTableRows(workflowDeploymentVersion, [ { id: 'dv-active', version: 5, state: versionState('failed-kb') }, { id: 'dv-old', version: 4, state: versionState('other-kb') }, ]) await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test') - expect(dbMock.updates).toHaveLength(1) + expect(updates()).toHaveLength(1) expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1) expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-active') }) it('does nothing when no workflows were affected', async () => { await clearFailedReferencesInDeploymentVersions(new Set(), failedByKind(), 'test') - expect(dbMock.updates).toHaveLength(0) + expect(updates()).toHaveLength(0) expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled() }) }) describe('clearFailedForkResourceReferences', () => { it('threads the draft sweep into the deployed sweep, then drops the placeholder', async () => { - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')]) - dbMock.queueRead(workflowDeploymentVersion, [ + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('failed-kb')]) + queueTableRows(workflowDeploymentVersion, [ { id: 'dv-active', version: 5, state: versionState('failed-kb') }, { id: 'dv-old', version: 4, state: versionState('other-kb') }, ]) @@ -336,18 +290,18 @@ describe('cleanup-failed', () => { expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) // One draft block update + one deployed version update (only the referencing version). - const updatedTables = dbMock.updates.map((u) => u.table) + const updatedTables = updates().map((u) => u.table) expect(updatedTables).toEqual([workflowBlocks, workflowDeploymentVersion]) expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1) expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-active') // The orphaned KB placeholder is dropped after both sweeps. - expect(dbMock.deletes).toHaveLength(1) - expect(dbMock.deletes[0].table).toBe(knowledgeBase) + expect(deletes()).toHaveLength(1) + expect(deletes()[0].table).toBe(knowledgeBase) }) it('still drops the placeholder when no workflow referenced the failed resource', async () => { - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')]) + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('other-kb')]) const cleaned = await clearFailedForkResourceReferences({ childWorkspaceId: 'child-ws', @@ -358,18 +312,18 @@ describe('cleanup-failed', () => { expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) // No draft block referenced the failed id AND no deployed targets were threaded, so the // deployed sweep is skipped entirely. - expect(dbMock.updates).toHaveLength(0) + expect(updates()).toHaveLength(0) expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled() - expect(dbMock.deletes).toHaveLength(1) - expect(dbMock.deletes[0].table).toBe(knowledgeBase) + expect(deletes()).toHaveLength(1) + expect(deletes()[0].table).toBe(knowledgeBase) }) it('sweeps a deployed target version even when no draft referenced the failed id', async () => { // Draft is clean (other-kb), but a deployed target version still points at the dropped // placeholder - the deployed-target scope (not draft divergence) catches it. - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')]) - dbMock.queueRead(workflowDeploymentVersion, [ + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('other-kb')]) + queueTableRows(workflowDeploymentVersion, [ { id: 'dv-1', version: 5, state: versionState('failed-kb') }, ]) @@ -381,15 +335,15 @@ describe('cleanup-failed', () => { }) expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) - expect(dbMock.updates.map((u) => u.table)).toContain(workflowDeploymentVersion) + expect(updates().map((u) => u.table)).toContain(workflowDeploymentVersion) expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-1') // Clearing succeeded, so the placeholder is dropped. - expect(dbMock.deletes[0].table).toBe(knowledgeBase) + expect(deletes()[0].table).toBe(knowledgeBase) }) it('clears a file-upload reference to a failed copied blob and drops no row', async () => { - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [fileBlockRow('workspace/child/failed.png')]) + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [fileBlockRow('workspace/child/failed.png')]) const cleaned = await clearFailedForkResourceReferences({ childWorkspaceId: 'child-ws', @@ -398,36 +352,31 @@ describe('cleanup-failed', () => { }) expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) - expect(dbMock.updates).toHaveLength(1) - expect(dbMock.updates[0].table).toBe(workflowBlocks) - const cleared = dbMock.updates[0].values.subBlocks as Record + expect(updates()).toHaveLength(1) + expect(updates()[0].table).toBe(workflowBlocks) + const cleared = updates()[0].values.subBlocks as Record expect(cleared.file.value).toBe('') // A failed file has no placeholder row to drop (the metadata row stays re-uploadable). - expect(dbMock.deletes).toHaveLength(0) + expect(deletes()).toHaveLength(0) }) it('reports cleared:0 + clearingFailed and skips the placeholder drop when a clear phase throws', async () => { // A clear-phase failure must not drop the placeholder: that would turn an empty placeholder // into a dangling reference to a deleted row. Make the draft block UPDATE throw. - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')]) - const originalUpdate = dbMock.db.update - dbMock.db.update = () => { + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('failed-kb')]) + dbChainMockFns.update.mockImplementation(() => { throw new Error('update failed') - } - try { - const cleaned = await clearFailedForkResourceReferences({ - childWorkspaceId: 'child-ws', - failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }], - requestId: 'test', - }) - // The count must NOT overstate: nothing was cleared and the flag marks cleanup incomplete. - expect(cleaned).toEqual({ cleared: 0, clearingFailed: true }) - } finally { - dbMock.db.update = originalUpdate - } + }) + const cleaned = await clearFailedForkResourceReferences({ + childWorkspaceId: 'child-ws', + failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }], + requestId: 'test', + }) + // The count must NOT overstate: nothing was cleared and the flag marks cleanup incomplete. + expect(cleaned).toEqual({ cleared: 0, clearingFailed: true }) // The drop is skipped, so the placeholder row survives (no delete issued). - expect(dbMock.deletes).toHaveLength(0) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) }) }) diff --git a/apps/sim/ee/workspace-forking/lib/lineage/unlink.test.ts b/apps/sim/ee/workspace-forking/lib/lineage/unlink.test.ts index f2a34ce1c25..8ce5fc2f4d4 100644 --- a/apps/sim/ee/workspace-forking/lib/lineage/unlink.test.ts +++ b/apps/sim/ee/workspace-forking/lib/lineage/unlink.test.ts @@ -1,15 +1,15 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockTransaction, mockSetForkLockTimeout, mockAcquireForkEdgeLock } = vi.hoisted(() => ({ - mockTransaction: vi.fn(), +const { mockSetForkLockTimeout, mockAcquireForkEdgeLock } = vi.hoisted(() => ({ mockSetForkLockTimeout: vi.fn(), mockAcquireForkEdgeLock: vi.fn(), })) -vi.mock('@sim/db', () => ({ db: { transaction: mockTransaction } })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ setForkLockTimeout: mockSetForkLockTimeout, acquireForkEdgeLock: mockAcquireForkEdgeLock, @@ -17,49 +17,40 @@ vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink' -/** A fake tx whose update returns `updatedRows` and whose deletes record their calls. */ -function fakeTx(updatedRows: Array<{ id: string }>) { - const updateWhere = vi.fn(() => ({ returning: vi.fn().mockResolvedValue(updatedRows) })) - const updateSet = vi.fn(() => ({ where: updateWhere })) - const update = vi.fn(() => ({ set: updateSet })) - const deleteWhere = vi.fn().mockResolvedValue(undefined) - const del = vi.fn(() => ({ where: deleteWhere })) - return { tx: { update, delete: del }, update, updateSet, del } -} - const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' } +afterAll(resetDbChainMock) + describe('unlinkForkEdge', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it('nulls the child pointer and purges all four edge tables under the edge lock', async () => { - const { tx, update, updateSet, del } = fakeTx([{ id: 'child-ws' }]) - mockTransaction.mockImplementation(async (cb: (t: unknown) => unknown) => cb(tx)) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'child-ws' }]) const result = await unlinkForkEdge(EDGE, 'req-1') expect(result).toEqual({ unlinked: true }) expect(mockSetForkLockTimeout).toHaveBeenCalledTimes(1) - expect(mockAcquireForkEdgeLock).toHaveBeenCalledWith(tx, 'child-ws') - expect(update).toHaveBeenCalledTimes(1) - expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ forkedFromWorkspaceId: null })) - expect(del).toHaveBeenCalledTimes(4) + expect(mockAcquireForkEdgeLock).toHaveBeenCalledWith(dbChainMock.db, 'child-ws') + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ forkedFromWorkspaceId: null }) + ) + expect(dbChainMockFns.delete).toHaveBeenCalledTimes(4) }) it('is an idempotent no-op when the edge was already dissolved', async () => { - const { tx, del } = fakeTx([]) - mockTransaction.mockImplementation(async (cb: (t: unknown) => unknown) => cb(tx)) - const result = await unlinkForkEdge(EDGE) expect(result).toEqual({ unlinked: false }) - expect(del).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) it('propagates a transaction failure without swallowing it', async () => { - mockTransaction.mockRejectedValue(new Error('lock timeout')) + dbChainMockFns.transaction.mockRejectedValueOnce(new Error('lock timeout')) await expect(unlinkForkEdge(EDGE)).rejects.toThrow('lock timeout') }) }) diff --git a/apps/sim/lib/admin/external-collaborators.test.ts b/apps/sim/lib/admin/external-collaborators.test.ts index 61cc5cb6960..8cdff708d0b 100644 --- a/apps/sim/lib/admin/external-collaborators.test.ts +++ b/apps/sim/lib/admin/external-collaborators.test.ts @@ -1,10 +1,11 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { member, permissions } from '@sim/db/schema' +import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - rows: [] as unknown[][], setLimit: vi.fn(), acquireLock: vi.fn(), recordAudit: vi.fn(), @@ -16,22 +17,7 @@ vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit, })) -vi.mock('@sim/db', () => { - const makeSelectChain = () => { - const chain: Record = {} - chain.from = () => chain - chain.innerJoin = () => chain - chain.where = () => chain - chain.limit = () => Promise.resolve(mocks.rows.shift() ?? []) - return chain - } - const tx = { select: () => makeSelectChain() } - return { - db: { - transaction: async (operation: (executor: typeof tx) => Promise) => operation(tx), - }, - } -}) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/organizations/member-limits', () => ({ setOrgMemberUsageLimit: mocks.setLimit, @@ -44,14 +30,17 @@ import { updateDashboardExternalCollaboratorUsageLimit } from '@/lib/admin/exter const actor = { id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' } +afterAll(resetDbChainMock) + describe('updateDashboardExternalCollaboratorUsageLimit', () => { beforeEach(() => { vi.clearAllMocks() - mocks.rows = [] + resetDbChainMock() }) it('sets a cap through the canonical organization usage-limit service', async () => { - mocks.rows = [[], [{ userId: 'external-1' }]] + queueTableRows(member, []) + queueTableRows(permissions, [{ userId: 'external-1' }]) await updateDashboardExternalCollaboratorUsageLimit('org-1', 'external-1', 30, actor) @@ -73,7 +62,8 @@ describe('updateDashboardExternalCollaboratorUsageLimit', () => { }) it('clears an existing cap', async () => { - mocks.rows = [[], [{ userId: 'external-1' }]] + queueTableRows(member, []) + queueTableRows(permissions, [{ userId: 'external-1' }]) await updateDashboardExternalCollaboratorUsageLimit('org-1', 'external-1', null, actor) @@ -87,7 +77,7 @@ describe('updateDashboardExternalCollaboratorUsageLimit', () => { }) it('rejects internal organization members', async () => { - mocks.rows = [[{ id: 'member-1' }]] + queueTableRows(member, [{ id: 'member-1' }]) await expect( updateDashboardExternalCollaboratorUsageLimit('org-1', 'user-1', 100, actor) @@ -97,7 +87,8 @@ describe('updateDashboardExternalCollaboratorUsageLimit', () => { }) it('rejects users without a current non-archived workspace permission', async () => { - mocks.rows = [[], []] + queueTableRows(member, []) + queueTableRows(permissions, []) await expect( updateDashboardExternalCollaboratorUsageLimit('org-1', 'user-1', 100, actor) diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index 6b288b6213f..aec86b6e6b1 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -1,22 +1,14 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockOrderBy, mockDecryptSecret } = vi.hoisted(() => ({ - mockOrderBy: vi.fn(), +const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ orderBy: mockOrderBy })), - })), - })), - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, @@ -62,10 +54,12 @@ const uniqueWorkspaceId = () => `workspace-${++testIndex}` const storedKey = (id: string) => ({ id, encryptedApiKey: `encrypted-${id}` }) +afterAll(resetDbChainMock) + describe('getBYOKKey', () => { beforeEach(() => { vi.clearAllMocks() - mockOrderBy.mockResolvedValue([]) + resetDbChainMock() mockDecryptSecret.mockImplementation(async (encrypted: string) => ({ decrypted: encrypted.replace('encrypted-', 'decrypted-'), })) @@ -82,7 +76,7 @@ describe('getBYOKKey', () => { it('returns the same key on every call when only one key is stored', async () => { const workspaceId = uniqueWorkspaceId() - mockOrderBy.mockResolvedValue([storedKey('key-1')]) + dbChainMockFns.orderBy.mockResolvedValue([storedKey('key-1')]) for (let call = 0; call < 3; call++) { expect(await getBYOKKey(workspaceId, 'openai')).toEqual({ @@ -94,7 +88,11 @@ describe('getBYOKKey', () => { it('round-robins across multiple keys in creation order', async () => { const workspaceId = uniqueWorkspaceId() - mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2'), storedKey('key-3')]) + dbChainMockFns.orderBy.mockResolvedValue([ + storedKey('key-1'), + storedKey('key-2'), + storedKey('key-3'), + ]) const apiKeys = [] for (let call = 0; call < 4; call++) { @@ -112,18 +110,18 @@ describe('getBYOKKey', () => { it('reads the key list fresh from the database on every call', async () => { const workspaceId = uniqueWorkspaceId() - mockOrderBy.mockResolvedValue([storedKey('key-1')]) + dbChainMockFns.orderBy.mockResolvedValue([storedKey('key-1')]) await getBYOKKey(workspaceId, 'openai') await getBYOKKey(workspaceId, 'openai') await getBYOKKey(workspaceId, 'openai') - expect(mockOrderBy).toHaveBeenCalledTimes(3) + expect(dbChainMockFns.orderBy).toHaveBeenCalledTimes(3) }) it('tracks rotation independently per provider within a workspace', async () => { const workspaceId = uniqueWorkspaceId() - mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')]) + dbChainMockFns.orderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')]) expect((await getBYOKKey(workspaceId, 'openai'))?.apiKey).toBe('decrypted-key-1') expect((await getBYOKKey(workspaceId, 'anthropic'))?.apiKey).toBe('decrypted-key-1') @@ -132,7 +130,7 @@ describe('getBYOKKey', () => { it('skips a key that fails to decrypt and returns the next one', async () => { const workspaceId = uniqueWorkspaceId() - mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')]) + dbChainMockFns.orderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')]) mockDecryptSecret.mockImplementation(async (encrypted: string) => { if (encrypted === 'encrypted-key-1') { throw new Error('corrupt ciphertext') @@ -148,14 +146,14 @@ describe('getBYOKKey', () => { it('returns null when every key fails to decrypt', async () => { const workspaceId = uniqueWorkspaceId() - mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')]) + dbChainMockFns.orderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')]) mockDecryptSecret.mockRejectedValue(new Error('corrupt ciphertext')) expect(await getBYOKKey(workspaceId, 'openai')).toBeNull() }) it('returns null when the keys query throws', async () => { - mockOrderBy.mockRejectedValue(new Error('database unavailable')) + dbChainMockFns.orderBy.mockRejectedValue(new Error('database unavailable')) expect(await getBYOKKey(uniqueWorkspaceId(), 'openai')).toBeNull() }) diff --git a/apps/sim/lib/auth/ban.test.ts b/apps/sim/lib/auth/ban.test.ts index 7a38b914294..fffdfb634ae 100644 --- a/apps/sim/lib/auth/ban.test.ts +++ b/apps/sim/lib/auth/ban.test.ts @@ -1,21 +1,24 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockWhere, envRef } = vi.hoisted(() => ({ - mockWhere: vi.fn(), +import { user } from '@sim/db/schema' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { envRef } = vi.hoisted(() => ({ envRef: { BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined, BLOCKED_EMAILS: undefined as string | undefined, }, })) -vi.mock('@sim/db', () => ({ - db: { select: vi.fn(() => ({ from: vi.fn(() => ({ where: mockWhere })) })) }, - user: { id: 'id', email: 'email', banned: 'banned', banExpires: 'banExpires' }, -})) -vi.mock('drizzle-orm', () => ({ inArray: vi.fn(), sql: vi.fn() })) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/core/config/appconfig', () => ({ fetchAppConfigProfile: vi.fn() })) vi.mock('@/lib/core/config/env', () => ({ get env() { @@ -26,6 +29,8 @@ vi.mock('@/lib/core/config/env-flags', () => ({ isAppConfigEnabled: false })) import { getActivelyBannedUserIds, isBanActive, isEmailBlocked } from '@/lib/auth/ban' +afterAll(resetDbChainMock) + describe('isBanActive', () => { it('returns true for a permanent ban', () => { expect(isBanActive({ banned: true, banExpires: null })).toBe(true) @@ -48,24 +53,24 @@ describe('isBanActive', () => { describe('isEmailBlocked', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com' envRef.BLOCKED_EMAILS = 'spam@evil.com' - mockWhere.mockResolvedValue([]) }) it('returns true for blocked domains and subdomains without querying users', async () => { expect(await isEmailBlocked('a@bad.com')).toBe(true) expect(await isEmailBlocked('a@mail.bad.com')).toBe(true) - expect(mockWhere).not.toHaveBeenCalled() + expect(dbChainMockFns.where).not.toHaveBeenCalled() }) it('returns true for individually blocked emails without querying users', async () => { expect(await isEmailBlocked('spam@evil.com')).toBe(true) - expect(mockWhere).not.toHaveBeenCalled() + expect(dbChainMockFns.where).not.toHaveBeenCalled() }) it('returns true when the email belongs to an actively banned account', async () => { - mockWhere.mockResolvedValue([{ banned: true, banExpires: null }]) + queueTableRows(user, [{ banned: true, banExpires: null }]) expect(await isEmailBlocked('a@good.com')).toBe(true) }) @@ -79,19 +84,19 @@ describe('isEmailBlocked', () => { describe('getActivelyBannedUserIds', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() envRef.BLOCKED_SIGNUP_DOMAINS = undefined envRef.BLOCKED_EMAILS = undefined - mockWhere.mockResolvedValue([]) }) it('short-circuits on empty input without querying', async () => { expect(await getActivelyBannedUserIds([])).toEqual([]) expect(await getActivelyBannedUserIds([''])).toEqual([]) - expect(mockWhere).not.toHaveBeenCalled() + expect(dbChainMockFns.where).not.toHaveBeenCalled() }) it('returns ids with an active db ban', async () => { - mockWhere.mockResolvedValue([ + queueTableRows(user, [ { id: 'u1', email: 'a@ok.com', banned: true, banExpires: null }, { id: 'u2', email: 'b@ok.com', banned: false, banExpires: null }, ]) @@ -99,7 +104,7 @@ describe('getActivelyBannedUserIds', () => { }) it('treats an expired ban as lifted', async () => { - mockWhere.mockResolvedValue([ + queueTableRows(user, [ { id: 'u1', email: 'a@ok.com', banned: true, banExpires: new Date(Date.now() - 1000) }, ]) expect(await getActivelyBannedUserIds(['u1'])).toEqual([]) @@ -107,7 +112,7 @@ describe('getActivelyBannedUserIds', () => { it('returns ids whose email is individually blocked', async () => { envRef.BLOCKED_EMAILS = 'spam@evil.com' - mockWhere.mockResolvedValue([ + queueTableRows(user, [ { id: 'u1', email: 'spam@evil.com', banned: false, banExpires: null }, { id: 'u2', email: 'ok@evil.com', banned: false, banExpires: null }, ]) @@ -116,7 +121,7 @@ describe('getActivelyBannedUserIds', () => { it('returns ids whose email domain is in the blocked-domains list, including subdomains', async () => { envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com' - mockWhere.mockResolvedValue([ + queueTableRows(user, [ { id: 'u1', email: 'a@bad.com', banned: false, banExpires: null }, { id: 'u2', email: 'b@mail.bad.com', banned: false, banExpires: null }, { id: 'u3', email: 'c@good.com', banned: false, banExpires: null }, @@ -125,7 +130,7 @@ describe('getActivelyBannedUserIds', () => { }) it('propagates db failures so callers fail closed', async () => { - mockWhere.mockRejectedValue(new Error('db down')) + dbChainMockFns.where.mockImplementationOnce(() => Promise.reject(new Error('db down'))) await expect(getActivelyBannedUserIds(['u1'])).rejects.toThrow('db down') }) }) diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 92eea63c09f..bace6856104 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -4,13 +4,15 @@ import { authMockFns, + dbChainMock, permissionsMock, permissionsMockFns, + resetDbChainMock, workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const resolveWorkflowIdForUser = workflowsUtilsMockFns.mockResolveWorkflowIdForUser const getUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions @@ -115,42 +117,18 @@ vi.mock('@/lib/copilot/chat-status', () => ({ }, })) -vi.mock('@sim/db', () => { - const update = vi.fn(() => ({ - set: vi.fn(() => ({ - where: vi.fn(() => ({ - returning: vi.fn().mockResolvedValue([]), - })), - })), - })) - const select = vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn().mockResolvedValue([{ permissionType: 'write' }]), - })), - })), - })) - return { - db: { - update, - select, - transaction: async (cb: (tx: { update: typeof update; select: typeof select }) => unknown) => - cb({ update, select }), - }, - } -}) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn(() => ({})), - eq: vi.fn(() => ({})), - sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }), -})) +vi.mock('@sim/db', () => dbChainMock) import { handleUnifiedChatPost } from './post' describe('handleUnifiedChatPost', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() getSession.mockResolvedValue({ user: { id: 'user-1' } }) resolveWorkflowIdForUser.mockResolvedValue({ status: 'resolved', diff --git a/apps/sim/lib/copilot/chat/stream-liveness.test.ts b/apps/sim/lib/copilot/chat/stream-liveness.test.ts index f7294b4c6ec..27a17434878 100644 --- a/apps/sim/lib/copilot/chat/stream-liveness.test.ts +++ b/apps/sim/lib/copilot/chat/stream-liveness.test.ts @@ -1,33 +1,15 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockAnd, mockEq, mockGetChatStreamLockOwners, mockSet, mockUpdate, mockWhere } = vi.hoisted( - () => ({ - mockAnd: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - mockEq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - mockGetChatStreamLockOwners: vi.fn(), - mockSet: vi.fn(), - mockUpdate: vi.fn(), - mockWhere: vi.fn(), - }) -) - -vi.mock('@sim/db', () => ({ - db: { update: mockUpdate }, -})) +import { copilotChats } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { and, eq } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - conversationId: 'copilotChats.conversationId', - }, -})) +vi.mock('@sim/db', () => dbChainMock) -vi.mock('drizzle-orm', () => ({ - and: mockAnd, - eq: mockEq, +const { mockGetChatStreamLockOwners } = vi.hoisted(() => ({ + mockGetChatStreamLockOwners: vi.fn(), })) vi.mock('@/lib/copilot/request/session', () => ({ @@ -39,15 +21,17 @@ import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' describe('reconcileChatStreamMarkers', () => { beforeEach(() => { vi.clearAllMocks() - mockSet.mockReturnValue({ where: mockWhere }) - mockUpdate.mockReturnValue({ set: mockSet }) - mockWhere.mockResolvedValue(undefined) + resetDbChainMock() mockGetChatStreamLockOwners.mockResolvedValue({ status: 'verified', ownersByChatId: new Map(), }) }) + afterAll(() => { + resetDbChainMock() + }) + it('clears a persisted stream marker when Redis verifies no lock owner exists', async () => { const markers = await reconcileChatStreamMarkers([ { chatId: 'chat-stuck', streamId: 'stream-orphaned' }, @@ -66,13 +50,10 @@ describe('reconcileChatStreamMarkers', () => { repairVerifiedStaleMarkers: true, }) - expect(mockUpdate).toHaveBeenCalled() - expect(mockSet).toHaveBeenCalledWith({ conversationId: null }) - expect(mockWhere).toHaveBeenCalledWith( - mockAnd( - mockEq('copilotChats.id', 'chat-stuck'), - mockEq('copilotChats.conversationId', 'stream-orphaned') - ) + expect(dbChainMockFns.update).toHaveBeenCalledWith(copilotChats) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ conversationId: null }) + expect(dbChainMockFns.where).toHaveBeenCalledWith( + and(eq(copilotChats.id, 'chat-stuck'), eq(copilotChats.conversationId, 'stream-orphaned')) ) }) diff --git a/apps/sim/lib/copilot/chat/workspace-context.test.ts b/apps/sim/lib/copilot/chat/workspace-context.test.ts index 44e36084b0d..3051019eda2 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.test.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.test.ts @@ -2,20 +2,7 @@ * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => ({ db: {} })) -vi.mock('@sim/db/schema', () => ({ - knowledgeBase: {}, - knowledgeConnector: {}, - mcpServers: {}, - userTableDefinitions: {}, - userTableRows: {}, - workflow: {}, - workflowFolder: {}, - workflowSchedule: {}, -})) - +import { describe, expect, it } from 'vitest' import { canonicalWorkflowVfsDir } from '@/lib/copilot/vfs/path-utils' import { buildVfsSnapshot, buildWorkspaceMd, type WorkspaceMdData } from './workspace-context' diff --git a/apps/sim/lib/copilot/request/lifecycle/start.test.ts b/apps/sim/lib/copilot/request/lifecycle/start.test.ts index fa73ebd53b0..c3da25457c2 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.test.ts @@ -5,7 +5,8 @@ import { propagation, trace } from '@opentelemetry/api' import { W3CTraceContextPropagator } from '@opentelemetry/core' import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, resetDbChainMock } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -117,15 +118,7 @@ vi.mock('@/lib/copilot/request/session/sse', () => ({ SSE_RESPONSE_HEADERS: {}, })) -vi.mock('@sim/db', () => ({ - db: { - update: vi.fn(() => ({ - set: vi.fn(() => ({ - where: vi.fn(), - })), - })), - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/copilot/chat-status', () => ({ chatPubSub: null, @@ -160,8 +153,13 @@ async function drainStream(stream: ReadableStream) { } describe('createSSEStream terminal error handling', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() billingFlags.isHosted = false billingFlags.isCopilotBillingAttributionV1Enabled = false fetchGo.mockResolvedValue( @@ -342,8 +340,13 @@ describe('createSSEStream terminal error handling', () => { }) describe('requestChatTitle billing protocol', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() billingFlags.isHosted = true billingFlags.isCopilotBillingAttributionV1Enabled = true fetchGo.mockResolvedValue( diff --git a/apps/sim/lib/copilot/server/agent-url.test.ts b/apps/sim/lib/copilot/server/agent-url.test.ts index 6d6d1a14b8a..dae4021ad0a 100644 --- a/apps/sim/lib/copilot/server/agent-url.test.ts +++ b/apps/sim/lib/copilot/server/agent-url.test.ts @@ -1,47 +1,22 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { user } from '@sim/db/schema' +import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { getMothershipBaseURL, getMothershipSourceEnvHeaders, MOTHERSHIP_SOURCE_ENV_HEADER, } from './agent-url' -const { dbMock, envMock, mockRows } = vi.hoisted(() => { - const mockRows: any[] = [] - const dbMock = { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - leftJoin: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(async () => mockRows), - })), - })), - })), - })), - } - const envMock = { +const { envMock } = vi.hoisted(() => ({ + envMock: { COPILOT_DEV_URL: 'https://dev.mothership.test', COPILOT_STAGING_URL: 'https://staging.mothership.test', COPILOT_PROD_URL: 'https://prod.mothership.test', COPILOT_SOURCE_ENV: undefined as string | undefined, - } - return { dbMock, envMock, mockRows } -}) - -vi.mock('@sim/db', () => ({ db: dbMock })) -vi.mock('@sim/db/schema', () => ({ - settings: { - userId: 'settings.userId', - superUserModeEnabled: 'settings.superUserModeEnabled', - mothershipEnvironment: 'settings.mothershipEnvironment', - }, - user: { - id: 'user.id', - role: 'user.role', }, })) -vi.mock('drizzle-orm', () => ({ - eq: vi.fn(() => ({})), -})) + +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/api/contracts', () => ({ mothershipEnvironmentSchema: { safeParse: (value: unknown) => @@ -60,11 +35,15 @@ vi.mock('@/lib/core/config/env', () => ({ describe('getMothershipBaseURL', () => { beforeEach(() => { - mockRows.length = 0 - dbMock.select.mockClear() + vi.clearAllMocks() + resetDbChainMock() envMock.COPILOT_SOURCE_ENV = undefined }) + afterAll(() => { + resetDbChainMock() + }) + it('uses the default URL when there is no user context', async () => { await expect(getMothershipBaseURL()).resolves.toBe('https://default.mothership.test') await expect(getMothershipBaseURL({ environment: 'dev' })).resolves.toBe( @@ -73,11 +52,9 @@ describe('getMothershipBaseURL', () => { }) it('ignores stored and explicit environments for non-admin users', async () => { - mockRows.push({ - role: 'user', - superUserModeEnabled: true, - mothershipEnvironment: 'dev', - }) + queueTableRows(user, [ + { role: 'user', superUserModeEnabled: true, mothershipEnvironment: 'dev' }, + ]) await expect(getMothershipBaseURL({ userId: 'user-1', environment: 'staging' })).resolves.toBe( 'https://default.mothership.test' @@ -85,11 +62,9 @@ describe('getMothershipBaseURL', () => { }) it('ignores stored and explicit environments when super user mode is off', async () => { - mockRows.push({ - role: 'admin', - superUserModeEnabled: false, - mothershipEnvironment: 'dev', - }) + queueTableRows(user, [ + { role: 'admin', superUserModeEnabled: false, mothershipEnvironment: 'dev' }, + ]) await expect(getMothershipBaseURL({ userId: 'admin-1', environment: 'prod' })).resolves.toBe( 'https://default.mothership.test' @@ -97,11 +72,9 @@ describe('getMothershipBaseURL', () => { }) it('uses default for super admins until they select a concrete environment', async () => { - mockRows.push({ - role: 'admin', - superUserModeEnabled: true, - mothershipEnvironment: 'default', - }) + queueTableRows(user, [ + { role: 'admin', superUserModeEnabled: true, mothershipEnvironment: 'default' }, + ]) await expect(getMothershipBaseURL({ userId: 'admin-1' })).resolves.toBe( 'https://default.mothership.test' @@ -109,11 +82,13 @@ describe('getMothershipBaseURL', () => { }) it('allows effective super admins to use a selected environment', async () => { - mockRows.push({ + const superAdminRow = { role: 'admin', superUserModeEnabled: true, mothershipEnvironment: 'dev', - }) + } + queueTableRows(user, [superAdminRow]) + queueTableRows(user, [superAdminRow]) await expect(getMothershipBaseURL({ userId: 'admin-1' })).resolves.toBe( 'https://dev.mothership.test' diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index 7055cee52f6..aa0dd96c9f6 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -1,46 +1,25 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { knowledgeConnector } from '@sim/db/schema' +import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockAssertBillingAttributionSnapshot, mockCheckKnowledgeBaseWriteAccess, - mockDbChain, mockFetch, mockGenerateInternalToken, mockSerializeBillingAttributionHeader, -} = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn(), - } - return { - mockAssertBillingAttributionSnapshot: vi.fn(), - mockCheckKnowledgeBaseWriteAccess: vi.fn(), - mockDbChain: chain, - mockFetch: vi.fn(), - mockGenerateInternalToken: vi.fn(), - mockSerializeBillingAttributionHeader: vi.fn(), - } -}) - -vi.mock('@sim/db', () => ({ db: mockDbChain })) -vi.mock('@sim/db/schema', () => ({ - knowledgeConnector: { - id: 'knowledgeConnector.id', - knowledgeBaseId: 'knowledgeConnector.knowledgeBaseId', - archivedAt: 'knowledgeConnector.archivedAt', - deletedAt: 'knowledgeConnector.deletedAt', - }, -})) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), +} = vi.hoisted(() => ({ + mockAssertBillingAttributionSnapshot: vi.fn(), + mockCheckKnowledgeBaseWriteAccess: vi.fn(), + mockFetch: vi.fn(), + mockGenerateInternalToken: vi.fn(), + mockSerializeBillingAttributionHeader: vi.fn(), })) + +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockGenerateInternalToken, })) @@ -119,13 +98,15 @@ const BILLING_ATTRIBUTION = { } describe('knowledge base connector Copilot operations', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('fetch', mockFetch) - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.limit.mockResolvedValue([{ knowledgeBaseId: 'knowledge-base-1' }]) + queueTableRows(knowledgeConnector, [{ knowledgeBaseId: 'knowledge-base-1' }]) mockAssertBillingAttributionSnapshot.mockReturnValue(BILLING_ATTRIBUTION) mockSerializeBillingAttributionHeader.mockReturnValue('serialized-attribution') mockGenerateInternalToken.mockResolvedValue('internal-token') diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts index 07c0bfc2c3f..716612512a7 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts @@ -5,21 +5,21 @@ * never the connected account's OAuth access/refresh token. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { account, user } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const SECRET_ACCESS_TOKEN = 'ya29.a0SECRET_GOOGLE_BEARER_TOKEN_DO_NOT_LEAK' -const { selectMock, getAllOAuthServicesMock, getPersonalAndWorkspaceEnvMock, decodeJwtMock } = - vi.hoisted(() => ({ - selectMock: vi.fn(), +const { getAllOAuthServicesMock, getPersonalAndWorkspaceEnvMock, decodeJwtMock } = vi.hoisted( + () => ({ getAllOAuthServicesMock: vi.fn(), getPersonalAndWorkspaceEnvMock: vi.fn(), decodeJwtMock: vi.fn(), - })) + }) +) -vi.mock('@sim/db', () => ({ - db: { select: selectMock }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/oauth', () => ({ getAllOAuthServices: getAllOAuthServicesMock, @@ -41,17 +41,18 @@ import { getCredentialsServerTool } from './get-credentials' * 2. `select({...}).from(user).where().limit(1)` → user row */ function wireDb(accountRows: unknown[], userRows: Array<{ email: string }>) { - const whereThenable = { - then: (resolve: (rows: unknown[]) => unknown) => resolve(accountRows), - limit: () => Promise.resolve(userRows), - } - const builder = { from: () => builder, where: () => whereThenable } - selectMock.mockReturnValue(builder) + queueTableRows(account, accountRows) + queueTableRows(user, userRows) } describe('getCredentialsServerTool', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() wireDb( [ @@ -129,6 +130,6 @@ describe('getCredentialsServerTool', () => { await expect(getCredentialsServerTool.execute({}, undefined)).rejects.toThrow( 'Authentication required' ) - expect(selectMock).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/core/idempotency/cleanup.test.ts b/apps/sim/lib/core/idempotency/cleanup.test.ts index 08933a75422..3e460083168 100644 --- a/apps/sim/lib/core/idempotency/cleanup.test.ts +++ b/apps/sim/lib/core/idempotency/cleanup.test.ts @@ -1,65 +1,40 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { idempotencyKey } from '@sim/db/schema' +import { dbChainMock, resetDbChainMock } from '@sim/testing' +import { like, notLike } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ - notLike: vi.fn(() => 'not-like'), - like: vi.fn(() => 'like'), -})) - -vi.mock('@sim/db', () => { - const selectChain = () => { - const chain: Record = {} - chain.from = () => chain - chain.where = () => chain - chain.limit = () => Promise.resolve([]) - return chain - } - return { db: { select: () => selectChain() } } -}) -vi.mock('@sim/db/schema', () => ({ - idempotencyKey: { key: 'key', createdAt: 'createdAt' }, -})) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ info: vi.fn(), error: vi.fn() }), -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/utils/helpers', () => ({ sleep: vi.fn() })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...values: unknown[]) => values), - count: vi.fn(), - inArray: vi.fn(), - like: mocks.like, - lt: vi.fn(() => 'older-than'), - max: vi.fn(), - min: vi.fn(), - notLike: mocks.notLike, - sql: vi.fn(), -})) import { cleanupExpiredIdempotencyKeys } from '@/lib/core/idempotency/cleanup' +afterAll(resetDbChainMock) + describe('cleanupExpiredIdempotencyKeys', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it('retains irreversible admin credit-grant keys during global cleanup', async () => { await cleanupExpiredIdempotencyKeys() - expect(mocks.notLike).toHaveBeenCalledWith('key', 'admin-credit-grant:%') + expect(notLike).toHaveBeenCalledWith(idempotencyKey.key, 'admin-credit-grant:%') }) it('retains permanent workflow execution ID claims during global cleanup', async () => { await cleanupExpiredIdempotencyKeys() - expect(mocks.notLike).toHaveBeenCalledWith('key', 'workflow-execution-id:%') + expect(notLike).toHaveBeenCalledWith(idempotencyKey.key, 'workflow-execution-id:%') }) it('keeps explicit namespace cleanup behavior unchanged', async () => { await cleanupExpiredIdempotencyKeys({ namespace: 'webhook' }) - expect(mocks.like).toHaveBeenCalledWith('key', 'webhook:%') - expect(mocks.notLike).not.toHaveBeenCalled() + expect(like).toHaveBeenCalledWith(idempotencyKey.key, 'webhook:%') + expect(notLike).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/core/outbox/service.test.ts b/apps/sim/lib/core/outbox/service.test.ts index 3fd89d21f61..0c3390fbf95 100644 --- a/apps/sim/lib/core/outbox/service.test.ts +++ b/apps/sim/lib/core/outbox/service.test.ts @@ -2,7 +2,9 @@ * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { outboxEvent } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type OutboxRow = { id: string @@ -18,116 +20,7 @@ type OutboxRow = { processedAt: Date | null } -// Hoisted mock state — all tests manipulate these directly. -const { state, mockDb } = vi.hoisted(() => { - const state = { - // Rows returned from the FOR UPDATE SKIP LOCKED select in claimBatch. - claimedRows: [] as OutboxRow[], - // Whether the terminal update (lease CAS) should report a match. - leaseHeld: true, - // IDs the reaper's UPDATE should return (simulates stuck `processing` rows). - reapedRowIds: [] as string[], - // Everything written (for assertions). - inserts: [] as Array<{ values: unknown }>, - updates: [] as Array<{ set: Record; where?: unknown }>, - } - - const makeUpdateChain = () => { - const row: { set: Record; where?: unknown } = { set: {} } - const chain: Record = {} - chain.set = vi.fn((s: Record) => { - row.set = s - return chain - }) - chain.where = vi.fn((w: unknown) => { - row.where = w - state.updates.push(row) - return chain - }) - chain.returning = vi.fn(async () => { - // Terminal UPDATE (lease CAS): has `attempts` + `availableAt` - // on retry, or explicit completed/dead_letter. Reaper path sets - // status='pending' without attempts/availableAt. - const isReaperUpdate = - row.set.status === 'pending' && !('attempts' in row.set) && !('availableAt' in row.set) - - if (isReaperUpdate) { - return state.reapedRowIds.map((id) => ({ id })) - } - - if ( - row.set.status === 'completed' || - row.set.status === 'dead_letter' || - (row.set.status === 'pending' && 'attempts' in row.set && 'availableAt' in row.set) || - (!('status' in row.set) && 'attempts' in row.set && 'lockedAt' in row.set) || - 'payload' in row.set - ) { - return state.leaseHeld ? [{ id: 'evt-1' }] : [] - } - - return [] - }) - return chain - } - - const makeSelectChain = () => { - const chain: Record = {} - const self = () => chain - chain.from = vi.fn(self) - chain.where = vi.fn(self) - chain.orderBy = vi.fn(self) - chain.limit = vi.fn(self) - chain.for = vi.fn(async () => state.claimedRows.splice(0, 1)) - return chain - } - - const mockDb = { - insert: vi.fn(() => { - const chain: Record = {} - chain.values = vi.fn(async (v: unknown) => { - state.inserts.push({ values: v }) - }) - return chain - }), - update: vi.fn(() => makeUpdateChain()), - select: vi.fn(() => makeSelectChain()), - transaction: vi.fn(async (fn: (tx: unknown) => Promise) => fn(mockDb)), - } - - return { state, mockDb } -}) - -vi.mock('@sim/db', () => ({ db: mockDb })) - -vi.mock('@sim/db/schema', () => ({ - outboxEvent: { - id: 'outbox_event.id', - eventType: 'outbox_event.event_type', - payload: 'outbox_event.payload', - status: 'outbox_event.status', - attempts: 'outbox_event.attempts', - maxAttempts: 'outbox_event.max_attempts', - availableAt: 'outbox_event.available_at', - lockedAt: 'outbox_event.locked_at', - lastError: 'outbox_event.last_error', - createdAt: 'outbox_event.created_at', - processedAt: 'outbox_event.processed_at', - $inferSelect: {} as OutboxRow, - }, -})) - -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...args) => ({ _op: 'and', args })), - asc: vi.fn((col) => ({ _op: 'asc', col })), - eq: vi.fn((col, val) => ({ _op: 'eq', col, val })), - inArray: vi.fn((col, vals) => ({ _op: 'inArray', col, vals })), - lte: vi.fn((col, val) => ({ _op: 'lte', col, val })), - sql: vi.fn(() => ({ _op: 'sql' })), -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'test-event-id'), @@ -152,24 +45,32 @@ function makePendingRow(overrides: Partial = {}): OutboxRow { } } -function resetState() { - state.claimedRows = [] - state.leaseHeld = true - state.reapedRowIds = [] - state.inserts.length = 0 - state.updates.length = 0 +/** The values object of every `set(...)` call, in call order. */ +const updateSets = (): Record[] => + dbChainMockFns.set.mock.calls.map((call) => call[0] as Record) + +/** + * Simulate a held processing lease: the reaper's `returning` (always the first + * `.returning()` of a run) reaps nothing, and every later terminal / + * checkpoint UPDATE's lease CAS reports a matched row. Without this priming, + * `returning` defaults to `[]` everywhere, which models a lost lease. + */ +function holdLease() { + dbChainMockFns.returning.mockResolvedValueOnce([]).mockResolvedValue([{ id: 'evt-1' }]) } +afterAll(resetDbChainMock) + describe('enqueueOutboxEvent', () => { beforeEach(() => { vi.clearAllMocks() - resetState() + resetDbChainMock() }) it('inserts a row with the given event type and payload', async () => { - const id = await enqueueOutboxEvent(mockDb, 'test.event', { foo: 'bar' }) + const id = await enqueueOutboxEvent(dbChainMock.db, 'test.event', { foo: 'bar' }) expect(id).toBe('test-event-id') - expect(state.inserts[0].values).toMatchObject({ + expect(dbChainMockFns.values.mock.calls[0][0]).toMatchObject({ id: 'test-event-id', eventType: 'test.event', payload: { foo: 'bar' }, @@ -178,21 +79,23 @@ describe('enqueueOutboxEvent', () => { }) it('respects maxAttempts override', async () => { - await enqueueOutboxEvent(mockDb, 'test.event', {}, { maxAttempts: 3 }) - expect(state.inserts[0].values).toMatchObject({ maxAttempts: 3 }) + await enqueueOutboxEvent(dbChainMock.db, 'test.event', {}, { maxAttempts: 3 }) + expect(dbChainMockFns.values.mock.calls[0][0]).toMatchObject({ maxAttempts: 3 }) }) it('respects availableAt override for delayed processing', async () => { const future = new Date(Date.now() + 60_000) - await enqueueOutboxEvent(mockDb, 'test.event', {}, { availableAt: future }) - expect((state.inserts[0].values as { availableAt: Date }).availableAt).toBe(future) + await enqueueOutboxEvent(dbChainMock.db, 'test.event', {}, { availableAt: future }) + expect((dbChainMockFns.values.mock.calls[0][0] as { availableAt: Date }).availableAt).toBe( + future + ) }) }) describe('processOutboxEvents — empty / no handler', () => { beforeEach(() => { vi.clearAllMocks() - resetState() + resetDbChainMock() }) it('returns zero counts when no events are due', async () => { @@ -207,21 +110,22 @@ describe('processOutboxEvents — empty / no handler', () => { }) it('dead-letters events with no registered handler', async () => { - state.claimedRows = [makePendingRow({ eventType: 'unknown.event' })] + queueTableRows(outboxEvent, [makePendingRow({ eventType: 'unknown.event' })]) + holdLease() const result = await processOutboxEvents({}) expect(result.deadLettered).toBe(1) - const terminal = state.updates.find((u) => u.set.status === 'dead_letter') + const terminal = updateSets().find((set) => set.status === 'dead_letter') expect(terminal).toBeDefined() - expect(terminal?.set.lastError).toMatch(/No handler registered/) + expect(terminal?.lastError).toMatch(/No handler registered/) }) }) describe('processOutboxEvents — handler success and retry', () => { beforeEach(() => { vi.clearAllMocks() - resetState() + resetDbChainMock() }) it('transitions to completed on handler success and passes context to handler', async () => { @@ -230,15 +134,16 @@ describe('processOutboxEvents — handler success and retry', () => { handlerCalls.push({ payload, eventId: ctx.eventId, attempts: ctx.attempts }) }) - state.claimedRows = [makePendingRow()] + queueTableRows(outboxEvent, [makePendingRow()]) + holdLease() const result = await processOutboxEvents({ 'test.event': handler }) expect(result.processed).toBe(1) expect(handlerCalls).toEqual([{ payload: { foo: 'bar' }, eventId: 'evt-1', attempts: 0 }]) - const completeUpdate = state.updates.find((u) => u.set.status === 'completed') + const completeUpdate = updateSets().find((set) => set.status === 'completed') expect(completeUpdate).toBeDefined() - expect(completeUpdate?.set.lastError).toBeNull() + expect(completeUpdate?.lastError).toBeNull() }) it('checkpoints payload fields only while the processing lease is held', async () => { @@ -250,12 +155,13 @@ describe('processOutboxEvents — handler success and retry', () => { await ctx.checkpointPayload({ stripeProgress: { customerId: 'cus_1' } }) } ) - state.claimedRows = [makePendingRow()] + queueTableRows(outboxEvent, [makePendingRow()]) + holdLease() const result = await processOutboxEvents({ 'test.event': handler }) expect(result.processed).toBe(1) - expect(state.updates.some((update) => 'payload' in update.set)).toBe(true) + expect(updateSets().some((set) => 'payload' in set)).toBe(true) }) it('stops a handler whose payload checkpoint loses the processing lease', async () => { @@ -267,8 +173,7 @@ describe('processOutboxEvents — handler success and retry', () => { await ctx.checkpointPayload({ stripeProgress: { customerId: 'cus_1' } }) } ) - state.claimedRows = [makePendingRow()] - state.leaseHeld = false + queueTableRows(outboxEvent, [makePendingRow()]) const result = await processOutboxEvents({ 'test.event': handler }) @@ -281,18 +186,19 @@ describe('processOutboxEvents — handler success and retry', () => { throw new Error('transient failure') }) - state.claimedRows = [makePendingRow({ attempts: 2 })] + queueTableRows(outboxEvent, [makePendingRow({ attempts: 2 })]) + holdLease() const before = Date.now() const result = await processOutboxEvents({ 'test.event': handler }) expect(result.retried).toBe(1) - const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set) + const retryUpdate = updateSets().find((set) => set.status === 'pending' && 'attempts' in set) expect(retryUpdate).toBeDefined() - expect(retryUpdate?.set.attempts).toBe(3) - expect(retryUpdate?.set.lastError).toBe('transient failure') + expect(retryUpdate?.attempts).toBe(3) + expect(retryUpdate?.lastError).toBe('transient failure') // Backoff after nextAttempts=3: 1000 * 2^3 = 8000ms - const scheduledAt = retryUpdate?.set.availableAt as Date + const scheduledAt = retryUpdate?.availableAt as Date expect(scheduledAt.getTime()).toBeGreaterThan(before + 7500) expect(scheduledAt.getTime()).toBeLessThan(before + 10_000) }) @@ -302,15 +208,16 @@ describe('processOutboxEvents — handler success and retry', () => { throw new Error('permanent failure') }) - state.claimedRows = [makePendingRow({ attempts: 9, maxAttempts: 10 })] + queueTableRows(outboxEvent, [makePendingRow({ attempts: 9, maxAttempts: 10 })]) + holdLease() const result = await processOutboxEvents({ 'test.event': handler }) expect(result.deadLettered).toBe(1) - const deadUpdate = state.updates.find((u) => u.set.status === 'dead_letter') + const deadUpdate = updateSets().find((set) => set.status === 'dead_letter') expect(deadUpdate).toBeDefined() - expect(deadUpdate?.set.attempts).toBe(10) - expect(deadUpdate?.set.lastError).toBe('permanent failure') + expect(deadUpdate?.attempts).toBe(10) + expect(deadUpdate?.lastError).toBe('permanent failure') }) it('caps exponential backoff at 1 hour', async () => { @@ -318,14 +225,15 @@ describe('processOutboxEvents — handler success and retry', () => { throw new Error('transient') }) - state.claimedRows = [makePendingRow({ attempts: 20, maxAttempts: 100 })] + queueTableRows(outboxEvent, [makePendingRow({ attempts: 20, maxAttempts: 100 })]) + holdLease() const before = Date.now() await processOutboxEvents({ 'test.event': handler }) - const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set) + const retryUpdate = updateSets().find((set) => set.status === 'pending' && 'attempts' in set) expect(retryUpdate).toBeDefined() - const scheduledAt = retryUpdate?.set.availableAt as Date + const scheduledAt = retryUpdate?.availableAt as Date // 1hr = 3,600,000ms expect(scheduledAt.getTime()).toBeLessThan(before + 3_600_000 + 1000) expect(scheduledAt.getTime()).toBeGreaterThan(before + 3_599_000) @@ -335,7 +243,7 @@ describe('processOutboxEvents — handler success and retry', () => { describe('processOutboxEvents — lease CAS / reaper race', () => { beforeEach(() => { vi.clearAllMocks() - resetState() + resetDbChainMock() }) it('reports leaseLost when completion UPDATE affects zero rows', async () => { @@ -343,8 +251,7 @@ describe('processOutboxEvents — lease CAS / reaper race', () => { // "succeeds" but terminal write will fail the lease CAS }) - state.claimedRows = [makePendingRow()] - state.leaseHeld = false + queueTableRows(outboxEvent, [makePendingRow()]) const result = await processOutboxEvents({ 'test.event': handler }) @@ -357,8 +264,7 @@ describe('processOutboxEvents — lease CAS / reaper race', () => { throw new Error('transient') }) - state.claimedRows = [makePendingRow({ attempts: 2 })] - state.leaseHeld = false + queueTableRows(outboxEvent, [makePendingRow({ attempts: 2 })]) const result = await processOutboxEvents({ 'test.event': handler }) @@ -370,7 +276,7 @@ describe('processOutboxEvents — lease CAS / reaper race', () => { describe('processOutboxEvents — handler timeout', () => { beforeEach(() => { vi.clearAllMocks() - resetState() + resetDbChainMock() vi.useFakeTimers() }) @@ -381,7 +287,8 @@ describe('processOutboxEvents — handler timeout', () => { it('times out a stuck handler without releasing it for overlapping retry', async () => { const neverResolves = vi.fn(() => new Promise(() => {})) - state.claimedRows = [makePendingRow({ attempts: 0 })] + queueTableRows(outboxEvent, [makePendingRow({ attempts: 0 })]) + holdLease() const promise = processOutboxEvents({ 'test.event': neverResolves }) // Must exceed DEFAULT_HANDLER_TIMEOUT_MS (90s). @@ -389,11 +296,11 @@ describe('processOutboxEvents — handler timeout', () => { const result = await promise expect(result.leaseLost).toBe(1) - const timeoutUpdate = state.updates.find( - (u) => !('status' in u.set) && 'attempts' in u.set && 'lockedAt' in u.set + const timeoutUpdate = updateSets().find( + (set) => !('status' in set) && 'attempts' in set && 'lockedAt' in set ) - expect(timeoutUpdate?.set.attempts).toBe(1) - expect(timeoutUpdate?.set.lastError).toMatch(/timed out/) + expect(timeoutUpdate?.attempts).toBe(1) + expect(timeoutUpdate?.lastError).toMatch(/timed out/) }) it('aborts the handler signal when its execution window expires', async () => { @@ -410,7 +317,8 @@ describe('processOutboxEvents — handler timeout', () => { }) } ) - state.claimedRows = [makePendingRow({ attempts: 0 })] + queueTableRows(outboxEvent, [makePendingRow({ attempts: 0 })]) + holdLease() const promise = processOutboxEvents({ 'test.event': handler }) await vi.advanceTimersByTimeAsync(90 * 1000 + 1) @@ -424,11 +332,15 @@ describe('processOutboxEvents — handler timeout', () => { describe('processOutboxEvents — reaper recovery', () => { beforeEach(() => { vi.clearAllMocks() - resetState() + resetDbChainMock() }) it('reaps stuck processing rows back to pending and reports count', async () => { - state.reapedRowIds = ['stuck-1', 'stuck-2', 'stuck-3'] + dbChainMockFns.returning.mockResolvedValueOnce([ + { id: 'stuck-1' }, + { id: 'stuck-2' }, + { id: 'stuck-3' }, + ]) const result = await processOutboxEvents({}) @@ -437,11 +349,11 @@ describe('processOutboxEvents — reaper recovery', () => { // The reaper's UPDATE sets status='pending' with NO attempts / availableAt // fields — that's how runHandler's retry update is distinguished from it. - const reaperUpdate = state.updates.find( - (u) => u.set.status === 'pending' && !('attempts' in u.set) && !('availableAt' in u.set) + const reaperUpdate = updateSets().find( + (set) => set.status === 'pending' && !('attempts' in set) && !('availableAt' in set) ) expect(reaperUpdate).toBeDefined() - expect(reaperUpdate?.set.lockedAt).toBeNull() + expect(reaperUpdate?.lockedAt).toBeNull() }) it('returns zero reaped when no rows are stuck', async () => { diff --git a/apps/sim/lib/organizations/settings-access.test.ts b/apps/sim/lib/organizations/settings-access.test.ts index 51e6be13d96..616eaae5f00 100644 --- a/apps/sim/lib/organizations/settings-access.test.ts +++ b/apps/sim/lib/organizations/settings-access.test.ts @@ -1,46 +1,23 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { member } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAnd, mockEq, mockLimit, mockWhere } = vi.hoisted(() => ({ - mockAnd: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - mockEq: vi.fn((left: unknown, right: unknown) => ({ type: 'eq', left, right })), - mockLimit: vi.fn(), - mockWhere: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: mockWhere.mockImplementation(() => ({ limit: mockLimit })), - })), - })), - }, -})) - -vi.mock('@sim/db/schema', () => ({ - member: { - organizationId: 'member.organizationId', - role: 'member.role', - userId: 'member.userId', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: mockAnd, - eq: mockEq, -})) +vi.mock('@sim/db', () => dbChainMock) import { canOpenOrganizationSettingsSection, getOrganizationSettingsAccess, } from '@/lib/organizations/settings-access' +afterAll(resetDbChainMock) + describe('organization settings access', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it.each([ @@ -48,25 +25,23 @@ describe('organization settings access', () => { { role: 'admin', isAdmin: true }, { role: 'member', isAdmin: false }, ])('derives $role access from the route organization membership', async ({ role, isAdmin }) => { - mockLimit.mockResolvedValueOnce([{ role }]) + queueTableRows(member, [{ role }]) await expect(getOrganizationSettingsAccess('organization-route', 'viewer')).resolves.toEqual({ role, isMember: true, isAdmin, }) - expect(mockWhere).toHaveBeenCalledWith({ + expect(dbChainMockFns.where).toHaveBeenCalledWith({ type: 'and', conditions: [ - { type: 'eq', left: 'member.organizationId', right: 'organization-route' }, - { type: 'eq', left: 'member.userId', right: 'viewer' }, + { type: 'eq', left: member.organizationId, right: 'organization-route' }, + { type: 'eq', left: member.userId, right: 'viewer' }, ], }) }) it('rejects users without membership in the route organization', async () => { - mockLimit.mockResolvedValueOnce([]) - await expect(getOrganizationSettingsAccess('organization-route', 'viewer')).resolves.toEqual({ role: null, isMember: false, @@ -75,12 +50,12 @@ describe('organization settings access', () => { }) it('allows members to view the roster but reserves control-plane sections for admins', async () => { - mockLimit.mockResolvedValueOnce([{ role: 'member' }]) + queueTableRows(member, [{ role: 'member' }]) await expect( canOpenOrganizationSettingsSection('organization-route', 'viewer', 'members') ).resolves.toBe(true) - mockLimit.mockResolvedValueOnce([{ role: 'member' }]) + queueTableRows(member, [{ role: 'member' }]) await expect( canOpenOrganizationSettingsSection('organization-route', 'viewer', 'sso') ).resolves.toBe(false) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts index 6b032412bd9..b106ce0aa54 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts @@ -1,33 +1,20 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => { - const chain = { - from: vi.fn(), - where: vi.fn(), - orderBy: vi.fn(), - } - chain.from.mockReturnValue(chain) - chain.where.mockReturnValue(chain) - return { - chain, - select: vi.fn(() => chain), - } -}) - -vi.mock('@sim/db', () => ({ db: { select: mocks.select } })) +vi.mock('@sim/db', () => dbChainMock) import { listWorkspaceFiles } from './workspace-file-manager' +afterAll(resetDbChainMock) + describe('listWorkspaceFiles error handling', () => { beforeEach(() => { vi.clearAllMocks() - mocks.chain.from.mockReturnValue(mocks.chain) - mocks.chain.where.mockReturnValue(mocks.chain) - mocks.chain.orderBy.mockRejectedValue(new Error('database unavailable')) - mocks.select.mockReturnValue(mocks.chain) + resetDbChainMock() + dbChainMockFns.orderBy.mockRejectedValue(new Error('database unavailable')) }) it('keeps the established best-effort behavior by default', async () => { diff --git a/apps/sim/lib/workflows/deployment-outbox.test.ts b/apps/sim/lib/workflows/deployment-outbox.test.ts index 72881739136..164538fb65d 100644 --- a/apps/sim/lib/workflows/deployment-outbox.test.ts +++ b/apps/sim/lib/workflows/deployment-outbox.test.ts @@ -1,10 +1,16 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockLimit, mockPrepareWebhooks, mockGetDeploymentOperation, mockMarkDeploymentComponentReadiness, @@ -26,7 +32,6 @@ const { mockCaptureServerEvent, mockTx, } = vi.hoisted(() => ({ - mockLimit: vi.fn(), mockPrepareWebhooks: vi.fn(), mockGetDeploymentOperation: vi.fn(), mockMarkDeploymentComponentReadiness: vi.fn(), @@ -58,41 +63,7 @@ vi.mock('@sim/audit', () => ({ recordAudit: mockRecordAudit, })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: mockLimit, - })), - })), - })), - insert: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - transaction: vi.fn(), - }, - workflow: { - id: 'workflow.id', - isDeployed: 'workflow.isDeployed', - }, - workflowDeploymentVersion: { - id: 'workflowDeploymentVersion.id', - workflowId: 'workflowDeploymentVersion.workflowId', - state: 'workflowDeploymentVersion.state', - isActive: 'workflowDeploymentVersion.isActive', - }, -})) - -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...args) => ({ type: 'and', args })), - eq: vi.fn((column, value) => ({ type: 'eq', column, value })), - ne: vi.fn((column, value) => ({ type: 'ne', column, value })), -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/core/config/env', () => ({ env: { INTERNAL_API_SECRET: 'secret' }, @@ -231,9 +202,21 @@ function handler() { })[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.PREPARE_V2] } +afterAll(() => { + resetDbChainMock() +}) + describe('versioned deployment preparation outbox', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + /** + * These handlers only reach db.transaction in deferred cleanup helpers the + * suite intentionally keeps inert (the previous private factory returned + * undefined without running the callback); the default chain-mock + * transaction would execute the callback and consume queued select rows. + */ + dbChainMockFns.transaction.mockResolvedValue(undefined) vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) mockPrepareWebhooks.mockResolvedValue(undefined) mockActivateWebhookRegistrations.mockResolvedValue(undefined) @@ -280,9 +263,12 @@ describe('versioned deployment preparation outbox', () => { completedAt: NOW, }) mockGetDeploymentOperation.mockResolvedValue(preparing) - mockLimit - .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) - .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: 'version-2', state: { blocks: {} } }, + ]) mockMarkDeploymentComponentReadiness .mockResolvedValueOnce({ success: true, operation: webhooksReady }) .mockResolvedValueOnce({ success: true, operation: schedulesReady }) @@ -366,9 +352,12 @@ describe('versioned deployment preparation outbox', () => { it('generation-guards failure on the final outbox attempt', async () => { const preparing = operation() mockGetDeploymentOperation.mockResolvedValue(preparing) - mockLimit - .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) - .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: 'version-2', state: { blocks: {} } }, + ]) mockPrepareWebhooks.mockRejectedValue(new Error('provider unavailable')) await expect(handler()(payload(), context(new AbortController(), 3))).rejects.toThrow( @@ -388,9 +377,12 @@ describe('versioned deployment preparation outbox', () => { it('retries transient mid-attempt failures without failing the operation', async () => { const preparing = operation() mockGetDeploymentOperation.mockResolvedValue(preparing) - mockLimit - .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) - .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: 'version-2', state: { blocks: {} } }, + ]) mockPrepareWebhooks.mockRejectedValue(new Error('provider briefly unavailable')) await expect(handler()(payload(), context(new AbortController(), 0))).rejects.toThrow( @@ -428,9 +420,12 @@ describe('versioned deployment preparation outbox', () => { }, }) mockGetDeploymentOperation.mockResolvedValue(preparing) - mockLimit - .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) - .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: 'version-2', state: { blocks: {} } }, + ]) mockMarkDeploymentComponentReadiness .mockResolvedValueOnce({ success: true, operation: webhooksReady }) .mockResolvedValueOnce({ success: true, operation: schedulesReady }) @@ -464,9 +459,12 @@ describe('versioned deployment preparation outbox', () => { it('fails the operation immediately on a non-retryable preparation error', async () => { const preparing = operation() mockGetDeploymentOperation.mockResolvedValue(preparing) - mockLimit - .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) - .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: 'version-2', state: { blocks: {} } }, + ]) mockPrepareWebhooks.mockRejectedValue( new NonRetryableDeploymentError( 'Webhook path "/leads" is already in use. Choose a different path.', @@ -489,10 +487,11 @@ describe('versioned deployment preparation outbox', () => { }) it('keeps v1 cleanup from deleting a candidate owned by the current v2 operation', async () => { - mockLimit - .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) - .mockResolvedValueOnce([{ isActive: false }]) - .mockResolvedValueOnce([{ isDeployed: true }]) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + queueTableRows(schemaMock.workflowDeploymentVersion, [{ isActive: false }]) + queueTableRows(schemaMock.workflow, [{ isDeployed: true }]) mockIsDeploymentVersionProtectedByCurrentOperation.mockResolvedValue(true) const cleanupHandler = createWorkflowDeploymentOutboxHandlers()[ diff --git a/apps/sim/lib/workflows/executor/execution-state.test.ts b/apps/sim/lib/workflows/executor/execution-state.test.ts index 689371c0ffd..49101daa652 100644 --- a/apps/sim/lib/workflows/executor/execution-state.test.ts +++ b/apps/sim/lib/workflows/executor/execution-state.test.ts @@ -1,16 +1,14 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockMaterializeExecutionData, mockSelect } = vi.hoisted(() => ({ +const { mockMaterializeExecutionData } = vi.hoisted(() => ({ mockMaterializeExecutionData: vi.fn(), - mockSelect: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { select: mockSelect }, -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/logs/execution/trace-store', () => ({ materializeExecutionData: mockMaterializeExecutionData, @@ -32,24 +30,15 @@ const EXECUTION_STATE = { activeExecutionPath: [], } -function createSelectChain(rows: unknown[]) { - const chain = { - from: vi.fn(), - where: vi.fn(), - orderBy: vi.fn(), - limit: vi.fn().mockResolvedValue(rows), - } - chain.from.mockReturnValue(chain) - chain.where.mockReturnValue(chain) - chain.orderBy.mockReturnValue(chain) - return chain -} - describe('execution state lookup', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockMaterializeExecutionData.mockReset() - mockSelect.mockReset() + }) + + afterAll(() => { + resetDbChainMock() }) it('materializes externalized execution data for a specific execution', async () => { @@ -64,16 +53,14 @@ describe('execution state lookup', () => { executionId: 'execution-1', }, } - mockSelect.mockReturnValueOnce( - createSelectChain([ - { - executionId: 'execution-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - executionData: slimExecutionData, - }, - ]) - ) + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionData: slimExecutionData, + }, + ]) mockMaterializeExecutionData.mockResolvedValueOnce({ executionState: EXECUTION_STATE, }) @@ -100,16 +87,14 @@ describe('execution state lookup', () => { executionId: 'execution-1', }, } - mockSelect.mockReturnValueOnce( - createSelectChain([ - { - executionId: 'execution-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - executionData: slimExecutionData, - }, - ]) - ) + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionData: slimExecutionData, + }, + ]) mockMaterializeExecutionData.mockResolvedValueOnce({ workflowInput: { leadId: 'lead-1' }, }) @@ -128,24 +113,22 @@ describe('execution state lookup', () => { }) it('checks older pointer-backed candidates when the latest has no execution state', async () => { - mockSelect.mockReturnValueOnce( - createSelectChain([ - { - executionId: 'execution-2', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - executionState: null, - traceStoreRef: { id: 'value-2' }, - }, - { - executionId: 'execution-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - executionState: null, - traceStoreRef: { id: 'value-1' }, - }, - ]) - ) + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-2', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionState: null, + traceStoreRef: { id: 'value-2' }, + }, + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionState: null, + traceStoreRef: { id: 'value-1' }, + }, + ]) mockMaterializeExecutionData .mockResolvedValueOnce({}) .mockImplementationOnce(async (executionData: Record) => { diff --git a/apps/sim/lib/workflows/lifecycle.test.ts b/apps/sim/lib/workflows/lifecycle.test.ts index 2a6d3f03182..5749f6408c6 100644 --- a/apps/sim/lib/workflows/lifecycle.test.ts +++ b/apps/sim/lib/workflows/lifecycle.test.ts @@ -3,33 +3,25 @@ */ import { createEnvMock, + dbChainMock, + dbChainMockFns, + resetDbChainMock, + schemaMock, urlsMock, urlsMockFns, workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockSelect, mockTransaction, mockCleanupExternalWebhook, mockWorkflowDeleted } = vi.hoisted( - () => ({ - mockSelect: vi.fn(), - mockTransaction: vi.fn(), - mockCleanupExternalWebhook: vi.fn(), - mockWorkflowDeleted: vi.fn(), - }) -) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCleanupExternalWebhook, mockWorkflowDeleted } = vi.hoisted(() => ({ + mockCleanupExternalWebhook: vi.fn(), + mockWorkflowDeleted: vi.fn(), +})) const mockGetWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - transaction: mockTransaction, - }, - workflow: { id: 'id' }, - workflowDeploymentOperation: { workflowId: 'workflowId', status: 'status' }, - workflowDeploymentVersion: { workflowId: 'workflowId', isActive: 'isActive' }, -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) @@ -51,31 +43,18 @@ vi.mock('@/lib/core/telemetry', () => ({ import { archiveWorkflow } from '@/lib/workflows/lifecycle' -function createSelectChain(result: T) { - const chain = { - from: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockResolvedValue(result), - } - - return chain -} - -function createUpdateChain() { - return { - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - } -} - describe('workflow lifecycle', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() urlsMockFns.mockGetSocketServerUrl.mockReturnValue('http://socket.test') vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) }) + afterAll(() => { + resetDbChainMock() + }) + it('archives workflow and disables live surfaces', async () => { mockGetWorkflowById .mockResolvedValueOnce({ @@ -93,25 +72,14 @@ describe('workflow lifecycle', () => { archivedAt: new Date(), }) - mockSelect.mockReturnValue(createSelectChain([])) - - const tx = { - update: vi.fn().mockImplementation(() => createUpdateChain()), - delete: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockResolvedValue([]), - })), - } - mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise) => - callback(tx) - ) - const result = await archiveWorkflow('workflow-1', { requestId: 'req-1' }) expect(result.archived).toBe(true) - expect(tx.update).toHaveBeenCalledTimes(7) - const supersedeSet = tx.update.mock.results[0]?.value.set - expect(supersedeSet).toHaveBeenCalledWith(expect.objectContaining({ status: 'superseded' })) - expect(tx.delete).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(7) + expect(dbChainMockFns.set.mock.calls[0][0]).toEqual( + expect.objectContaining({ status: 'superseded' }) + ) + expect(dbChainMockFns.delete).toHaveBeenCalledTimes(1) expect(mockWorkflowDeleted).toHaveBeenCalledWith({ workflowId: 'workflow-1', workspaceId: 'workspace-1', @@ -134,7 +102,7 @@ describe('workflow lifecycle', () => { const result = await archiveWorkflow('workflow-1', { requestId: 'req-1' }) expect(result.archived).toBe(false) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() expect(fetch).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts index 8df4a4fed44..b9afcdd9fa7 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts @@ -1,15 +1,19 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockLimit, - mockUpdateSet, mockSaveWorkflowToNormalizedTables, mockRecordAudit, mockCaptureServerEvent, - mockTransaction, mockValidateWorkflowSchedules, mockValidateTriggerWebhookConfigForDeploy, mockEmitWorkflowDeployedEvent, @@ -22,12 +26,9 @@ const { mockLoadWorkflowDeploymentSnapshot, mockTx, } = vi.hoisted(() => ({ - mockLimit: vi.fn(), - mockUpdateSet: vi.fn(), mockSaveWorkflowToNormalizedTables: vi.fn(), mockRecordAudit: vi.fn(), mockCaptureServerEvent: vi.fn(), - mockTransaction: vi.fn(), mockValidateWorkflowSchedules: vi.fn(), mockValidateTriggerWebhookConfigForDeploy: vi.fn(), mockEmitWorkflowDeployedEvent: vi.fn(), @@ -38,49 +39,15 @@ const { mockProcessWorkflowDeploymentOutboxEvent: vi.fn(), mockNotifySocketDeploymentChanged: vi.fn(), mockLoadWorkflowDeploymentSnapshot: vi.fn(), - mockTx: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(() => ({ - for: vi.fn().mockResolvedValue([{ id: 'workflow-1' }]), - })), - })), - })), - })), - update: vi.fn(() => ({ - set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })), - })), - execute: vi.fn().mockResolvedValue(undefined), - }, + /** + * Sentinel transaction handle the mocked prepare functions hand to the real + * onPrepareTransaction callback, which only forwards it into the (mocked) + * outbox enqueue — identity is asserted, never chained on. + */ + mockTx: { sentinel: 'tx' }, })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: mockLimit, - })), - })), - })), - update: vi.fn(() => ({ - set: mockUpdateSet, - })), - transaction: mockTransaction, - }, - workflow: { - id: 'workflow.id', - deployedAt: 'workflow.deployedAt', - workspaceId: 'workflow.workspaceId', - }, - workflowDeploymentVersion: { - workflowId: 'workflowDeploymentVersion.workflowId', - version: 'workflowDeploymentVersion.version', - isActive: 'workflowDeploymentVersion.isActive', - state: 'workflowDeploymentVersion.state', - }, -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@sim/audit', () => ({ AuditAction: { @@ -145,30 +112,20 @@ import { performRevertToVersion, } from '@/lib/workflows/orchestration/deploy' +afterAll(() => { + resetDbChainMock() +}) + describe('performRevertToVersion', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) - mockTransaction.mockImplementation(async (callback) => callback(mockTx)) - mockTx.select.mockImplementation((selection?: Record) => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: - selection && Object.hasOwn(selection, 'state') - ? mockLimit - : vi.fn(() => ({ - for: vi.fn().mockResolvedValue([{ id: 'workflow-1' }]), - })), - })), - })), - })) - mockTx.update.mockReturnValue({ set: mockUpdateSet }) - mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) }) it('restores variables when the deployment snapshot includes them', async () => { - mockLimit.mockResolvedValue([ + queueTableRows(schemaMock.workflowDeploymentVersion, [ { state: { blocks: {}, @@ -207,9 +164,9 @@ describe('performRevertToVersion', () => { }, }, }), - mockTx + dbChainMock.db ) - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ variables: { variableA: { @@ -224,7 +181,7 @@ describe('performRevertToVersion', () => { }) it('preserves existing variables when reverting a legacy snapshot without variables', async () => { - mockLimit.mockResolvedValue([ + queueTableRows(schemaMock.workflowDeploymentVersion, [ { state: { blocks: {}, @@ -245,7 +202,7 @@ describe('performRevertToVersion', () => { expect(result.success).toBe(true) const savedState = mockSaveWorkflowToNormalizedTables.mock.calls[0][1] expect(Object.hasOwn(savedState, 'variables')).toBe(false) - const workflowUpdate = mockUpdateSet.mock.calls[0][0] + const workflowUpdate = dbChainMockFns.set.mock.calls[0][0] expect(Object.hasOwn(workflowUpdate, 'variables')).toBe(false) }) }) @@ -253,6 +210,7 @@ describe('performRevertToVersion', () => { describe('performFullDeploy workspace event emission', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) const now = new Date('2026-07-14T08:00:00.000Z') const operation = { @@ -281,7 +239,7 @@ describe('performFullDeploy workspace event emission', () => { } mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('completed') mockNotifySocketDeploymentChanged.mockResolvedValue(undefined) - mockLimit.mockResolvedValue([ + queueTableRows(schemaMock.workflow, [ { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, ]) mockLoadWorkflowDeploymentSnapshot.mockResolvedValue({ @@ -544,6 +502,7 @@ describe('performFullDeploy workspace event emission', () => { describe('performActivateVersion workspace event emission', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) const now = new Date('2026-07-14T08:00:00.000Z') const operation = { @@ -574,7 +533,9 @@ describe('performActivateVersion workspace event emission', () => { mockNotifySocketDeploymentChanged.mockResolvedValue(undefined) mockValidateWorkflowSchedules.mockReturnValue({ isValid: true }) mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true }) - mockLimit.mockResolvedValue([{ id: 'dv-2', state: { blocks: {} }, isActive: false }]) + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: 'dv-2', state: { blocks: {} }, isActive: false }, + ]) mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-activate-default') mockPrepareWorkflowVersionActivation.mockImplementation(async (input) => { await input.onPrepareTransaction?.(mockTx, operation) @@ -671,7 +632,12 @@ describe('performActivateVersion workspace event emission', () => { }) it('does not emit when the version is already active (no-op activation)', async () => { - mockLimit + /** + * Per-chain overrides answer the two selects directly (version row, then + * workflow deployedAt); the default row queued in beforeEach stays + * unconsumed and is cleared by the next reset. + */ + dbChainMockFns.limit .mockResolvedValueOnce([{ id: 'dv-2', state: { blocks: {} }, isActive: true }]) .mockResolvedValueOnce([{ deployedAt: new Date() }]) diff --git a/apps/sim/lib/workflows/persistence/duplicate.test.ts b/apps/sim/lib/workflows/persistence/duplicate.test.ts index 5d7be01ed4f..b8328c900e2 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.test.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.test.ts @@ -1,76 +1,63 @@ /** * @vitest-environment node */ -import { workflowAuthzMockFns, workflowsUtilsMock, workflowsUtilsMockFns } from '@sim/testing' -import { drizzleOrmMock } from '@sim/testing/mocks' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, + workflowAuthzMockFns, + workflowsUtilsMock, + workflowsUtilsMockFns, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mockAuthorizeWorkflowByWorkspacePermission = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission -const { mockDb } = vi.hoisted(() => ({ - mockDb: { - transaction: vi.fn(), - }, -})) - -vi.mock('drizzle-orm', () => ({ - ...drizzleOrmMock, - min: vi.fn((field) => ({ type: 'min', field })), -})) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('@sim/db', () => ({ - db: mockDb, -})) - -import { duplicateWorkflow } from './duplicate' - -function createMockTx( - selectResults: unknown[], - onWorkflowInsert?: (values: Record) => void, - onInsert?: (values: unknown) => void -) { - let selectCallCount = 0 - - const select = vi.fn().mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => { - const result = selectResults[selectCallCount++] ?? [] - if (selectCallCount === 1) { - return { - limit: vi.fn().mockResolvedValue(result), - } - } - return Promise.resolve(result) - }), - }), - })) - - const insert = vi.fn().mockReturnValue({ - values: vi.fn().mockImplementation((values: Record) => { - onWorkflowInsert?.(values) - onInsert?.(values) - return Promise.resolve(undefined) - }), - }) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) - const update = vi.fn().mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(undefined), - }), - }) +import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' - return { - select, - insert, - update, - } +/** + * Queues the table-routed result sets consumed by `duplicateWorkflow`, in + * chain order: source workflow lookup, sibling/folder minimum sort-order + * aggregates, then the source blocks/edges/subflows reads. + */ +function queueDuplicateFixtures(options: { + sourceWorkflow: Record + workflowMin?: unknown[] + folderMin?: unknown[] + blocks?: unknown[] + edges?: unknown[] + subflows?: unknown[] +}) { + queueTableRows(schemaMock.workflow, [options.sourceWorkflow]) + queueTableRows(schemaMock.workflow, options.workflowMin ?? []) + queueTableRows(schemaMock.workflowFolder, options.folderMin ?? []) + queueTableRows(schemaMock.workflowBlocks, options.blocks ?? []) + queueTableRows(schemaMock.workflowEdges, options.edges ?? []) + queueTableRows(schemaMock.workflowSubflows, options.subflows ?? []) +} + +/** + * Returns each payload passed to `insert(table).values(payload)` for the given + * schema table. Insert/values chains run sequentially in the code under test, + * so the two spies' call lists stay index-aligned. + */ +function insertedValuesFor(table: unknown): unknown[] { + return dbChainMockFns.insert.mock.calls.flatMap(([calledTable], index) => + calledTable === table ? [dbChainMockFns.values.mock.calls[index]?.[0]] : [] + ) } describe('duplicateWorkflow ordering', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('new-workflow-id'), @@ -87,33 +74,22 @@ describe('duplicateWorkflow ordering', () => { ) }) - it('uses mixed-sibling top insertion sort order', async () => { - let insertedWorkflowValues: Record | null = null - const tx = createMockTx( - [ - [ - { - id: 'source-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'source', - variables: {}, - }, - ], - [{ minOrder: 5 }], - [{ minOrder: 2 }], - [], - [], - [], - ], - (values) => { - insertedWorkflowValues = values - } - ) + afterAll(() => { + resetDbChainMock() + }) - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + it('uses mixed-sibling top insertion sort order', async () => { + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', + variables: {}, + }, + workflowMin: [{ minOrder: 5 }], + folderMin: [{ minOrder: 2 }], + }) const result = await duplicateWorkflow({ sourceWorkflowId: 'source-workflow-id', @@ -125,36 +101,23 @@ describe('duplicateWorkflow ordering', () => { }) expect(result.sortOrder).toBe(1) + const insertedWorkflowValues = insertedValuesFor(schemaMock.workflow)[0] as Record< + string, + unknown + > expect(insertedWorkflowValues?.sortOrder).toBe(1) }) it('defaults to sortOrder 0 when target has no siblings', async () => { - let insertedWorkflowValues: Record | null = null - const tx = createMockTx( - [ - [ - { - id: 'source-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'source', - variables: {}, - }, - ], - [], - [], - [], - [], - [], - ], - (values) => { - insertedWorkflowValues = values - } - ) - - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', + variables: {}, + }, + }) const result = await duplicateWorkflow({ sourceWorkflowId: 'source-workflow-id', @@ -166,91 +129,76 @@ describe('duplicateWorkflow ordering', () => { }) expect(result.sortOrder).toBe(0) + const insertedWorkflowValues = insertedValuesFor(schemaMock.workflow)[0] as Record< + string, + unknown + > expect(insertedWorkflowValues?.sortOrder).toBe(0) }) it('strips copied webhook runtime subblocks and remaps variable assignments', async () => { - let insertedBlocks: Array> | null = null - const tx = createMockTx( - [ - [ - { - id: 'source-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'source', + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', - variables: { - 'old-var-id': { - id: 'old-var-id', - workflowId: 'source-workflow-id', - name: 'customerName', - type: 'string', - value: 'Ada', - }, - }, - }, - ], - [], - [], - [ - { - id: 'source-block-id', + variables: { + 'old-var-id': { + id: 'old-var-id', workflowId: 'source-workflow-id', - type: 'generic_webhook', - name: 'Webhook', - parentId: null, - extent: null, - data: {}, - subBlocks: { - triggerPath: { id: 'triggerPath', type: 'short-input', value: 'old-webhook-path' }, - webhookId: { id: 'webhookId', type: 'short-input', value: 'old-webhook-id' }, - webhookUrlDisplay: { - id: 'webhookUrlDisplay', - type: 'short-input', - value: 'https://example.test/api/webhooks/trigger/old-webhook-path', - }, - variables: { - id: 'variables', - type: 'variables-input', - value: JSON.stringify([ - { - id: 'assignment-1', - variableId: 'old-var-id', - variableName: 'customerName', - type: 'string', - value: 'Grace', - isExisting: true, - }, - ]), - }, + name: 'customerName', + type: 'string', + value: 'Ada', + }, + }, + }, + blocks: [ + { + id: 'source-block-id', + workflowId: 'source-workflow-id', + type: 'generic_webhook', + name: 'Webhook', + parentId: null, + extent: null, + data: {}, + subBlocks: { + triggerPath: { id: 'triggerPath', type: 'short-input', value: 'old-webhook-path' }, + webhookId: { id: 'webhookId', type: 'short-input', value: 'old-webhook-id' }, + webhookUrlDisplay: { + id: 'webhookUrlDisplay', + type: 'short-input', + value: 'https://example.test/api/webhooks/trigger/old-webhook-path', + }, + variables: { + id: 'variables', + type: 'variables-input', + value: JSON.stringify([ + { + id: 'assignment-1', + variableId: 'old-var-id', + variableName: 'customerName', + type: 'string', + value: 'Grace', + isExisting: true, + }, + ]), }, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: true, - locked: true, - createdAt: new Date(), - updatedAt: new Date(), }, - ], - [], - [], + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: true, + locked: true, + createdAt: new Date(), + updatedAt: new Date(), + }, ], - undefined, - (values) => { - if (Array.isArray(values)) { - insertedBlocks = values as Array> - } - } - ) - - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + }) await duplicateWorkflow({ sourceWorkflowId: 'source-workflow-id', @@ -261,6 +209,9 @@ describe('duplicateWorkflow ordering', () => { requestId: 'req-3', }) + const insertedBlocks = insertedValuesFor(schemaMock.workflowBlocks)[0] as Array< + Record + > expect(insertedBlocks).toHaveLength(1) const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record expect(copiedSubBlocks.triggerPath).toBeUndefined() @@ -272,85 +223,61 @@ describe('duplicateWorkflow ordering', () => { }) it('remaps variable assignments when duplicating an already-duplicated source (array value)', async () => { - let insertedBlocks: Array> | null = null - let insertedWorkflowValues: Record | null = null - const tx = createMockTx( - [ - [ - { - id: 'first-copy-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'first copy', + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'first-copy-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'first copy', - variables: { - 'first-copy-var-id': { - id: 'first-copy-var-id', - workflowId: 'first-copy-workflow-id', - name: 'customerName', - type: 'string', - value: 'Ada', - }, - }, - }, - ], - [], - [], - [ - { - id: 'first-copy-block-id', + variables: { + 'first-copy-var-id': { + id: 'first-copy-var-id', workflowId: 'first-copy-workflow-id', - type: 'agent', - name: 'Agent', - parentId: null, - extent: null, - data: {}, - subBlocks: { - variables: { - id: 'variables', - type: 'variables-input', - value: [ - { - id: 'assignment-1', - variableId: 'first-copy-var-id', - variableName: 'customerName', - type: 'string', - value: 'Grace', - isExisting: true, - }, - ], - }, + name: 'customerName', + type: 'string', + value: 'Ada', + }, + }, + }, + blocks: [ + { + id: 'first-copy-block-id', + workflowId: 'first-copy-workflow-id', + type: 'agent', + name: 'Agent', + parentId: null, + extent: null, + data: {}, + subBlocks: { + variables: { + id: 'variables', + type: 'variables-input', + value: [ + { + id: 'assignment-1', + variableId: 'first-copy-var-id', + variableName: 'customerName', + type: 'string', + value: 'Grace', + isExisting: true, + }, + ], }, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: false, - locked: false, - createdAt: new Date(), - updatedAt: new Date(), }, - ], - [], - [], + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: false, + locked: false, + createdAt: new Date(), + updatedAt: new Date(), + }, ], - (values) => { - if (!insertedWorkflowValues && !Array.isArray(values)) { - insertedWorkflowValues = values - } - }, - (values) => { - if (Array.isArray(values)) { - insertedBlocks = values as Array> - } - } - ) - - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + }) await duplicateWorkflow({ sourceWorkflowId: 'first-copy-workflow-id', @@ -361,11 +288,18 @@ describe('duplicateWorkflow ordering', () => { requestId: 'req-second-copy', }) + const insertedBlocks = insertedValuesFor(schemaMock.workflowBlocks)[0] as Array< + Record + > expect(insertedBlocks).toHaveLength(1) const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record expect(Array.isArray(copiedSubBlocks.variables.value)).toBe(true) expect(copiedSubBlocks.variables.value).toHaveLength(1) + const insertedWorkflowValues = insertedValuesFor(schemaMock.workflow)[0] as Record< + string, + unknown + > const newVarIds = Object.keys( (insertedWorkflowValues?.variables as Record) || {} ) @@ -377,101 +311,79 @@ describe('duplicateWorkflow ordering', () => { }) it('preserves remap when an edge references an unknown source block (drops the edge with a warning)', async () => { - let insertedEdges: Array> | null = null - const tx = createMockTx( - [ - [ - { - id: 'source-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'source', - variables: {}, - }, - ], - [], - [], - [ - { - id: 'block-a', - workflowId: 'source-workflow-id', - type: 'agent', - name: 'Agent A', - parentId: null, - extent: null, - data: {}, - subBlocks: {}, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: false, - locked: false, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 'block-b', - workflowId: 'source-workflow-id', - type: 'agent', - name: 'Agent B', - parentId: null, - extent: null, - data: {}, - subBlocks: {}, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: false, - locked: false, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - [ - { - id: 'edge-valid', - workflowId: 'source-workflow-id', - sourceBlockId: 'block-a', - targetBlockId: 'block-b', - sourceHandle: null, - targetHandle: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 'edge-orphan', - workflowId: 'source-workflow-id', - sourceBlockId: 'unknown-source-block', - targetBlockId: 'block-b', - sourceHandle: null, - targetHandle: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - [], + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', + variables: {}, + }, + blocks: [ + { + id: 'block-a', + workflowId: 'source-workflow-id', + type: 'agent', + name: 'Agent A', + parentId: null, + extent: null, + data: {}, + subBlocks: {}, + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: false, + locked: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'block-b', + workflowId: 'source-workflow-id', + type: 'agent', + name: 'Agent B', + parentId: null, + extent: null, + data: {}, + subBlocks: {}, + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: false, + locked: false, + createdAt: new Date(), + updatedAt: new Date(), + }, ], - undefined, - (values) => { - if ( - Array.isArray(values) && - values.length > 0 && - (values[0] as Record)?.sourceBlockId !== undefined - ) { - insertedEdges = values as Array> - } - } - ) - - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + edges: [ + { + id: 'edge-valid', + workflowId: 'source-workflow-id', + sourceBlockId: 'block-a', + targetBlockId: 'block-b', + sourceHandle: null, + targetHandle: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'edge-orphan', + workflowId: 'source-workflow-id', + sourceBlockId: 'unknown-source-block', + targetBlockId: 'block-b', + sourceHandle: null, + targetHandle: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }) await expect( duplicateWorkflow({ @@ -484,6 +396,9 @@ describe('duplicateWorkflow ordering', () => { }) ).resolves.toBeDefined() + const insertedEdges = insertedValuesFor(schemaMock.workflowEdges)[0] as Array< + Record + > expect(insertedEdges).toHaveLength(1) const onlyEdge = insertedEdges?.[0] expect(onlyEdge?.sourceBlockId).not.toBe('unknown-source-block') @@ -492,94 +407,72 @@ describe('duplicateWorkflow ordering', () => { }) it('preserves remap when a subflow references an unknown node (drops the node with a warning)', async () => { - let insertedSubflows: Array> | null = null - const tx = createMockTx( - [ - [ - { - id: 'source-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'source', - variables: {}, - }, - ], - [], - [], - [ - { - id: 'loop-block', - workflowId: 'source-workflow-id', - type: 'loop', - name: 'Loop', - parentId: null, - extent: null, - data: {}, - subBlocks: {}, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: false, - locked: false, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 'known-node', - workflowId: 'source-workflow-id', - type: 'agent', - name: 'Agent', - parentId: null, - extent: null, - data: {}, - subBlocks: {}, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: false, - locked: false, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - [], - [ - { + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', + variables: {}, + }, + blocks: [ + { + id: 'loop-block', + workflowId: 'source-workflow-id', + type: 'loop', + name: 'Loop', + parentId: null, + extent: null, + data: {}, + subBlocks: {}, + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: false, + locked: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'known-node', + workflowId: 'source-workflow-id', + type: 'agent', + name: 'Agent', + parentId: null, + extent: null, + data: {}, + subBlocks: {}, + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: false, + locked: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + subflows: [ + { + id: 'loop-block', + workflowId: 'source-workflow-id', + type: 'loop', + config: { id: 'loop-block', - workflowId: 'source-workflow-id', - type: 'loop', - config: { - id: 'loop-block', - nodes: ['known-node', 'unknown-node'], - iterations: 1, - loopType: 'for', - }, - createdAt: new Date(), - updatedAt: new Date(), + nodes: ['known-node', 'unknown-node'], + iterations: 1, + loopType: 'for', }, - ], + createdAt: new Date(), + updatedAt: new Date(), + }, ], - undefined, - (values) => { - if ( - Array.isArray(values) && - values.length > 0 && - (values[0] as Record)?.config !== undefined - ) { - insertedSubflows = values as Array> - } - } - ) - - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + }) await expect( duplicateWorkflow({ @@ -592,6 +485,9 @@ describe('duplicateWorkflow ordering', () => { }) ).resolves.toBeDefined() + const insertedSubflows = insertedValuesFor(schemaMock.workflowSubflows)[0] as Array< + Record + > expect(insertedSubflows).toHaveLength(1) const remappedConfig = insertedSubflows?.[0].config as { nodes: string[] } expect(Array.isArray(remappedConfig.nodes)).toBe(true) @@ -601,80 +497,61 @@ describe('duplicateWorkflow ordering', () => { }) it('preserves stale variable references instead of failing the duplicate', async () => { - let insertedBlocks: Array> | null = null - const tx = createMockTx( - [ - [ - { - id: 'source-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'source', + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', - variables: { - 'live-var-id': { - id: 'live-var-id', - workflowId: 'source-workflow-id', - name: 'customerName', - type: 'string', - value: 'Ada', - }, - }, - }, - ], - [], - [], - [ - { - id: 'source-block-id', + variables: { + 'live-var-id': { + id: 'live-var-id', workflowId: 'source-workflow-id', - type: 'agent', - name: 'Agent', - parentId: null, - extent: null, - data: {}, - subBlocks: { - variables: { - id: 'variables', - type: 'variables-input', - value: [ - { - id: 'assignment-1', - variableId: 'deleted-var-id', - variableName: 'customerName', - type: 'string', - value: 'Grace', - isExisting: true, - }, - ], - }, + name: 'customerName', + type: 'string', + value: 'Ada', + }, + }, + }, + blocks: [ + { + id: 'source-block-id', + workflowId: 'source-workflow-id', + type: 'agent', + name: 'Agent', + parentId: null, + extent: null, + data: {}, + subBlocks: { + variables: { + id: 'variables', + type: 'variables-input', + value: [ + { + id: 'assignment-1', + variableId: 'deleted-var-id', + variableName: 'customerName', + type: 'string', + value: 'Grace', + isExisting: true, + }, + ], }, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: false, - locked: false, - createdAt: new Date(), - updatedAt: new Date(), }, - ], - [], - [], + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: false, + locked: false, + createdAt: new Date(), + updatedAt: new Date(), + }, ], - undefined, - (values) => { - if (Array.isArray(values)) { - insertedBlocks = values as Array> - } - } - ) - - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + }) await expect( duplicateWorkflow({ @@ -687,6 +564,9 @@ describe('duplicateWorkflow ordering', () => { }) ).resolves.toBeDefined() + const insertedBlocks = insertedValuesFor(schemaMock.workflowBlocks)[0] as Array< + Record + > expect(insertedBlocks).toHaveLength(1) const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record expect(copiedSubBlocks.variables.value[0].variableId).toBe('deleted-var-id') diff --git a/apps/sim/lib/workflows/schedules/deploy.test.ts b/apps/sim/lib/workflows/schedules/deploy.test.ts index f38093ca6fd..afb5dc2abdc 100644 --- a/apps/sim/lib/workflows/schedules/deploy.test.ts +++ b/apps/sim/lib/workflows/schedules/deploy.test.ts @@ -3,51 +3,14 @@ * * @vitest-environment node */ +import { dbChainMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockInsert, - mockDelete, - mockOnConflictDoUpdate, - mockValues, - mockWhere, - mockRandomUUID, - mockTransaction, - mockSelect, - mockFrom, -} = vi.hoisted(() => ({ - mockInsert: vi.fn(), - mockDelete: vi.fn(), - mockOnConflictDoUpdate: vi.fn(), - mockValues: vi.fn(), - mockWhere: vi.fn(), +const { mockRandomUUID } = vi.hoisted(() => ({ mockRandomUUID: vi.fn(), - mockTransaction: vi.fn(), - mockSelect: vi.fn(), - mockFrom: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - transaction: mockTransaction, - }, - workflowSchedule: { - workflowId: 'workflow_id', - blockId: 'block_id', - deploymentVersionId: 'deployment_version_id', - deploymentOperationId: 'deployment_operation_id', - id: 'id', - archivedAt: 'archived_at', - }, -})) - -vi.mock('drizzle-orm', () => ({ - eq: vi.fn((...args) => ({ type: 'eq', args })), - and: vi.fn((...args) => ({ type: 'and', args })), - inArray: vi.fn((...args) => ({ type: 'inArray', args })), - isNull: vi.fn((...args) => ({ type: 'isNull', args })), - sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })), -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/webhooks/deploy', () => ({ cleanupWebhooksForWorkflow: vi.fn().mockResolvedValue(undefined), @@ -75,11 +38,13 @@ afterAll(() => { mockCalculateNextRunTime.mockRestore() mockValidateCronExpression.mockRestore() mockGetScheduleTimeValues.mockRestore() + resetDbChainMock() }) describe('Schedule Deploy Utilities', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() /** * Re-stub per test: `unstubGlobals: true` unstubs all globals before each @@ -104,29 +69,6 @@ describe('Schedule Deploy Utilities', () => { monthlyTime: [9, 0], cronExpression: null, }) - - // Setup mock chain for insert - mockOnConflictDoUpdate.mockResolvedValue({}) - mockValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }) - mockInsert.mockReturnValue({ values: mockValues }) - - // Setup mock chain for delete - mockWhere.mockResolvedValue({}) - mockDelete.mockReturnValue({ where: mockWhere }) - - // Setup mock chain for select - mockFrom.mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) - mockSelect.mockReturnValue({ from: mockFrom }) - - // Setup transaction mock to execute callback with mock tx - mockTransaction.mockImplementation(async (callback) => { - const mockTx = { - insert: mockInsert, - delete: mockDelete, - select: mockSelect, - } - return callback(mockTx) - }) }) afterEach(() => { @@ -712,24 +654,15 @@ describe('Schedule Deploy Utilities', () => { }) describe('createSchedulesForDeploy', () => { - const setupMockTransaction = ( - existingSchedules: Array<{ id: string; blockId: string }> = [] - ) => { - mockFrom.mockReturnValue({ where: vi.fn().mockResolvedValue(existingSchedules) }) - mockSelect.mockReturnValue({ from: mockFrom }) - } - it('should return success with no schedule blocks', async () => { const blocks: Record = { 'block-1': { id: 'block-1', type: 'agent', subBlocks: {} } as BlockState, } - setupMockTransaction() - const result = await createSchedulesForDeploy('workflow-1', blocks) expect(result.success).toBe(true) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('should create schedule for valid schedule block', async () => { @@ -745,17 +678,15 @@ describe('Schedule Deploy Utilities', () => { } as BlockState, } - setupMockTransaction() - const result = await createSchedulesForDeploy('workflow-1', blocks) expect(result.success).toBe(true) expect(result.scheduleId).toBe('test-uuid') expect(result.cronExpression).toBe('0 9 * * *') expect(result.nextRunAt).toEqual(new Date('2025-04-15T09:00:00Z')) - expect(mockTransaction).toHaveBeenCalled() - expect(mockInsert).toHaveBeenCalled() - expect(mockOnConflictDoUpdate).toHaveBeenCalled() + expect(dbChainMockFns.transaction).toHaveBeenCalled() + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalled() }) it('should return error for invalid schedule block', async () => { @@ -770,13 +701,11 @@ describe('Schedule Deploy Utilities', () => { } as BlockState, } - setupMockTransaction() - const result = await createSchedulesForDeploy('workflow-1', blocks) expect(result.success).toBe(false) expect(result.error).toBe('Time is required for daily schedules') - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('should write through a provided transaction without opening a new one', async () => { @@ -792,15 +721,19 @@ describe('Schedule Deploy Utilities', () => { } as BlockState, } - setupMockTransaction() - const callerTx = { insert: mockInsert, delete: mockDelete, select: mockSelect } as any + /** + * A distinct object identity from `db` (the code under test treats + * `tx === db` as "no caller transaction"), but backed by the same chain + * spies so writes are still observable. + */ + const callerTx = { ...dbChainMock.db } as any const result = await createSchedulesForDeploy('workflow-1', blocks, callerTx) expect(result.success).toBe(true) - expect(mockTransaction).not.toHaveBeenCalled() - expect(mockInsert).toHaveBeenCalled() - expect(mockOnConflictDoUpdate).toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalled() }) it('should use onConflictDoUpdate for existing schedules', async () => { @@ -816,11 +749,9 @@ describe('Schedule Deploy Utilities', () => { } as BlockState, } - setupMockTransaction() - await createSchedulesForDeploy('workflow-1', blocks, undefined, 'version-1', 'operation-1') - expect(mockOnConflictDoUpdate).toHaveBeenCalledWith({ + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith({ target: expect.any(Array), targetWhere: expect.objectContaining({ type: 'isNull' }), set: expect.objectContaining({ @@ -846,7 +777,7 @@ describe('Schedule Deploy Utilities', () => { } as BlockState, } - mockTransaction.mockRejectedValueOnce(new Error('Database error')) + dbChainMockFns.transaction.mockRejectedValueOnce(new Error('Database error')) const result = await createSchedulesForDeploy('workflow-1', blocks) @@ -857,15 +788,10 @@ describe('Schedule Deploy Utilities', () => { describe('deleteSchedulesForWorkflow', () => { it('should delete all schedules for a workflow', async () => { - const mockTx = { - insert: mockInsert, - delete: mockDelete, - } - - await deleteSchedulesForWorkflow('workflow-1', mockTx as any) + await deleteSchedulesForWorkflow('workflow-1', dbChainMock.db as any) - expect(mockDelete).toHaveBeenCalled() - expect(mockWhere).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalled() }) }) }) diff --git a/apps/sim/lib/workflows/schedules/orchestration.test.ts b/apps/sim/lib/workflows/schedules/orchestration.test.ts index 145341655b3..b656490043d 100644 --- a/apps/sim/lib/workflows/schedules/orchestration.test.ts +++ b/apps/sim/lib/workflows/schedules/orchestration.test.ts @@ -1,20 +1,16 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockSelect, - mockUpdate, - mockUpdateSet, - mockUpdateWhere, - mockRecordAudit, - mockCaptureServerEvent, -} = vi.hoisted(() => ({ - mockSelect: vi.fn(), - mockUpdate: vi.fn(), - mockUpdateSet: vi.fn(), - mockUpdateWhere: vi.fn(), +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRecordAudit, mockCaptureServerEvent } = vi.hoisted(() => ({ mockRecordAudit: vi.fn(), mockCaptureServerEvent: vi.fn(), })) @@ -25,36 +21,7 @@ vi.mock('@sim/audit', () => ({ recordAudit: mockRecordAudit, })) -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - update: mockUpdate, - }, - workflowSchedule: { - id: 'id', - sourceWorkspaceId: 'sourceWorkspaceId', - sourceType: 'sourceType', - archivedAt: 'archivedAt', - timezone: 'timezone', - status: 'status', - cronExpression: 'cronExpression', - jobTitle: 'jobTitle', - }, -})) - -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ - error: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - }), -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent, @@ -73,26 +40,18 @@ const BASE_JOB = { status: 'disabled', } -function mockExistingJob(job: typeof BASE_JOB) { - mockSelect.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue([job]), - }), - }), - }) -} - describe('performUpdateJob', () => { beforeEach(() => { vi.clearAllMocks() - mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) - mockUpdate.mockReturnValue({ set: mockUpdateSet }) - mockUpdateWhere.mockResolvedValue(undefined) + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() }) it('does not schedule a next run when editing time on a disabled job', async () => { - mockExistingJob({ ...BASE_JOB, status: 'disabled' }) + queueTableRows(schemaMock.workflowSchedule, [{ ...BASE_JOB, status: 'disabled' }]) const result = await performUpdateJob({ jobId: 'job-1', @@ -102,12 +61,12 @@ describe('performUpdateJob', () => { }) expect(result.success).toBe(true) - expect(mockUpdateSet).toHaveBeenCalledTimes(1) - expect(mockUpdateSet.mock.calls[0][0]).not.toHaveProperty('nextRunAt') + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set.mock.calls[0][0]).not.toHaveProperty('nextRunAt') }) it('schedules the next run when editing time on an active job', async () => { - mockExistingJob({ ...BASE_JOB, status: 'active' }) + queueTableRows(schemaMock.workflowSchedule, [{ ...BASE_JOB, status: 'active' }]) const result = await performUpdateJob({ jobId: 'job-1', @@ -117,8 +76,8 @@ describe('performUpdateJob', () => { }) expect(result.success).toBe(true) - expect(mockUpdateSet).toHaveBeenCalledTimes(1) - expect(mockUpdateSet.mock.calls[0][0]).toMatchObject({ + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({ nextRunAt: new Date('2099-01-01T09:00:00Z'), }) }) diff --git a/apps/sim/lib/workflows/skills/operations.test.ts b/apps/sim/lib/workflows/skills/operations.test.ts index 93d0c552267..9c5ebc167e1 100644 --- a/apps/sim/lib/workflows/skills/operations.test.ts +++ b/apps/sim/lib/workflows/skills/operations.test.ts @@ -1,46 +1,36 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { orderByMock } = vi.hoisted(() => ({ orderByMock: vi.fn() })) - -vi.mock('@sim/db', () => ({ - db: { select: () => ({ from: () => ({ where: () => ({ orderBy: orderByMock }) }) }) }, -})) -vi.mock('@sim/db/schema', () => ({ - skill: { workspaceId: 'workspaceId', name: 'name', createdAt: 'createdAt' }, -})) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@sim/utils/id', () => ({ generateShortId: () => 'gen-id' })) -vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'req-id' })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(() => ({})), - desc: vi.fn(() => ({})), - eq: vi.fn(() => ({})), - ne: vi.fn(() => ({})), -})) -import { listSkills } from './operations' +import { listSkills } from '@/lib/workflows/skills/operations' describe('listSkills includeBuiltins', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() }) it('prepends builtin template skills by default', async () => { - orderByMock.mockResolvedValue([]) const result = await listSkills({ workspaceId: 'ws-1' }) expect(result.length).toBeGreaterThan(0) expect(result.every((s) => s.id.startsWith('builtin-'))).toBe(true) }) - // The mothership skill inventory passes includeBuiltins: false so it never sees - // the code-only template skills. + /** + * The mothership skill inventory passes includeBuiltins: false so it never + * sees the code-only template skills. + */ it('excludes builtin template skills when includeBuiltins is false', async () => { - orderByMock.mockResolvedValue([ + queueTableRows(schemaMock.skill, [ { id: 'sk-1', name: 'mine', description: 'd', content: 'c', workspaceId: 'ws-1' }, ]) const result = await listSkills({ workspaceId: 'ws-1', includeBuiltins: false }) diff --git a/apps/sim/lib/workspaces/admin-move.test.ts b/apps/sim/lib/workspaces/admin-move.test.ts index 04873c40f77..e77473ffa3a 100644 --- a/apps/sim/lib/workspaces/admin-move.test.ts +++ b/apps/sim/lib/workspaces/admin-move.test.ts @@ -1,14 +1,9 @@ /** @vitest-environment node */ -import { - invitation, - invitationWorkspaceGrant, - organization, - permissions, - workspace, -} from '@sim/db/schema' +import { organization, workspace } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { PgDialect } from 'drizzle-orm/pg-core' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceMoveError } from '@/lib/workspaces/admin-move' import { buildPendingInvitationMergeScopeCondition, @@ -20,25 +15,18 @@ import { WORKSPACE_MODE } from '@/lib/workspaces/policy' vi.unmock('drizzle-orm') const { - mockDb, recordAudit, enqueueOutboxEvent, invalidateWorkspaceTableLimitsCache, changeWorkspaceStoragePayerInTx, } = vi.hoisted(() => ({ - mockDb: { - select: vi.fn(), - insert: vi.fn(), - update: vi.fn(), - transaction: vi.fn(), - }, recordAudit: vi.fn(), enqueueOutboxEvent: vi.fn(), invalidateWorkspaceTableLimitsCache: vi.fn(), changeWorkspaceStoragePayerInTx: vi.fn(), })) -vi.mock('@sim/db', () => ({ db: mockDb })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/audit', () => ({ AuditAction: { WORKSPACE_UPDATED: 'workspace.updated', INVITATION_UPDATED: 'invitation.updated' }, AuditResourceType: { WORKSPACE: 'workspace' }, @@ -86,79 +74,29 @@ const destination = { ownerEmail: 'org-owner@example.com', } -let selectedWorkspace = movedWorkspace -const operationOrder: string[] = [] - -function createSelectChain() { - let source: unknown - const rows = () => { - if (source === workspace) return [selectedWorkspace] - if (source === organization) return [destination] - if (source === invitation || source === invitationWorkspaceGrant || source === permissions) { - return [] - } - return [] - } - const chain = { - from(table: unknown) { - source = table - return chain - }, - innerJoin() { - return chain - }, - leftJoin() { - return chain - }, - where() { - return chain - }, - orderBy() { - return chain - }, - for() { - if (source === workspace) operationOrder.push('workspace-lock') - return chain - }, - groupBy() { - return chain - }, - async limit() { - return rows() - }, - then(resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) { - return Promise.resolve(rows()).then(resolve, reject) - }, - } - return chain +/** + * The move flow reads the workspace three times in order — the pre-lock + * `FOR UPDATE` select (rows ignored), the classification row, and the final + * summary reload — so the workspace queue gets one set per read. All + * invitation/grant/permission selects resolve the queue-less empty default. + */ +function queueMoveSelects(workspaceRow: Record) { + queueTableRows(workspace, [workspaceRow]) + queueTableRows(workspace, [workspaceRow]) + queueTableRows(workspace, [workspaceRow]) + queueTableRows(organization, [destination]) } +afterAll(resetDbChainMock) + beforeEach(() => { vi.clearAllMocks() - operationOrder.length = 0 - selectedWorkspace = movedWorkspace - mockDb.select.mockImplementation(() => createSelectChain()) - mockDb.transaction.mockImplementation(async (callback: (tx: typeof mockDb) => unknown) => - callback(mockDb) - ) - mockDb.update.mockReturnValue({ - set: () => ({ - where: vi.fn().mockResolvedValue([]), - }), - }) - mockDb.insert.mockReturnValue({ - values: () => ({ - onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), - }), - }) - changeWorkspaceStoragePayerInTx.mockImplementation(async () => { - operationOrder.push('payer-mutation') - return { - billableBytes: 128, - newPayer: { type: 'organization', id: destination.id }, - oldPayer: { type: 'user', id: personalWorkspace.billedAccountUserId }, - repairedWorkspaceLedger: false, - } + resetDbChainMock() + changeWorkspaceStoragePayerInTx.mockResolvedValue({ + billableBytes: 128, + newPayer: { type: 'organization', id: destination.id }, + oldPayer: { type: 'user', id: personalWorkspace.billedAccountUserId }, + repairedWorkspaceLedger: false, }) }) @@ -226,6 +164,8 @@ describe('pending invitation destination identity', () => { describe('moveWorkspaceToOrganization retries', () => { it('returns the existing destination summary without repeating side effects', async () => { + queueMoveSelects(movedWorkspace) + const result = await moveWorkspaceToOrganization({ workspaceId: movedWorkspace.id, destinationOrganizationId: destination.id, @@ -240,13 +180,13 @@ describe('moveWorkspaceToOrganization retries', () => { expect(enqueueOutboxEvent).not.toHaveBeenCalled() expect(recordAudit).not.toHaveBeenCalled() expect(invalidateWorkspaceTableLimitsCache).not.toHaveBeenCalled() - expect(mockDb.insert).not.toHaveBeenCalled() - expect(mockDb.update).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(changeWorkspaceStoragePayerInTx).not.toHaveBeenCalled() }) it('pre-locks a nonzero workspace before changing its storage payer', async () => { - selectedWorkspace = personalWorkspace + queueMoveSelects(personalWorkspace) await moveWorkspaceToOrganization({ workspaceId: personalWorkspace.id, @@ -254,9 +194,12 @@ describe('moveWorkspaceToOrganization retries', () => { adminEmail: 'admin@sim.ai', }) - const firstWorkspaceLock = operationOrder.indexOf('workspace-lock') - const payerMutation = operationOrder.indexOf('payer-mutation') - expect(firstWorkspaceLock).toBeGreaterThanOrEqual(0) - expect(payerMutation).toBeGreaterThan(firstWorkspaceLock) + // The first `.for('update')` in the move path is the workspace pre-lock + // select (the earlier invitation-scan selects carry no row lock), so its + // invocation order against the payer mutation proves lock-before-payer. + const firstForUpdate = dbChainMockFns.for.mock.invocationCallOrder[0] + const payerMutation = changeWorkspaceStoragePayerInTx.mock.invocationCallOrder[0] + expect(firstForUpdate).toBeGreaterThan(0) + expect(payerMutation).toBeGreaterThan(firstForUpdate) }) }) diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 31ed0ed7e26..0fc262b6036 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -1,50 +1,23 @@ /** * @vitest-environment node */ -import { schemaMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { member, workspace } from '@sim/db/schema' +import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetUserOrganization, mockGetOrganizationSubscription, mockGetHighestPrioritySubscription, - mockDbResults, mockFeatureFlags, -} = vi.hoisted(() => { - const mockGetUserOrganization = vi.fn() - const mockGetOrganizationSubscription = vi.fn() - const mockGetHighestPrioritySubscription = vi.fn() - const mockDbResults: { value: any[] } = { value: [] } - const mockFeatureFlags = { isBillingEnabled: true } - - return { - mockGetUserOrganization, - mockGetOrganizationSubscription, - mockGetHighestPrioritySubscription, - mockDbResults, - mockFeatureFlags, - } -}) - -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn().mockImplementation(() => { - const chain: any = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockReturnValue(chain) - chain.limit = vi - .fn() - .mockImplementation(() => Promise.resolve(mockDbResults.value.shift() || [])) - chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => { - const result = mockDbResults.value.shift() || [] - return Promise.resolve(callback ? callback(result) : result) - }) - return chain - }), - }, +} = vi.hoisted(() => ({ + mockGetUserOrganization: vi.fn(), + mockGetOrganizationSubscription: vi.fn(), + mockGetHighestPrioritySubscription: vi.fn(), + mockFeatureFlags: { isBillingEnabled: true }, })) -vi.mock('@sim/db/schema', () => schemaMock) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/organizations/membership', () => ({ getUserOrganization: mockGetUserOrganization, @@ -71,10 +44,12 @@ import { } from '@/lib/workspaces/policy' import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants' +afterAll(resetDbChainMock) + describe('getWorkspaceCreationPolicy', () => { beforeEach(() => { vi.clearAllMocks() - mockDbResults.value = [] + resetDbChainMock() mockFeatureFlags.isBillingEnabled = true mockGetUserOrganization.mockResolvedValue(null) mockGetOrganizationSubscription.mockResolvedValue(null) @@ -82,7 +57,7 @@ describe('getWorkspaceCreationPolicy', () => { }) it('blocks free users once they already own one non-organization workspace', async () => { - mockDbResults.value = [[{ value: 1 }]] + queueTableRows(workspace, [{ value: 1 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) @@ -98,7 +73,7 @@ describe('getWorkspaceCreationPolicy', () => { plan: 'pro_6000', status: 'active', }) - mockDbResults.value = [[{ value: 2 }]] + queueTableRows(workspace, [{ value: 2 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) @@ -114,7 +89,7 @@ describe('getWorkspaceCreationPolicy', () => { plan: 'pro_25000', status: 'active', }) - mockDbResults.value = [[{ value: 5 }]] + queueTableRows(workspace, [{ value: 5 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) @@ -130,7 +105,7 @@ describe('getWorkspaceCreationPolicy', () => { plan: 'pro_25000', status: 'active', }) - mockDbResults.value = [[{ value: 10 }]] + queueTableRows(workspace, [{ value: 10 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) @@ -141,7 +116,7 @@ describe('getWorkspaceCreationPolicy', () => { it('allows unlimited personal workspaces when billing is disabled', async () => { mockFeatureFlags.isBillingEnabled = false - mockDbResults.value = [[{ value: 9 }]] + queueTableRows(workspace, [{ value: 9 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) @@ -159,7 +134,7 @@ describe('getWorkspaceCreationPolicy', () => { role: 'admin', memberId: 'member-1', }) - mockDbResults.value = [[{ userId: 'owner-1' }]] + queueTableRows(member, [{ userId: 'owner-1' }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1', @@ -177,7 +152,7 @@ describe('getWorkspaceCreationPolicy', () => { role: 'admin', memberId: 'member-1', }) - mockDbResults.value = [[{ value: 0 }]] + queueTableRows(workspace, [{ value: 0 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1', @@ -202,7 +177,7 @@ describe('getWorkspaceCreationPolicy', () => { plan: 'team_6000', status: 'active', }) - mockDbResults.value = [[{ userId: 'owner-1' }]] + queueTableRows(member, [{ userId: 'owner-1' }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1', @@ -222,7 +197,7 @@ describe('getWorkspaceCreationPolicy', () => { role: 'admin', memberId: 'member-1', }) - mockDbResults.value = [[{ userId: 'owner-1' }]] + queueTableRows(member, [{ userId: 'owner-1' }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1', @@ -247,7 +222,7 @@ describe('getWorkspaceCreationPolicy', () => { plan: 'enterprise', status: 'active', }) - mockDbResults.value = [[{ userId: 'owner-1' }]] + queueTableRows(member, [{ userId: 'owner-1' }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1', @@ -260,7 +235,8 @@ describe('getWorkspaceCreationPolicy', () => { }) it('blocks users without org membership from creating workspaces in the active org context', async () => { - mockDbResults.value = [[], [{ userId: 'owner-1' }]] + queueTableRows(member, []) + queueTableRows(member, [{ userId: 'owner-1' }]) const result = await getWorkspaceCreationPolicy({ userId: 'external-user-1', @@ -280,6 +256,7 @@ describe('getWorkspaceCreationPolicy', () => { describe('getWorkspaceInvitePolicy', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockFeatureFlags.isBillingEnabled = true mockGetOrganizationSubscription.mockResolvedValue(null) mockGetHighestPrioritySubscription.mockResolvedValue(null)