import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, act } from "@testing-library/react"; vi.mock("cookies-next", () => ({ getCookie: vi.fn(() => undefined), })); vi.mock("next/navigation", () => ({ usePathname: () => "/", })); const socketMock = vi.hoisted(() => { const handlers: Record void)[]> = {}; return { socket: { on: (event: string, handler: (data: any) => void) => { (handlers[event] ??= []).push(handler); }, off: (event: string, handler: (data: any) => void) => { handlers[event] = (handlers[event] ?? []).filter((h) => h !== handler); }, }, emit: (event: string, data: any) => { (handlers[event] ?? []).forEach((h) => h(data)); }, reset: () => { for (const key of Object.keys(handlers)) delete handlers[key]; }, }; }); vi.mock("../../../../contexts/SocketContext", () => ({ useSocketContext: () => ({ socket: socketMock.socket, isConnected: true }), })); vi.mock("../../../../core", () => { // Mirrors the real Company model: public getters over private backing fields. // The token overlay writes the backing fields, so a plain object would not // exercise it. class FakeCompany { id = ""; monthlyCredits = 0; _availableMonthlyCredits = 0; _availableExtraCredits = 0; get availableMonthlyCredits() { return this._availableMonthlyCredits ?? 0; } get availableExtraCredits() { return this._availableExtraCredits ?? 0; } } return { Modules: { User: { name: "users" } }, rehydrate: (_module: unknown, data: any) => ({ ...data, company: data.company ? Object.assign(new FakeCompany(), data.company) : null, }), }; }); vi.mock("../../../../permissions", () => ({ Action: { Read: "read", Write: "write", Create: "create", Update: "update", Delete: "delete" }, checkPermissions: () => true, })); vi.mock("../../../../roles", () => ({ getRoleId: () => ({ Administrator: "admin" }), })); vi.mock("../../../auth/config", () => ({ getTokenHandler: vi.fn(() => ({ updateToken: vi.fn().mockResolvedValue(undefined), removeToken: vi.fn().mockResolvedValue(undefined), })), })); vi.mock("../../data", () => ({ UserService: { findFullUser: vi.fn(), }, })); import { CurrentUserProvider, useCurrentUserContext } from "../CurrentUserContext"; import { getCookie } from "cookies-next"; import { getTokenHandler } from "../../../auth/config"; import { UserService } from "../../data"; function Consumer() { const { currentUser } = useCurrentUserContext(); return
{currentUser ? (currentUser as { id: string }).id : "null"}
; } describe("CurrentUserProvider", () => { beforeEach(() => { localStorage.clear(); vi.clearAllMocks(); }); it("keeps user hydrated from localStorage when token cookie is absent", async () => { const storedUser = { id: "user-1", roles: [], company: null, modules: [], }; localStorage.setItem("user", JSON.stringify(storedUser)); vi.mocked(getCookie).mockReturnValue(undefined); const { getByTestId } = render( , ); await new Promise((resolve) => setTimeout(resolve, 100)); expect(getByTestId("current-user-id").textContent).toBe("user-1"); expect(localStorage.getItem("user")).toBe(JSON.stringify(storedUser)); }); it("awaits updateToken before writing the user atom in refreshUser", async () => { const oldUser = { id: "old", roles: [], company: null, modules: [] }; localStorage.setItem("user", JSON.stringify(oldUser)); const newDehydrated = { id: "new", roles: [], company: null, modules: [] }; const newFullUser = { id: "new", roles: [], company: null, modules: [], dehydrate: () => newDehydrated, }; vi.mocked(UserService.findFullUser).mockResolvedValue(newFullUser as any); let resolveUpdateToken!: () => void; const updateTokenPromise = new Promise((resolve) => { resolveUpdateToken = resolve; }); const updateToken = vi.fn(async () => { await updateTokenPromise; }); vi.mocked(getTokenHandler).mockReturnValue({ updateToken, removeToken: vi.fn().mockResolvedValue(undefined), } as any); let capturedRefreshUser: ((options?: { skipCookieUpdate?: boolean }) => Promise) | undefined; function InnerConsumer() { const ctx = useCurrentUserContext(); capturedRefreshUser = ctx.refreshUser; return (
{ctx.currentUser ? (ctx.currentUser as { id: string }).id : "null"}
); } const { getByTestId } = render( , ); await new Promise((resolve) => setTimeout(resolve, 50)); expect(getByTestId("current-user-id").textContent).toBe("old"); expect(capturedRefreshUser).toBeDefined(); const refreshPromise = capturedRefreshUser!(); await Promise.resolve(); await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 50)); expect(updateToken).toHaveBeenCalledTimes(1); expect(getByTestId("current-user-id").textContent).toBe("old"); resolveUpdateToken(); await refreshPromise; await new Promise((resolve) => setTimeout(resolve, 50)); expect(getByTestId("current-user-id").textContent).toBe("new"); }); }); describe("CurrentUserProvider token updates", () => { const STORED_USER = { id: "user-1", roles: [], modules: [], company: { id: "company-1", monthlyCredits: 1000, _availableMonthlyCredits: 900, _availableExtraCredits: 50, }, }; function CompanyConsumer() { const { company } = useCurrentUserContext(); return (
{company ? `${(company as any).availableMonthlyCredits}/${(company as any).availableExtraCredits}` : "null"}
); } beforeEach(() => { localStorage.clear(); socketMock.reset(); vi.clearAllMocks(); }); it("patches balances from company:credits_updated without refetching the user", async () => { localStorage.setItem("user", JSON.stringify(STORED_USER)); const { getByTestId } = render( , ); await new Promise((resolve) => setTimeout(resolve, 50)); expect(getByTestId("balances").textContent).toBe("900/50"); await act(async () => { socketMock.emit("company:credits_updated", { type: "company:credits_updated", companyId: "company-1", availableMonthlyCredits: 750, availableExtraCredits: 40, }); }); expect(getByTestId("balances").textContent).toBe("750/40"); expect(UserService.findFullUser).not.toHaveBeenCalled(); }); it("ignores company:credits_updated for a different company", async () => { localStorage.setItem("user", JSON.stringify(STORED_USER)); const { getByTestId } = render( , ); await new Promise((resolve) => setTimeout(resolve, 50)); await act(async () => { socketMock.emit("company:credits_updated", { type: "company:credits_updated", companyId: "someone-else", availableMonthlyCredits: 1, availableExtraCredits: 1, }); }); expect(getByTestId("balances").textContent).toBe("900/50"); }); it("still refetches the full user on company:subscription_updated", async () => { localStorage.setItem("user", JSON.stringify(STORED_USER)); vi.mocked(UserService.findFullUser).mockResolvedValue({ ...STORED_USER, dehydrate: () => STORED_USER, } as any); render( , ); await new Promise((resolve) => setTimeout(resolve, 50)); await act(async () => { socketMock.emit("company:subscription_updated", { type: "company:subscription_updated", companyId: "company-1", }); await new Promise((resolve) => setTimeout(resolve, 50)); }); expect(UserService.findFullUser).toHaveBeenCalledTimes(1); }); });