Files
simstudioai--sim/apps/sim/ee/workspace-forking/lib/create-fork.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

216 lines
7.2 KiB
TypeScript

/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockSumForkCopyBytes,
mockAssertForkStorageHeadroom,
mockLoadSourceDeployedStates,
mockPlanForkFileCopies,
mockCopyForkResourceContainers,
mockStartBackgroundWork,
mockFinishBackgroundWork,
mockScheduleForkContentCopy,
mockSeedEdgeMappings,
} = vi.hoisted(() => ({
mockSumForkCopyBytes: vi.fn(),
mockAssertForkStorageHeadroom: vi.fn(),
mockLoadSourceDeployedStates: vi.fn(),
mockPlanForkFileCopies: vi.fn(),
mockCopyForkResourceContainers: vi.fn(),
mockStartBackgroundWork: vi.fn(),
mockFinishBackgroundWork: vi.fn(),
mockScheduleForkContentCopy: vi.fn(),
mockSeedEdgeMappings: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/workflows/defaults', () => ({
buildDefaultWorkflowArtifacts: vi.fn(() => ({ workflowState: {} })),
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
saveWorkflowToNormalizedTables: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/background-work/store', () => ({
startBackgroundWork: mockStartBackgroundWork,
finishBackgroundWork: mockFinishBackgroundWork,
}))
vi.mock('@/ee/workspace-forking/lib/copy/content-copy-runner', () => ({
hasForkContentToCopy: vi.fn(() => false),
scheduleForkContentCopy: mockScheduleForkContentCopy,
serializeContentRefMaps: vi.fn(() => ({})),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-chats', () => ({
copyForkChatDeployments: vi.fn(async () => ({ created: 0 })),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-files', () => ({
planForkFileCopies: mockPlanForkFileCopies,
}))
vi.mock('@/ee/workspace-forking/lib/copy/workflow-mcp-attachments', () => ({
copyForkWorkflowMcpAttachments: vi.fn(async () => ({ copied: 0 })),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-resources', () => ({
copyForkResourceContainers: mockCopyForkResourceContainers,
}))
vi.mock('@/ee/workspace-forking/lib/copy/storage-quota', () => ({
sumForkCopyBytes: mockSumForkCopyBytes,
assertForkStorageHeadroom: mockAssertForkStorageHeadroom,
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-workflows', () => ({
copyWorkflowStateIntoTarget: vi.fn(),
loadWorkflowNameRegistry: vi.fn(async () => new Map()),
resolveForkFolderMapping: vi.fn(async () => new Map()),
}))
vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({
loadSourceDeployedStates: mockLoadSourceDeployedStates,
}))
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
setForkLockTimeout: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({
reconcileForkBlockPairs: vi.fn(),
toForkBlockPairs: vi.fn(() => []),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
seedEdgeMappings: mockSeedEdgeMappings,
}))
vi.mock('@/ee/workspace-forking/lib/remap/fork-bootstrap', () => ({
createForkBootstrapTransform: vi.fn(() => (subBlocks: unknown) => subBlocks),
}))
vi.mock('@/ee/workspace-forking/lib/remap/reference-scan', () => ({
collectReferencedDocumentIds: vi.fn(() => new Set<string>()),
}))
vi.mock('@/lib/workspaces/policy', () => ({
WORKSPACE_MODE: {
PERSONAL: 'personal',
ORGANIZATION: 'organization',
GRANDFATHERED_SHARED: 'grandfathered_shared',
},
}))
import { createFork } from '@/ee/workspace-forking/lib/create-fork'
const SOURCE = { id: 'src-ws', name: 'Parent' } as never
const POLICY = {
organizationId: null,
workspaceMode: 'personal',
billedAccountUserId: null,
} as never
function forkParams(selection?: {
files?: string[]
knowledgeBases?: string[]
}): Parameters<typeof createFork>[0] {
return {
source: SOURCE,
policy: POLICY,
userId: 'user-1',
name: 'My Fork',
selection: {
files: selection?.files ?? [],
tables: [],
knowledgeBases: selection?.knowledgeBases ?? [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
},
requestId: 'test',
}
}
describe('createFork storage headroom gate', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockSumForkCopyBytes.mockResolvedValue(0)
mockAssertForkStorageHeadroom.mockResolvedValue(undefined)
mockLoadSourceDeployedStates.mockResolvedValue({
deployedWorkflows: [],
sourceStates: new Map(),
})
mockPlanForkFileCopies.mockResolvedValue({
keyMap: new Map(),
idMap: new Map(),
blobTasks: [],
})
mockCopyForkResourceContainers.mockResolvedValue({
idMap: new Map(),
mappingEntries: [],
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'child-ws',
userId: 'user-1',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
},
names: {
tables: [],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
},
})
mockStartBackgroundWork.mockResolvedValue('status-1')
mockFinishBackgroundWork.mockResolvedValue(undefined)
})
it('fails an over-quota fork BEFORE any read or write, with the storage error', async () => {
mockSumForkCopyBytes.mockResolvedValue(999_999)
mockAssertForkStorageHeadroom.mockRejectedValue(
new Error(
'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB'
)
)
await expect(
createFork(forkParams({ files: ['wf-1'], knowledgeBases: ['kb-1'] }))
).rejects.toThrow('Not enough storage to copy the selected resources')
expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 999_999 })
// Nothing was read, created, or recorded: the fork failed before all of it.
expect(mockLoadSourceDeployedStates).not.toHaveBeenCalled()
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
expect(mockStartBackgroundWork).not.toHaveBeenCalled()
})
it('proceeds under quota, summing exactly the selected files + knowledge bases', async () => {
mockSumForkCopyBytes.mockResolvedValue(500)
const result = await createFork(forkParams({ files: ['wf-1'], knowledgeBases: ['kb-1'] }))
expect(result.workspace.name).toBe('My Fork')
expect(result.workflowsCopied).toBe(0)
expect(mockSumForkCopyBytes).toHaveBeenCalledWith(expect.anything(), 'src-ws', {
fileIds: ['wf-1'],
knowledgeBaseIds: ['kb-1'],
})
expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 500 })
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
})
it('seeds identity mappings for copied FILES by storage key (a later sync must not re-offer them)', async () => {
mockPlanForkFileCopies.mockResolvedValue({
keyMap: new Map([['workspace/src-ws/a.png', 'workspace/child/a.png']]),
idMap: new Map([['file-1', 'file-1-copy']]),
blobTasks: [],
})
await createFork(forkParams({ files: ['file-1'] }))
expect(mockSeedEdgeMappings).toHaveBeenCalledTimes(1)
const seeded = mockSeedEdgeMappings.mock.calls[0][3] as Array<Record<string, unknown>>
expect(seeded).toContainEqual({
resourceType: 'file',
parentResourceId: 'workspace/src-ws/a.png',
childResourceId: 'workspace/child/a.png',
})
})
})