diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..2c7db5ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,8 @@ jobs: - name: Run TypeScript type checking run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + - name: Run shared library unit tests + run: bun test packages/hooks packages/lib + - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/packages/hooks/use-org-onboarding.test.ts b/packages/hooks/use-org-onboarding.test.ts new file mode 100644 index 00000000..7ae70042 --- /dev/null +++ b/packages/hooks/use-org-onboarding.test.ts @@ -0,0 +1,145 @@ +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test" +import { createElement } from "react" +import { renderToStaticMarkup } from "react-dom/server" +import { mergeOrganizationMetadata } from "../lib/organization-metadata" + +interface TestOrganization { + id: string + metadata: Record +} + +type UpdateResult = { + error: { message: string } | null +} + +let activeOrganization: TestOrganization | null = null +let persistOrganization: () => Promise + +const updateOrgMetadata = mock( + (organizationId: string, partial: Record) => { + activeOrganization = mergeOrganizationMetadata( + activeOrganization, + organizationId, + partial, + ) + }, +) + +mock.module("@lib/auth-context", () => ({ + useAuth: () => ({ + org: activeOrganization, + updateOrgMetadata, + }), +})) + +mock.module("@lib/auth", () => ({ + authClient: { + organization: { + update: () => persistOrganization(), + }, + }, +})) + +let useOrgOnboarding: typeof import("./use-org-onboarding").useOrgOnboarding + +beforeAll(async () => { + ;({ useOrgOnboarding } = await import("./use-org-onboarding")) +}) + +beforeEach(() => { + activeOrganization = { + id: "org-a", + metadata: { isOnboarded: false }, + } + persistOrganization = async () => ({ error: null }) + updateOrgMetadata.mockClear() + spyOn(console, "error").mockImplementation(() => {}) +}) + +afterEach(() => { + mock.restore() +}) + +function renderOnboardingHook() { + let hook: ReturnType | undefined + + renderToStaticMarkup( + createElement(() => { + hook = useOrgOnboarding() + return null + }), + ) + + if (!hook) throw new Error("Onboarding hook did not render") + return hook +} + +async function flushUpdate() { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe("useOrgOnboarding", () => { + it("rolls back a rejected mark update in the same organization", async () => { + persistOrganization = async () => ({ + error: { message: "Update rejected" }, + }) + + renderOnboardingHook().markOrgOnboarded() + expect(activeOrganization?.metadata.isOnboarded).toBe(true) + + await flushUpdate() + + expect(activeOrganization?.metadata.isOnboarded).toBe(false) + }) + + it("rolls back a rejected reset update in the same organization", async () => { + activeOrganization = { + id: "org-a", + metadata: { isOnboarded: true }, + } + persistOrganization = async () => ({ + error: { message: "Update rejected" }, + }) + + renderOnboardingHook().resetOrgOnboarded() + expect(activeOrganization?.metadata.isOnboarded).toBe(false) + + await flushUpdate() + + expect(activeOrganization?.metadata.isOnboarded).toBe(true) + }) + + it("does not roll back a different organization", async () => { + let resolveUpdate: (result: UpdateResult) => void = () => {} + persistOrganization = () => + new Promise((resolve) => { + resolveUpdate = resolve + }) + + renderOnboardingHook().markOrgOnboarded() + expect(activeOrganization?.metadata.isOnboarded).toBe(true) + + activeOrganization = { + id: "org-b", + metadata: { isOnboarded: true }, + } + resolveUpdate({ error: { message: "Update rejected" } }) + await flushUpdate() + + expect(activeOrganization).toEqual({ + id: "org-b", + metadata: { isOnboarded: true }, + }) + }) +}) diff --git a/packages/hooks/use-org-onboarding.ts b/packages/hooks/use-org-onboarding.ts index e71eab8f..7c1eb958 100644 --- a/packages/hooks/use-org-onboarding.ts +++ b/packages/hooks/use-org-onboarding.ts @@ -27,7 +27,7 @@ export function useOrgOnboarding() { } // Optimistic update: update in-memory state immediately - updateOrgMetadata({ isOnboarded: true }) + updateOrgMetadata(org.id, { isOnboarded: true }) authClient.organization .update({ @@ -39,9 +39,16 @@ export function useOrgOnboarding() { }, }, }) + .then((result) => { + if (result.error) { + throw new Error( + result.error.message ?? "Failed to mark organization as onboarded", + ) + } + }) .catch((error) => { console.error("Failed to mark organization as onboarded:", error) - updateOrgMetadata({ isOnboarded: false }) + updateOrgMetadata(org.id, { isOnboarded: false }) }) }, [org, updateOrgMetadata]) @@ -52,7 +59,7 @@ export function useOrgOnboarding() { } // Optimistic update: update in-memory state immediately - updateOrgMetadata({ isOnboarded: false }) + updateOrgMetadata(org.id, { isOnboarded: false }) authClient.organization .update({ @@ -64,9 +71,16 @@ export function useOrgOnboarding() { }, }, }) + .then((result) => { + if (result.error) { + throw new Error( + result.error.message ?? "Failed to reset organization onboarding", + ) + } + }) .catch((error) => { console.error("Failed to reset organization onboarding:", error) - updateOrgMetadata({ isOnboarded: true }) + updateOrgMetadata(org.id, { isOnboarded: true }) }) }, [org, updateOrgMetadata]) diff --git a/packages/lib/auth-context.tsx b/packages/lib/auth-context.tsx index acd15e88..6d6672c9 100644 --- a/packages/lib/auth-context.tsx +++ b/packages/lib/auth-context.tsx @@ -9,6 +9,7 @@ import { useState, } from "react" import { authClient, useSession } from "./auth" +import { mergeOrganizationMetadata } from "./organization-metadata" type Organization = typeof authClient.$Infer.ActiveOrganization type SessionData = NonNullable["data"]> @@ -44,7 +45,10 @@ interface AuthContextType { isSessionPending: boolean setActiveOrg: (orgSlug: string) => Promise clearActiveOrg: () => Promise - updateOrgMetadata: (partial: Record) => void + updateOrgMetadata: ( + organizationId: string, + partial: Record, + ) => void refetchActiveOrg: () => Promise refetchOrganizations: () => Promise } @@ -89,18 +93,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { } catch {} }, []) - const updateOrgMetadata = useCallback((partial: Record) => { - setOrg((prev) => { - if (!prev) return prev - return { - ...prev, - metadata: { - ...prev.metadata, - ...partial, - }, - } - }) - }, []) + const updateOrgMetadata = useCallback( + (organizationId: string, partial: Record) => { + setOrg((prev) => mergeOrganizationMetadata(prev, organizationId, partial)) + }, + [], + ) const refetchActiveOrg = useCallback(async () => { const full = await authClient.organization.getFullOrganization() diff --git a/packages/lib/organization-metadata.test.ts b/packages/lib/organization-metadata.test.ts new file mode 100644 index 00000000..ede14161 --- /dev/null +++ b/packages/lib/organization-metadata.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test" +import { mergeOrganizationMetadata } from "./organization-metadata" + +describe("mergeOrganizationMetadata", () => { + it("merges metadata for the expected organization", () => { + expect( + mergeOrganizationMetadata( + { + id: "org-a", + name: "Organization A", + metadata: { plan: "pro", isOnboarded: false }, + }, + "org-a", + { isOnboarded: true }, + ), + ).toEqual({ + id: "org-a", + name: "Organization A", + metadata: { plan: "pro", isOnboarded: true }, + }) + }) + + it("leaves a different current organization untouched", () => { + const current = { + id: "org-b", + metadata: { isOnboarded: true }, + } + + expect( + mergeOrganizationMetadata(current, "org-a", { + isOnboarded: false, + }), + ).toBe(current) + }) + + it("leaves an empty organization state untouched", () => { + expect( + mergeOrganizationMetadata(null, "org-a", { isOnboarded: true }), + ).toBeNull() + }) + + it("replaces malformed metadata without spreading it", () => { + const current: { id: string; metadata?: unknown } = { + id: "org-a", + metadata: ["unexpected"], + } + + expect( + mergeOrganizationMetadata(current, "org-a", { isOnboarded: true }), + ).toEqual({ id: "org-a", metadata: { isOnboarded: true } }) + }) +}) diff --git a/packages/lib/organization-metadata.ts b/packages/lib/organization-metadata.ts new file mode 100644 index 00000000..78ee02df --- /dev/null +++ b/packages/lib/organization-metadata.ts @@ -0,0 +1,25 @@ +interface OrganizationWithMetadata { + id: string + metadata?: unknown +} + +function isMetadataRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export function mergeOrganizationMetadata< + Organization extends OrganizationWithMetadata, +>( + current: Organization | null, + organizationId: string, + partial: Record, +): Organization | null { + if (!current || current.id !== organizationId) return current + + const metadata = { + ...(isMetadataRecord(current.metadata) ? current.metadata : {}), + ...partial, + } + + return Object.assign({}, current, { metadata }) +}