|
1 | 1 | /** |
2 | 2 | * @vitest-environment node |
3 | 3 | */ |
4 | | -import { hybridAuthMockFns, workflowAuthzMockFns } from '@sim/testing' |
5 | | -import { NextRequest } from 'next/server' |
6 | | -import { beforeEach, describe, expect, it, vi } from 'vitest' |
7 | | -import type { TableDefinition } from '@/lib/table' |
| 4 | +import { describe, expect, it, vi } from 'vitest' |
8 | 5 |
|
9 | | -const { mockCheckAccess, mockAddWorkflowGroup, mockUpdateWorkflowGroup } = vi.hoisted(() => ({ |
10 | | - mockCheckAccess: vi.fn(), |
11 | | - mockAddWorkflowGroup: vi.fn(), |
12 | | - mockUpdateWorkflowGroup: vi.fn(), |
13 | | -})) |
| 6 | +interface CapturedDefinition { |
| 7 | + contract: { method: string; path: string } |
| 8 | + auth: unknown |
| 9 | + operation: { id: string } |
| 10 | + useCase: unknown |
| 11 | +} |
14 | 12 |
|
15 | | -vi.mock('@/app/api/table/utils', async () => { |
16 | | - const { NextResponse } = await import('next/server') |
17 | | - return { |
18 | | - accessError: (result: { status: number }) => |
19 | | - NextResponse.json({ error: 'denied' }, { status: result.status }), |
20 | | - checkAccess: mockCheckAccess, |
21 | | - normalizeColumn: (column: unknown) => column, |
22 | | - } |
23 | | -}) |
| 13 | +const mocks = vi.hoisted(() => ({ |
| 14 | + auth: { kind: 'session-or-executor' }, |
| 15 | + definitions: [] as CapturedDefinition[], |
| 16 | + useCases: { |
| 17 | + create: { operation: { id: 'tables.groups.create' } }, |
| 18 | + remove: { operation: { id: 'tables.groups.delete' } }, |
| 19 | + update: { operation: { id: 'tables.groups.update' } }, |
| 20 | + }, |
| 21 | +})) |
24 | 22 |
|
25 | | -vi.mock('@/lib/table/workflow-groups/service', () => ({ |
26 | | - addWorkflowGroup: mockAddWorkflowGroup, |
27 | | - updateWorkflowGroup: mockUpdateWorkflowGroup, |
28 | | - deleteWorkflowGroup: vi.fn(), |
| 23 | +vi.mock('@/lib/api/server/routes', () => ({ |
| 24 | + defineInternalJsonRoute: (definition: CapturedDefinition) => { |
| 25 | + mocks.definitions.push(definition) |
| 26 | + return vi.fn() |
| 27 | + }, |
| 28 | + extendInternalErrorPolicy: vi.fn(() => ({ kind: 'table' })), |
| 29 | + internalErrorResponse: vi.fn(), |
| 30 | + internalPlainOrchestrationErrorPolicy: { kind: 'plain' }, |
| 31 | + internalRateLimits: { |
| 32 | + none: ({ reason }: { reason: string }) => ({ kind: 'none', reason }), |
| 33 | + }, |
29 | 34 | })) |
30 | 35 |
|
31 | | -import { PATCH, POST } from '@/app/api/table/[tableId]/groups/route' |
| 36 | +vi.mock('@/lib/table/api', () => ({ internalTableSessionOrExecutorAuth: mocks.auth })) |
32 | 37 |
|
33 | | -function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition { |
34 | | - return { |
35 | | - id: 'tbl_1', |
36 | | - name: 'People', |
37 | | - description: null, |
38 | | - schema: { columns: [] }, |
39 | | - metadata: null, |
40 | | - rowCount: 0, |
41 | | - maxRows: 100, |
42 | | - workspaceId: 'workspace-1', |
43 | | - createdBy: 'user-1', |
44 | | - archivedAt: null, |
45 | | - createdAt: new Date('2024-01-01'), |
46 | | - updatedAt: new Date('2024-01-01'), |
47 | | - ...overrides, |
48 | | - } |
49 | | -} |
| 38 | +vi.mock('@/lib/table/application/groups', () => ({ |
| 39 | + createTableGroupUseCase: mocks.useCases.create, |
| 40 | + deleteTableGroupUseCase: mocks.useCases.remove, |
| 41 | + updateTableGroupUseCase: mocks.useCases.update, |
| 42 | +})) |
50 | 43 |
|
51 | | -function callPost(body: Record<string, unknown>, tableId = 'tbl_1') { |
52 | | - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/groups`, { |
53 | | - method: 'POST', |
54 | | - body: JSON.stringify(body), |
55 | | - headers: { 'Content-Type': 'application/json' }, |
56 | | - }) |
57 | | - return POST(req, { params: Promise.resolve({ tableId }) }) |
58 | | -} |
| 44 | +vi.mock('@/app/api/table/utils', () => ({ |
| 45 | + normalizeColumn: vi.fn(), |
| 46 | +})) |
59 | 47 |
|
60 | | -function callPatch(body: Record<string, unknown>, tableId = 'tbl_1') { |
61 | | - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/groups`, { |
62 | | - method: 'PATCH', |
63 | | - body: JSON.stringify(body), |
64 | | - headers: { 'Content-Type': 'application/json' }, |
65 | | - }) |
66 | | - return PATCH(req, { params: Promise.resolve({ tableId }) }) |
67 | | -} |
| 48 | +import '@/app/api/table/[tableId]/groups/route' |
68 | 49 |
|
69 | | -const baseGroup = { |
70 | | - id: 'grp_1', |
71 | | - workflowId: 'wf_1', |
72 | | - outputs: [{ blockId: 'block_1', path: 'result', columnName: 'result' }], |
| 50 | +function definition(method: string): CapturedDefinition { |
| 51 | + const match = mocks.definitions.find((candidate) => candidate.contract.method === method) |
| 52 | + if (!match) throw new Error(`Missing ${method} group route definition`) |
| 53 | + return match |
73 | 54 | } |
74 | 55 |
|
75 | | -const baseOutputColumns = [{ name: 'result', type: 'string', workflowGroupId: 'grp_1' }] |
76 | | - |
77 | | -describe('POST /api/table/[tableId]/groups', () => { |
78 | | - beforeEach(() => { |
79 | | - vi.clearAllMocks() |
80 | | - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ |
81 | | - success: true, |
82 | | - userId: 'user-1', |
83 | | - authType: 'session', |
84 | | - }) |
85 | | - mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) |
86 | | - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({ |
87 | | - workflow: { id: 'wf_1' }, |
88 | | - workspaceId: 'workspace-1', |
89 | | - workspaceOrganizationId: null, |
90 | | - }) |
91 | | - mockAddWorkflowGroup.mockResolvedValue({ |
92 | | - schema: { columns: baseOutputColumns, workflowGroups: [baseGroup] }, |
93 | | - }) |
94 | | - }) |
95 | | - |
96 | | - it('rejects a workflowId belonging to a different workspace', async () => { |
97 | | - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({ |
98 | | - workflow: { id: 'wf_1' }, |
99 | | - workspaceId: 'other-workspace', |
100 | | - workspaceOrganizationId: null, |
101 | | - }) |
102 | | - const res = await callPost({ |
103 | | - workspaceId: 'workspace-1', |
104 | | - group: baseGroup, |
105 | | - outputColumns: baseOutputColumns, |
106 | | - }) |
107 | | - expect(res.status).toBe(400) |
108 | | - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() |
109 | | - }) |
110 | | - |
111 | | - it('rejects a nonexistent workflowId', async () => { |
112 | | - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue(null) |
113 | | - const res = await callPost({ |
114 | | - workspaceId: 'workspace-1', |
115 | | - group: baseGroup, |
116 | | - outputColumns: baseOutputColumns, |
117 | | - }) |
118 | | - expect(res.status).toBe(400) |
119 | | - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() |
120 | | - }) |
121 | | - |
122 | | - it('succeeds when the workflow belongs to the same workspace', async () => { |
123 | | - const res = await callPost({ |
124 | | - workspaceId: 'workspace-1', |
125 | | - group: baseGroup, |
126 | | - outputColumns: baseOutputColumns, |
127 | | - }) |
128 | | - expect(res.status).toBe(200) |
129 | | - expect(mockAddWorkflowGroup).toHaveBeenCalled() |
130 | | - }) |
131 | | - |
132 | | - it('skips the workflow check for enrichment groups without a workflowId', async () => { |
133 | | - const res = await callPost({ |
134 | | - workspaceId: 'workspace-1', |
135 | | - group: { ...baseGroup, workflowId: '', enrichmentId: 'enrich_1' }, |
136 | | - outputColumns: baseOutputColumns, |
137 | | - }) |
138 | | - expect(res.status).toBe(200) |
139 | | - expect(workflowAuthzMockFns.mockGetActiveWorkflowContext).not.toHaveBeenCalled() |
140 | | - expect(mockAddWorkflowGroup).toHaveBeenCalled() |
141 | | - }) |
142 | | -}) |
143 | | - |
144 | | -describe('PATCH /api/table/[tableId]/groups', () => { |
145 | | - beforeEach(() => { |
146 | | - vi.clearAllMocks() |
147 | | - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ |
148 | | - success: true, |
149 | | - userId: 'user-1', |
150 | | - authType: 'session', |
151 | | - }) |
152 | | - mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) |
153 | | - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({ |
154 | | - workflow: { id: 'wf_1' }, |
155 | | - workspaceId: 'workspace-1', |
156 | | - workspaceOrganizationId: null, |
157 | | - }) |
158 | | - mockUpdateWorkflowGroup.mockResolvedValue({ |
159 | | - schema: { columns: baseOutputColumns, workflowGroups: [baseGroup] }, |
160 | | - }) |
161 | | - }) |
162 | | - |
163 | | - it('rejects changing workflowId to one in a different workspace', async () => { |
164 | | - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({ |
165 | | - workflow: { id: 'wf_2' }, |
166 | | - workspaceId: 'other-workspace', |
167 | | - workspaceOrganizationId: null, |
168 | | - }) |
169 | | - const res = await callPatch({ |
170 | | - workspaceId: 'workspace-1', |
171 | | - groupId: 'grp_1', |
172 | | - workflowId: 'wf_2', |
173 | | - }) |
174 | | - expect(res.status).toBe(400) |
175 | | - expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() |
176 | | - }) |
177 | | - |
178 | | - it('rejects a nonexistent workflowId', async () => { |
179 | | - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue(null) |
180 | | - const res = await callPatch({ |
181 | | - workspaceId: 'workspace-1', |
182 | | - groupId: 'grp_1', |
183 | | - workflowId: 'wf_missing', |
184 | | - }) |
185 | | - expect(res.status).toBe(400) |
186 | | - expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() |
187 | | - }) |
188 | | - |
189 | | - it('succeeds when changing workflowId to one in the same workspace', async () => { |
190 | | - const res = await callPatch({ |
191 | | - workspaceId: 'workspace-1', |
192 | | - groupId: 'grp_1', |
193 | | - workflowId: 'wf_1', |
194 | | - }) |
195 | | - expect(res.status).toBe(200) |
196 | | - expect(mockUpdateWorkflowGroup).toHaveBeenCalled() |
197 | | - }) |
198 | | - |
199 | | - it('skips the workflow check when workflowId is not being changed', async () => { |
200 | | - const res = await callPatch({ |
201 | | - workspaceId: 'workspace-1', |
202 | | - groupId: 'grp_1', |
203 | | - name: 'Renamed group', |
204 | | - }) |
205 | | - expect(res.status).toBe(200) |
206 | | - expect(workflowAuthzMockFns.mockGetActiveWorkflowContext).not.toHaveBeenCalled() |
207 | | - expect(mockUpdateWorkflowGroup).toHaveBeenCalled() |
| 56 | +describe('/api/table/[tableId]/groups', () => { |
| 57 | + it('routes every mutation through its session-or-executor application use case', () => { |
| 58 | + const expected = [ |
| 59 | + ['POST', mocks.useCases.create], |
| 60 | + ['PATCH', mocks.useCases.update], |
| 61 | + ['DELETE', mocks.useCases.remove], |
| 62 | + ] as const |
| 63 | + |
| 64 | + expect(mocks.definitions).toHaveLength(expected.length) |
| 65 | + for (const [method, useCase] of expected) { |
| 66 | + const route = definition(method) |
| 67 | + expect(route.contract.path).toBe('/api/table/[tableId]/groups') |
| 68 | + expect(route.auth).toBe(mocks.auth) |
| 69 | + expect(route.useCase).toBe(useCase) |
| 70 | + expect(route.operation.id).toBe(useCase.operation.id) |
| 71 | + } |
208 | 72 | }) |
209 | 73 | }) |
0 commit comments