chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,801 @@
|
||||
/**
|
||||
* Tests for copilot checkpoints revert API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { authMockFns, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockSelect,
|
||||
mockFrom,
|
||||
mockWhere,
|
||||
mockThen,
|
||||
mockDelete,
|
||||
mockDeleteWhere,
|
||||
mockGetAccessibleCopilotChat,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSelect: vi.fn(),
|
||||
mockFrom: vi.fn(),
|
||||
mockWhere: vi.fn(),
|
||||
mockThen: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockDeleteWhere: vi.fn(),
|
||||
mockGetAccessibleCopilotChat: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getBaseUrl: vi.fn(() => 'http://localhost:3000'),
|
||||
getInternalApiBaseUrl: vi.fn(() => 'http://localhost:3000'),
|
||||
getBaseDomain: vi.fn(() => 'localhost:3000'),
|
||||
getEmailDomain: vi.fn(() => 'localhost:3000'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
vi.mock('@/lib/copilot/chat/lifecycle', () => ({
|
||||
getAccessibleCopilotChat: mockGetAccessibleCopilotChat,
|
||||
getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
delete: mockDelete,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/copilot/checkpoints/revert/route'
|
||||
|
||||
describe('Copilot Checkpoints Revert API Route', () => {
|
||||
/** Queued results for successive `.then()` calls in the db select chain */
|
||||
let thenResults: unknown[]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
thenResults = []
|
||||
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
})
|
||||
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockReturnValue({ then: mockThen })
|
||||
|
||||
// Drizzle's .then() is a thenable: it receives a callback like (rows) => rows[0].
|
||||
// We invoke the callback with our mock rows array so the route gets the expected value.
|
||||
mockThen.mockImplementation((callback: (rows: unknown[]) => unknown) => {
|
||||
const result = thenResults.shift()
|
||||
if (result instanceof Error) {
|
||||
return Promise.reject(result)
|
||||
}
|
||||
const rows = result === undefined ? [] : [result]
|
||||
return Promise.resolve(callback(rows))
|
||||
})
|
||||
|
||||
// Mock delete chain
|
||||
mockDelete.mockReturnValue({ where: mockDeleteWhere })
|
||||
mockDeleteWhere.mockResolvedValue(undefined)
|
||||
mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123' })
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1640995200000)
|
||||
|
||||
const originalDate = Date
|
||||
const buildDate = (args: any[]): Date => {
|
||||
if (args.length === 0) {
|
||||
return new originalDate('2024-01-01T00:00:00.000Z')
|
||||
}
|
||||
if (args.length === 1) {
|
||||
return new originalDate(args[0])
|
||||
}
|
||||
return new originalDate(args[0], args[1], args[2], args[3], args[4], args[5], args[6])
|
||||
}
|
||||
vi.spyOn(global, 'Date').mockImplementation(
|
||||
class {
|
||||
constructor(...args: any[]) {
|
||||
// biome-ignore lint/correctness/noConstructorReturn: vitest 4 constructs mocks via Reflect.construct; returning a real Date overrides the instance so `new Date(...)` yields a genuine Date the route can call .toISOString()/.getTime() on
|
||||
return buildDate(args)
|
||||
}
|
||||
} as any
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/** Helper to set authenticated state */
|
||||
function setAuthenticated(user = { id: 'user-123', email: 'test@example.com' }) {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user })
|
||||
}
|
||||
|
||||
/** Helper to set unauthenticated state */
|
||||
function setUnauthenticated() {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
}
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
setUnauthenticated()
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return 400 for invalid request body - missing checkpointId', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(typeof responseData.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should return 400 for empty checkpointId', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: '' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(typeof responseData.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should return 404 when checkpoint is not found', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
// Mock checkpoint not found
|
||||
thenResults.push(undefined)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'non-existent-checkpoint' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Checkpoint not found or access denied')
|
||||
})
|
||||
|
||||
it('should return 404 when checkpoint belongs to different user', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
// Mock checkpoint not found (due to user mismatch in query)
|
||||
thenResults.push(undefined)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'other-user-checkpoint' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Checkpoint not found or access denied')
|
||||
})
|
||||
|
||||
it('should return 404 when workflow is not found', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const mockCheckpoint = {
|
||||
id: 'checkpoint-123',
|
||||
workflowId: 'a1b2c3d4-e5f6-4a78-b9c0-d1e2f3a4b5c6',
|
||||
userId: 'user-123',
|
||||
workflowState: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
thenResults.push(mockCheckpoint) // Checkpoint found
|
||||
thenResults.push(undefined) // Workflow not found
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Workflow not found')
|
||||
})
|
||||
|
||||
it('should return 401 when workflow belongs to different user', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const mockCheckpoint = {
|
||||
id: 'checkpoint-123',
|
||||
workflowId: 'b2c3d4e5-f6a7-4b89-a0d1-e2f3a4b5c6d7',
|
||||
userId: 'user-123',
|
||||
workflowState: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
const mockWorkflow = {
|
||||
id: 'b2c3d4e5-f6a7-4b89-a0d1-e2f3a4b5c6d7',
|
||||
userId: 'different-user',
|
||||
}
|
||||
|
||||
thenResults.push(mockCheckpoint) // Checkpoint found
|
||||
thenResults.push(mockWorkflow) // Workflow found but different user
|
||||
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should successfully revert checkpoint with basic workflow state', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const mockCheckpoint = {
|
||||
id: 'checkpoint-123',
|
||||
workflowId: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8',
|
||||
userId: 'user-123',
|
||||
workflowState: {
|
||||
blocks: { block1: { type: 'start' } },
|
||||
edges: [{ from: 'block1', to: 'block2' }],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isDeployed: true,
|
||||
},
|
||||
}
|
||||
|
||||
const mockWorkflow = {
|
||||
id: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8',
|
||||
userId: 'user-123',
|
||||
}
|
||||
|
||||
thenResults.push(mockCheckpoint) // Checkpoint found
|
||||
thenResults.push(mockWorkflow) // Workflow found
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: 'session=test-session',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
checkpointId: 'checkpoint-123',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: true,
|
||||
workflowId: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8',
|
||||
checkpointId: 'checkpoint-123',
|
||||
revertedAt: '2024-01-01T00:00:00.000Z',
|
||||
checkpoint: {
|
||||
id: 'checkpoint-123',
|
||||
workflowState: {
|
||||
blocks: { block1: { type: 'start' } },
|
||||
edges: [{ from: 'block1', to: 'block2' }],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isDeployed: true,
|
||||
lastSaved: 1640995200000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Verify fetch was called with correct parameters
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:3000/api/workflows/c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8/state',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: 'session=test-session',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
blocks: { block1: { type: 'start' } },
|
||||
edges: [{ from: 'block1', to: 'block2' }],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isDeployed: true,
|
||||
lastSaved: 1640995200000,
|
||||
}),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle checkpoint state with valid deployedAt date', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const mockCheckpoint = {
|
||||
id: 'checkpoint-with-date',
|
||||
workflowId: 'd4e5f6a7-b8c9-4d10-a2e3-a4b5c6d7e8f9',
|
||||
userId: 'user-123',
|
||||
workflowState: {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
deployedAt: '2024-01-01T12:00:00.000Z',
|
||||
isDeployed: true,
|
||||
},
|
||||
}
|
||||
|
||||
const mockWorkflow = {
|
||||
id: 'd4e5f6a7-b8c9-4d10-a2e3-a4b5c6d7e8f9',
|
||||
userId: 'user-123',
|
||||
}
|
||||
|
||||
thenResults.push(mockCheckpoint)
|
||||
thenResults.push(mockWorkflow)
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'checkpoint-with-date' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.checkpoint.workflowState.deployedAt).toBeDefined()
|
||||
expect(responseData.checkpoint.workflowState.deployedAt).toEqual('2024-01-01T12:00:00.000Z')
|
||||
})
|
||||
|
||||
it('should handle checkpoint state with invalid deployedAt date', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const mockCheckpoint = {
|
||||
id: 'checkpoint-invalid-date',
|
||||
workflowId: 'e5f6a7b8-c9d0-4e11-a3f4-b5c6d7e8f9a0',
|
||||
userId: 'user-123',
|
||||
workflowState: {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
deployedAt: 'invalid-date',
|
||||
isDeployed: true,
|
||||
},
|
||||
}
|
||||
|
||||
const mockWorkflow = {
|
||||
id: 'e5f6a7b8-c9d0-4e11-a3f4-b5c6d7e8f9a0',
|
||||
userId: 'user-123',
|
||||
}
|
||||
|
||||
thenResults.push(mockCheckpoint)
|
||||
thenResults.push(mockWorkflow)
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'checkpoint-invalid-date' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
// Invalid date should be filtered out
|
||||
expect(responseData.checkpoint.workflowState.deployedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle checkpoint state with null/undefined values', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const mockCheckpoint = {
|
||||
id: 'checkpoint-null-values',
|
||||
workflowId: 'f6a7b8c9-d0e1-4f23-a4b5-c6d7e8f9a0b1',
|
||||
userId: 'user-123',
|
||||
workflowState: {
|
||||
blocks: null,
|
||||
edges: undefined,
|
||||
loops: null,
|
||||
parallels: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
const mockWorkflow = {
|
||||
id: 'f6a7b8c9-d0e1-4f23-a4b5-c6d7e8f9a0b1',
|
||||
userId: 'user-123',
|
||||
}
|
||||
|
||||
thenResults.push(mockCheckpoint)
|
||||
thenResults.push(mockWorkflow)
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'checkpoint-null-values' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
|
||||
// Null/undefined values should be replaced with defaults
|
||||
expect(responseData.checkpoint.workflowState).toEqual({
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isDeployed: false,
|
||||
lastSaved: 1640995200000,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 500 when state API call fails', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const mockCheckpoint = {
|
||||
id: 'checkpoint-123',
|
||||
workflowId: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2',
|
||||
userId: 'user-123',
|
||||
workflowState: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
const mockWorkflow = {
|
||||
id: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2',
|
||||
userId: 'user-123',
|
||||
}
|
||||
|
||||
thenResults.push(mockCheckpoint)
|
||||
thenResults.push(mockWorkflow)
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: false,
|
||||
text: () => Promise.resolve('State validation failed'),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to revert workflow to checkpoint')
|
||||
})
|
||||
|
||||
it('should handle database errors during checkpoint lookup', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
// Mock database error
|
||||
thenResults.push(new Error('Database connection failed'))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to revert to checkpoint')
|
||||
})
|
||||
|
||||
it('should handle database errors during workflow lookup', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const mockCheckpoint = {
|
||||
id: 'checkpoint-123',
|
||||
workflowId: 'b8c9d0e1-f2a3-4b45-a6d7-e8f9a0b1c2d3',
|
||||
userId: 'user-123',
|
||||
workflowState: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
thenResults.push(mockCheckpoint) // Checkpoint found
|
||||
thenResults.push(new Error('Database error during workflow lookup')) // Workflow lookup fails
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to revert to checkpoint')
|
||||
})
|
||||
|
||||
it('should handle fetch network errors', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const mockCheckpoint = {
|
||||
id: 'checkpoint-123',
|
||||
workflowId: 'c9d0e1f2-a3b4-4c56-a7e8-f9a0b1c2d3e4',
|
||||
userId: 'user-123',
|
||||
workflowState: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
const mockWorkflow = {
|
||||
id: 'c9d0e1f2-a3b4-4c56-a7e8-f9a0b1c2d3e4',
|
||||
userId: 'user-123',
|
||||
}
|
||||
|
||||
thenResults.push(mockCheckpoint)
|
||||
thenResults.push(mockWorkflow)
|
||||
|
||||
;(global.fetch as any).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to revert to checkpoint')
|
||||
})
|
||||
|
||||
it('should handle JSON parsing errors in request body', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
body: '{invalid-json',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to revert to checkpoint')
|
||||
})
|
||||
|
||||
it('should forward cookies to state API call', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const mockCheckpoint = {
|
||||
id: 'checkpoint-123',
|
||||
workflowId: 'd0e1f2a3-b4c5-4d67-a8f9-a0b1c2d3e4f5',
|
||||
userId: 'user-123',
|
||||
workflowState: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
const mockWorkflow = {
|
||||
id: 'd0e1f2a3-b4c5-4d67-a8f9-a0b1c2d3e4f5',
|
||||
userId: 'user-123',
|
||||
}
|
||||
|
||||
thenResults.push(mockCheckpoint)
|
||||
thenResults.push(mockWorkflow)
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: 'session=test-session; auth=token123',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
checkpointId: 'checkpoint-123',
|
||||
}),
|
||||
})
|
||||
|
||||
await POST(req)
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:3000/api/workflows/d0e1f2a3-b4c5-4d67-a8f9-a0b1c2d3e4f5/state',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: 'session=test-session; auth=token123',
|
||||
},
|
||||
body: expect.any(String),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle missing cookies gracefully', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const mockCheckpoint = {
|
||||
id: 'checkpoint-123',
|
||||
workflowId: 'e1f2a3b4-c5d6-4e78-a9a0-b1c2d3e4f5a6',
|
||||
userId: 'user-123',
|
||||
workflowState: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
const mockWorkflow = {
|
||||
id: 'e1f2a3b4-c5d6-4e78-a9a0-b1c2d3e4f5a6',
|
||||
userId: 'user-123',
|
||||
}
|
||||
|
||||
thenResults.push(mockCheckpoint)
|
||||
thenResults.push(mockWorkflow)
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// No Cookie header
|
||||
},
|
||||
body: JSON.stringify({
|
||||
checkpointId: 'checkpoint-123',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:3000/api/workflows/e1f2a3b4-c5d6-4e78-a9a0-b1c2d3e4f5a6/state',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: '', // Empty string when no cookies
|
||||
},
|
||||
body: expect.any(String),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle complex checkpoint state with all fields', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
const mockCheckpoint = {
|
||||
id: 'checkpoint-complex',
|
||||
workflowId: 'f2a3b4c5-d6e7-4f89-a0b1-c2d3e4f5a6b7',
|
||||
userId: 'user-123',
|
||||
workflowState: {
|
||||
blocks: {
|
||||
start: { type: 'start', config: {} },
|
||||
http: { type: 'http', config: { url: 'https://api.example.com' } },
|
||||
end: { type: 'end', config: {} },
|
||||
},
|
||||
edges: [
|
||||
{ from: 'start', to: 'http' },
|
||||
{ from: 'http', to: 'end' },
|
||||
],
|
||||
loops: {
|
||||
loop1: { condition: 'true', iterations: 3 },
|
||||
},
|
||||
parallels: {
|
||||
parallel1: { branches: ['branch1', 'branch2'] },
|
||||
},
|
||||
isDeployed: true,
|
||||
deployedAt: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
}
|
||||
|
||||
const mockWorkflow = {
|
||||
id: 'f2a3b4c5-d6e7-4f89-a0b1-c2d3e4f5a6b7',
|
||||
userId: 'user-123',
|
||||
}
|
||||
|
||||
thenResults.push(mockCheckpoint)
|
||||
thenResults.push(mockWorkflow)
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkpointId: 'checkpoint-complex' }),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.checkpoint.workflowState).toEqual({
|
||||
blocks: {
|
||||
start: { type: 'start', config: {} },
|
||||
http: { type: 'http', config: { url: 'https://api.example.com' } },
|
||||
end: { type: 'end', config: {} },
|
||||
},
|
||||
edges: [
|
||||
{ from: 'start', to: 'http' },
|
||||
{ from: 'http', to: 'end' },
|
||||
],
|
||||
loops: {
|
||||
loop1: { condition: 'true', iterations: 3 },
|
||||
},
|
||||
parallels: {
|
||||
parallel1: { branches: ['branch1', 'branch2'] },
|
||||
},
|
||||
isDeployed: true,
|
||||
deployedAt: '2024-01-01T10:00:00.000Z',
|
||||
lastSaved: 1640995200000,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowCheckpoints, workflow as workflowTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { revertCopilotCheckpointContract } from '@/lib/api/contracts/copilot'
|
||||
import type { CleanedWorkflowState } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createInternalServerErrorResponse,
|
||||
createNotFoundResponse,
|
||||
createRequestTracker,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request/http'
|
||||
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { isUuidV4 } from '@/executor/constants'
|
||||
|
||||
const logger = createLogger('CheckpointRevertAPI')
|
||||
|
||||
/**
|
||||
* POST /api/copilot/checkpoints/revert
|
||||
* Revert workflow to a specific checkpoint state
|
||||
*/
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const tracker = createRequestTracker()
|
||||
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
revertCopilotCheckpointContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
invalidJson: 'throw',
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { checkpointId } = parsed.data.body
|
||||
|
||||
logger.info(`[${tracker.requestId}] Reverting to checkpoint ${checkpointId}`)
|
||||
|
||||
const checkpoint = await db
|
||||
.select()
|
||||
.from(workflowCheckpoints)
|
||||
.where(and(eq(workflowCheckpoints.id, checkpointId), eq(workflowCheckpoints.userId, userId)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!checkpoint) {
|
||||
return createNotFoundResponse('Checkpoint not found or access denied')
|
||||
}
|
||||
|
||||
const chat = await getAccessibleCopilotChatAuth(checkpoint.chatId, userId)
|
||||
if (!chat) {
|
||||
return createNotFoundResponse('Checkpoint not found or access denied')
|
||||
}
|
||||
|
||||
const workflowData = await db
|
||||
.select()
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, checkpoint.workflowId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workflowData) {
|
||||
return createNotFoundResponse('Workflow not found')
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: checkpoint.workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const checkpointState: Record<string, unknown> =
|
||||
checkpoint.workflowState && typeof checkpoint.workflowState === 'object'
|
||||
? (checkpoint.workflowState as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
const rawBlocks = checkpointState.blocks
|
||||
const rawEdges = checkpointState.edges
|
||||
const rawLoops = checkpointState.loops
|
||||
const rawParallels = checkpointState.parallels
|
||||
const rawDeployedAt = checkpointState.deployedAt
|
||||
|
||||
const parsedDeployedAt =
|
||||
rawDeployedAt === null || rawDeployedAt === undefined
|
||||
? null
|
||||
: new Date(rawDeployedAt as string | number | Date)
|
||||
|
||||
const cleanedState: CleanedWorkflowState = {
|
||||
blocks: (rawBlocks ?? {}) as Record<string, unknown>,
|
||||
edges: (rawEdges ?? []) as unknown[],
|
||||
loops: (rawLoops ?? {}) as Record<string, unknown>,
|
||||
parallels: (rawParallels ?? {}) as Record<string, unknown>,
|
||||
isDeployed: Boolean(checkpointState.isDeployed),
|
||||
lastSaved: Date.now(),
|
||||
...(parsedDeployedAt && !Number.isNaN(parsedDeployedAt.getTime())
|
||||
? { deployedAt: parsedDeployedAt }
|
||||
: {}),
|
||||
}
|
||||
|
||||
logger.info(`[${tracker.requestId}] Applying cleaned checkpoint state`, {
|
||||
blocksCount: Object.keys(cleanedState.blocks).length,
|
||||
edgesCount: cleanedState.edges.length,
|
||||
hasDeployedAt: !!cleanedState.deployedAt,
|
||||
isDeployed: cleanedState.isDeployed,
|
||||
})
|
||||
|
||||
if (!isUuidV4(checkpoint.workflowId)) {
|
||||
logger.error(`[${tracker.requestId}] Invalid workflow ID format`)
|
||||
return NextResponse.json({ error: 'Invalid workflow ID format' }, { status: 400 })
|
||||
}
|
||||
|
||||
const stateResponse = await fetch(
|
||||
`${getInternalApiBaseUrl()}/api/workflows/${checkpoint.workflowId}/state`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: request.headers.get('Cookie') || '',
|
||||
},
|
||||
body: JSON.stringify(cleanedState),
|
||||
}
|
||||
)
|
||||
|
||||
if (!stateResponse.ok) {
|
||||
const errorData = await stateResponse.text()
|
||||
logger.error(`[${tracker.requestId}] Failed to apply checkpoint state: ${errorData}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to revert workflow to checkpoint' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await stateResponse.json()
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Successfully reverted workflow ${checkpoint.workflowId} to checkpoint ${checkpointId}`
|
||||
)
|
||||
|
||||
// Delete the checkpoint after successfully reverting to it
|
||||
try {
|
||||
await db.delete(workflowCheckpoints).where(eq(workflowCheckpoints.id, checkpointId))
|
||||
logger.info(`[${tracker.requestId}] Deleted checkpoint after reverting`, { checkpointId })
|
||||
} catch (deleteError) {
|
||||
logger.warn(`[${tracker.requestId}] Failed to delete checkpoint after revert`, {
|
||||
checkpointId,
|
||||
error: deleteError,
|
||||
})
|
||||
// Don't fail the request if deletion fails - the revert was successful
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
workflowId: checkpoint.workflowId,
|
||||
checkpointId,
|
||||
revertedAt: new Date().toISOString(),
|
||||
checkpoint: {
|
||||
id: checkpoint.id,
|
||||
workflowState: cleanedState,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Error reverting to checkpoint:`, error)
|
||||
return createInternalServerErrorResponse('Failed to revert to checkpoint')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Tests for copilot checkpoints API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { authMockFns, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockSelect,
|
||||
mockFrom,
|
||||
mockWhere,
|
||||
mockLimit,
|
||||
mockOrderBy,
|
||||
mockInsert,
|
||||
mockValues,
|
||||
mockReturning,
|
||||
mockGetAccessibleCopilotChat,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSelect: vi.fn(),
|
||||
mockFrom: vi.fn(),
|
||||
mockWhere: vi.fn(),
|
||||
mockLimit: vi.fn(),
|
||||
mockOrderBy: vi.fn(),
|
||||
mockInsert: vi.fn(),
|
||||
mockValues: vi.fn(),
|
||||
mockReturning: vi.fn(),
|
||||
mockGetAccessibleCopilotChat: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
insert: mockInsert,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
desc: vi.fn((field: unknown) => ({ field, type: 'desc' })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat/lifecycle', () => ({
|
||||
getAccessibleCopilotChat: mockGetAccessibleCopilotChat,
|
||||
getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
import { GET, POST } from './route'
|
||||
|
||||
function createMockRequest(method: string, body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest('http://localhost:3000/api/copilot/checkpoints', {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Copilot Checkpoints API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockReturnValue({
|
||||
orderBy: mockOrderBy,
|
||||
limit: mockLimit,
|
||||
})
|
||||
mockOrderBy.mockResolvedValue([])
|
||||
mockLimit.mockResolvedValue([])
|
||||
mockInsert.mockReturnValue({ values: mockValues })
|
||||
mockValues.mockReturnValue({ returning: mockReturning })
|
||||
mockGetAccessibleCopilotChat.mockResolvedValue({
|
||||
id: 'chat-123',
|
||||
userId: 'user-123',
|
||||
workflowId: 'workflow-123',
|
||||
})
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
workflowState: '{"blocks": []}',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return 400 for invalid request body', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
workflowId: 'workflow-123',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(typeof responseData.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should return 400 when chat not found or unauthorized', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
mockGetAccessibleCopilotChat.mockResolvedValueOnce(null)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
workflowState: '{"blocks": []}',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Chat not found or unauthorized')
|
||||
})
|
||||
|
||||
it('should return 400 for invalid workflow state JSON', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
workflowState: 'invalid-json',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Invalid workflow state JSON')
|
||||
})
|
||||
|
||||
it('should successfully create a checkpoint', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const checkpoint = {
|
||||
id: 'checkpoint-123',
|
||||
userId: 'user-123',
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
messageId: 'message-123',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
}
|
||||
mockReturning.mockResolvedValue([checkpoint])
|
||||
|
||||
const workflowState = { blocks: [], connections: [] }
|
||||
const req = createMockRequest('POST', {
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
messageId: 'message-123',
|
||||
workflowState: JSON.stringify(workflowState),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: true,
|
||||
checkpoint: {
|
||||
id: 'checkpoint-123',
|
||||
userId: 'user-123',
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
messageId: 'message-123',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockInsert).toHaveBeenCalled()
|
||||
expect(mockValues).toHaveBeenCalledWith({
|
||||
userId: 'user-123',
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
messageId: 'message-123',
|
||||
workflowState: workflowState,
|
||||
})
|
||||
})
|
||||
|
||||
it('should create checkpoint without messageId', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const checkpoint = {
|
||||
id: 'checkpoint-123',
|
||||
userId: 'user-123',
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
messageId: undefined,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
}
|
||||
mockReturning.mockResolvedValue([checkpoint])
|
||||
|
||||
const workflowState = { blocks: [] }
|
||||
const req = createMockRequest('POST', {
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
workflowState: JSON.stringify(workflowState),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.success).toBe(true)
|
||||
expect(responseData.checkpoint.messageId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle database errors during checkpoint creation', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
mockReturning.mockRejectedValue(new Error('Database insert failed'))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
workflowState: '{"blocks": []}',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to create checkpoint')
|
||||
})
|
||||
|
||||
it('should handle database errors during chat lookup', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetAccessibleCopilotChat.mockRejectedValueOnce(new Error('Database query failed'))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
workflowState: '{"blocks": []}',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to create checkpoint')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123')
|
||||
|
||||
const response = await GET(req)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return 400 when chatId is missing', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints')
|
||||
|
||||
const response = await GET(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('chatId is required')
|
||||
})
|
||||
|
||||
it('should return checkpoints for authenticated user and chat', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const mockCheckpoints = [
|
||||
{
|
||||
id: 'checkpoint-1',
|
||||
userId: 'user-123',
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
messageId: 'message-1',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
},
|
||||
{
|
||||
id: 'checkpoint-2',
|
||||
userId: 'user-123',
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
messageId: 'message-2',
|
||||
createdAt: new Date('2024-01-02'),
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
},
|
||||
]
|
||||
|
||||
mockOrderBy.mockResolvedValue(mockCheckpoints)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123')
|
||||
|
||||
const response = await GET(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: true,
|
||||
checkpoints: [
|
||||
{
|
||||
id: 'checkpoint-1',
|
||||
userId: 'user-123',
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
messageId: 'message-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'checkpoint-2',
|
||||
userId: 'user-123',
|
||||
workflowId: 'workflow-123',
|
||||
chatId: 'chat-123',
|
||||
messageId: 'message-2',
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
updatedAt: '2024-01-02T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(mockSelect).toHaveBeenCalled()
|
||||
expect(mockWhere).toHaveBeenCalled()
|
||||
expect(mockOrderBy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle database errors when fetching checkpoints', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
mockOrderBy.mockRejectedValue(new Error('Database query failed'))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123')
|
||||
|
||||
const response = await GET(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to fetch checkpoints')
|
||||
})
|
||||
|
||||
it('should return empty array when no checkpoints found', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
mockOrderBy.mockResolvedValue([])
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123')
|
||||
|
||||
const response = await GET(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: true,
|
||||
checkpoints: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowCheckpoints } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
createCopilotCheckpointContract,
|
||||
listCopilotCheckpointsContract,
|
||||
} from '@/lib/api/contracts/copilot'
|
||||
import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server'
|
||||
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
createInternalServerErrorResponse,
|
||||
createRequestTracker,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request/http'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('WorkflowCheckpointsAPI')
|
||||
|
||||
/**
|
||||
* POST /api/copilot/checkpoints
|
||||
* Create a new checkpoint with JSON workflow state
|
||||
*/
|
||||
export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
const tracker = createRequestTracker()
|
||||
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
createCopilotCheckpointContract,
|
||||
req,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
validationErrorResponse(
|
||||
error,
|
||||
getValidationErrorMessage(error, 'Invalid checkpoint payload')
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { workflowId, chatId, messageId, workflowState } = parsed.data.body
|
||||
|
||||
logger.info(`[${tracker.requestId}] Creating workflow checkpoint`, {
|
||||
userId,
|
||||
workflowId,
|
||||
chatId,
|
||||
messageId,
|
||||
parsedData: { workflowId, chatId, messageId },
|
||||
messageIdType: typeof messageId,
|
||||
messageIdExists: !!messageId,
|
||||
})
|
||||
|
||||
// Verify that the chat belongs to the user
|
||||
const chat = await getAccessibleCopilotChatAuth(chatId, userId)
|
||||
|
||||
if (!chat) {
|
||||
return createBadRequestResponse('Chat not found or unauthorized')
|
||||
}
|
||||
|
||||
if (chat.workflowId !== workflowId) {
|
||||
return createBadRequestResponse('Chat does not belong to the requested workflow')
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
// Parse the workflow state to validate it's valid JSON
|
||||
let parsedWorkflowState
|
||||
try {
|
||||
parsedWorkflowState = JSON.parse(workflowState)
|
||||
} catch (error) {
|
||||
return createBadRequestResponse('Invalid workflow state JSON')
|
||||
}
|
||||
|
||||
// Create checkpoint with JSON workflow state
|
||||
const [checkpoint] = await db
|
||||
.insert(workflowCheckpoints)
|
||||
.values({
|
||||
userId,
|
||||
workflowId,
|
||||
chatId,
|
||||
messageId,
|
||||
workflowState: parsedWorkflowState, // Store as JSON object
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info(`[${tracker.requestId}] Workflow checkpoint created successfully`, {
|
||||
checkpointId: checkpoint.id,
|
||||
savedData: {
|
||||
checkpointId: checkpoint.id,
|
||||
userId: checkpoint.userId,
|
||||
workflowId: checkpoint.workflowId,
|
||||
chatId: checkpoint.chatId,
|
||||
messageId: checkpoint.messageId,
|
||||
createdAt: checkpoint.createdAt,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
checkpoint: {
|
||||
id: checkpoint.id,
|
||||
userId: checkpoint.userId,
|
||||
workflowId: checkpoint.workflowId,
|
||||
chatId: checkpoint.chatId,
|
||||
messageId: checkpoint.messageId,
|
||||
createdAt: checkpoint.createdAt,
|
||||
updatedAt: checkpoint.updatedAt,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Failed to create workflow checkpoint:`, error)
|
||||
return createInternalServerErrorResponse('Failed to create checkpoint')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/copilot/checkpoints?chatId=xxx
|
||||
* Retrieve workflow checkpoints for a chat
|
||||
*/
|
||||
export const GET = withRouteHandler(async (req: NextRequest) => {
|
||||
const tracker = createRequestTracker()
|
||||
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
listCopilotCheckpointsContract,
|
||||
req,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
validationErrorResponse(error, getValidationErrorMessage(error)),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { chatId } = parsed.data.query
|
||||
|
||||
logger.info(`[${tracker.requestId}] Fetching workflow checkpoints for chat`, {
|
||||
userId,
|
||||
chatId,
|
||||
})
|
||||
|
||||
const chat = await getAccessibleCopilotChatAuth(chatId, userId)
|
||||
if (!chat) {
|
||||
return createBadRequestResponse('Chat not found or unauthorized')
|
||||
}
|
||||
|
||||
// Fetch checkpoints for this user and chat
|
||||
const checkpoints = await db
|
||||
.select({
|
||||
id: workflowCheckpoints.id,
|
||||
userId: workflowCheckpoints.userId,
|
||||
workflowId: workflowCheckpoints.workflowId,
|
||||
chatId: workflowCheckpoints.chatId,
|
||||
messageId: workflowCheckpoints.messageId,
|
||||
createdAt: workflowCheckpoints.createdAt,
|
||||
updatedAt: workflowCheckpoints.updatedAt,
|
||||
})
|
||||
.from(workflowCheckpoints)
|
||||
.where(and(eq(workflowCheckpoints.chatId, chatId), eq(workflowCheckpoints.userId, userId)))
|
||||
.orderBy(desc(workflowCheckpoints.createdAt))
|
||||
|
||||
logger.info(`[${tracker.requestId}] Retrieved ${checkpoints.length} workflow checkpoints`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
checkpoints,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Failed to fetch workflow checkpoints:`, error)
|
||||
return createInternalServerErrorResponse('Failed to fetch checkpoints')
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user