Skip to content
File

Blob: tests/client/components/workspace/view-provider.test.ts

typescript401 lines
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2import { createFreshDb, deleteDb } from "@tests/client/util/idb";
3import { createPage, createMember, createWorkspace } from "@tests/client/util/fixtures";
4import type { Page } from "@/shared/types";
5import type { BlandDatabase } from "@/client/stores/db/bland-db";
6import type { WorkspaceRouteState } from "@/client/lib/workspace-route-model";
7 
8/**
9 * Tests for the WorkspaceViewProvider's state model and recovery logic,
10 * exercised against the new projection stores and Dexie-backed commands.
11 * Verifies the observable contract around:
12 * - recoverPageAccess (cross-workspace page resolution via api.pages.context)
13 * - seedFromCache transitions (known slug vs unknown vs page-id fallback)
14 * - cache-to-live snapshot upgrade on re-resolve
15 */
16 
17const listPagesMock = vi.fn();
18const listMembersMock = vi.fn();
19const listWorkspacesMock = vi.fn();
20const pageContextMock = vi.fn();
21const pageGetMock = vi.fn();
22 
23let db: BlandDatabase;
24let replicaCommands: typeof import("@/client/stores/db/workspace-replica").replicaCommands;
25let directoryCommands: typeof import("@/client/stores/db/workspace-directory").directoryCommands;
26let replicaStoreMod: typeof import("@/client/stores/workspace-replica");
27let directoryStoreMod: typeof import("@/client/stores/workspace-directory");
28 
29beforeEach(async () => {
30 vi.resetModules();
31 
32 listPagesMock.mockReset();
33 listMembersMock.mockReset();
34 listWorkspacesMock.mockReset();
35 pageContextMock.mockReset();
36 pageGetMock.mockReset();
37 
38 db = createFreshDb();
39 vi.doMock("@/client/stores/db/bland-db", async () => {
40 const actual = await vi.importActual<typeof import("@/client/stores/db/bland-db")>("@/client/stores/db/bland-db");
41 return { ...actual, db };
42 });
43 
44 vi.doMock("@/client/lib/api", () => ({
45 api: {
46 pages: {
47 list: listPagesMock,
48 context: pageContextMock,
49 get: pageGetMock,
50 },
51 workspaces: {
52 list: listWorkspacesMock,
53 members: listMembersMock,
54 },
55 },
56 toApiError: (err: unknown) => {
57 if (err && typeof err === "object" && "error" in err) return err;
58 return { error: "unknown", message: String(err) };
59 },
60 }));
61 
62 vi.doMock("@/client/stores/auth-store", () => ({
63 useAuthStore: Object.assign(
64 vi.fn(() => false),
65 {
66 getState: () => ({ accessToken: null, user: null, sessionMode: "LOCAL_ONLY" }),
67 subscribe: vi.fn(() => vi.fn()),
68 setState: vi.fn(),
69 destroy: vi.fn(),
70 },
71 ),
72 selectIsAuthenticated: () => false,
73 selectHasLocalSession: () => true,
74 }));
75 
76 replicaCommands = (await import("@/client/stores/db/workspace-replica")).replicaCommands;
77 directoryCommands = (await import("@/client/stores/db/workspace-directory")).directoryCommands;
78 replicaStoreMod = await import("@/client/stores/workspace-replica");
79 directoryStoreMod = await import("@/client/stores/workspace-directory");
80});
81 
82afterEach(async () => {
83 await deleteDb(db);
84 vi.restoreAllMocks();
85});
86 
87/** Bridge writes into the projection store the way bootstrap's liveQuery does
88 * in production. Tests don't run bootstrap, so we seed the projection
89 * directly from Dexie after each write. */
90async function syncProjectionsFromDexie(): Promise<void> {
91 const [memberWorkspaces, replicas, pages, members, pageAccess] = await Promise.all([
92 db.memberWorkspaces.toArray(),
93 db.workspaceReplicas.toArray(),
94 db.workspacePages.toArray(),
95 db.workspaceMembers.toArray(),
96 db.pageAccess.toArray(),
97 ]);
98 directoryStoreMod.applyWorkspaceDirectoryProjection(memberWorkspaces);
99 replicaStoreMod.applyWorkspaceReplicaProjection({ replicas, pages, members, pageAccess });
100}
101 
102describe("recoverPageAccess contract", () => {
103 const wsA = createWorkspace({ id: "ws-a", slug: "workspace-a" });
104 const wsB = createWorkspace({ id: "ws-b", slug: "workspace-b" });
105 const pageInB = createPage({ id: "page-in-b", workspace_id: "ws-b" });
106 const membersB = [createMember({ workspace_id: "ws-b" })];
107 
108 it("api.pages.context returns the correct workspace for a cross-workspace page", async () => {
109 pageContextMock.mockResolvedValue({
110 workspace: wsB,
111 viewer: { access_mode: "member", principal_type: "user", route_kind: "canonical", workspace_slug: "workspace-b" },
112 });
113 listPagesMock.mockResolvedValue([pageInB]);
114 listMembersMock.mockResolvedValue(membersB);
115 
116 const ctx = await pageContextMock("page-in-b");
117 expect(ctx.workspace.id).toBe("ws-b");
118 expect(ctx.workspace.id).not.toBe(wsA.id);
119 
120 const pages = await listPagesMock(ctx.workspace.id);
121 const members = await listMembersMock(ctx.workspace.id);
122 
123 await replicaCommands.replaceWorkspace({
124 workspace: wsB,
125 accessMode: ctx.viewer.access_mode,
126 workspaceRole: ctx.viewer.workspace_role,
127 pages,
128 members,
129 });
130 
131 await syncProjectionsFromDexie();
132 
133 const replica = replicaStoreMod.selectWorkspaceReplica(replicaStoreMod.useWorkspaceReplicaStore.getState(), "ws-b");
134 expect(replica).toBeTruthy();
135 expect(replica!.workspace.slug).toBe("workspace-b");
136 expect(replica!.accessMode).toBe("member");
137 const storedPages = replicaStoreMod.selectWorkspacePages(
138 replicaStoreMod.useWorkspaceReplicaStore.getState(),
139 "ws-b",
140 );
141 const storedMembers = replicaStoreMod.selectWorkspaceMembers(
142 replicaStoreMod.useWorkspaceReplicaStore.getState(),
143 "ws-b",
144 );
145 expect(storedPages).toEqual([pageInB]);
146 expect(storedMembers).toEqual(membersB);
147 });
148 
149 it("returns false when page belongs to the same workspace", async () => {
150 pageContextMock.mockResolvedValue({
151 workspace: wsA,
152 viewer: { access_mode: "member", principal_type: "user", route_kind: "canonical", workspace_slug: "workspace-a" },
153 });
154 const ctx = await pageContextMock("page-in-a");
155 expect(ctx.workspace.id).toBe(wsA.id);
156 });
157 
158 it("handles api.pages.context failure gracefully", async () => {
159 pageContextMock.mockRejectedValue({ error: "not_found", message: "Page not found" });
160 let failed = false;
161 try {
162 await pageContextMock("nonexistent-page");
163 } catch {
164 failed = true;
165 }
166 expect(failed).toBe(true);
167 });
168 
169 it("fetches pages only (no members) for shared-access discovery", async () => {
170 pageContextMock.mockResolvedValue({
171 workspace: wsB,
172 viewer: { access_mode: "shared", principal_type: "user", route_kind: "canonical", workspace_slug: "workspace-b" },
173 });
174 listPagesMock.mockResolvedValue([pageInB]);
175 
176 const ctx = await pageContextMock("page-in-b");
177 expect(ctx.viewer.access_mode).toBe("shared");
178 
179 const pages = await listPagesMock(ctx.workspace.id);
180 
181 await replicaCommands.replaceWorkspace({
182 workspace: wsB,
183 accessMode: "shared",
184 workspaceRole: null,
185 pages,
186 members: [],
187 });
188 await syncProjectionsFromDexie();
189 
190 const replica = replicaStoreMod.selectWorkspaceReplica(replicaStoreMod.useWorkspaceReplicaStore.getState(), "ws-b");
191 expect(replica!.accessMode).toBe("shared");
192 const storedMembers = replicaStoreMod.selectWorkspaceMembers(
193 replicaStoreMod.useWorkspaceReplicaStore.getState(),
194 "ws-b",
195 );
196 expect(storedMembers).toEqual([]);
197 });
198});
199 
200describe("seedFromCache state transitions", () => {
201 it("returns loading when slug is unknown", () => {
202 const dir = directoryStoreMod.useWorkspaceDirectoryStore.getState();
203 const replicas = replicaStoreMod.useWorkspaceReplicaStore.getState();
204 expect(directoryStoreMod.selectMemberWorkspaces(dir)).toEqual([]);
205 expect(replicas.replicas.size).toBe(0);
206 });
207 
208 it("returns ready-member with cache for member workspace with replica", async () => {
209 const ws = createWorkspace({ id: "ws-1", slug: "my-ws" });
210 await directoryCommands.upsert({ ...ws, role: "member" });
211 await replicaCommands.replaceWorkspace({
212 workspace: ws,
213 accessMode: "member",
214 workspaceRole: "member",
215 pages: [createPage({ workspace_id: "ws-1" })],
216 members: [createMember({ workspace_id: "ws-1" })],
217 });
218 await syncProjectionsFromDexie();
219 
220 const replicaState = replicaStoreMod.useWorkspaceReplicaStore.getState();
221 const replica = replicaStoreMod.selectWorkspaceReplica(replicaState, "ws-1");
222 expect(replica).toBeTruthy();
223 expect(replica!.workspace.slug).toBe("my-ws");
224 expect(replica!.accessMode).toBe("member");
225 });
226 
227 it("returns seeded-identity for member workspace without replica", async () => {
228 const ws = createWorkspace({ id: "ws-1", slug: "my-ws" });
229 await directoryCommands.upsert({ ...ws, role: "member" });
230 await syncProjectionsFromDexie();
231 
232 const replicaState = replicaStoreMod.useWorkspaceReplicaStore.getState();
233 expect(replicaStoreMod.selectWorkspaceReplica(replicaState, "ws-1")).toBeNull();
234 
235 const dir = directoryStoreMod.useWorkspaceDirectoryStore.getState();
236 const memberWs = directoryStoreMod.selectWorkspaceBySlug(dir, "my-ws");
237 expect(memberWs).toBeTruthy();
238 expect(memberWs!.id).toBe("ws-1");
239 });
240 
241 it("returns ready-shared with cache for shared workspace by slug", async () => {
242 const ws = createWorkspace({ id: "ws-2", slug: "shared-ws" });
243 await replicaCommands.replaceWorkspace({
244 workspace: ws,
245 accessMode: "shared",
246 workspaceRole: null,
247 pages: [createPage({ workspace_id: "ws-2" })],
248 members: [],
249 });
250 await syncProjectionsFromDexie();
251 
252 const dir = directoryStoreMod.useWorkspaceDirectoryStore.getState();
253 expect(directoryStoreMod.selectWorkspaceBySlug(dir, "shared-ws")).toBeNull();
254 const replica = replicaStoreMod.selectReplicaBySlug(
255 replicaStoreMod.useWorkspaceReplicaStore.getState(),
256 "shared-ws",
257 );
258 expect(replica).toBeTruthy();
259 expect(replica!.accessMode).toBe("shared");
260 });
261 
262 it("selectWorkspaceByPageId matches archived pages too (preserves findCachedWorkspaceIdForPage semantics)", async () => {
263 const ws = createWorkspace({ id: "ws-1" });
264 const archived = createPage({
265 id: "archived-page",
266 workspace_id: "ws-1",
267 archived_at: "2026-04-01T00:00:00.000Z",
268 });
269 await replicaCommands.replaceWorkspace({
270 workspace: ws,
271 accessMode: "member",
272 workspaceRole: "member",
273 pages: [archived],
274 members: [],
275 });
276 await syncProjectionsFromDexie();
277 
278 expect(
279 replicaStoreMod.selectWorkspaceByPageId(replicaStoreMod.useWorkspaceReplicaStore.getState(), "archived-page"),
280 ).toBe("ws-1");
281 });
282});
283 
284/**
285 * The live-skip guard in WorkspaceViewProvider.resolve() reads a private
286 * `cacheStatusRef` rather than a public field on the route. The observable
287 * contract covered by this suite is the replica-upgrade path after a
288 * successful re-resolve.
289 */
290function getRouteAfterWorkspaceListFailure(prev: WorkspaceRouteState): WorkspaceRouteState {
291 if (prev.phase === "ready" || prev.phase === "degraded") return prev;
292 return { phase: "error", errorKind: "network", message: "Failed to load workspace" };
293}
294 
295function getRouteAfterSharedDowngradeProbe(
296 workspaceSlug: string,
297 workspaceId: string,
298 pages: Page[],
299): WorkspaceRouteState {
300 if (pages.length === 0) {
301 return {
302 phase: "degraded",
303 workspaceId,
304 workspaceSlug,
305 reason: "stale-shared",
306 };
307 }
308 return { phase: "ready", workspaceId, accessMode: "shared" };
309}
310 
311describe("cache-to-live replica upgrade", () => {
312 it("cache-backed workspace upgrades replica after simulated re-resolve", async () => {
313 const ws = createWorkspace({ id: "ws-1", slug: "my-ws" });
314 const stalePage = createPage({ id: "stale-page", workspace_id: "ws-1", title: "Stale" });
315 const staleMember = createMember({ workspace_id: "ws-1", user_id: "old-user" });
316 
317 await directoryCommands.upsert({ ...ws, role: "member" });
318 await replicaCommands.replaceWorkspace({
319 workspace: ws,
320 accessMode: "member",
321 workspaceRole: "member",
322 pages: [stalePage],
323 members: [staleMember],
324 });
325 await syncProjectionsFromDexie();
326 
327 const stalePages = replicaStoreMod.selectWorkspacePages(
328 replicaStoreMod.useWorkspaceReplicaStore.getState(),
329 "ws-1",
330 );
331 expect(stalePages[0].id).toBe("stale-page");
332 
333 const freshPage = createPage({ id: "fresh-page", workspace_id: "ws-1", title: "Fresh" });
334 const freshMember = createMember({ workspace_id: "ws-1", user_id: "new-user" });
335 await replicaCommands.replaceWorkspace({
336 workspace: ws,
337 accessMode: "member",
338 workspaceRole: "member",
339 pages: [freshPage],
340 members: [freshMember],
341 });
342 await syncProjectionsFromDexie();
343 
344 const freshPages = replicaStoreMod.selectWorkspacePages(
345 replicaStoreMod.useWorkspaceReplicaStore.getState(),
346 "ws-1",
347 );
348 const freshMembers = replicaStoreMod.selectWorkspaceMembers(
349 replicaStoreMod.useWorkspaceReplicaStore.getState(),
350 "ws-1",
351 );
352 expect(freshPages[0].id).toBe("fresh-page");
353 expect(freshPages[0].title).toBe("Fresh");
354 expect(freshMembers[0].user_id).toBe("new-user");
355 });
356});
357 
358describe("workspace provider error states", () => {
359 it("workspaces.list network error from a non-ready route is terminal", () => {
360 const route: WorkspaceRouteState = { phase: "loading", workspaceId: null };
361 expect(getRouteAfterWorkspaceListFailure(route)).toEqual({
362 phase: "error",
363 errorKind: "network",
364 message: "Failed to load workspace",
365 });
366 });
367 
368 it("preserves an already-ready route on workspaces.list failure", () => {
369 const ready: WorkspaceRouteState = { phase: "ready", workspaceId: "ws-1", accessMode: "member" };
370 expect(getRouteAfterWorkspaceListFailure(ready)).toEqual(ready);
371 });
372 
373 it("network error with cached ready state preserves cache", async () => {
374 const ws = createWorkspace({ id: "ws-1", slug: "my-ws" });
375 await directoryCommands.upsert({ ...ws, role: "member" });
376 await replicaCommands.replaceWorkspace({
377 workspace: ws,
378 accessMode: "member",
379 workspaceRole: "member",
380 pages: [createPage({ workspace_id: "ws-1" })],
381 members: [createMember({ workspace_id: "ws-1" })],
382 });
383 await syncProjectionsFromDexie();
384 
385 const replica = replicaStoreMod.selectWorkspaceReplica(replicaStoreMod.useWorkspaceReplicaStore.getState(), "ws-1");
386 expect(replica).toBeTruthy();
387 const pages = replicaStoreMod.selectWorkspacePages(replicaStoreMod.useWorkspaceReplicaStore.getState(), "ws-1");
388 expect(pages.length).toBe(1);
389 });
390 
391 it("does not promote an empty shared tree to ready/shared/live", () => {
392 const ws = createWorkspace({ id: "ws-shared", slug: "shared-ws" });
393 expect(getRouteAfterSharedDowngradeProbe(ws.slug, ws.id, [])).toEqual({
394 phase: "degraded",
395 workspaceId: ws.id,
396 workspaceSlug: ws.slug,
397 reason: "stale-shared",
398 });
399 });
400});