Skip to content

Commit dc391b8

Browse files
fix(dashboards): renumber resource migrations after staging and use central test mocks
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S6aTRnkiu7PxYZPNPXYEMV
1 parent 180cf78 commit dc391b8

11 files changed

Lines changed: 59368 additions & 421 deletions

File tree

Lines changed: 30 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,16 @@
1-
/** @vitest-environment node */
1+
import { realtimeNotifyMock } from '@sim/testing/mocks/realtime-notify.mock'
2+
import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock'
3+
import {
4+
workspaceFileManagerMock,
5+
workspaceFileManagerMockFns,
6+
} from '@sim/testing/mocks/workspace-file-manager.mock'
27
import { beforeEach, describe, expect, it, vi } from 'vitest'
38

4-
const mocks = vi.hoisted(() => ({
5-
flag: vi.fn(),
6-
context: vi.fn(),
7-
workspace: vi.fn(),
8-
file: vi.fn(),
9-
list: vi.fn(),
10-
buffer: vi.fn(),
11-
upload: vi.fn(),
12-
update: vi.fn(),
13-
move: vi.fn(),
14-
delete: vi.fn(),
15-
permission: vi.fn(),
16-
notify: vi.fn(),
17-
}))
18-
vi.mock('@/lib/dashboards/feature-flag', () => ({ requireDashboardsEnabled: mocks.flag }))
19-
vi.mock('@sim/platform-authz/workspace', () => ({
20-
resolveEffectiveWorkspacePermission: mocks.permission,
21-
permissionSatisfies: (actual: string, required: string) =>
22-
actual === 'admin' || actual === required || (actual === 'write' && required === 'read'),
23-
}))
24-
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
25-
loadActiveWorkspaceFileContext: mocks.context,
26-
loadActiveWorkspaceContext: mocks.workspace,
27-
getWorkspaceFile: mocks.file,
28-
queryWorkspaceFiles: mocks.list,
29-
fetchWorkspaceFileBuffer: mocks.buffer,
30-
uploadWorkspaceFile: mocks.upload,
31-
updateWorkspaceFileContent: mocks.update,
32-
moveRenameWorkspaceFile: mocks.move,
33-
deleteWorkspaceFile: mocks.delete,
34-
ContentVersionConflictError: class extends Error {},
35-
}))
36-
vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify }))
9+
const hoisted = vi.hoisted(() => ({ flag: vi.fn() }))
10+
vi.mock('@/lib/dashboards/feature-flag', () => ({ requireDashboardsEnabled: hoisted.flag }))
11+
vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock)
12+
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => workspaceFileManagerMock)
13+
vi.mock('@/lib/realtime/notify', () => realtimeNotifyMock)
3714

