From fd013de3250d080bbbb0e745ce2c07efb8f82dc0 Mon Sep 17 00:00:00 2001 From: Zimeng Xiong Date: Sat, 7 Feb 2026 18:03:05 -0800 Subject: [PATCH] add tests on refactor --- .../src/__tests__/auth-enabled.integration.ts | 44 ++++ backend/src/auth.ts | 1 + backend/src/auth/authMode.test.ts | 131 +++++++++++ backend/src/auth/authMode.ts | 13 ++ backend/src/auth/coreRoutes.ts | 3 + backend/src/middleware/auth.test.ts | 220 ++++++++++++++++++ backend/src/security/csrfClient.test.ts | 74 ++++++ backend/src/server/drawingsCache.test.ts | 73 ++++++ .../pages/dashboard/useDashboardData.test.ts | 201 ++++++++++++++++ .../src/pages/editor/useEditorChrome.test.ts | 103 ++++++++ .../pages/editor/useEditorIdentity.test.ts | 60 +++++ 11 files changed, 923 insertions(+) create mode 100644 backend/src/auth/authMode.test.ts create mode 100644 backend/src/middleware/auth.test.ts create mode 100644 backend/src/security/csrfClient.test.ts create mode 100644 backend/src/server/drawingsCache.test.ts create mode 100644 frontend/src/pages/dashboard/useDashboardData.test.ts create mode 100644 frontend/src/pages/editor/useEditorChrome.test.ts create mode 100644 frontend/src/pages/editor/useEditorIdentity.test.ts diff --git a/backend/src/__tests__/auth-enabled.integration.ts b/backend/src/__tests__/auth-enabled.integration.ts index f2f9eb6..0ac5785 100644 --- a/backend/src/__tests__/auth-enabled.integration.ts +++ b/backend/src/__tests__/auth-enabled.integration.ts @@ -14,6 +14,7 @@ describe("Auth Enabled Toggle Authorization", () => { let csrfHeaderName: string; let csrfToken: string; let regularUserToken: string; + let adminUserToken: string; beforeAll(async () => { setupTestDb(); @@ -58,6 +59,26 @@ describe("Auth Enabled Toggle Authorization", () => { signOptions ); + const admin = await prisma.user.create({ + data: { + email: "admin-user@test.local", + passwordHash, + name: "Admin User", + role: "ADMIN", + isActive: true, + }, + select: { + id: true, + email: true, + }, + }); + + adminUserToken = jwt.sign( + { userId: admin.id, email: admin.email, type: "access" }, + config.jwtSecret, + signOptions + ); + const agent = request.agent(app); const csrfRes = await agent .get("/csrf-token") @@ -91,4 +112,27 @@ describe("Auth Enabled Toggle Authorization", () => { expect(response.status).toBe(403); expect(response.body?.message).toContain("Admin access required"); }); + + it("applies auth mode change immediately for subsequent requests", async () => { + const warmStatusResponse = await request(app) + .get("/auth/status") + .set("User-Agent", userAgent); + expect(warmStatusResponse.status).toBe(200); + expect(warmStatusResponse.body?.authEnabled).toBe(true); + + const toggleResponse = await request(app) + .post("/auth/auth-enabled") + .set("User-Agent", userAgent) + .set("Authorization", `Bearer ${adminUserToken}`) + .set(csrfHeaderName, csrfToken) + .send({ enabled: false }); + expect(toggleResponse.status).toBe(200); + expect(toggleResponse.body?.authEnabled).toBe(false); + + const drawingsResponse = await request(app) + .get("/drawings") + .set("User-Agent", userAgent); + expect(drawingsResponse.status).toBe(200); + expect(Array.isArray(drawingsResponse.body?.drawings)).toBe(true); + }); }); diff --git a/backend/src/auth.ts b/backend/src/auth.ts index a2ef16e..8171cfe 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -342,6 +342,7 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router => isMissingRefreshTokenTableError, bootstrapUserId: BOOTSTRAP_USER_ID, defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID, + clearAuthEnabledCache: authModeService.clearAuthEnabledCache, }); registerAdminRoutes({ diff --git a/backend/src/auth/authMode.test.ts b/backend/src/auth/authMode.test.ts new file mode 100644 index 0000000..fefc4c5 --- /dev/null +++ b/backend/src/auth/authMode.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { PrismaClient } from "../generated/client"; +import { + BOOTSTRAP_USER_ID, + DEFAULT_SYSTEM_CONFIG_ID, + createAuthModeService, +} from "./authMode"; + +const createPrismaMock = () => + ({ + systemConfig: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + user: { + upsert: vi.fn(), + }, + }) as unknown as PrismaClient; + +describe("authMode service", () => { + let now = 1_000_000; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(Date, "now").mockImplementation(() => now); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("caches authEnabled reads within TTL", async () => { + const prisma = createPrismaMock(); + const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType; + const upsert = prisma.systemConfig.upsert as unknown as ReturnType; + findUnique + .mockResolvedValueOnce({ authEnabled: true }) + .mockResolvedValueOnce({ authEnabled: false }); + + const service = createAuthModeService(prisma, { authEnabledTtlMs: 5000 }); + + await expect(service.getAuthEnabled()).resolves.toBe(true); + + now += 1000; + await expect(service.getAuthEnabled()).resolves.toBe(true); + expect(findUnique).toHaveBeenCalledTimes(1); + + now += 6000; + await expect(service.getAuthEnabled()).resolves.toBe(false); + expect(findUnique).toHaveBeenCalledTimes(2); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("clears auth cache when requested", async () => { + const prisma = createPrismaMock(); + const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType; + const upsert = prisma.systemConfig.upsert as unknown as ReturnType; + findUnique.mockResolvedValue({ authEnabled: true }); + + const service = createAuthModeService(prisma); + await service.getAuthEnabled(); + service.clearAuthEnabledCache(); + await service.getAuthEnabled(); + + expect(findUnique).toHaveBeenCalledTimes(2); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("falls back to upsert when system config row is missing", async () => { + const prisma = createPrismaMock(); + const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType; + const upsert = prisma.systemConfig.upsert as unknown as ReturnType; + findUnique.mockResolvedValue(null); + upsert.mockResolvedValue({ authEnabled: false }); + + const service = createAuthModeService(prisma); + await expect(service.getAuthEnabled()).resolves.toBe(false); + + expect(findUnique).toHaveBeenCalledTimes(1); + expect(upsert).toHaveBeenCalledTimes(1); + }); + + it("creates/bootstrap user via upsert", async () => { + const prisma = createPrismaMock(); + const userUpsert = prisma.user.upsert as unknown as ReturnType; + userUpsert.mockResolvedValue({ + id: BOOTSTRAP_USER_ID, + email: "bootstrap@excalidash.local", + name: "Bootstrap Admin", + role: "ADMIN", + isActive: false, + mustResetPassword: true, + username: null, + }); + + const service = createAuthModeService(prisma); + const bootstrapUser = await service.getBootstrapActingUser(); + + expect(bootstrapUser.id).toBe(BOOTSTRAP_USER_ID); + expect(userUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: BOOTSTRAP_USER_ID }, + create: expect.objectContaining({ + id: BOOTSTRAP_USER_ID, + email: "bootstrap@excalidash.local", + role: "ADMIN", + }), + }) + ); + }); + + it("ensures system config defaults", async () => { + const prisma = createPrismaMock(); + const upsert = prisma.systemConfig.upsert as unknown as ReturnType; + upsert.mockResolvedValue({ authEnabled: false }); + + const service = createAuthModeService(prisma); + await service.ensureSystemConfig(); + + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + create: expect.objectContaining({ + id: DEFAULT_SYSTEM_CONFIG_ID, + authEnabled: false, + registrationEnabled: false, + }), + }) + ); + }); +}); diff --git a/backend/src/auth/authMode.ts b/backend/src/auth/authMode.ts index 504e51c..deca0d7 100644 --- a/backend/src/auth/authMode.ts +++ b/backend/src/auth/authMode.ts @@ -17,6 +17,13 @@ export const createAuthModeService = ( const authEnabledTtlMs = options?.authEnabledTtlMs ?? 5000; let authEnabledCache: AuthEnabledCache | null = null; + const getSystemConfigAuthEnabled = async () => { + return prisma.systemConfig.findUnique({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + select: { authEnabled: true }, + }); + }; + const ensureSystemConfig = async () => { return prisma.systemConfig.upsert({ where: { id: DEFAULT_SYSTEM_CONFIG_ID }, @@ -38,6 +45,12 @@ export const createAuthModeService = ( return authEnabledCache.value; } + const existingSystemConfig = await getSystemConfigAuthEnabled(); + if (existingSystemConfig) { + authEnabledCache = { value: existingSystemConfig.authEnabled, fetchedAt: now }; + return existingSystemConfig.authEnabled; + } + const systemConfig = await ensureSystemConfig(); authEnabledCache = { value: systemConfig.authEnabled, fetchedAt: now }; return systemConfig.authEnabled; diff --git a/backend/src/auth/coreRoutes.ts b/backend/src/auth/coreRoutes.ts index 1b8eedf..fec7d77 100644 --- a/backend/src/auth/coreRoutes.ts +++ b/backend/src/auth/coreRoutes.ts @@ -56,6 +56,7 @@ type RegisterCoreRoutesDeps = { isMissingRefreshTokenTableError: (error: unknown) => boolean; bootstrapUserId: string; defaultSystemConfigId: string; + clearAuthEnabledCache: () => void; }; class HttpError extends Error { @@ -86,6 +87,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { isMissingRefreshTokenTableError, bootstrapUserId, defaultSystemConfigId, + clearAuthEnabledCache, } = deps; const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; @@ -713,6 +715,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { registrationEnabled: systemConfig.registrationEnabled, }, }); + clearAuthEnabledCache(); const bootstrapUser = await prisma.user.findUnique({ where: { id: bootstrapUserId }, diff --git a/backend/src/middleware/auth.test.ts b/backend/src/middleware/auth.test.ts new file mode 100644 index 0000000..4a9a592 --- /dev/null +++ b/backend/src/middleware/auth.test.ts @@ -0,0 +1,220 @@ +import type { NextFunction, Request, Response } from "express"; +import jwt from "jsonwebtoken"; +import { describe, expect, it, vi } from "vitest"; +import { config } from "../config"; +import { createAuthMiddleware } from "./auth"; + +const createRequest = (overrides?: Partial): Request => + ({ + method: "GET", + originalUrl: "/drawings", + url: "/drawings", + headers: {}, + ...overrides, + }) as Request; + +const createResponse = (): Response => + ({ + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }) as unknown as Response; + +const createDeps = () => { + const prisma = { + user: { + findUnique: vi.fn(), + }, + } as any; + + const authModeService = { + getAuthEnabled: vi.fn(), + getBootstrapActingUser: vi.fn(), + } as any; + + return { prisma, authModeService }; +}; + +const makeAccessToken = (payload?: { userId?: string; email?: string; impersonatorId?: string }) => + jwt.sign( + { + userId: payload?.userId ?? "user-1", + email: payload?.email ?? "user-1@test.local", + type: "access", + impersonatorId: payload?.impersonatorId, + }, + config.jwtSecret + ); + +const makeRefreshToken = () => + jwt.sign( + { + userId: "user-1", + email: "user-1@test.local", + type: "refresh", + }, + config.jwtSecret + ); + +describe("auth middleware", () => { + it("treats requests as bootstrap user when auth is disabled", async () => { + const { prisma, authModeService } = createDeps(); + authModeService.getAuthEnabled.mockResolvedValue(false); + authModeService.getBootstrapActingUser.mockResolvedValue({ + id: "bootstrap-admin", + username: null, + email: "bootstrap@excalidash.local", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + }); + const { requireAuth } = createAuthMiddleware({ prisma, authModeService }); + + const req = createRequest(); + const res = createResponse(); + const next = vi.fn() as NextFunction; + + await requireAuth(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(req.user).toMatchObject({ + id: "bootstrap-admin", + role: "ADMIN", + }); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns 401 when token is missing and auth is enabled", async () => { + const { prisma, authModeService } = createDeps(); + authModeService.getAuthEnabled.mockResolvedValue(true); + const { requireAuth } = createAuthMiddleware({ prisma, authModeService }); + + const req = createRequest(); + const res = createResponse(); + const next = vi.fn() as NextFunction; + + await requireAuth(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: "Authentication token required" }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it("rejects non-access JWT payloads", async () => { + const { prisma, authModeService } = createDeps(); + authModeService.getAuthEnabled.mockResolvedValue(true); + const { requireAuth } = createAuthMiddleware({ prisma, authModeService }); + + const req = createRequest({ + headers: { + authorization: `Bearer ${makeRefreshToken()}`, + }, + }); + const res = createResponse(); + const next = vi.fn() as NextFunction; + + await requireAuth(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: "Invalid or expired token" }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it("attaches active user for valid access token", async () => { + const { prisma, authModeService } = createDeps(); + authModeService.getAuthEnabled.mockResolvedValue(true); + prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + username: "user1", + email: "user-1@test.local", + name: "User One", + role: "USER", + mustResetPassword: false, + isActive: true, + }); + const { requireAuth } = createAuthMiddleware({ prisma, authModeService }); + + const req = createRequest({ + headers: { + authorization: `Bearer ${makeAccessToken({ impersonatorId: "admin-1" })}`, + }, + }); + const res = createResponse(); + const next = vi.fn() as NextFunction; + + await requireAuth(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(req.user).toMatchObject({ + id: "user-1", + email: "user-1@test.local", + impersonatorId: "admin-1", + }); + }); + + it("blocks non-auth routes when password reset is required", async () => { + const { prisma, authModeService } = createDeps(); + authModeService.getAuthEnabled.mockResolvedValue(true); + prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + username: "user1", + email: "user-1@test.local", + name: "User One", + role: "USER", + mustResetPassword: true, + isActive: true, + }); + const { requireAuth } = createAuthMiddleware({ prisma, authModeService }); + + const req = createRequest({ + method: "GET", + originalUrl: "/drawings", + headers: { + authorization: `Bearer ${makeAccessToken()}`, + }, + }); + const res = createResponse(); + const next = vi.fn() as NextFunction; + + await requireAuth(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ code: "MUST_RESET_PASSWORD" }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it("allows /api/auth/me when password reset is required", async () => { + const { prisma, authModeService } = createDeps(); + authModeService.getAuthEnabled.mockResolvedValue(true); + prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + username: "user1", + email: "user-1@test.local", + name: "User One", + role: "USER", + mustResetPassword: true, + isActive: true, + }); + const { requireAuth } = createAuthMiddleware({ prisma, authModeService }); + + const req = createRequest({ + method: "GET", + originalUrl: "/api/auth/me?include=roles", + headers: { + authorization: `Bearer ${makeAccessToken()}`, + }, + }); + const res = createResponse(); + const next = vi.fn() as NextFunction; + + await requireAuth(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/security/csrfClient.test.ts b/backend/src/security/csrfClient.test.ts new file mode 100644 index 0000000..048f72b --- /dev/null +++ b/backend/src/security/csrfClient.test.ts @@ -0,0 +1,74 @@ +import { Request } from "express"; +import { describe, expect, it } from "vitest"; +import { + CSRF_CLIENT_COOKIE_NAME, + getCsrfClientCookieValue, + getCsrfValidationClientIds, + getLegacyClientId, + parseCookies, +} from "./csrfClient"; + +const makeRequest = (overrides?: Partial): Request => + ({ + headers: { + cookie: "", + "user-agent": "UnitTestAgent/1.0", + }, + ip: "203.0.113.10", + connection: { remoteAddress: "198.51.100.8" }, + ...overrides, + }) as unknown as Request; + +describe("csrfClient helpers", () => { + it("parses cookies and tolerates bad encoding", () => { + const parsed = parseCookies("a=1; b=hello%20world; c=%E0%A4%A"); + expect(parsed).toEqual({ + a: "1", + b: "hello world", + c: "%E0%A4%A", + }); + }); + + it("reads only valid csrf cookie values", () => { + const validReq = makeRequest({ + headers: { + cookie: `${CSRF_CLIENT_COOKIE_NAME}=abcDEF1234567890_-`, + }, + }); + expect(getCsrfClientCookieValue(validReq)).toBe("abcDEF1234567890_-"); + + const invalidReq = makeRequest({ + headers: { + cookie: `${CSRF_CLIENT_COOKIE_NAME}=bad!`, + }, + }); + expect(getCsrfClientCookieValue(invalidReq)).toBeNull(); + }); + + it("builds legacy client id from IP + user agent and truncates", () => { + const longAgent = "x".repeat(600); + const req = makeRequest({ + headers: { + "user-agent": longAgent, + }, + }); + const id = getLegacyClientId(req); + expect(id.startsWith("203.0.113.10:")).toBe(true); + expect(id.length).toBeLessThanOrEqual(256); + }); + + it("returns validation candidates with cookie id first when present", () => { + const req = makeRequest({ + headers: { + cookie: `${CSRF_CLIENT_COOKIE_NAME}=cookieToken123456`, + "user-agent": "Agent", + }, + ip: "10.0.0.1", + }); + + const candidates = getCsrfValidationClientIds(req); + expect(candidates[0]).toBe("cookie:cookieToken123456"); + expect(candidates[1]).toContain("10.0.0.1:Agent"); + expect(new Set(candidates).size).toBe(candidates.length); + }); +}); diff --git a/backend/src/server/drawingsCache.test.ts b/backend/src/server/drawingsCache.test.ts new file mode 100644 index 0000000..80ed3c1 --- /dev/null +++ b/backend/src/server/drawingsCache.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createDrawingsCacheStore } from "./drawingsCache"; + +describe("drawings cache store", () => { + let now = 0; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(Date, "now").mockImplementation(() => now); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("builds deterministic cache keys", () => { + const { buildDrawingsCacheKey } = createDrawingsCacheStore(5000); + const keyA = buildDrawingsCacheKey({ + userId: "u1", + searchTerm: "roadmap", + collectionFilter: "default", + includeData: false, + sortField: "updatedAt", + sortDirection: "desc", + }); + const keyB = buildDrawingsCacheKey({ + userId: "u1", + searchTerm: "roadmap", + collectionFilter: "default", + includeData: false, + sortField: "updatedAt", + sortDirection: "desc", + }); + const keyC = buildDrawingsCacheKey({ + userId: "u1", + searchTerm: "roadmap", + collectionFilter: "default", + includeData: true, + sortField: "updatedAt", + sortDirection: "desc", + }); + + expect(keyA).toBe(keyB); + expect(keyA).not.toBe(keyC); + }); + + it("caches payloads and expires by TTL", () => { + const { cacheDrawingsResponse, getCachedDrawingsBody } = createDrawingsCacheStore(1000); + const key = "drawings:key:1"; + const payload = { drawings: [{ id: "d1" }], totalCount: 1 }; + + const body = cacheDrawingsResponse(key, payload); + expect(body.toString("utf8")).toContain("\"totalCount\":1"); + + now = 800; + expect(getCachedDrawingsBody(key)?.toString("utf8")).toContain("\"d1\""); + + now = 1200; + expect(getCachedDrawingsBody(key)).toBeNull(); + }); + + it("supports manual invalidation", () => { + const { cacheDrawingsResponse, getCachedDrawingsBody, invalidateDrawingsCache } = + createDrawingsCacheStore(10_000); + const key = "drawings:key:2"; + + cacheDrawingsResponse(key, { drawings: [], totalCount: 0 }); + expect(getCachedDrawingsBody(key)).not.toBeNull(); + + invalidateDrawingsCache(); + expect(getCachedDrawingsBody(key)).toBeNull(); + }); +}); diff --git a/frontend/src/pages/dashboard/useDashboardData.test.ts b/frontend/src/pages/dashboard/useDashboardData.test.ts new file mode 100644 index 0000000..3028e83 --- /dev/null +++ b/frontend/src/pages/dashboard/useDashboardData.test.ts @@ -0,0 +1,201 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "../../api"; +import { useDashboardData } from "./useDashboardData"; + +vi.mock("../../api", () => ({ + getDrawings: vi.fn(), + getCollections: vi.fn(), +})); + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +const deferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const makeDrawing = (id: string) => ({ + id, + name: id, + collectionId: null, + createdAt: 1, + updatedAt: 1, + version: 1, + preview: null, +}); + +const makeCollection = (id: string) => ({ + id, + name: id, + createdAt: 1, +}); + +describe("useDashboardData", () => { + const getDrawingsMock = vi.mocked(api.getDrawings); + const getCollectionsMock = vi.mocked(api.getCollections); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads drawings and collections on mount", async () => { + getDrawingsMock.mockResolvedValue({ + drawings: [makeDrawing("d1")], + totalCount: 1, + limit: 24, + offset: 0, + }); + getCollectionsMock.mockResolvedValue([makeCollection("c1")]); + + const onRefreshSuccess = vi.fn(); + const { result } = renderHook(() => + useDashboardData({ + debouncedSearch: "", + selectedCollectionId: undefined, + sortField: "updatedAt", + sortDirection: "desc", + pageSize: 24, + onRefreshSuccess, + }) + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(getDrawingsMock).toHaveBeenCalledWith("", undefined, { + limit: 24, + offset: 0, + sortField: "updatedAt", + sortDirection: "desc", + }); + expect(getCollectionsMock).toHaveBeenCalledTimes(1); + expect(result.current.drawings.map((drawing) => drawing.id)).toEqual(["d1"]); + expect(result.current.collections.map((collection) => collection.id)).toEqual(["c1"]); + expect(result.current.totalCount).toBe(1); + expect(onRefreshSuccess).toHaveBeenCalledTimes(1); + }); + + it("fetches more drawings and merges unique results", async () => { + getDrawingsMock + .mockResolvedValueOnce({ + drawings: [makeDrawing("d1"), makeDrawing("d2")], + totalCount: 3, + limit: 24, + offset: 0, + }) + .mockResolvedValueOnce({ + drawings: [makeDrawing("d2"), makeDrawing("d3")], + totalCount: 3, + limit: 24, + offset: 2, + }); + getCollectionsMock.mockResolvedValue([]); + + const { result } = renderHook(() => + useDashboardData({ + debouncedSearch: "", + selectedCollectionId: undefined, + sortField: "updatedAt", + sortDirection: "desc", + pageSize: 24, + }) + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.fetchMore(); + }); + + expect(getDrawingsMock).toHaveBeenNthCalledWith(2, "", undefined, { + limit: 24, + offset: 2, + sortField: "updatedAt", + sortDirection: "desc", + }); + expect(result.current.drawings.map((drawing) => drawing.id)).toEqual(["d1", "d2", "d3"]); + expect(result.current.hasMore).toBe(false); + }); + + it("ignores stale refresh responses from older requests", async () => { + const firstDrawings = deferred<{ + drawings: ReturnType[]; + totalCount: number; + limit: number; + offset: number; + }>(); + const secondDrawings = deferred<{ + drawings: ReturnType[]; + totalCount: number; + limit: number; + offset: number; + }>(); + const firstCollections = deferred[]>(); + const secondCollections = deferred[]>(); + + getDrawingsMock + .mockReturnValueOnce(firstDrawings.promise as ReturnType) + .mockReturnValueOnce(secondDrawings.promise as ReturnType); + getCollectionsMock + .mockReturnValueOnce(firstCollections.promise as ReturnType) + .mockReturnValueOnce(secondCollections.promise as ReturnType); + + const { result, rerender } = renderHook( + (search: string) => + useDashboardData({ + debouncedSearch: search, + selectedCollectionId: undefined, + sortField: "updatedAt", + sortDirection: "desc", + pageSize: 24, + }), + { initialProps: "first" } + ); + + rerender("second"); + + await act(async () => { + secondDrawings.resolve({ + drawings: [makeDrawing("new")], + totalCount: 1, + limit: 24, + offset: 0, + }); + secondCollections.resolve([makeCollection("new-collection")]); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(result.current.drawings.map((drawing) => drawing.id)).toEqual(["new"]); + }); + + await act(async () => { + firstDrawings.resolve({ + drawings: [makeDrawing("old")], + totalCount: 1, + limit: 24, + offset: 0, + }); + firstCollections.resolve([makeCollection("old-collection")]); + await Promise.resolve(); + }); + + expect(result.current.drawings.map((drawing) => drawing.id)).toEqual(["new"]); + expect(result.current.collections.map((collection) => collection.id)).toEqual([ + "new-collection", + ]); + }); +}); diff --git a/frontend/src/pages/editor/useEditorChrome.test.ts b/frontend/src/pages/editor/useEditorChrome.test.ts new file mode 100644 index 0000000..dc36cf0 --- /dev/null +++ b/frontend/src/pages/editor/useEditorChrome.test.ts @@ -0,0 +1,103 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useEditorChrome } from "./useEditorChrome"; + +vi.mock("lodash/throttle", () => ({ + default: (fn: (...args: unknown[]) => unknown) => fn, +})); + +describe("useEditorChrome", () => { + beforeEach(() => { + vi.useFakeTimers(); + document.title = "Original Title"; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("updates document title and restores app title on unmount", () => { + const { rerender, unmount } = renderHook( + ({ drawingName }) => + useEditorChrome({ + drawingName, + autoHideEnabled: false, + isRenaming: false, + }), + { initialProps: { drawingName: "Roadmap" } } + ); + + expect(document.title).toBe("Roadmap - ExcaliDash"); + + rerender({ drawingName: "Architecture" }); + expect(document.title).toBe("Architecture - ExcaliDash"); + + unmount(); + expect(document.title).toBe("ExcaliDash"); + }); + + it("keeps header visible when auto-hide is disabled", () => { + const { result } = renderHook(() => + useEditorChrome({ + drawingName: "Test", + autoHideEnabled: false, + isRenaming: false, + }) + ); + + act(() => { + vi.advanceTimersByTime(10_000); + }); + + expect(result.current.isHeaderVisible).toBe(true); + }); + + it("auto-hides header after inactivity timeout", () => { + const { result } = renderHook(() => + useEditorChrome({ + drawingName: "Test", + autoHideEnabled: true, + isRenaming: false, + }) + ); + + expect(result.current.isHeaderVisible).toBe(true); + + act(() => { + vi.advanceTimersByTime(3001); + }); + + expect(result.current.isHeaderVisible).toBe(false); + }); + + it("shows header in trigger zone and hides it again after leaving", () => { + const { result } = renderHook(() => + useEditorChrome({ + drawingName: "Test", + autoHideEnabled: true, + isRenaming: false, + }) + ); + + act(() => { + vi.advanceTimersByTime(3001); + }); + expect(result.current.isHeaderVisible).toBe(false); + + act(() => { + window.dispatchEvent(new MouseEvent("mousemove", { clientY: 0 })); + }); + expect(result.current.isHeaderVisible).toBe(true); + + act(() => { + window.dispatchEvent(new MouseEvent("mousemove", { clientY: 40 })); + vi.advanceTimersByTime(1999); + }); + expect(result.current.isHeaderVisible).toBe(true); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(result.current.isHeaderVisible).toBe(false); + }); +}); diff --git a/frontend/src/pages/editor/useEditorIdentity.test.ts b/frontend/src/pages/editor/useEditorIdentity.test.ts new file mode 100644 index 0000000..d14eab9 --- /dev/null +++ b/frontend/src/pages/editor/useEditorIdentity.test.ts @@ -0,0 +1,60 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useEditorIdentity } from "./useEditorIdentity"; +import { getColorFromString } from "./shared"; +import { getUserIdentity } from "../../utils/identity"; + +vi.mock("../../utils/identity", () => ({ + getUserIdentity: vi.fn(), +})); + +describe("useEditorIdentity", () => { + const getUserIdentityMock = vi.mocked(getUserIdentity); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("builds identity from authenticated user", () => { + getUserIdentityMock.mockReturnValue({ + id: "anon", + name: "Anonymous", + initials: "AN", + color: "#999", + }); + + const { result } = renderHook(() => + useEditorIdentity({ + id: "u-1", + name: "Jane Doe", + }) + ); + + expect(result.current).toEqual({ + id: "u-1", + name: "Jane Doe", + initials: "JD", + color: getColorFromString("u-1"), + }); + expect(getUserIdentityMock).not.toHaveBeenCalled(); + }); + + it("falls back to generated identity when user is missing", () => { + getUserIdentityMock.mockReturnValue({ + id: "guest-1", + name: "Guest User", + initials: "GU", + color: "#123456", + }); + + const { result } = renderHook(() => useEditorIdentity(null)); + + expect(getUserIdentityMock).toHaveBeenCalledTimes(1); + expect(result.current).toEqual({ + id: "guest-1", + name: "Guest User", + initials: "GU", + color: "#123456", + }); + }); +});