Skip to content

Commit 9db00ad

Browse files
committed
chore(folders): final polish pass — dealias imports, TSDoc comments, pinned-items test coverage
- Remove unnecessary import aliasing across the folder/pinned-items diff, keeping aliases only where a genuine local-identifier collision exists - Convert route-handler summary comments to TSDoc, drop pure WHAT-restatement comments - Add route + hook test coverage for /api/pinned-items (cross-workspace validation, unique-constraint conflict, per-user delete scoping) — the one area flagged by a final architecture pass as under-tested relative to risk - Document the intentional workflow-only scope of the folder duplicate route
1 parent fed33ce commit 9db00ad

21 files changed

Lines changed: 841 additions & 148 deletions

File tree

apps/sim/app/api/folders/[id]/duplicate/route.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,18 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1717

1818
const logger = createLogger('FolderDuplicateAPI')
1919

20-
// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows
20+
/**
21+
* POST /api/folders/[id]/duplicate — deep-copies a workflow folder (its child
22+
* folder tree and every contained workflow).
23+
*
24+
* This endpoint is intentionally `workflow`-only: duplication means cloning
25+
* workflow *definitions*, which has no generic equivalent for file/
26+
* knowledge_base/table folders yet, so — unlike the other `/api/folders`
27+
* routes — it does not read `resourceType` off the row and dispatch. Passing
28+
* a non-workflow folder id resolves to no source row and returns 404. When a
29+
* second resource type gains a real duplicate semantic, lift this into a
30+
* `performDuplicateFolder` dispatcher alongside the other orchestration fns.
31+
*/
2132
export const POST = withRouteHandler(
2233
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
2334
const { id: sourceFolderId } = await context.params

apps/sim/app/api/folders/[id]/restore/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from '@sim/db'
2-
import { folder as folderTable } from '@sim/db/schema'
2+
import { folder } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { getErrorMessage } from '@sim/utils/errors'
55
import { and, eq } from 'drizzle-orm'
@@ -34,9 +34,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
3434
}
3535

3636
const [existingFolder] = await db
37-
.select({ resourceType: folderTable.resourceType })
38-
.from(folderTable)
39-
.where(and(eq(folderTable.id, folderId), eq(folderTable.workspaceId, workspaceId)))
37+
.select({ resourceType: folder.resourceType })
38+
.from(folder)
39+
.where(and(eq(folder.id, folderId), eq(folder.workspaceId, workspaceId)))
4040
.limit(1)
4141