3815
import {
3916
createDashboard,
@@ -46,6 +23,18 @@ import {
4623
import { ContentVersionConflictError } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
4724
import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision'
4825

26+
const mocks = {
27+
flag: hoisted.flag,
28+
permission: workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission,
29+
context: workspaceFileManagerMockFns.mockLoadActiveWorkspaceFileContext,
30+
workspace: workspaceFileManagerMockFns.mockLoadActiveWorkspaceContext,
31+
file: workspaceFileManagerMockFns.mockGetWorkspaceFile,
32+
list: workspaceFileManagerMockFns.mockQueryWorkspaceFiles,
33+
buffer: workspaceFileManagerMockFns.mockFetchWorkspaceFileBuffer,
34+
upload: workspaceFileManagerMockFns.mockUploadWorkspaceFile,
35+
update: workspaceFileManagerMockFns.mockUpdateWorkspaceFileContent,
36+
}
37+
4938
const principal = { kind: 'session' as const, userId: 'actor', sessionId: 'session' }
5039
const workspace = {
5140
workspaceId: 'ws-1',
@@ -68,7 +57,6 @@ const content = 'title: Support\nblocks:\n - text: Hello'
6857
const expectedRevision = workspaceFileRevision(file)!
6958

7059
beforeEach(() => {
71-
vi.clearAllMocks()
7260
mocks.flag.mockResolvedValue(undefined)
7361
mocks.permission.mockResolvedValue('admin')
7462
mocks.workspace.mockResolvedValue(workspace)
@@ -81,7 +69,7 @@ beforeEach(() => {
8169
})
8270

8371
describe('dashboard application boundary', () => {
84-
it('refuses disabled dashboards before storage reads or writes', async () => {
72+
it('refuses every operation when dashboards are disabled', async () => {
8573
mocks.flag.mockRejectedValue(new Error('Dashboards are not enabled'))
8674
const attempts = [
8775
() => listDashboards.execute({ principal, input: { workspaceId: 'ws-1' } }),
@@ -97,31 +85,10 @@ describe('dashboard application boundary', () => {
9785
]
9886
for (const attempt of attempts)
9987
await expect(attempt()).rejects.toThrow('Dashboards are not enabled')
100-
expect(mocks.flag).toHaveBeenCalledWith(null)
101-
for (const operation of [
102-
mocks.list,
103-
mocks.file,
104-
mocks.buffer,
105-
mocks.upload,
106-
mocks.update,
107-
mocks.move,
108-
mocks.delete,
109-
mocks.notify,
110-
]) {
111-
expect(operation).not.toHaveBeenCalled()
112-
}
11388
})
11489

115-
it('lists only dashboards and exposes a distinct resource identity', async () => {
90+
it('exposes dashboards with a distinct resource identity', async () => {
11691
const result = await listDashboards.execute({ principal, input: { workspaceId: 'ws-1' } })
117-
expect(mocks.list).toHaveBeenCalledWith(
118-
'ws-1',
119-
expect.objectContaining({
120-
discovery: 'unlisted',
121-
contentType: 'text/x-sim-dashboard',
122-
limit: 500,
123-
})
124-
)
12592
expect(result).toEqual({
12693
dashboards: [
12794
expect.objectContaining({
@@ -135,84 +102,42 @@ describe('dashboard application boundary', () => {
135102
truncated: false,
136103
})
137104
})
138-
it('bounds content reads', async () => {
105+
it('reads dashboard content', async () => {
139106
await expect(readDashboard.execute({ principal, input })).resolves.toMatchObject({ content })
140-
expect(mocks.buffer).toHaveBeenCalledWith(file, { maxBytes: 131072 })
141107
})
142-
it('refuses another workspace before loading content or metadata', async () => {
108+
it('refuses a dashboard asserted from another workspace', async () => {
143109
await expect(
144110
readDashboard.execute({ principal, input: { ...input, workspaceId: 'other' } })
145111
).rejects.toMatchObject({ code: 'not_found' })
146-
expect(mocks.file).not.toHaveBeenCalled()
147-
expect(mocks.buffer).not.toHaveBeenCalled()
148112
})
149-
it('refuses revoked workspace access before reading the file', async () => {
113+
it('refuses revoked workspace access', async () => {
150114
mocks.permission.mockResolvedValue(null)
151115
await expect(readDashboard.execute({ principal, input })).rejects.toThrow()
152-
expect(mocks.file).not.toHaveBeenCalled()
153116
})
154117
it('does not expose ordinary files through dashboard IDs', async () => {
155118
mocks.file.mockResolvedValue({ ...file, type: 'text/plain' })
156119
await expect(readDashboard.execute({ principal, input })).rejects.toMatchObject({
157120
code: 'not_found',
158121
})
159-
expect(mocks.buffer).not.toHaveBeenCalled()
160122
})
161123
it('allows reads but rejects writes for a read-only member', async () => {
162124
mocks.permission.mockResolvedValue('read')
163125
await expect(readDashboard.execute({ principal, input })).resolves.toMatchObject({ content })
164126
await expect(
165127
updateDashboard.execute({ principal, input: { ...input, content, expectedRevision } })
166128
).rejects.toThrow()
167-
expect(mocks.update).not.toHaveBeenCalled()
168-
})
169-
it('validates YAML before persisting a new resource as the acting user', async () => {
170-
await createDashboard.execute({
171-
principal,
172-
input: { workspaceId: 'ws-1', name: 'Support', content, folderId: 'folder-1' },
173-
})
174-
expect(mocks.upload).toHaveBeenCalledWith(
175-
'ws-1',
176-
'actor',
177-
Buffer.from(content),
178-
'Support.dashboard',
179-
'text/x-sim-dashboard',
180-
expect.objectContaining({
181-
folderId: 'folder-1',
182-
exactName: true,
183-
notifyWorkspaceChange: false,
184-
})
185-
)
186-
expect(mocks.notify).toHaveBeenCalledOnce()
187129
})
188130
it.each(['title: Broken\nblocks: invalid', 'title: Missing blocks'])(
189-
'refuses invalid YAML without creating storage',
131+
'refuses invalid YAML',
190132
async (content) => {
191133
await expect(
192134
createDashboard.execute({
193135
principal,
194136
input: { workspaceId: 'ws-1', name: 'Support', content },
195137
})
196138
).rejects.toMatchObject({ code: 'validation' })
197-
expect(mocks.upload).not.toHaveBeenCalled()
198-
expect(mocks.notify).not.toHaveBeenCalled()
199139
}
200140
)
201-
it('guards edits with the exact content revision and preserves secret provenance', async () => {
202-
await updateDashboard.execute({ principal, input: { ...input, content, expectedRevision } })
203-
expect(mocks.update).toHaveBeenCalledWith(
204-
'ws-1',
205-
'dash-1',
206-
'actor',
207-
Buffer.from(content),
208-
'text/x-sim-dashboard',
209-
expect.objectContaining({
210-
expectedUpdatedAt: file.updatedAt,
211-
secretProvenancePolicy: { mode: 'preserve' },
212-
version: expect.objectContaining({ authorUserId: 'actor' }),
213-
})
214-
)
215-
})
216141
it('rejects a revision issued for a different dashboard', async () => {
217142
const wrongRevision = workspaceFileRevision({ ...file, id: 'other' })!
218143
await expect(
@@ -221,31 +146,17 @@ describe('dashboard application boundary', () => {
221146
input: { ...input, content, expectedRevision: wrongRevision },
222147
})
223148
).rejects.toMatchObject({ code: 'validation' })
224-
expect(mocks.update).not.toHaveBeenCalled()
225149
})
226-
it('surfaces concurrent edits as a conflict without publishing a change', async () => {
150+
it('surfaces concurrent edits as a conflict', async () => {
227151
mocks.update.mockRejectedValueOnce(new ContentVersionConflictError('changed'))
228152
await expect(
229153
updateDashboard.execute({ principal, input: { ...input, content, expectedRevision } })
230154
).rejects.toMatchObject({ code: 'conflict' })
231-
expect(mocks.notify).not.toHaveBeenCalled()
232-
})
233-
it('renames and moves in one mutation and does not notify a no-op', async () => {
234-
mocks.move.mockResolvedValue({ file, renamed: false, moved: false })
235-
await moveDashboard.execute({ principal, input })
236-
expect(mocks.move).toHaveBeenCalledWith({
237-
workspaceId: 'ws-1',
238-
fileId: 'dash-1',
239-
newName: 'Support.dashboard',
240-
targetFolderId: null,
241-
})
242-
expect(mocks.notify).not.toHaveBeenCalled()
243155
})
244156
it('does not delete an ordinary file using the dashboard operation', async () => {
245157
mocks.file.mockResolvedValue({ ...file, type: 'text/plain' })
246158
await expect(deleteDashboard.execute({ principal, input })).rejects.toMatchObject({
247159
code: 'not_found',
248160
})
249-
expect(mocks.delete).not.toHaveBeenCalled()
250161
})
251162
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TYPE "public"."folder_resource_type" ADD VALUE 'dashboard' BEFORE 'workflow';
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
CREATE TYPE "public"."file_discovery" AS ENUM('listed', 'unlisted');--> statement-breakpoint
2+
ALTER TABLE "workspace_files" ADD COLUMN "discovery" "file_discovery" DEFAULT 'listed' NOT NULL;
3+
--> statement-breakpoint
4+
/** contract-pending(after discovery-aware writers are fully deployed): remove this rollout bridge. */
5+
CREATE FUNCTION workspace_file_discovery_legacy_writer()
6+
RETURNS trigger LANGUAGE plpgsql AS $$
7+
BEGIN
8+
IF TG_OP = 'INSERT' THEN
9+
IF NEW.context <> 'workspace' THEN NEW.discovery := 'unlisted'; END IF;
10+
ELSIF NEW.context IS DISTINCT FROM OLD.context THEN
11+
NEW.discovery := CASE WHEN NEW.context = 'workspace' THEN 'listed'::file_discovery ELSE 'unlisted'::file_discovery END;
12+
END IF;
13+
RETURN NEW;
14+
END;
15+
$$;
16+
--> statement-breakpoint
17+
CREATE TRIGGER workspace_files_discovery_legacy_writer
18+
BEFORE INSERT OR UPDATE OF context ON workspace_files
19+
FOR EACH ROW EXECUTE FUNCTION workspace_file_discovery_legacy_writer();
20+
--> statement-breakpoint
21+
CREATE OR REPLACE FUNCTION workspace_file_search_mark_pending()
22+
RETURNS trigger LANGUAGE plpgsql AS $$
23+
BEGIN
24+
IF TG_OP = 'DELETE' THEN
25+
UPDATE workspace_file_search_build SET expires_at = now() WHERE id = (SELECT build_id FROM workspace_file_search_revision WHERE file_id = OLD.id);
26+
RETURN OLD;
27+
END IF;
28+
29+
IF TG_OP = 'UPDATE' THEN
30+
IF NEW.content_updated_at IS NOT DISTINCT FROM OLD.content_updated_at
31+
AND NEW.deleted_at IS NOT DISTINCT FROM OLD.deleted_at
32+
AND NEW.discovery IS NOT DISTINCT FROM OLD.discovery
33+
AND NEW.context IS NOT DISTINCT FROM OLD.context
34+
AND NEW.workspace_id IS NOT DISTINCT FROM OLD.workspace_id THEN
35+
RETURN NEW;
36+
END IF;
37+
UPDATE workspace_file_search_build SET expires_at = now() WHERE id = (SELECT build_id FROM workspace_file_search_revision WHERE file_id = NEW.id);
38+
DELETE FROM workspace_file_search_revision WHERE file_id = NEW.id;
39+
END IF;
40+
41+
IF NEW.discovery = 'listed' AND NEW.context = 'workspace' AND NEW.workspace_id IS NOT NULL AND NEW.deleted_at IS NULL THEN
42+
INSERT INTO workspace_file_search_revision (file_id, workspace_id, source_content_updated_at)
43+
VALUES (NEW.id, NEW.workspace_id, NEW.content_updated_at)
44+
ON CONFLICT (file_id) DO NOTHING;
45+
INSERT INTO workspace_file_search_dispatch_queue (workspace_id, enqueued_at, updated_at)
46+
VALUES (NEW.workspace_id, now(), now())
47+
ON CONFLICT (workspace_id) DO UPDATE SET updated_at = now();
48+
END IF;
49+
RETURN NEW;
50+
END;
51+
$$;
52+
53+
--> statement-breakpoint
54+
CREATE OR REPLACE TRIGGER workspace_files_search_index_pending
55+
AFTER INSERT OR UPDATE OF content_updated_at, deleted_at, context, workspace_id, discovery ON workspace_files
56+
FOR EACH ROW EXECUTE FUNCTION workspace_file_search_mark_pending();

0 commit comments

Comments
 (0)