Skip to content
File

Blob: tests/client/stores/bootstrap.test.ts

typescript238 lines
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2import { createFreshDb, deleteDb } from "@tests/client/util/idb";
3import { createMembershipSummary, createPage } from "@tests/client/util/fixtures";
4import { SESSION_MODES } from "@/client/lib/constants";
5import type { BlandDatabase } from "@/client/stores/db/bland-db";
6 
7let db: BlandDatabase;
8let bootstrap: typeof import("@/client/stores/bootstrap");
9let replicaCommands: typeof import("@/client/stores/db/workspace-replica").replicaCommands;
10let directoryCommands: typeof import("@/client/stores/db/workspace-directory").directoryCommands;
11let directoryStore: typeof import("@/client/stores/workspace-directory").useWorkspaceDirectoryStore;
12let replicaStore: typeof import("@/client/stores/workspace-replica").useWorkspaceReplicaStore;
13let navigationStore: typeof import("@/client/stores/workspace-navigation").useWorkspaceNavigationStore;
14 
15const docCacheClear = vi.fn();
16const queryClientClear = vi.fn();
17 
18beforeEach(async () => {
19 vi.resetModules();
20 docCacheClear.mockReset();
21 queryClientClear.mockReset();
22 db = createFreshDb();
23 vi.doMock("@/client/stores/db/bland-db", async () => {
24 const actual = await vi.importActual<typeof import("@/client/stores/db/bland-db")>("@/client/stores/db/bland-db");
25 return { ...actual, db };
26 });
27 vi.doMock("@/client/lib/doc-cache-registry", () => ({
28 docCache: { clearAll: docCacheClear, has: () => false, mark: () => {}, remove: () => {} },
29 }));
30 vi.doMock("@/client/lib/query-client", () => ({
31 queryClient: { clear: queryClientClear },
32 }));
33 
34 bootstrap = await import("@/client/stores/bootstrap");
35 replicaCommands = (await import("@/client/stores/db/workspace-replica")).replicaCommands;
36 directoryCommands = (await import("@/client/stores/db/workspace-directory")).directoryCommands;
37 directoryStore = (await import("@/client/stores/workspace-directory")).useWorkspaceDirectoryStore;
38 replicaStore = (await import("@/client/stores/workspace-replica")).useWorkspaceReplicaStore;
39 navigationStore = (await import("@/client/stores/workspace-navigation")).useWorkspaceNavigationStore;
40});
41 
42afterEach(async () => {
43 bootstrap.teardownWorkspaceLocalOwner();
44 await deleteDb(db);
45 vi.restoreAllMocks();
46});
47 
48describe("getNeedsLocalWorkspace", () => {
49 it("returns false for anonymous sessions regardless of path", () => {
50 expect(bootstrap.getNeedsLocalWorkspace("/", SESSION_MODES.ANONYMOUS)).toBe(false);
51 expect(bootstrap.getNeedsLocalWorkspace("/my-workspace", SESSION_MODES.ANONYMOUS)).toBe(false);
52 });
53 
54 it("returns false on login / invite / share-token routes", () => {
55 expect(bootstrap.getNeedsLocalWorkspace("/login", SESSION_MODES.AUTHENTICATED)).toBe(false);
56 expect(bootstrap.getNeedsLocalWorkspace("/invite/abc", SESSION_MODES.AUTHENTICATED)).toBe(false);
57 expect(bootstrap.getNeedsLocalWorkspace("/s/xyz", SESSION_MODES.LOCAL_ONLY)).toBe(false);
58 });
59 
60 it("returns true on authenticated / local-only / expired sessions for workspace-ish paths", () => {
61 expect(bootstrap.getNeedsLocalWorkspace("/", SESSION_MODES.AUTHENTICATED)).toBe(true);
62 expect(bootstrap.getNeedsLocalWorkspace("/profile", SESSION_MODES.AUTHENTICATED)).toBe(true);
63 expect(bootstrap.getNeedsLocalWorkspace("/shared-with-me", SESSION_MODES.AUTHENTICATED)).toBe(true);
64 expect(bootstrap.getNeedsLocalWorkspace("/my-workspace", SESSION_MODES.LOCAL_ONLY)).toBe(true);
65 expect(bootstrap.getNeedsLocalWorkspace("/my-workspace/page", SESSION_MODES.EXPIRED)).toBe(true);
66 });
67});
68 
69describe("ensureWorkspaceLocalOwner", () => {
70 it("no-ops when needsLocal is false: does not open Dexie or seed stores", async () => {
71 await bootstrap.ensureWorkspaceLocalOwner("user-1", false);
72 expect(db.isOpen()).toBe(false);
73 expect(directoryStore.getState().workspaces).toEqual([]);
74 });
75 
76 it("first call with a userId writes the owner meta row and seeds empty projections", async () => {
77 await bootstrap.ensureWorkspaceLocalOwner("user-1", true);
78 expect((await db.workspaceMeta.get("owner"))?.value).toBe("user-1");
79 expect(directoryStore.getState().workspaces).toEqual([]);
80 });
81 
82 it("same-owner reopen preserves persisted data", async () => {
83 await bootstrap.ensureWorkspaceLocalOwner("user-1", true);
84 await directoryCommands.upsert(createMembershipSummary({ id: "ws-1" }));
85 
86 bootstrap.teardownWorkspaceLocalOwner();
87 await bootstrap.ensureWorkspaceLocalOwner("user-1", true);
88 
89 expect(directoryStore.getState().workspaces.map((w) => w.id)).toEqual(["ws-1"]);
90 expect(docCacheClear).not.toHaveBeenCalled();
91 expect(queryClientClear).not.toHaveBeenCalled();
92 });
93 
94 it("owner change clears every local table, drops dependent caches, and rewrites the owner row", async () => {
95 await bootstrap.ensureWorkspaceLocalOwner("user-1", true);
96 await directoryCommands.upsert(createMembershipSummary({ id: "ws-1" }));
97 await replicaCommands.replaceWorkspace({
98 workspace: createMembershipSummary({ id: "ws-1" }),
99 accessMode: "member",
100 workspaceRole: "member",
101 pages: [createPage({ id: "p1", workspace_id: "ws-1" })],
102 members: [],
103 });
104 
105 bootstrap.teardownWorkspaceLocalOwner();
106 await bootstrap.ensureWorkspaceLocalOwner("user-2", true);
107 
108 expect((await db.workspaceMeta.get("owner"))?.value).toBe("user-2");
109 expect(await db.memberWorkspaces.count()).toBe(0);
110 expect(await db.workspaceReplicas.count()).toBe(0);
111 expect(await db.workspacePages.count()).toBe(0);
112 expect(docCacheClear).toHaveBeenCalledTimes(1);
113 expect(queryClientClear).toHaveBeenCalledTimes(1);
114 expect(directoryStore.getState().workspaces).toEqual([]);
115 expect(replicaStore.getState().replicas.size).toBe(0);
116 });
117 
118 it("bulk-reads existing Dexie rows into the projection on first call", async () => {
119 await directoryCommands.upsert(createMembershipSummary({ id: "ws-1", slug: "seeded" }));
120 await bootstrap.ensureWorkspaceLocalOwner("user-1", true);
121 expect(directoryStore.getState().workspaces.map((w) => w.slug)).toEqual(["seeded"]);
122 });
123});
124 
125describe("resetWorkspaceLocalOwner", () => {
126 it("clears every table, drops dependent caches, resets projections, and writes owner=null", async () => {
127 // Seed Dexie before the first hydration so ensureWorkspaceLocalOwner's
128 // bulk read picks up the row; this avoids racing liveQuery in-test.
129 await directoryCommands.upsert(createMembershipSummary({ id: "ws-1" }));
130 await bootstrap.ensureWorkspaceLocalOwner("user-1", true);
131 expect(directoryStore.getState().workspaces.length).toBe(1);
132 
133 await bootstrap.resetWorkspaceLocalOwner();
134 
135 expect((await db.workspaceMeta.get("owner"))?.value).toBeNull();
136 expect(await db.memberWorkspaces.count()).toBe(0);
137 expect(directoryStore.getState().workspaces).toEqual([]);
138 expect(navigationStore.getState().lastVisitedWorkspaceId).toBeNull();
139 expect(docCacheClear).toHaveBeenCalled();
140 expect(queryClientClear).toHaveBeenCalled();
141 });
142});
143 
144describe("rehydrateWorkspaceLocalOwner (route-change bridge)", () => {
145 const originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, "location");
146 
147 function stubPathname(pathname: string): void {
148 Object.defineProperty(globalThis, "location", {
149 configurable: true,
150 writable: true,
151 value: { pathname },
152 });
153 }
154 
155 afterEach(() => {
156 if (originalLocationDescriptor) {
157 Object.defineProperty(globalThis, "location", originalLocationDescriptor);
158 } else {
159 delete (globalThis as { location?: unknown }).location;
160 }
161 });
162 
163 it("hydrates projections when the user navigates from a non-local path into a local one", async () => {
164 // Boot on `/login` where needsLocal is false: ensureWorkspaceLocalOwner is
165 // a no-op and Dexie stays closed.
166 stubPathname("/login");
167 const authMod = await import("@/client/stores/auth-store");
168 authMod.useAuthStore.setState({
169 accessToken: "tok",
170 user: { id: "user-1", email: "e", name: "n", avatar_url: null, created_at: "t" },
171 sessionMode: "authenticated" as const,
172 refreshState: "idle",
173 });
174 await bootstrap.rehydrateWorkspaceLocalOwner();
175 
176 // Seed a row directly via the real db (transparently opens Dexie).
177 await directoryCommands.upsert(createMembershipSummary({ id: "ws-1", slug: "live" }));
178 expect(directoryStore.getState().workspaces).toEqual([]);
179 
180 // Simulate route change into a workspace path.
181 stubPathname("/live");
182 await bootstrap.rehydrateWorkspaceLocalOwner();
183 expect(directoryStore.getState().workspaces.map((w) => w.slug)).toEqual(["live"]);
184 });
185 
186 it("is idempotent across intra-workspace navigation (same owner, still local)", async () => {
187 stubPathname("/ws-a/page-1");
188 const authMod = await import("@/client/stores/auth-store");
189 authMod.useAuthStore.setState({
190 accessToken: "tok",
191 user: { id: "user-1", email: "e", name: "n", avatar_url: null, created_at: "t" },
192 sessionMode: "authenticated" as const,
193 refreshState: "idle",
194 });
195 // First call: actually hydrates.
196 await bootstrap.rehydrateWorkspaceLocalOwner();
197 const ownerRowAfterFirst = await db.workspaceMeta.get("owner");
198 expect(ownerRowAfterFirst?.value).toBe("user-1");
199 
200 // Spy on ensureWorkspaceLocalOwner via a proxy: subsequent calls should
201 // not tear down subscriptions, so we sentinel-write a row then ensure it
202 // survives multiple rehydrate calls (a re-seed would emit a new projection
203 // reference; we don't assert that here, but the db state check confirms
204 // there's no clearAllLocal re-run).
205 await directoryCommands.upsert(createMembershipSummary({ id: "ws-a", slug: "ws-a" }));
206 
207 stubPathname("/ws-a/page-2");
208 await bootstrap.rehydrateWorkspaceLocalOwner();
209 stubPathname("/ws-a");
210 await bootstrap.rehydrateWorkspaceLocalOwner();
211 stubPathname("/shared-with-me");
212 await bootstrap.rehydrateWorkspaceLocalOwner();
213 
214 // Owner row untouched, seeded directory row untouched.
215 expect((await db.workspaceMeta.get("owner"))?.value).toBe("user-1");
216 expect((await db.memberWorkspaces.get("ws-a"))?.slug).toBe("ws-a");
217 });
218 
219 it("re-hydrates when transitioning from non-local to local", async () => {
220 stubPathname("/login");
221 const authMod = await import("@/client/stores/auth-store");
222 authMod.useAuthStore.setState({
223 accessToken: "tok",
224 user: { id: "user-1", email: "e", name: "n", avatar_url: null, created_at: "t" },
225 sessionMode: "authenticated" as const,
226 refreshState: "idle",
227 });
228 await bootstrap.rehydrateWorkspaceLocalOwner();
229 // needsLocal=false path; db isn't even opened.
230 expect(db.isOpen()).toBe(false);
231 
232 stubPathname("/ws-a");
233 await bootstrap.rehydrateWorkspaceLocalOwner();
234 expect(db.isOpen()).toBe(true);
235 expect((await db.workspaceMeta.get("owner"))?.value).toBe("user-1");
236 });
237});