mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user