diff --git a/web/src/auth/AuthContext.test.tsx b/web/src/auth/AuthContext.test.tsx index 4b38c35..05f6d48 100644 --- a/web/src/auth/AuthContext.test.tsx +++ b/web/src/auth/AuthContext.test.tsx @@ -67,6 +67,18 @@ describe("AuthContext", () => { expect(screen.getByTestId("project").textContent).toBe(PROJECT.name) }) + it("treats a non-401 error from api.auth.me() as logged-out but logs it for diagnostics", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const err = new Error("network down") + vi.spyOn(api.auth, "me").mockRejectedValue(err) + renderProbe() + + await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false")) + expect(screen.getByTestId("user").textContent).toBe("none") + expect(screen.getByTestId("project").textContent).toBe("none") + expect(consoleErrorSpy).toHaveBeenCalledWith(err) + }) + it("logout clears user/project from context", async () => { vi.spyOn(api.auth, "me").mockResolvedValue({ user: USER, project: PROJECT }) vi.spyOn(api.auth, "logout").mockResolvedValue(undefined) diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx index cb00a94..1c9fa77 100644 --- a/web/src/auth/AuthContext.tsx +++ b/web/src/auth/AuthContext.tsx @@ -1,5 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react" -import { api } from "@/api/client" +import { api, UnauthorizedError } from "@/api/client" import type { User, Project } from "@/api/types" interface AuthContextValue { @@ -27,9 +27,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(state.user) setProject(state.project) }) - .catch(() => { - // Unauthenticated (401) or any other failure — treat as logged-out, - // not as a fatal error. Redirect handling is out of scope here (Task 6). + .catch((err) => { + // Unauthenticated (401) — normal logged-out state, no need to log. + // Any other failure (network/500/etc) — still treat as logged-out so + // we don't get stuck in loading, but surface it for diagnostics + // instead of swallowing it silently. Redirect handling is out of + // scope here (Task 6). + if (!(err instanceof UnauthorizedError)) { + console.error(err) + } if (cancelled) return setUser(null) setProject(null) diff --git a/web/src/hooks/useApi.test.tsx b/web/src/hooks/useApi.test.tsx new file mode 100644 index 0000000..e27f765 --- /dev/null +++ b/web/src/hooks/useApi.test.tsx @@ -0,0 +1,39 @@ +import { renderHook, waitFor } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { describe, it, expect, vi, beforeEach } from "vitest" +import type { ReactNode } from "react" +import { AuthProvider } from "@/auth/AuthContext" +import { api, UnauthorizedError } from "@/api/client" +import { useDeleteAccount } from "./useApi" + +beforeEach(() => { + vi.restoreAllMocks() +}) + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + return ( + + {children} + + ) +} + +describe("useApi mutations — null project guard", () => { + it("mutate() without an active project fails with a clear error, not a TypeError", async () => { + // No session yet => AuthContext resolves project to null. + vi.spyOn(api.auth, "me").mockRejectedValue(new UnauthorizedError()) + vi.spyOn(api, "deleteAccount") + + const { result } = renderHook(() => useDeleteAccount(), { wrapper }) + + result.current.mutate("acc-1") + + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error).not.toBeInstanceOf(TypeError) + expect((result.current.error as Error).message).toBe("no active project") + expect(api.deleteAccount).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/hooks/useApi.ts b/web/src/hooks/useApi.ts index 363d327..2c8b3cd 100644 --- a/web/src/hooks/useApi.ts +++ b/web/src/hooks/useApi.ts @@ -1,7 +1,12 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { api } from "@/api/client" import { useAuth } from "@/auth/AuthContext" -import type { CreateAccountInput, CreateTemplateInput, ApplyRequest } from "@/api/types" +import type { CreateAccountInput, CreateTemplateInput, ApplyRequest, Project } from "@/api/types" + +function requireProjectId(project: Project | null): string { + if (!project) throw new Error("no active project") + return project.id +} export function useAccounts() { const { project } = useAuth() @@ -15,7 +20,10 @@ export function useCreateAccount() { const { project } = useAuth() const qc = useQueryClient() return useMutation({ - mutationFn: (input: CreateAccountInput) => api.createAccount(project!.id, input), + mutationFn: (input: CreateAccountInput) => { + const pid = requireProjectId(project) + return api.createAccount(pid, input) + }, onSuccess: () => qc.invalidateQueries({ queryKey: ["accounts", project?.id] }), }) } @@ -23,7 +31,10 @@ export function useDeleteAccount() { const { project } = useAuth() const qc = useQueryClient() return useMutation({ - mutationFn: (id: string) => api.deleteAccount(project!.id, id), + mutationFn: (id: string) => { + const pid = requireProjectId(project) + return api.deleteAccount(pid, id) + }, onSuccess: () => qc.invalidateQueries({ queryKey: ["accounts", project?.id] }), }) } @@ -40,7 +51,10 @@ export function useCreateTemplate() { const { project } = useAuth() const qc = useQueryClient() return useMutation({ - mutationFn: (input: CreateTemplateInput) => api.createTemplate(project!.id, input), + mutationFn: (input: CreateTemplateInput) => { + const pid = requireProjectId(project) + return api.createTemplate(pid, input) + }, onSuccess: () => qc.invalidateQueries({ queryKey: ["templates", project?.id] }), }) } @@ -48,8 +62,10 @@ export function useUpdateTemplate() { const { project } = useAuth() const qc = useQueryClient() return useMutation({ - mutationFn: ({ id, input }: { id: string; input: CreateTemplateInput }) => - api.updateTemplate(project!.id, id, input), + mutationFn: ({ id, input }: { id: string; input: CreateTemplateInput }) => { + const pid = requireProjectId(project) + return api.updateTemplate(pid, id, input) + }, onSuccess: () => qc.invalidateQueries({ queryKey: ["templates", project?.id] }), }) } @@ -57,7 +73,10 @@ export function useDeleteTemplate() { const { project } = useAuth() const qc = useQueryClient() return useMutation({ - mutationFn: (id: string) => api.deleteTemplate(project!.id, id), + mutationFn: (id: string) => { + const pid = requireProjectId(project) + return api.deleteTemplate(pid, id) + }, onSuccess: () => qc.invalidateQueries({ queryKey: ["templates", project?.id] }), }) } @@ -74,7 +93,10 @@ export function useImportZones() { const { project } = useAuth() const qc = useQueryClient() return useMutation({ - mutationFn: (accountId: string) => api.importZones(project!.id, accountId), + mutationFn: (accountId: string) => { + const pid = requireProjectId(project) + return api.importZones(pid, accountId) + }, onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }), }) } @@ -82,8 +104,10 @@ export function useSetDomainTemplate() { const { project } = useAuth() const qc = useQueryClient() return useMutation({ - mutationFn: ({ id, templateId }: { id: string; templateId: string | null }) => - api.setDomainTemplate(project!.id, id, templateId), + mutationFn: ({ id, templateId }: { id: string; templateId: string | null }) => { + const pid = requireProjectId(project) + return api.setDomainTemplate(pid, id, templateId) + }, onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }), }) } @@ -91,7 +115,10 @@ export function useDeleteDomain() { const { project } = useAuth() const qc = useQueryClient() return useMutation({ - mutationFn: (id: string) => api.deleteDomain(project!.id, id), + mutationFn: (id: string) => { + const pid = requireProjectId(project) + return api.deleteDomain(pid, id) + }, onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }), }) } @@ -107,7 +134,10 @@ export function useApplyDomain(id: string) { const { project } = useAuth() const qc = useQueryClient() return useMutation({ - mutationFn: (body: ApplyRequest) => api.applyDomain(project!.id, id, body), + mutationFn: (body: ApplyRequest) => { + const pid = requireProjectId(project) + return api.applyDomain(pid, id, body) + }, onSuccess: () => qc.invalidateQueries({ queryKey: ["check", project?.id, id] }), }) }