diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..c1398b7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,9 @@ jobs: - name: Run TypeScript type checking run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + - name: Run web unit tests + working-directory: apps/web + run: bun test + - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/apps/web/hooks/use-personalization.test.tsx b/apps/web/hooks/use-personalization.test.tsx new file mode 100644 index 00000000..93b97ec6 --- /dev/null +++ b/apps/web/hooks/use-personalization.test.tsx @@ -0,0 +1,291 @@ +import { afterEach, beforeAll, describe, expect, it, mock } from "bun:test" +import { Window } from "happy-dom" +import { act, cleanup, renderHook, waitFor } from "@testing-library/react" + +type AuthState = { + isSessionPending: boolean + isRestoring: boolean + session: { userId: string; activeOrganizationId: string } | null + user: { id: string } | null + org: { id: string } | null +} + +type SearchResponse = { + data: { + results: Array<{ title: string; summary: string; chunks: never[] }> + } +} + +const search = mock( + (): Promise => Promise.resolve({ data: { results: [] } }), +) +let auth: AuthState + +mock.module("@lib/api", () => ({ $fetch: search })) +mock.module("@lib/auth-context", () => ({ useAuth: () => auth })) + +let usePersonalization: typeof import("./use-personalization").usePersonalization +let clearPersonalizationCache: typeof import("./use-personalization").clearPersonalizationCache + +function settledAuth(userId: string, orgId: string): AuthState { + return { + isSessionPending: false, + isRestoring: false, + session: { userId, activeOrganizationId: orgId }, + user: { id: userId }, + org: { id: orgId }, + } +} + +function cacheKey(userId: string, orgId: string) { + return `sm_profession_v2:u:${encodeURIComponent(userId)}:o:${encodeURIComponent(orgId)}` +} + +function cacheProfession(userId: string, orgId: string, profession: string) { + localStorage.setItem( + cacheKey(userId, orgId), + JSON.stringify({ profession, ts: Date.now() }), + ) +} + +function searchResponse(keyword: string): SearchResponse { + return { + data: { + results: [{ title: keyword, summary: "", chunks: [] }], + }, + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +beforeAll(async () => { + const window = new Window({ url: "https://app.supermemory.ai" }) + Object.defineProperties(globalThis, { + window: { configurable: true, value: window }, + document: { configurable: true, value: window.document }, + localStorage: { configurable: true, value: window.localStorage }, + navigator: { configurable: true, value: window.navigator }, + HTMLElement: { configurable: true, value: window.HTMLElement }, + Node: { configurable: true, value: window.Node }, + }) + ;( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + ;({ usePersonalization, clearPersonalizationCache } = await import( + "./use-personalization" + )) +}) + +afterEach(() => { + cleanup() + localStorage.clear() + search.mockReset() +}) + +describe("usePersonalization auth scoping", () => { + it("does not reuse account A's cache for account B", async () => { + auth = settledAuth("user-a", "shared-org") + cacheProfession("user-a", "shared-org", "developer") + search.mockResolvedValue(searchResponse("finance portfolio")) + const { result, rerender } = renderHook(() => usePersonalization()) + + await waitFor(() => expect(result.current.profession).toBe("developer")) + expect(search).not.toHaveBeenCalled() + + auth = { + ...settledAuth("user-b", "shared-org"), + isSessionPending: true, + } + rerender() + expect(result.current.profession).toBe("default") + expect(search).not.toHaveBeenCalled() + + auth = settledAuth("user-b", "shared-org") + rerender() + expect(result.current.profession).toBe("default") + await waitFor(() => expect(result.current.profession).toBe("finance")) + expect(search).toHaveBeenCalledTimes(1) + }) + + it("resets to defaults without searching after logout", async () => { + auth = settledAuth("logout-user", "logout-org") + cacheProfession("logout-user", "logout-org", "developer") + const { result, rerender } = renderHook(() => usePersonalization()) + await waitFor(() => expect(result.current.profession).toBe("developer")) + + auth = { + isSessionPending: false, + isRestoring: false, + session: null, + user: null, + org: null, + } + rerender() + + expect(result.current.profession).toBe("default") + expect(search).not.toHaveBeenCalled() + }) + + it("ignores account A's late result after switching to account B", async () => { + const accountA = deferred>() + const accountB = deferred>() + search + .mockImplementationOnce(() => accountA.promise) + .mockImplementationOnce(() => accountB.promise) + auth = settledAuth("deferred-a", "shared-org") + const { result, rerender } = renderHook(() => usePersonalization()) + await waitFor(() => expect(search).toHaveBeenCalledTimes(1)) + + auth = settledAuth("deferred-b", "shared-org") + rerender() + expect(result.current.profession).toBe("default") + await waitFor(() => expect(search).toHaveBeenCalledTimes(2)) + + await act(async () => accountB.resolve(searchResponse("medical clinical"))) + await waitFor(() => expect(result.current.profession).toBe("medical")) + await act(async () => + accountA.resolve(searchResponse("software developer")), + ) + expect(result.current.profession).toBe("medical") + expect( + JSON.parse( + localStorage.getItem(cacheKey("deferred-b", "shared-org")) ?? "{}", + ).profession, + ).toBe("medical") + expect( + localStorage.getItem(cacheKey("deferred-a", "shared-org")), + ).toBeNull() + }) + + it("keeps a manual choice when an older detection resolves", async () => { + const pendingDetection = deferred>() + search.mockImplementationOnce(() => pendingDetection.promise) + auth = settledAuth("manual-race-user", "manual-race-org") + const { result } = renderHook(() => usePersonalization()) + await waitFor(() => expect(search).toHaveBeenCalledTimes(1)) + + act(() => result.current.setProfession("marketing")) + expect(result.current.profession).toBe("marketing") + expect( + JSON.parse( + localStorage.getItem(cacheKey("manual-race-user", "manual-race-org")) ?? + "{}", + ).profession, + ).toBe("marketing") + + await act(async () => + pendingDetection.resolve(searchResponse("software developer")), + ) + + expect(result.current.profession).toBe("marketing") + expect( + JSON.parse( + localStorage.getItem(cacheKey("manual-race-user", "manual-race-org")) ?? + "{}", + ).profession, + ).toBe("marketing") + }) + + it("uses a fresh cache for the same account and org without searching", async () => { + auth = settledAuth("cached-user", "cached-org") + cacheProfession("cached-user", "cached-org", "research") + const { result } = renderHook(() => usePersonalization()) + + await waitFor(() => expect(result.current.profession).toBe("research")) + expect(search).not.toHaveBeenCalled() + }) + + it("writes manual profession changes only to the active scope", async () => { + auth = settledAuth("manual-user", "org-one") + cacheProfession("manual-user", "org-one", "developer") + cacheProfession("manual-user", "org-two", "finance") + const { result, rerender } = renderHook(() => usePersonalization()) + await waitFor(() => expect(result.current.profession).toBe("developer")) + + act(() => result.current.setProfession("marketing")) + expect( + JSON.parse( + localStorage.getItem(cacheKey("manual-user", "org-one")) ?? "{}", + ).profession, + ).toBe("marketing") + + auth = settledAuth("manual-user", "org-two") + rerender() + await waitFor(() => expect(result.current.profession).toBe("finance")) + act(() => result.current.setProfession("medical")) + + expect( + JSON.parse( + localStorage.getItem(cacheKey("manual-user", "org-one")) ?? "{}", + ).profession, + ).toBe("marketing") + expect( + JSON.parse( + localStorage.getItem(cacheKey("manual-user", "org-two")) ?? "{}", + ).profession, + ).toBe("medical") + expect(search).not.toHaveBeenCalled() + }) + + it("waits for an org switch to settle before reading or searching", async () => { + auth = settledAuth("org-user", "org-one") + cacheProfession("org-user", "org-one", "legal") + search.mockResolvedValue(searchResponse("figma product design")) + const { result, rerender } = renderHook(() => usePersonalization()) + await waitFor(() => expect(result.current.profession).toBe("legal")) + + auth = { ...auth, org: { id: "org-two" } } + rerender() + expect(result.current.profession).toBe("default") + expect(search).not.toHaveBeenCalled() + + auth = settledAuth("org-user", "org-two") + rerender() + await waitFor(() => expect(result.current.profession).toBe("design")) + expect(search).toHaveBeenCalledTimes(1) + }) + + it("does not read the unscoped v1 cache", async () => { + auth = settledAuth("legacy-user", "legacy-org") + localStorage.setItem( + "sm_profession_v1", + JSON.stringify({ profession: "developer", ts: Date.now() }), + ) + search.mockResolvedValue(searchResponse("finance investment")) + const { result } = renderHook(() => usePersonalization()) + + await waitFor(() => expect(result.current.profession).toBe("finance")) + expect(search).toHaveBeenCalledTimes(1) + expect(localStorage.getItem("sm_profession_v1")).toBeNull() + }) + + it("ignores inherited-property profession values in a scoped cache", async () => { + auth = settledAuth("malformed-user", "malformed-org") + cacheProfession("malformed-user", "malformed-org", "toString") + search.mockResolvedValue(searchResponse("finance investment")) + const { result } = renderHook(() => usePersonalization()) + + await waitFor(() => expect(result.current.profession).toBe("finance")) + expect(search).toHaveBeenCalledTimes(1) + }) + + it("clears every personalization cache without touching unrelated storage", () => { + localStorage.setItem("sm_profession_v1", "legacy") + localStorage.setItem(cacheKey("clear-a", "org-a"), "a") + localStorage.setItem(cacheKey("clear-b", "org-b"), "b") + localStorage.setItem("unrelated", "keep") + + clearPersonalizationCache() + + expect(localStorage.getItem("sm_profession_v1")).toBeNull() + expect(localStorage.getItem(cacheKey("clear-a", "org-a"))).toBeNull() + expect(localStorage.getItem(cacheKey("clear-b", "org-b"))).toBeNull() + expect(localStorage.getItem("unrelated")).toBe("keep") + }) +}) diff --git a/apps/web/hooks/use-personalization.ts b/apps/web/hooks/use-personalization.ts index 619b3ac2..be4d5b1e 100644 --- a/apps/web/hooks/use-personalization.ts +++ b/apps/web/hooks/use-personalization.ts @@ -2,9 +2,11 @@ import { useState, useEffect, useCallback } from "react" import { $fetch } from "@lib/api" +import { useAuth } from "@lib/auth-context" import type { SearchResult } from "@repo/validation/api" -const CACHE_KEY = "sm_profession_v1" +const CACHE_KEY_PREFIX = "sm_profession_v2" +const LEGACY_CACHE_KEY = "sm_profession_v1" const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 export type Profession = @@ -244,6 +246,60 @@ function defaultCopy(p: Profession): PersonalizedCopy { } } +const DEFAULT_COPY = defaultCopy("default") + +type PersonalizationState = { + scopeKey: string | null + copy: PersonalizedCopy + profession: Profession +} + +function defaultState(scopeKey: string | null): PersonalizationState { + return { scopeKey, copy: DEFAULT_COPY, profession: "default" } +} + +function getScopeKey(userId: string, orgId: string): string { + return `u:${encodeURIComponent(userId)}:o:${encodeURIComponent(orgId)}` +} + +function getCacheKey(scopeKey: string): string { + return `${CACHE_KEY_PREFIX}:${scopeKey}` +} + +function readCachedProfession(scopeKey: string): Profession | null { + try { + const raw = localStorage.getItem(getCacheKey(scopeKey)) + if (!raw) return null + const cached = JSON.parse(raw) as { + profession?: unknown + ts?: unknown + } + const age = Date.now() - Number(cached.ts) + if ( + typeof cached.profession !== "string" || + !Object.hasOwn(COPY_POOLS, cached.profession) || + typeof cached.ts !== "number" || + !Number.isFinite(cached.ts) || + age < 0 || + age >= CACHE_TTL_MS + ) { + return null + } + return cached.profession as Profession + } catch { + return null + } +} + +function writeCachedProfession(scopeKey: string, profession: Profession) { + try { + localStorage.setItem( + getCacheKey(scopeKey), + JSON.stringify({ profession, ts: Date.now() }), + ) + } catch {} +} + const sessionCopyCache: Partial> = {} function getSessionCopy(p: Profession): PersonalizedCopy { @@ -399,96 +455,132 @@ function classifyProfession(results: SearchResult[]): Profession { return best && best[1] > 0 ? best[0] : "default" } -let inflightPromise: Promise | null = null +const inflightPromises = new Map>() +const manualSelectionVersions = new Map() + +function detectProfession(scopeKey: string): Promise { + const inflight = inflightPromises.get(scopeKey) + if (inflight) return inflight + + const request = $fetch("@post/search", { + body: { + q: "career profession field industry background work role", + limit: 8, + }, + }) + .then((res) => { + const results = res.data?.results + if (!results?.length) return null + return classifyProfession(results) + }) + .catch(() => null) + + inflightPromises.set(scopeKey, request) + void request.finally(() => { + if (inflightPromises.get(scopeKey) === request) { + inflightPromises.delete(scopeKey) + } + }) + return request +} export function usePersonalization(): { copy: PersonalizedCopy profession: Profession setProfession: (p: Profession) => void } { - const [copy, setCopy] = useState(() => - defaultCopy("default"), + const { isSessionPending, isRestoring, session, user, org } = useAuth() + const scopeKey = + !isSessionPending && + !isRestoring && + session && + user && + org && + session.userId === user.id && + session.activeOrganizationId === org.id + ? getScopeKey(user.id, org.id) + : null + const [state, setState] = useState(() => + defaultState(null), ) - const [profession, setProfessionState] = useState("default") + const visibleState = + state.scopeKey === scopeKey ? state : defaultState(scopeKey) - const setProfession = useCallback((p: Profession) => { - try { - localStorage.setItem( - CACHE_KEY, - JSON.stringify({ profession: p, ts: Date.now() }), + const setProfession = useCallback( + (p: Profession) => { + if (!scopeKey) return + manualSelectionVersions.set( + scopeKey, + (manualSelectionVersions.get(scopeKey) ?? 0) + 1, ) - } catch {} - // Re-pick on explicit change so the user sees fresh copy for the new identity - const freshCopy = pickCopy(p) - sessionCopyCache[p] = freshCopy - setCopy(freshCopy) - setProfessionState(p) - }, []) + writeCachedProfession(scopeKey, p) + // Re-pick on explicit change so the user sees fresh copy for the new identity + const freshCopy = pickCopy(p) + sessionCopyCache[p] = freshCopy + setState({ scopeKey, copy: freshCopy, profession: p }) + }, + [scopeKey], + ) useEffect(() => { try { - const raw = localStorage.getItem(CACHE_KEY) - if (raw) { - const { profession: cached, ts } = JSON.parse(raw) as { - profession: Profession - ts: number - } - if (Date.now() - ts < CACHE_TTL_MS && COPY_POOLS[cached]) { - setCopy(getSessionCopy(cached)) - setProfessionState(cached) - return - } - } + localStorage.removeItem(LEGACY_CACHE_KEY) } catch {} + setState(defaultState(scopeKey)) + if (!scopeKey) return - if (inflightPromise) { - inflightPromise.then(() => { - try { - const raw = localStorage.getItem(CACHE_KEY) - if (raw) { - const { profession: cached } = JSON.parse(raw) as { - profession: Profession - } - if (COPY_POOLS[cached]) { - setCopy(getSessionCopy(cached)) - setProfessionState(cached) - } - } - } catch {} + const cached = readCachedProfession(scopeKey) + if (cached) { + setState({ + scopeKey, + copy: getSessionCopy(cached), + profession: cached, }) return } - inflightPromise = $fetch("@post/search", { - body: { - q: "career profession field industry background work role", - limit: 8, - }, + let cancelled = false + const manualSelectionVersion = manualSelectionVersions.get(scopeKey) ?? 0 + void detectProfession(scopeKey).then((detected) => { + if ( + cancelled || + !detected || + (manualSelectionVersions.get(scopeKey) ?? 0) !== manualSelectionVersion + ) { + return + } + const cachedAfterRequest = readCachedProfession(scopeKey) + const resolvedProfession = cachedAfterRequest ?? detected + if (!cachedAfterRequest) { + writeCachedProfession(scopeKey, detected) + } + setState({ + scopeKey, + copy: getSessionCopy(resolvedProfession), + profession: resolvedProfession, + }) }) - .then((res) => { - const results = res.data?.results - if (!results?.length) return - const detected = classifyProfession(results) - try { - localStorage.setItem( - CACHE_KEY, - JSON.stringify({ profession: detected, ts: Date.now() }), - ) - } catch {} - setCopy(getSessionCopy(detected)) - setProfessionState(detected) - }) - .catch(() => {}) - .finally(() => { - inflightPromise = null - }) - }, []) + return () => { + cancelled = true + } + }, [scopeKey]) - return { copy, profession, setProfession } + return { + copy: visibleState.copy, + profession: visibleState.profession, + setProfession, + } } export function clearPersonalizationCache() { try { - localStorage.removeItem(CACHE_KEY) + manualSelectionVersions.clear() + localStorage.removeItem(LEGACY_CACHE_KEY) + for (let index = localStorage.length - 1; index >= 0; index--) { + const key = localStorage.key(index) + if (key?.startsWith(`${CACHE_KEY_PREFIX}:`)) { + localStorage.removeItem(key) + } + } } catch {} } diff --git a/apps/web/package.json b/apps/web/package.json index 63c4acee..baf29f73 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -120,12 +120,14 @@ "@biomejs/biome": "^2.2.2", "@sentry/cli": "^2.52.0", "@tailwindcss/postcss": "^4.1.11", + "@testing-library/react": "^16.3.2", "@total-typescript/tsconfig": "^1.0.4", "@types/canvas-confetti": "^1.9.0", "@types/is-hotkey": "^0.1.10", "@types/node": "^24.0.4", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", + "happy-dom": "^20.9.0", "tailwindcss": "^4.1.11", "typescript": "^5.8.3", "wrangler": "^4.26.0" diff --git a/bun.lock b/bun.lock index d55c8237..17e5232e 100644 --- a/bun.lock +++ b/bun.lock @@ -245,12 +245,14 @@ "@biomejs/biome": "^2.2.2", "@sentry/cli": "^2.52.0", "@tailwindcss/postcss": "^4.1.11", + "@testing-library/react": "^16.3.2", "@total-typescript/tsconfig": "^1.0.4", "@types/canvas-confetti": "^1.9.0", "@types/is-hotkey": "^0.1.10", "@types/node": "^24.0.4", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", + "happy-dom": "^20.9.0", "tailwindcss": "^4.1.11", "typescript": "^5.8.3", "wrangler": "^4.26.0",