Files
simstudioai--sim/apps/sim/lib/copilot/tools/server/table/user-table.test.ts
T
wehub-resource-sync d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1006 lines
34 KiB
TypeScript

/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableDefinition } from '@/lib/table'
const {
mockResolveWorkspaceFileReference,
mockDownloadWorkspaceFile,
mockGetTableById,
mockBatchInsertRows,
mockReplaceTableRows,
mockAddWorkflowGroup,
mockCreateTable,
mockDeleteTable,
mockGetWorkspaceTableLimits,
mockMarkTableJobRunning,
mockReleaseJobClaim,
mockQueryRows,
mockDeleteRowsByFilter,
mockUpdateRowsByFilter,
mockRunTableImport,
mockRunTableDelete,
mockRunTableUpdate,
fakeEnrichment,
} = vi.hoisted(() => ({
mockResolveWorkspaceFileReference: vi.fn(),
mockDownloadWorkspaceFile: vi.fn(),
mockGetTableById: vi.fn(),
mockBatchInsertRows: vi.fn(),
mockReplaceTableRows: vi.fn(),
mockAddWorkflowGroup: vi.fn(),
mockCreateTable: vi.fn(),
mockDeleteTable: vi.fn(),
mockGetWorkspaceTableLimits: vi.fn(),
mockMarkTableJobRunning: vi.fn(),
mockReleaseJobClaim: vi.fn(),
mockQueryRows: vi.fn(),
mockDeleteRowsByFilter: vi.fn(),
mockUpdateRowsByFilter: vi.fn(),
mockRunTableImport: vi.fn(),
mockRunTableDelete: vi.fn(),
mockRunTableUpdate: vi.fn(),
fakeEnrichment: {
id: 'work-email',
name: 'Work Email',
description: 'Find work email',
icon: () => null,
inputs: [
{ id: 'fullName', name: 'Full name', type: 'string', required: true },
{ id: 'companyDomain', name: 'Company domain', type: 'string', required: true },
],
outputs: [{ id: 'email', name: 'email', type: 'string' }],
providers: [],
},
}))
vi.mock('@sim/utils/id', () => ({
generateId: vi.fn().mockReturnValue('deadbeefcafef00d'),
generateShortId: vi.fn().mockReturnValue('short-id'),
}))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
resolveWorkspaceFileReference: mockResolveWorkspaceFileReference,
fetchWorkspaceFileBuffer: mockDownloadWorkspaceFile,
}))
vi.mock('@/enrichments/registry', () => ({
ALL_ENRICHMENTS: [fakeEnrichment],
getEnrichment: (id: string) => (id === fakeEnrichment.id ? fakeEnrichment : undefined),
}))
vi.mock('@/lib/table/service', () => ({
createTable: mockCreateTable,
deleteTable: mockDeleteTable,
getTableById: mockGetTableById,
renameTable: vi.fn(),
}))
vi.mock('@/lib/table/workflow-groups/service', () => ({
addWorkflowGroup: mockAddWorkflowGroup,
addWorkflowGroupOutput: vi.fn(),
deleteWorkflowGroup: vi.fn(),
deleteWorkflowGroupOutput: vi.fn(),
updateWorkflowGroup: vi.fn(),
}))
vi.mock('@/lib/table/columns/service', () => ({
addTableColumn: vi.fn(),
deleteColumn: vi.fn(),
deleteColumns: vi.fn(),
renameColumn: vi.fn(),
updateColumnConstraints: vi.fn(),
updateColumnType: vi.fn(),
}))
vi.mock('@/lib/table/rows/service', () => ({
batchInsertRows: mockBatchInsertRows,
batchUpdateRows: vi.fn(),
deleteRow: vi.fn(),
deleteRowsByFilter: mockDeleteRowsByFilter,
deleteRowsByIds: vi.fn(),
getRowById: vi.fn(),
insertRow: vi.fn(),
queryRows: mockQueryRows,
replaceTableRows: mockReplaceTableRows,
updateRow: vi.fn(),
updateRowsByFilter: mockUpdateRowsByFilter,
}))
vi.mock('@/lib/table/jobs/service', () => ({
markTableJobRunning: mockMarkTableJobRunning,
releaseJobClaim: mockReleaseJobClaim,
}))
vi.mock('@/lib/table/import-runner', () => ({
runTableImport: mockRunTableImport,
}))
vi.mock('@/lib/table/delete-runner', () => ({
markTableDeleteFailed: vi.fn(),
runTableDelete: mockRunTableDelete,
}))
vi.mock('@/lib/table/update-runner', () => ({
markTableUpdateFailed: vi.fn(),
runTableUpdate: mockRunTableUpdate,
}))
vi.mock('@/lib/table/billing', () => ({
getWorkspaceTableLimits: mockGetWorkspaceTableLimits,
}))
import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table'
function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition {
return {
id: 'tbl_1',
name: 'People',
description: null,
schema: {
columns: [
{ name: 'name', type: 'string', required: true },
{ name: 'age', type: 'number' },
],
},
metadata: null,
rowCount: 0,
maxRows: 100,
workspaceId: 'workspace-1',
createdBy: 'user-1',
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
...overrides,
}
}
/** Lets a runDetached microtask chain run before asserting on the work it dispatched. */
async function flushDetached(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('userTableServerTool.import_file', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolveWorkspaceFileReference.mockResolvedValue({
name: 'people.csv',
type: 'text/csv',
key: 'workspace/workspace-1/people.csv',
size: 100,
})
mockDownloadWorkspaceFile.mockResolvedValue(Buffer.from('name,age\nAlice,30\nBob,40'))
mockGetTableById.mockResolvedValue(buildTable())
mockMarkTableJobRunning.mockResolvedValue(true)
mockReleaseJobClaim.mockResolvedValue(undefined)
mockBatchInsertRows.mockImplementation(async (data: { rows: unknown[] }) =>
data.rows.map((_, i) => ({ id: `row_${i}` }))
)
mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 0 })
})
it('appends rows using auto-mapping by default', async () => {
const result = await userTableServerTool.execute(
{
operation: 'import_file',
args: { tableId: 'tbl_1', fileId: 'file-1' },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(result.data?.mode).toBe('append')
expect(result.data?.rowCount).toBe(2)
expect(mockBatchInsertRows).toHaveBeenCalledTimes(1)
expect(mockReplaceTableRows).not.toHaveBeenCalled()
const call = mockBatchInsertRows.mock.calls[0][0] as { rows: unknown[] }
expect(call.rows).toEqual([
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 40 },
])
})
it('replaces rows in replace mode', async () => {
mockReplaceTableRows.mockResolvedValueOnce({ deletedCount: 3, insertedCount: 2 })
const result = await userTableServerTool.execute(
{
operation: 'import_file',
args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'replace' },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(result.data?.mode).toBe('replace')
expect(result.data?.deletedCount).toBe(3)
expect(result.data?.insertedCount).toBe(2)
expect(mockReplaceTableRows).toHaveBeenCalledTimes(1)
expect(mockBatchInsertRows).not.toHaveBeenCalled()
})
it('uses the caller-provided mapping', async () => {
mockDownloadWorkspaceFile.mockResolvedValueOnce(
Buffer.from('Full Name,Years\nAlice,30\nBob,40')
)
const result = await userTableServerTool.execute(
{
operation: 'import_file',
args: {
tableId: 'tbl_1',
fileId: 'file-1',
mapping: { 'Full Name': 'name', Years: 'age' },
},
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
const call = mockBatchInsertRows.mock.calls[0][0] as { rows: unknown[] }
expect(call.rows).toEqual([
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 40 },
])
})
it('rejects unknown modes', async () => {
const result = await userTableServerTool.execute(
{
operation: 'import_file',
args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'merge' },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(false)
expect(result.message).toMatch(/Invalid mode/)
expect(mockBatchInsertRows).not.toHaveBeenCalled()
})
it('refuses to import into an archived table', async () => {
mockGetTableById.mockResolvedValueOnce(buildTable({ archivedAt: new Date('2024-02-01') }))
const result = await userTableServerTool.execute(
{
operation: 'import_file',
args: { tableId: 'tbl_1', fileId: 'file-1' },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(false)
expect(result.message).toMatch(/archived/i)
})
it('refuses to import when the table belongs to a different workspace', async () => {
mockGetTableById.mockResolvedValueOnce(buildTable({ workspaceId: 'workspace-other' }))
const result = await userTableServerTool.execute(
{
operation: 'import_file',
args: { tableId: 'tbl_1', fileId: 'file-1' },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(false)
expect(result.message).toMatch(/not found/i)
expect(mockBatchInsertRows).not.toHaveBeenCalled()
})
it('reports missing required columns instead of inserting', async () => {
mockDownloadWorkspaceFile.mockResolvedValueOnce(Buffer.from('age\n30'))
const result = await userTableServerTool.execute(
{
operation: 'import_file',
args: { tableId: 'tbl_1', fileId: 'file-1' },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(false)
expect(result.message).toMatch(/missing required columns/i)
expect(mockBatchInsertRows).not.toHaveBeenCalled()
})
it('claims and releases the table job slot around an inline import', async () => {
const result = await userTableServerTool.execute(
{ operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', expect.any(String), 'import')
expect(mockReleaseJobClaim).toHaveBeenCalledWith(
'tbl_1',
mockMarkTableJobRunning.mock.calls[0][1]
)
})
it('rejects an inline import while another job holds the table slot', async () => {
mockMarkTableJobRunning.mockResolvedValueOnce(false)
const result = await userTableServerTool.execute(
{ operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(false)
expect(result.message).toMatch(/job is already in progress/i)
expect(mockBatchInsertRows).not.toHaveBeenCalled()
expect(mockReleaseJobClaim).not.toHaveBeenCalled()
})
it('dispatches a background import for large CSV files', async () => {
mockResolveWorkspaceFileReference.mockResolvedValueOnce({
name: 'big.csv',
type: 'text/csv',
key: 'workspace/workspace-1/big.csv',
size: 9 * 1024 * 1024,
})
const result = await userTableServerTool.execute(
{ operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'replace' } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
await flushDetached()
expect(result.success).toBe(true)
expect(result.data?.jobId).toBeDefined()
expect(result.message).toMatch(/background/i)
expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', expect.any(String), 'import')
expect(mockBatchInsertRows).not.toHaveBeenCalled()
expect(mockReplaceTableRows).not.toHaveBeenCalled()
expect(mockDownloadWorkspaceFile).not.toHaveBeenCalled()
expect(mockRunTableImport).toHaveBeenCalledTimes(1)
expect(mockRunTableImport.mock.calls[0][0]).toMatchObject({
tableId: 'tbl_1',
workspaceId: 'workspace-1',
fileKey: 'workspace/workspace-1/big.csv',
mode: 'replace',
deleteSourceFile: false,
})
})
it('rejects a background import while another job holds the table slot', async () => {
mockResolveWorkspaceFileReference.mockResolvedValueOnce({
name: 'big.csv',
type: 'text/csv',
key: 'workspace/workspace-1/big.csv',
size: 9 * 1024 * 1024,
})
mockMarkTableJobRunning.mockResolvedValueOnce(false)
const result = await userTableServerTool.execute(
{ operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
await flushDetached()
expect(result.success).toBe(false)
expect(result.message).toMatch(/job is already in progress/i)
expect(mockRunTableImport).not.toHaveBeenCalled()
})
})
describe('userTableServerTool.create_from_file', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolveWorkspaceFileReference.mockResolvedValue({
name: 'people.csv',
type: 'text/csv',
key: 'workspace/workspace-1/people.csv',
size: 100,
})
mockDownloadWorkspaceFile.mockResolvedValue(Buffer.from('name,age\nAlice,30\nBob,40'))
mockGetWorkspaceTableLimits.mockResolvedValue({ maxRowsPerTable: 1000, maxTables: 3 })
mockCreateTable.mockResolvedValue(buildTable({ id: 'tbl_new', name: 'people' }))
mockDeleteTable.mockResolvedValue(undefined)
mockBatchInsertRows.mockImplementation(async (data: { rows: unknown[] }) =>
data.rows.map((_, i) => ({ id: `row_${i}` }))
)
})
it('stamps the workspace plan limits on the created table', async () => {
const result = await userTableServerTool.execute(
{ operation: 'create_from_file', args: { fileId: 'file-1' } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(mockGetWorkspaceTableLimits).toHaveBeenCalledWith('workspace-1')
expect(mockCreateTable).toHaveBeenCalledTimes(1)
const createArgs = mockCreateTable.mock.calls[0][0] as { maxTables: number }
expect(createArgs.maxTables).toBe(3)
})
it('truncates to the plan row limit and reports dropped rows', async () => {
// File has 2 data rows (Alice, Bob); plan cap is 1.
mockGetWorkspaceTableLimits.mockResolvedValueOnce({ maxRowsPerTable: 1, maxTables: 3 })
const result = await userTableServerTool.execute(
{ operation: 'create_from_file', args: { fileId: 'file-1' } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(mockCreateTable).toHaveBeenCalledTimes(1)
const insertCall = mockBatchInsertRows.mock.calls[0][0] as { rows: unknown[] }
expect(insertCall.rows).toHaveLength(1)
expect(result.data?.rowCount).toBe(1)
expect(result.message).toMatch(/dropped 1 row/i)
expect(mockDeleteTable).not.toHaveBeenCalled()
})
it('rolls back the created table and reports the reason when row insertion fails', async () => {
mockBatchInsertRows.mockRejectedValueOnce(new Error('Row 2: Column "email" must be unique'))
const result = await userTableServerTool.execute(
{ operation: 'create_from_file', args: { fileId: 'file-1' } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(false)
expect(mockDeleteTable).toHaveBeenCalledWith('tbl_new', expect.any(String))
expect(result.message).toMatch(/rolled back/i)
expect(result.message).toMatch(/must be unique/i)
})
it('creates a placeholder table and dispatches a background import for large CSV files', async () => {
mockResolveWorkspaceFileReference.mockResolvedValueOnce({
name: 'big.csv',
type: 'text/csv',
key: 'workspace/workspace-1/big.csv',
size: 9 * 1024 * 1024,
})
const result = await userTableServerTool.execute(
{ operation: 'create_from_file', args: { fileId: 'file-1' } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
await flushDetached()
expect(result.success).toBe(true)
expect(result.data?.tableId).toBe('tbl_new')
expect(result.data?.jobId).toBeDefined()
expect(mockDownloadWorkspaceFile).not.toHaveBeenCalled()
expect(mockBatchInsertRows).not.toHaveBeenCalled()
const createArgs = mockCreateTable.mock.calls[0][0] as Record<string, unknown>
expect(createArgs).toMatchObject({
jobStatus: 'running',
jobType: 'import',
jobId: result.data?.jobId,
})
expect(mockRunTableImport).toHaveBeenCalledTimes(1)
expect(mockRunTableImport.mock.calls[0][0]).toMatchObject({
tableId: 'tbl_new',
mode: 'create',
fileKey: 'workspace/workspace-1/big.csv',
deleteSourceFile: false,
})
})
})
describe('userTableServerTool.create', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetWorkspaceTableLimits.mockResolvedValue({ maxRowsPerTable: 1000, maxTables: 3 })
mockCreateTable.mockResolvedValue(buildTable({ id: 'tbl_new', name: 'People' }))
})
it('stamps the workspace plan limits on the created table', async () => {
const result = await userTableServerTool.execute(
{
operation: 'create',
args: {
name: 'People',
schema: { columns: [{ name: 'name', type: 'string', required: true }] },
},
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(mockGetWorkspaceTableLimits).toHaveBeenCalledWith('workspace-1')
const createArgs = mockCreateTable.mock.calls[0][0] as { maxTables: number }
expect(createArgs.maxTables).toBe(3)
})
})
describe('userTableServerTool.list_enrichments', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the enrichment catalog metadata', async () => {
const result = await userTableServerTool.execute(
{ operation: 'list_enrichments', args: {} },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(result.data?.enrichments).toEqual([
{
id: 'work-email',
name: 'Work Email',
description: 'Find work email',
inputs: [
{ id: 'fullName', name: 'Full name', type: 'string', required: true },
{ id: 'companyDomain', name: 'Company domain', type: 'string', required: true },
],
outputs: [{ id: 'email', name: 'email', type: 'string' }],
},
])
})
})
describe('userTableServerTool.add_enrichment', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetTableById.mockResolvedValue(
buildTable({
schema: {
columns: [
{ name: 'name', type: 'string' },
{ name: 'company', type: 'string' },
],
},
})
)
mockAddWorkflowGroup.mockResolvedValue(buildTable())
})
it('creates an enrichment group with mapped inputs and derived output columns', async () => {
const result = await userTableServerTool.execute(
{
operation: 'add_enrichment',
args: {
tableId: 'tbl_1',
enrichmentId: 'work-email',
inputMappings: [
{ inputName: 'fullName', columnName: 'name' },
{ inputName: 'companyDomain', columnName: 'company' },
],
},
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(result.data?.groupId).toBe('deadbeefcafef00d')
expect(mockAddWorkflowGroup).toHaveBeenCalledTimes(1)
const call = mockAddWorkflowGroup.mock.calls[0][0]
expect(call.autoRun).toBe(false)
expect(call.group).toMatchObject({
type: 'enrichment',
enrichmentId: 'work-email',
workflowId: '',
autoRun: false,
dependencies: { columns: ['name', 'company'] },
inputMappings: [
{ inputName: 'fullName', columnName: 'name' },
{ inputName: 'companyDomain', columnName: 'company' },
],
outputs: [{ blockId: '', path: '', outputId: 'email', columnName: 'email' }],
})
expect(call.outputColumns).toEqual([
{
name: 'email',
type: 'string',
required: false,
unique: false,
workflowGroupId: 'deadbeefcafef00d',
},
])
})
it('enables auto-run when explicitly requested', async () => {
const result = await userTableServerTool.execute(
{
operation: 'add_enrichment',
args: {
tableId: 'tbl_1',
enrichmentId: 'work-email',
inputMappings: [
{ inputName: 'fullName', columnName: 'name' },
{ inputName: 'companyDomain', columnName: 'company' },
],
autoRun: true,
},
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(result.message).toMatch(/auto-run enabled/)
const call = mockAddWorkflowGroup.mock.calls[0][0]
expect(call.autoRun).toBe(true)
expect(call.group.autoRun).toBe(true)
})
it('rejects an unknown enrichment id', async () => {
const result = await userTableServerTool.execute(
{
operation: 'add_enrichment',
args: { tableId: 'tbl_1', enrichmentId: 'nope', inputMappings: [] },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(false)
expect(result.message).toMatch(/Unknown enrichment/)
expect(mockAddWorkflowGroup).not.toHaveBeenCalled()
})
it('rejects when a required input is unmapped', async () => {
const result = await userTableServerTool.execute(
{
operation: 'add_enrichment',
args: {
tableId: 'tbl_1',
enrichmentId: 'work-email',
inputMappings: [{ inputName: 'fullName', columnName: 'name' }],
},
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(false)
expect(result.message).toMatch(/requires input "companyDomain"/)
expect(mockAddWorkflowGroup).not.toHaveBeenCalled()
})
it('rejects when a mapped column does not exist on the table', async () => {
const result = await userTableServerTool.execute(
{
operation: 'add_enrichment',
args: {
tableId: 'tbl_1',
enrichmentId: 'work-email',
inputMappings: [
{ inputName: 'fullName', columnName: 'name' },
{ inputName: 'companyDomain', columnName: 'missing_col' },
],
},
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(false)
expect(result.message).toMatch(/does not exist/)
expect(mockAddWorkflowGroup).not.toHaveBeenCalled()
})
})
describe('userTableServerTool.query_rows', () => {
const queryRow = (i: number) => ({
id: `row_${i}`,
data: { name: `r${i}` },
executions: {},
position: i,
orderKey: `a${i}`,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
})
beforeEach(() => {
vi.clearAllMocks()
mockGetTableById.mockResolvedValue(buildTable())
mockQueryRows.mockResolvedValue({
rows: [queryRow(1), queryRow(2)],
rowCount: 2,
totalCount: 10,
limit: 2,
offset: 0,
})
})
it('clamps an over-large query limit to MAX_QUERY_LIMIT instead of rejecting', async () => {
const result = await userTableServerTool.execute(
{ operation: 'query_rows', args: { tableId: 'tbl_1', limit: 100000 } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
const options = mockQueryRows.mock.calls[0][1] as Record<string, unknown>
expect(options.limit).toBe(1000)
})
it('queries without execution metadata and passes limit/offset through', async () => {
const result = await userTableServerTool.execute(
{ operation: 'query_rows', args: { tableId: 'tbl_1', limit: 2, offset: 10 } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
const options = mockQueryRows.mock.calls[0][1] as Record<string, unknown>
expect(options.withExecutions).toBe(false)
expect(options.offset).toBe(10)
expect(result.data?.nextCursor).toBeUndefined()
})
})
describe('userTableServerTool.delete_rows_by_filter', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetTableById.mockResolvedValue(buildTable({ rowCount: 50000, maxRows: 100000 }))
mockMarkTableJobRunning.mockResolvedValue(true)
mockDeleteRowsByFilter.mockResolvedValue({ affectedCount: 5, affectedRowIds: ['r1'] })
mockQueryRows.mockResolvedValue({
rows: [],
rowCount: 0,
totalCount: 5,
limit: 1,
offset: 0,
})
})
it('escalates an explicit limit above the cap to a background delete with maxRows (unmasked)', async () => {
mockQueryRows.mockResolvedValueOnce({
rows: [],
rowCount: 0,
totalCount: 20000,
limit: 1,
offset: 0,
})
const result = await userTableServerTool.execute(
{
operation: 'delete_rows_by_filter',
args: { tableId: 'tbl_1', filter: { name: 'x' }, limit: 5000 },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
await flushDetached()
expect(result.success).toBe(true)
// target = min(limit 5000, matchCount 20000) = 5000, above the inline cap → background.
expect(result.data?.doomedCount).toBe(5000)
expect(mockDeleteRowsByFilter).not.toHaveBeenCalled()
const [, , type, payload] = mockMarkTableJobRunning.mock.calls[0]
expect(type).toBe('delete')
// Bounded delete carries maxRows and omits doomedCount so the mask is skipped and the count
// isn't double-subtracted.
expect(payload).toMatchObject({ maxRows: 5000 })
expect((payload as { doomedCount?: number }).doomedCount).toBeUndefined()
expect(mockRunTableDelete.mock.calls[0][0]).toMatchObject({ maxRows: 5000 })
})
it('deletes inline when the unbounded match count is within the cap', async () => {
const result = await userTableServerTool.execute(
{ operation: 'delete_rows_by_filter', args: { tableId: 'tbl_1', filter: { name: 'x' } } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(result.data?.affectedCount).toBe(5)
expect(mockDeleteRowsByFilter).toHaveBeenCalledTimes(1)
// Inline delete still claims (and releases) the table's write-job slot.
expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', expect.any(String), 'delete')
expect(mockReleaseJobClaim).toHaveBeenCalled()
})
it('rejects an inline delete while another job holds the table slot', async () => {
mockMarkTableJobRunning.mockResolvedValueOnce(false)
const result = await userTableServerTool.execute(
{
operation: 'delete_rows_by_filter',
args: { tableId: 'tbl_1', filter: { name: 'x' }, limit: 100 },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(false)
expect(result.message).toMatch(/job is already in progress/i)
expect(mockDeleteRowsByFilter).not.toHaveBeenCalled()
})
it('dispatches a background delete when the unbounded match count exceeds the cap', async () => {
mockQueryRows.mockResolvedValueOnce({
rows: [],
rowCount: 0,
totalCount: 20000,
limit: 1,
offset: 0,
})
const result = await userTableServerTool.execute(
{ operation: 'delete_rows_by_filter', args: { tableId: 'tbl_1', filter: { name: 'x' } } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
await flushDetached()
expect(result.success).toBe(true)
expect(result.data?.jobId).toBeDefined()
expect(result.data?.doomedCount).toBe(20000)
expect(mockDeleteRowsByFilter).not.toHaveBeenCalled()
const [tableId, jobId, type, payload] = mockMarkTableJobRunning.mock.calls[0]
expect(tableId).toBe('tbl_1')
expect(type).toBe('delete')
expect(payload).toMatchObject({ doomedCount: 20000, cutoff: expect.any(String) })
// Unbounded delete masks the whole set — no maxRows cap.
expect((payload as { maxRows?: number }).maxRows).toBeUndefined()
expect(mockRunTableDelete).toHaveBeenCalledTimes(1)
expect(mockRunTableDelete.mock.calls[0][0]).toMatchObject({
jobId,
tableId: 'tbl_1',
workspaceId: 'workspace-1',
cutoff: expect.any(Date),
})
})
it('rejects a background delete while another job holds the table slot', async () => {
mockQueryRows.mockResolvedValueOnce({
rows: [],
rowCount: 0,
totalCount: 20000,
limit: 1,
offset: 0,
})
mockMarkTableJobRunning.mockResolvedValueOnce(false)
const result = await userTableServerTool.execute(
{ operation: 'delete_rows_by_filter', args: { tableId: 'tbl_1', filter: { name: 'x' } } },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(false)
expect(result.message).toMatch(/job is already in progress/i)
expect(mockDeleteRowsByFilter).not.toHaveBeenCalled()
expect(mockRunTableDelete).not.toHaveBeenCalled()
})
it('deletes inline with an explicit limit without counting first', async () => {
const result = await userTableServerTool.execute(
{
operation: 'delete_rows_by_filter',
args: { tableId: 'tbl_1', filter: { name: 'x' }, limit: 100 },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(mockQueryRows).not.toHaveBeenCalled()
expect(mockDeleteRowsByFilter).toHaveBeenCalledTimes(1)
})
})
describe('userTableServerTool.update_rows_by_filter', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetTableById.mockResolvedValue(buildTable())
mockMarkTableJobRunning.mockResolvedValue(true)
mockUpdateRowsByFilter.mockResolvedValue({ affectedCount: 5, affectedRowIds: ['r1'] })
mockQueryRows.mockResolvedValue({ rows: [], rowCount: 0, totalCount: 5, limit: 1, offset: 0 })
})
it('escalates an explicit limit above the cap to a background update with maxRows', async () => {
mockQueryRows.mockResolvedValueOnce({
rows: [],
rowCount: 0,
totalCount: 20000,
limit: 1,
offset: 0,
})
const result = await userTableServerTool.execute(
{
operation: 'update_rows_by_filter',
args: { tableId: 'tbl_1', filter: { name: 'x' }, data: { age: 1 }, limit: 5000 },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
await flushDetached()
expect(result.success).toBe(true)
// target = min(limit 5000, matchCount 20000) = 5000, above the inline cap → background.
expect(result.data?.affectedCount).toBe(5000)
expect(mockUpdateRowsByFilter).not.toHaveBeenCalled()
const [, , type, payload] = mockMarkTableJobRunning.mock.calls[0]
expect(type).toBe('update')
expect(payload).toMatchObject({ affectedCount: 5000, maxRows: 5000 })
expect(mockRunTableUpdate.mock.calls[0][0]).toMatchObject({ maxRows: 5000 })
})
it('updates inline when the unbounded match count is within the cap', async () => {
const result = await userTableServerTool.execute(
{
operation: 'update_rows_by_filter',
args: { tableId: 'tbl_1', filter: { name: 'x' }, data: { age: 1 } },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(result.data?.affectedCount).toBe(5)
expect(mockUpdateRowsByFilter).toHaveBeenCalledTimes(1)
expect(mockMarkTableJobRunning).not.toHaveBeenCalled()
})
it('dispatches a background update when the unbounded match count exceeds the cap', async () => {
mockQueryRows.mockResolvedValueOnce({
rows: [],
rowCount: 0,
totalCount: 20000,
limit: 1,
offset: 0,
})
const result = await userTableServerTool.execute(
{
operation: 'update_rows_by_filter',
args: { tableId: 'tbl_1', filter: { name: 'x' }, data: { age: 1 } },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
await flushDetached()
expect(result.success).toBe(true)
expect(result.data?.jobId).toBeDefined()
expect(result.data?.affectedCount).toBe(20000)
expect(mockUpdateRowsByFilter).not.toHaveBeenCalled()
const [tableId, jobId, type, payload] = mockMarkTableJobRunning.mock.calls[0]
expect(tableId).toBe('tbl_1')
expect(type).toBe('update')
expect(payload).toMatchObject({
affectedCount: 20000,
cutoff: expect.any(String),
data: { age: 1 },
})
// Unbounded match (no explicit limit) → the worker patches every match, no cap.
expect((payload as { maxRows?: number }).maxRows).toBeUndefined()
expect(mockRunTableUpdate).toHaveBeenCalledTimes(1)
expect(mockRunTableUpdate.mock.calls[0][0]).toMatchObject({
jobId,
tableId: 'tbl_1',
workspaceId: 'workspace-1',
cutoff: expect.any(Date),
})
})
it('keeps a unique-column patch inline even when many rows match', async () => {
mockGetTableById.mockResolvedValue(
buildTable({ schema: { columns: [{ name: 'email', type: 'string', unique: true }] } })
)
const result = await userTableServerTool.execute(
{
operation: 'update_rows_by_filter',
args: { tableId: 'tbl_1', filter: { email: 'x' }, data: { email: 'y' } },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(mockQueryRows).not.toHaveBeenCalled()
expect(mockMarkTableJobRunning).not.toHaveBeenCalled()
expect(mockUpdateRowsByFilter).toHaveBeenCalledTimes(1)
})
it('rejects a background update while another job holds the table slot', async () => {
mockQueryRows.mockResolvedValueOnce({
rows: [],
rowCount: 0,
totalCount: 20000,
limit: 1,
offset: 0,
})
mockMarkTableJobRunning.mockResolvedValueOnce(false)
const result = await userTableServerTool.execute(
{
operation: 'update_rows_by_filter',
args: { tableId: 'tbl_1', filter: { name: 'x' }, data: { age: 1 } },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(false)
expect(result.message).toMatch(/job is already in progress/i)
expect(mockUpdateRowsByFilter).not.toHaveBeenCalled()
expect(mockRunTableUpdate).not.toHaveBeenCalled()
})
it('updates inline with an explicit limit without counting first', async () => {
const result = await userTableServerTool.execute(
{
operation: 'update_rows_by_filter',
args: { tableId: 'tbl_1', filter: { name: 'x' }, data: { age: 1 }, limit: 100 },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(mockQueryRows).not.toHaveBeenCalled()
expect(mockUpdateRowsByFilter).toHaveBeenCalledTimes(1)
})
})