// @vitest-environment happy-dom import { cojsonInternals } from "cojson"; import { Account, Group, Loaded, co, getJazzErrorType, z } from "jazz-tools"; import { assertLoaded } from "jazz-tools/testing"; import { beforeEach, describe, expect, expectTypeOf, it } from "vitest"; import React, { Suspense } from "react"; import { useSuspenseAccount, useLogOut } from "../hooks.js"; import { createJazzTestAccount, createJazzTestGuest, setupJazzTestSync, } from "../testing.js"; import { act, render, renderHook, waitFor } from "./testUtils.js"; import { ErrorBoundary } from "react-error-boundary"; // Silence unhandled rejection errors coming from Suspense process.on("unhandledRejection", () => {}); beforeEach(async () => { await setupJazzTestSync({ asyncPeers: true, }); await createJazzTestAccount({ isCurrentActiveAccount: true, }); }); cojsonInternals.setCoValueLoadingRetryDelay(10); function ErrorFallback(props: { error: Error }) { return
Error: {getJazzErrorType(props.error)}
; } describe("useSuspenseAccount", () => { it("should return loaded account without suspending when data is available", async () => { const AccountRoot = co.map({ projects: co.list( co.map({ name: z.string(), description: z.string(), }), ), }); const MyAppAccount = co .account({ profile: co.profile({ name: z.string(), }), root: AccountRoot, }) .withMigration((account, creationProps) => { if (!account.$jazz.refs.profile) { account.$jazz.set("profile", { name: creationProps?.name || "John Doe", }); } if (!account.$jazz.refs.root) { account.$jazz.set("root", { projects: [], }); } }); const account = await createJazzTestAccount({ AccountSchema: MyAppAccount, isCurrentActiveAccount: true, creationProps: { name: "John Doe", }, }); let suspenseTriggered = false; const SuspenseFallback = () => { suspenseTriggered = true; return
Loading...
; }; const wrapper = ({ children }: { children: React.ReactNode }) => ( }>{children} ); const { result } = renderHook( () => useSuspenseAccount(MyAppAccount, { resolve: { profile: true, root: { projects: true, }, }, }), { account, wrapper, }, ); // Wait for any async operations to complete await waitFor(() => { expect(result.current).toBeDefined(); }); // Verify Suspense was not triggered since data was immediately available expect(suspenseTriggered).toBe(false); // Verify the hook returns loaded data assertLoaded(result.current); expect(result.current.profile.name).toBe("John Doe"); expect(result.current.root.projects).toEqual([]); }); it("should have Loaded return type", async () => { const AccountRoot = co.map({ value: z.string(), }); const MyAppAccount = co .account({ profile: co.profile({ name: z.string(), }), root: AccountRoot, }) .withMigration((account, creationProps) => { if (!account.$jazz.refs.profile) { account.$jazz.set("profile", { name: creationProps?.name || "Test User", }); } if (!account.$jazz.refs.root) { account.$jazz.set("root", { value: "test", }); } }); const account = await createJazzTestAccount({ AccountSchema: MyAppAccount, isCurrentActiveAccount: true, }); const wrapper = ({ children }: { children: React.ReactNode }) => ( Loading...}>{children} ); const { result } = renderHook(() => useSuspenseAccount(MyAppAccount), { account, wrapper, }); await waitFor(() => { expect(result.current).toBeDefined(); }); // Verify the return type is Loaded expectTypeOf(result.current).toEqualTypeOf>(); }); it("should suspend when account data is not immediately available", async () => { const Project = co.map({ name: z.string(), description: z.string(), }); const AccountRoot = co.map({ projects: co.list(Project), }); const root = AccountRoot.create( { projects: [ { name: "My Project", description: "A test project", }, ], }, Group.create().makePublic(), ); const MyAppAccount = co.account({ profile: co.profile({ name: z.string(), }), root: AccountRoot, }); const account = await createJazzTestAccount({ AccountSchema: MyAppAccount, isCurrentActiveAccount: true, creationProps: { name: "John Doe", }, }); account.$jazz.set("root", root); let suspenseTriggered = false; const SuspenseFallback = () => { suspenseTriggered = true; return
Loading...
; }; const TestComponent = () => { const account = useSuspenseAccount(MyAppAccount, { resolve: { root: { projects: { $each: true, }, }, }, }); return
{account.root.projects[0]?.name || "No project"}
; }; const { container } = await act(async () => { return render( }> , { account, }, ); }); expect(suspenseTriggered).toBe(true); // Wait for data to load - the subscription should update and resolve await waitFor(() => { expect(container.textContent).toContain("My Project"); expect(container.textContent).not.toContain("Loading..."); }); }); it("should throw error when a required resolved child is deleted", async () => { const AccountRoot = co.map({ value: z.string(), }); const MyAppAccount = co .account({ profile: co.profile({ name: z.string(), }), root: AccountRoot, }) .withMigration((account, creationProps) => { if (!account.$jazz.refs.profile) { account.$jazz.set("profile", { name: creationProps?.name || "John Doe", }); } if (!account.$jazz.refs.root) { account.$jazz.set("root", { value: "123", }); } }); const account = await createJazzTestAccount({ AccountSchema: MyAppAccount, isCurrentActiveAccount: true, creationProps: { name: "John Doe", }, }); // Ensure root exists, then delete it. const loaded = await account.$jazz.ensureLoaded({ resolve: { root: true }, }); loaded.root.$jazz.raw.core.deleteCoValue(); const TestComponent = () => { const account = useSuspenseAccount(MyAppAccount, { resolve: { root: true, profile: true, }, }); return
{account.profile.name}
; }; const { container } = await act(async () => { return render( Loading...}> , { account }, ); }); await waitFor( () => { expect(container.textContent).toContain("Error: deleted"); }, { timeout: 10_000 }, ); }); it("should throw error for anonymous agent", async () => { const MyAppAccount = co.account({ profile: co.profile({ name: z.string(), }), root: co.map({ value: z.string(), }), }); const guestAccount = await createJazzTestGuest(); const TestComponent = () => { useSuspenseAccount(MyAppAccount); return
Should not render
; }; const { container } = await act(async () => { return render( Loading...}> , { account: guestAccount, }, ); }); // Verify error is displayed in error boundary await waitFor( () => { expect(container.textContent).toContain("Error: unknown"); }, { timeout: 1000 }, ); }); it("should handle account logout", async () => { const MyAppAccount = co .account({ profile: co.profile({ name: z.string(), }), root: co.map({ value: z.string(), }), }) .withMigration((account, creationProps) => { if (!account.$jazz.refs.profile) { account.$jazz.set("profile", { name: creationProps?.name || "John Doe", }); } if (!account.$jazz.refs.root) { account.$jazz.set("root", { value: "test", }); } }); const account = await createJazzTestAccount({ AccountSchema: MyAppAccount, isCurrentActiveAccount: true, creationProps: { name: "John Doe", }, }); const wrapper = ({ children }: { children: React.ReactNode }) => ( Loading...}> {children} ); const { result } = renderHook( () => { const account = useSuspenseAccount(MyAppAccount); const logOut = useLogOut(); return { account, logOut }; }, { account, wrapper, }, ); // Wait for account to load await waitFor(() => { expect(result.current.account).toBeDefined(); }); // Verify initial account data assertLoaded(result.current.account); const initialAccountId = result.current.account.$jazz.id; // Logout should cause an error since useSuspenseAccount requires authentication await act(async () => { result.current.logOut(); }); expect(result.current.account.$jazz.id).not.toBe(initialAccountId); }); });