improvement(tests): db-mock migration tranche 3 — lib/workflows, lib/copilot remainder, ee/core/misc (#5864)

* improvement(tests): db-mock migration tranche 3 — lib/workflows, lib/copilot remainder, ee/core/misc

* improvement(tests): use the shared notLike operator in idempotency cleanup suite
This commit is contained in:
Waleed
2026-07-22 15:29:58 -07:00
committed by GitHub
parent 52659d4a89
commit 51307c40c0
27 changed files with 1098 additions and 1863 deletions
@@ -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<string, unknown>
isMember?: boolean
hasMembers?: boolean
}>,
},
mockDefaultGroup: { value: [] as Array<{ config: Record<string, unknown> }> },
}))
vi.mock('@sim/db', () => ({
db: {
select: vi.fn().mockImplementation(() => {
let usedInnerJoin = false
const resolveRows = () => (usedInnerJoin ? mockWorkspaceGroups.value : mockDefaultGroup.value)
const chain: Record<string, unknown> = {}
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<string, unknown>
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<string, unknown> }> = []
) {
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',
@@ -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<unknown, unknown[][]>()
const updates: Array<{ table: unknown; values: Record<string, unknown> }> = []
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<string, unknown>) => ({
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: <T>(fn: () => T): T => fn(),
instrumentPoolClient: <T>(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<string, unknown>,
}))
/** 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<string, { value: unknown }>
expect(updates()).toHaveLength(1)
expect(updates()[0].table).toBe(workflowBlocks)
const cleared = updates()[0].values.subBlocks as Record<string, { value: unknown }>
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<string, { value: unknown }>
expect(updates()).toHaveLength(1)
expect(updates()[0].table).toBe(workflowBlocks)
const cleared = updates()[0].values.subBlocks as Record<string, { value: unknown }>
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()
})
})
})
@@ -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')
})
})
@@ -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<string, unknown> = {}
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<unknown>) => 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)
+19 -21
View File
@@ -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()
})
+24 -19
View File
@@ -1,21 +1,24 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
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 { mockWhere, envRef } = vi.hoisted(() => ({
mockWhere: vi.fn(),
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')
})
})
+9 -31
View File
@@ -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',
@@ -1,33 +1,15 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
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'
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', () => dbChainMock)
vi.mock('@sim/db', () => ({
db: { update: mockUpdate },
}))
vi.mock('@sim/db/schema', () => ({
copilotChats: {
id: 'copilotChats.id',
conversationId: 'copilotChats.conversationId',
},
}))
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<string, string>(),
})
})
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'))
)
})
@@ -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'
@@ -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(
+27 -52
View File
@@ -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'
@@ -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.hoisted(() => ({
mockAssertBillingAttributionSnapshot: vi.fn(),
mockCheckKnowledgeBaseWriteAccess: vi.fn(),
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.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')
@@ -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()
})
})
+12 -37
View File
@@ -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<string, unknown> = {}
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()
})
})
+77 -165
View File
@@ -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<string, unknown>; where?: unknown }>,
}
const makeUpdateChain = () => {
const row: { set: Record<string, unknown>; where?: unknown } = { set: {} }
const chain: Record<string, unknown> = {}
chain.set = vi.fn((s: Record<string, unknown>) => {
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<string, unknown> = {}
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<string, unknown> = {}
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<unknown>) => 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> = {}): 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<string, unknown>[] =>
dbChainMockFns.set.mock.calls.map((call) => call[0] as Record<string, unknown>)
/**
* 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<void>(() => {}))
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 () => {
@@ -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)
@@ -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 () => {
@@ -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()[
@@ -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<string, unknown>) => {
+21 -53
View File
@@ -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'
import { afterAll, 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(),
})
)
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<T>(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<void>) =>
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()
})
})
@@ -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<string, unknown>) => ({
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() }])
File diff suppressed because it is too large Load Diff
+24 -98
View File
@@ -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<string, BlockState> = {
'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', dbChainMock.db as any)
await deleteSchedulesForWorkflow('workflow-1', mockTx as any)
expect(mockDelete).toHaveBeenCalled()
expect(mockWhere).toHaveBeenCalled()
expect(dbChainMockFns.delete).toHaveBeenCalled()
expect(dbChainMockFns.where).toHaveBeenCalled()
})
})
})
@@ -1,20 +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 {
mockSelect,
mockUpdate,
mockUpdateSet,
mockUpdateWhere,
mockRecordAudit,
mockCaptureServerEvent,
} = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockUpdate: vi.fn(),
mockUpdateSet: vi.fn(),
mockUpdateWhere: vi.fn(),
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'),
})
})
@@ -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 })
+35 -92
View File
@@ -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<string, unknown>) {
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)
})
})
+25 -48
View File
@@ -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)