Skip to content

Commit 971d6a2

Browse files
committed
test(chat): cover restore route; guard legacy copilot delete from hard-deleting mothership chats
1 parent a9d1cde commit 971d6a2

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

apps/sim/app/api/copilot/chat/delete/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
3535
return NextResponse.json({ success: true })
3636
}
3737

38+
// Mothership (sidebar) chats are soft-deleted through their own route so
39+
// they land in Recently Deleted — this legacy hard-delete must not bypass
40+
// that.
41+
if (chat.type === 'mothership') {
42+
return NextResponse.json(
43+
{ success: false, error: 'Use the mothership chat delete endpoint' },
44+
{ status: 400 }
45+
)
46+
}
47+
3848
const [deleted] = await db
3949
.delete(copilotChats)
4050
.where(and(eq(copilotChats.id, parsed.chatId), eq(copilotChats.userId, session.user.id)))
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const {
9+
mockDbSelect,
10+
mockSelectFrom,
11+
mockSelectWhere,
12+
mockSelectLimit,
13+
mockDbUpdate,
14+
mockDbSet,
15+
mockUpdateWhere,
16+
mockDbReturning,
17+
mockAssertActiveWorkspaceAccess,
18+
mockPublishStatusChanged,
19+
} = vi.hoisted(() => ({
20+
mockDbSelect: vi.fn(),
21+
mockSelectFrom: vi.fn(),
22+
mockSelectWhere: vi.fn(),
23+
mockSelectLimit: vi.fn(),
24+
mockDbUpdate: vi.fn(),
25+
mockDbSet: vi.fn(),
26+
mockUpdateWhere: vi.fn(),
27+
mockDbReturning: vi.fn(),
28+
mockAssertActiveWorkspaceAccess: vi.fn(),
29+
mockPublishStatusChanged: vi.fn(),
30+
}))
31+
32+
vi.mock('@sim/db', () => ({
33+
db: {
34+
select: mockDbSelect,
35+
update: mockDbUpdate,
36+
},
37+
}))
38+
39+
vi.mock('@sim/db/schema', () => ({
40+
copilotChats: {
41+
id: 'copilotChats.id',
42+
userId: 'copilotChats.userId',
43+
type: 'copilotChats.type',
44+
workspaceId: 'copilotChats.workspaceId',
45+
updatedAt: 'copilotChats.updatedAt',
46+
lastSeenAt: 'copilotChats.lastSeenAt',
47+
deletedAt: 'copilotChats.deletedAt',
48+
},
49+
}))
50+
51+
vi.mock('drizzle-orm', () => ({
52+
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
53+
eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
54+
isNotNull: vi.fn((field: unknown) => ({ type: 'isNotNull', field })),
55+
}))
56+
57+
vi.mock('@/lib/copilot/request/http', () => ({
58+
...copilotHttpMock,
59+
createForbiddenResponse: vi.fn((message: string) => ({
60+
status: 403,
61+
ok: false,
62+
json: async () => ({ error: message }),
63+
})),
64+
}))
65+
66+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
67+
assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess,
68+
isWorkspaceAccessDeniedError: (error: unknown) =>
69+
error instanceof Error && error.message === 'ACCESS_DENIED',
70+
}))
71+
72+
vi.mock('@/lib/copilot/chat-status', () => ({
73+
chatPubSub: { publishStatusChanged: mockPublishStatusChanged },
74+
}))
75+
76+
vi.mock('@/lib/posthog/server', () => ({
77+
captureServerEvent: vi.fn(),
78+
}))
79+
80+
import { POST } from '@/app/api/mothership/chats/[chatId]/restore/route'
81+
82+
function makeRequest(chatId: string) {
83+
return new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}/restore`, {
84+
method: 'POST',
85+
})
86+
}
87+
88+
function makeContext(chatId: string) {
89+
return { params: Promise.resolve({ chatId }) }
90+
}
91+
92+
describe('POST /api/mothership/chats/[chatId]/restore', () => {
93+
beforeEach(() => {
94+
vi.clearAllMocks()
95+
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
96+
userId: 'user-1',
97+
isAuthenticated: true,
98+
})
99+
mockSelectLimit.mockResolvedValue([{ workspaceId: 'workspace-1' }])
100+
mockSelectWhere.mockReturnValue({ limit: mockSelectLimit })
101+
mockSelectFrom.mockReturnValue({ where: mockSelectWhere })
102+
mockDbSelect.mockReturnValue({ from: mockSelectFrom })
103+
mockDbReturning.mockResolvedValue([{ workspaceId: 'workspace-1' }])
104+
mockUpdateWhere.mockReturnValue({ returning: mockDbReturning })
105+
mockDbSet.mockReturnValue({ where: mockUpdateWhere })
106+
mockDbUpdate.mockReturnValue({ set: mockDbSet })
107+
mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined)
108+
})
109+
110+
it('returns 401 when unauthenticated', async () => {
111+
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
112+
userId: null,
113+
isAuthenticated: false,
114+
})
115+
116+
const response = await POST(makeRequest('chat-1'), makeContext('chat-1'))
117+
expect(response.status).toBe(401)
118+
expect(mockDbUpdate).not.toHaveBeenCalled()
119+
})
120+
121+
it('returns 404 when no soft-deleted chat is owned by the caller', async () => {
122+
mockSelectLimit.mockResolvedValueOnce([])
123+
124+
const response = await POST(makeRequest('chat-missing'), makeContext('chat-missing'))
125+
expect(response.status).toBe(404)
126+
expect(mockAssertActiveWorkspaceAccess).not.toHaveBeenCalled()
127+
expect(mockDbUpdate).not.toHaveBeenCalled()
128+
})
129+
130+
it('returns 403 when the caller lost access to the workspace', async () => {
131+
mockAssertActiveWorkspaceAccess.mockRejectedValueOnce(new Error('ACCESS_DENIED'))
132+
133+
const response = await POST(makeRequest('chat-1'), makeContext('chat-1'))
134+
expect(response.status).toBe(403)
135+
expect(mockDbUpdate).not.toHaveBeenCalled()
136+
})
137+
138+
it('restores the chat, bumping updatedAt and lastSeenAt, and publishes the event', async () => {
139+
const response = await POST(makeRequest('chat-1'), makeContext('chat-1'))
140+
141+
expect(response.status).toBe(200)
142+
expect(await response.json()).toEqual({ success: true })
143+
expect(mockAssertActiveWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'user-1')
144+
expect(mockDbSet).toHaveBeenCalledWith({
145+
deletedAt: null,
146+
updatedAt: expect.any(Date),
147+
lastSeenAt: expect.any(Date),
148+
})
149+
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
150+
workspaceId: 'workspace-1',
151+
chatId: 'chat-1',
152+
type: 'created',
153+
})
154+
})
155+
156+
it('returns 404 when the chat is restored concurrently before the update lands', async () => {
157+
mockDbReturning.mockResolvedValueOnce([])
158+
159+
const response = await POST(makeRequest('chat-1'), makeContext('chat-1'))
160+
expect(response.status).toBe(404)
161+
expect(mockPublishStatusChanged).not.toHaveBeenCalled()
162+
})
163+
})

0 commit comments

Comments
 (0)