fix(web): null-guard в мутациях (no active project), AuthContext различает 401 и ошибки сервера
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -67,6 +67,18 @@ describe("AuthContext", () => {
|
|||||||
expect(screen.getByTestId("project").textContent).toBe(PROJECT.name)
|
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 () => {
|
it("logout clears user/project from context", async () => {
|
||||||
vi.spyOn(api.auth, "me").mockResolvedValue({ user: USER, project: PROJECT })
|
vi.spyOn(api.auth, "me").mockResolvedValue({ user: USER, project: PROJECT })
|
||||||
vi.spyOn(api.auth, "logout").mockResolvedValue(undefined)
|
vi.spyOn(api.auth, "logout").mockResolvedValue(undefined)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"
|
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"
|
import type { User, Project } from "@/api/types"
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
@@ -27,9 +27,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(state.user)
|
setUser(state.user)
|
||||||
setProject(state.project)
|
setProject(state.project)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
// Unauthenticated (401) or any other failure — treat as logged-out,
|
// Unauthenticated (401) — normal logged-out state, no need to log.
|
||||||
// not as a fatal error. Redirect handling is out of scope here (Task 6).
|
// 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
|
if (cancelled) return
|
||||||
setUser(null)
|
setUser(null)
|
||||||
setProject(null)
|
setProject(null)
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
+42
-12
@@ -1,7 +1,12 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { api } from "@/api/client"
|
import { api } from "@/api/client"
|
||||||
import { useAuth } from "@/auth/AuthContext"
|
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() {
|
export function useAccounts() {
|
||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
@@ -15,7 +20,10 @@ export function useCreateAccount() {
|
|||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
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] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["accounts", project?.id] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -23,7 +31,10 @@ export function useDeleteAccount() {
|
|||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
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] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["accounts", project?.id] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -40,7 +51,10 @@ export function useCreateTemplate() {
|
|||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
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] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates", project?.id] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -48,8 +62,10 @@ export function useUpdateTemplate() {
|
|||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, input }: { id: string; input: CreateTemplateInput }) =>
|
mutationFn: ({ id, input }: { id: string; input: CreateTemplateInput }) => {
|
||||||
api.updateTemplate(project!.id, id, input),
|
const pid = requireProjectId(project)
|
||||||
|
return api.updateTemplate(pid, id, input)
|
||||||
|
},
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates", project?.id] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates", project?.id] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -57,7 +73,10 @@ export function useDeleteTemplate() {
|
|||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
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] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates", project?.id] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -74,7 +93,10 @@ export function useImportZones() {
|
|||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
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] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -82,8 +104,10 @@ export function useSetDomainTemplate() {
|
|||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, templateId }: { id: string; templateId: string | null }) =>
|
mutationFn: ({ id, templateId }: { id: string; templateId: string | null }) => {
|
||||||
api.setDomainTemplate(project!.id, id, templateId),
|
const pid = requireProjectId(project)
|
||||||
|
return api.setDomainTemplate(pid, id, templateId)
|
||||||
|
},
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -91,7 +115,10 @@ export function useDeleteDomain() {
|
|||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
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] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -107,7 +134,10 @@ export function useApplyDomain(id: string) {
|
|||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
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] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["check", project?.id, id] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user