File
Blob: tests/client/stores/bootstrap.test.ts
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | import { createFreshDb, deleteDb } from "@tests/client/util/idb"; |
| 3 | import { createMembershipSummary, createPage } from "@tests/client/util/fixtures"; |
| 4 | import { SESSION_MODES } from "@/client/lib/constants"; |
| 5 | import type { BlandDatabase } from "@/client/stores/db/bland-db"; |
| 6 | |
| 7 | let db: BlandDatabase; |
| 8 | let bootstrap: typeof import("@/client/stores/bootstrap"); |
| 9 | let replicaCommands: typeof import("@/client/stores/db/workspace-replica").replicaCommands; |
| 10 | let directoryCommands: typeof import("@/client/stores/db/workspace-directory").directoryCommands; |
| 11 | let directoryStore: typeof import("@/client/stores/workspace-directory").useWorkspaceDirectoryStore; |
| 12 | let replicaStore: typeof import("@/client/stores/workspace-replica").useWorkspaceReplicaStore; |
| 13 | let navigationStore: typeof import("@/client/stores/workspace-navigation").useWorkspaceNavigationStore; |
| 14 | |
| 15 | const docCacheClear = vi.fn(); |
| 16 | const queryClientClear = vi.fn(); |
| 17 | |
| 18 | beforeEach(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 | |
| 42 | afterEach(async () => { |
| 43 | bootstrap.teardownWorkspaceLocalOwner(); |
| 44 | await deleteDb(db); |
| 45 | vi.restoreAllMocks(); |
| 46 | }); |
| 47 | |
| 48 | describe("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 | |
| 69 | describe("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 | |
| 125 | describe("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 | |
| 144 | describe("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 | }); |