4242
if (!existingFolder) {

apps/sim/app/api/folders/[id]/route.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from '@sim/db'
2-
import { folder as folderTable } from '@sim/db/schema'
2+
import { folder } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { FolderLockedError } from '@sim/platform-authz/workflow'
55
import { eq } from 'drizzle-orm'
@@ -44,8 +44,8 @@ export const PUT = withRouteHandler(
4444
// Verify the folder exists
4545
const existingFolder = await db
4646
.select()
47-
.from(folderTable)
48-
.where(eq(folderTable.id, id))
47+
.from(folder)
48+
.where(eq(folder.id, id))
4949
.then((rows) => rows[0])
5050

5151
if (!existingFolder) {
@@ -136,8 +136,8 @@ export const DELETE = withRouteHandler(
136136
// Verify the folder exists
137137
const existingFolder = await db
138138
.select()
139-
.from(folderTable)
140-
.where(eq(folderTable.id, id))
139+
.from(folder)
140+
.where(eq(folder.id, id))
141141
.then((rows) => rows[0])
142142

143143
if (!existingFolder) {

apps/sim/app/api/folders/reorder/route.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from '@sim/db'
2-
import { folder as folderTable } from '@sim/db/schema'
2+
import { folder } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { FolderLockedError } from '@sim/platform-authz/workflow'
55
import { eq, inArray } from 'drizzle-orm'
@@ -40,12 +40,12 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
4040
const folderIds = updates.map((u) => u.id)
4141
const existingFolders = await db
4242
.select({
43-
id: folderTable.id,
44-
workspaceId: folderTable.workspaceId,
45-
resourceType: folderTable.resourceType,
43+
id: folder.id,
44+
workspaceId: folder.workspaceId,
45+
resourceType: folder.resourceType,
4646
})
47-
.from(folderTable)
48-
.where(inArray(folderTable.id, folderIds))
47+
.from(folder)
48+
.where(inArray(folder.id, folderIds))
4949

5050
const validRows = existingFolders.filter((f) => f.workspaceId === workspaceId)
5151
const validIds = new Set(validRows.map((f) => f.id))
@@ -72,9 +72,9 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
7272
}
7373

7474
const workspaceFolders = await db
75-
.select({ id: folderTable.id, parentId: folderTable.parentId })
76-
.from(folderTable)
77-
.where(eq(folderTable.workspaceId, workspaceId))
75+
.select({ id: folder.id, parentId: folder.parentId })
76+
.from(folder)
77+
.where(eq(folder.workspaceId, workspaceId))
7878

7979
const parentById = new Map<string, string | null>()
8080
for (const folderRow of workspaceFolders) {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* Tests for the pinned-item delete route (/api/pinned-items/[resourceType]/[resourceId])
3+
*
4+
* @vitest-environment node
5+
*/
6+
import { authMockFns, createMockRequest, schemaMock } from '@sim/testing'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const { mockLogger, mockDb } = vi.hoisted(() => {
10+
const logger = {
11+
info: vi.fn(),
12+
warn: vi.fn(),
13+
error: vi.fn(),
14+
debug: vi.fn(),
15+
trace: vi.fn(),
16+
fatal: vi.fn(),
17+
child: vi.fn(),
18+
}
19+
return {
20+
mockLogger: logger,
21+
mockDb: { delete: vi.fn() },
22+
}
23+
})
24+
25+
vi.mock('@sim/logger', () => ({
26+
createLogger: vi.fn().mockReturnValue(mockLogger),
27+
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
28+
getRequestContext: () => undefined,
29+
}))
30+
// The route imports `pinnedItem` (a table schema object) from `@sim/db`
31+
// alongside `db` — merge in `schemaMock`'s table shapes like the sibling
32+
// pinned-items/route.test.ts does.
33+
vi.mock('@sim/db', () => ({ db: mockDb, ...schemaMock }))
34+
35+
import { DELETE } from '@/app/api/pinned-items/[resourceType]/[resourceId]/route'
36+
37+
const OWNER_USER = { id: 'user-123', email: 'owner@example.com', name: 'Owner User' }
38+
const OTHER_USER = { id: 'user-456', email: 'other@example.com', name: 'Other User' }
39+
40+
/** One pinned row: `resourceType`='workflow', `resourceId`='workflow-1', owned by OWNER_USER. */
41+
const ownerPinnedRow = { id: 'pinned-1' }
42+
43+
function makeParams(resourceType: string, resourceId: string) {
44+
return Promise.resolve({ resourceType, resourceId })
45+
}
46+
47+
describe('Pinned Item Delete Route', () => {
48+
const mockDelete = mockDb.delete
49+
const mockWhere = vi.fn()
50+
const mockReturning = vi.fn()
51+
52+
function mockAuthenticatedUser(user: typeof OWNER_USER = OWNER_USER) {
53+
authMockFns.mockGetSession.mockResolvedValue({ user })
54+
}
55+
56+
function mockUnauthenticated() {
57+
authMockFns.mockGetSession.mockResolvedValue(null)
58+
}
59+
60+
beforeEach(() => {
61+
vi.clearAllMocks()
62+
63+
mockDelete.mockReturnValue({ where: mockWhere })
64+
mockWhere.mockReturnValue({ returning: mockReturning })
65+
mockReturning.mockReturnValue([])
66+
})
67+
68+
it('should unpin a resource successfully', async () => {
69+
mockAuthenticatedUser(OWNER_USER)
70+
mockReturning.mockReturnValueOnce([ownerPinnedRow])
71+
72+
const req = createMockRequest('DELETE')
73+
const response = await DELETE(req, { params: makeParams('workflow', 'workflow-1') })
74+
75+
expect(response.status).toBe(200)
76+
const data = await response.json()
77+
expect(data).toHaveProperty('success', true)
78+
expect(mockLogger.info).toHaveBeenCalledWith('Unpinned resource', {
79+
resourceType: 'workflow',
80+
resourceId: 'workflow-1',
81+
userId: OWNER_USER.id,
82+
})
83+
})
84+
85+
it('should scope the delete by the caller userId, not by workspace, and never delete another user pin of the same resource', async () => {
86+
// Simulate the query condition by asserting the delete is filtered with the
87+
// requesting user's id, and that a mismatched-owner deletion yields zero rows.
88+
mockAuthenticatedUser(OTHER_USER)
89+
// Because the composite where() is keyed on (userId, resourceType, resourceId),
90+
// OTHER_USER's delete of OWNER_USER's pin matches nothing -> empty returning().
91+
mockReturning.mockReturnValueOnce([])
92+
93+
const req = createMockRequest('DELETE')
94+
const response = await DELETE(req, { params: makeParams('workflow', 'workflow-1') })
95+
96+
expect(response.status).toBe(404)
97+
const data = await response.json()
98+
expect(data).toHaveProperty('error', 'Pinned item not found')
99+
100+
// The delete must always be scoped to the authenticated caller's own userId.
101+
const { eq } = await import('drizzle-orm')
102+
expect(eq).toHaveBeenCalledWith(expect.anything(), OTHER_USER.id)
103+
expect(mockLogger.info).not.toHaveBeenCalled()
104+
})
105+
106+
it('should return 401 for unauthenticated requests', async () => {
107+
mockUnauthenticated()
108+
109+
const req = createMockRequest('DELETE')
110+
const response = await DELETE(req, { params: makeParams('workflow', 'workflow-1') })
111+
112+
expect(response.status).toBe(401)
113+
const data = await response.json()
114+
expect(data).toHaveProperty('error', 'Unauthorized')
115+
expect(mockDelete).not.toHaveBeenCalled()
116+
})
117+
118+
it('should return 404 when the pin does not exist', async () => {
119+
mockAuthenticatedUser(OWNER_USER)
120+
mockReturning.mockReturnValueOnce([])
121+
122+
const req = createMockRequest('DELETE')
123+
const response = await DELETE(req, { params: makeParams('workflow', 'workflow-missing') })
124+
125+
expect(response.status).toBe(404)
126+
const data = await response.json()
127+
expect(data).toHaveProperty('error', 'Pinned item not found')
128+
})
129+
130+
it('should return 400 for an invalid resourceType path segment', async () => {
131+
mockAuthenticatedUser(OWNER_USER)
132+
133+
const req = createMockRequest('DELETE')
134+
const response = await DELETE(req, { params: makeParams('not-a-real-type', 'workflow-1') })
135+
136+
expect(response.status).toBe(400)
137+
expect(mockDelete).not.toHaveBeenCalled()
138+
})
139+
})

apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const logger = createLogger('PinnedItemDeleteAPI')
1111

1212
type RouteContext = { params: Promise<{ resourceType: string; resourceId: string }> }
1313

14-
// DELETE - Unpin a resource, keyed by the composite (resourceType, resourceId)
14+
/** Unpins a resource, identified by the composite key (`resourceType`, `resourceId`). */
1515
export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
1616
const session = await getSession()
1717
if (!session?.user?.id) {

0 commit comments

Comments
 (0)