5a4d560e70
- ProtectedRoute: loading -> спиннер, !user -> /login, иначе children - LoginPage/RegisterPage: field+react-hook-form/zod, ошибка через role=alert, редирект на /domains при успехе/уже авторизован - main.tsx: AuthProvider + QueryCache/MutationCache onError -> notifyUnauthorized на UnauthorizedError (сброс сессии из кода вне React-дерева) - AuthContext: logout и notifyUnauthorized чистят react-query кэш (qc.clear()) - Layout: email пользователя + кнопка Выйти - App: /login и /register публичные (авторизованный -> /domains), остальное под ProtectedRoute Починка page-тестов (Accounts/Domains/Templates/DomainDiff/App): AuthProvider + мок api.auth.me, спай-ассерты обновлены под projectId-первым-аргументом сигнатур api.* (T5).
127 lines
5.0 KiB
TypeScript
127 lines
5.0 KiB
TypeScript
import { render, screen, waitFor } from "@testing-library/react"
|
|
import userEvent from "@testing-library/user-event"
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
|
import { AuthProvider, notifyUnauthorized, useAuth } from "./AuthContext"
|
|
import { api, UnauthorizedError } from "@/api/client"
|
|
|
|
const USER = { id: "u1", email: "a@b.com" }
|
|
const PROJECT = { id: "p1", name: "Default" }
|
|
|
|
function Probe() {
|
|
const { user, project, loading, login, register, logout } = useAuth()
|
|
return (
|
|
<div>
|
|
<span data-testid="loading">{String(loading)}</span>
|
|
<span data-testid="user">{user ? user.email : "none"}</span>
|
|
<span data-testid="project">{project ? project.name : "none"}</span>
|
|
<button onClick={() => login("a@b.com", "secret")}>login</button>
|
|
<button onClick={() => register("a@b.com", "secret")}>register</button>
|
|
<button onClick={() => logout()}>logout</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function renderProbe(qc: QueryClient = new QueryClient()) {
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
<AuthProvider>
|
|
<Probe />
|
|
</AuthProvider>
|
|
</QueryClientProvider>,
|
|
)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe("AuthContext", () => {
|
|
it("populates user/project from api.auth.me() on mount", async () => {
|
|
vi.spyOn(api.auth, "me").mockResolvedValue({ user: USER, project: PROJECT })
|
|
renderProbe()
|
|
|
|
expect(screen.getByTestId("loading").textContent).toBe("true")
|
|
|
|
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"))
|
|
expect(screen.getByTestId("user").textContent).toBe(USER.email)
|
|
expect(screen.getByTestId("project").textContent).toBe(PROJECT.name)
|
|
})
|
|
|
|
it("treats 401 from api.auth.me() as unauthenticated, not an error", async () => {
|
|
vi.spyOn(api.auth, "me").mockRejectedValue(new UnauthorizedError())
|
|
renderProbe()
|
|
|
|
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"))
|
|
expect(screen.getByTestId("user").textContent).toBe("none")
|
|
expect(screen.getByTestId("project").textContent).toBe("none")
|
|
})
|
|
|
|
it("login sets user/project in context", async () => {
|
|
vi.spyOn(api.auth, "me").mockRejectedValue(new UnauthorizedError())
|
|
vi.spyOn(api.auth, "login").mockResolvedValue({ user: USER, project: PROJECT })
|
|
const user = userEvent.setup()
|
|
renderProbe()
|
|
|
|
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"))
|
|
await user.click(screen.getByRole("button", { name: "login" }))
|
|
|
|
await waitFor(() => expect(screen.getByTestId("user").textContent).toBe(USER.email))
|
|
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)
|
|
const user = userEvent.setup()
|
|
renderProbe()
|
|
|
|
await waitFor(() => expect(screen.getByTestId("user").textContent).toBe(USER.email))
|
|
await user.click(screen.getByRole("button", { name: "logout" }))
|
|
|
|
await waitFor(() => expect(screen.getByTestId("user").textContent).toBe("none"))
|
|
expect(screen.getByTestId("project").textContent).toBe("none")
|
|
})
|
|
|
|
it("logout clears the react-query cache", async () => {
|
|
vi.spyOn(api.auth, "me").mockResolvedValue({ user: USER, project: PROJECT })
|
|
vi.spyOn(api.auth, "logout").mockResolvedValue(undefined)
|
|
const qc = new QueryClient()
|
|
const clearSpy = vi.spyOn(qc, "clear")
|
|
const user = userEvent.setup()
|
|
renderProbe(qc)
|
|
|
|
await waitFor(() => expect(screen.getByTestId("user").textContent).toBe(USER.email))
|
|
await user.click(screen.getByRole("button", { name: "logout" }))
|
|
|
|
await waitFor(() => expect(clearSpy).toHaveBeenCalled())
|
|
})
|
|
|
|
it("notifyUnauthorized (triggered by any 401 elsewhere in the app) drops the session and clears the cache", async () => {
|
|
vi.spyOn(api.auth, "me").mockResolvedValue({ user: USER, project: PROJECT })
|
|
const qc = new QueryClient()
|
|
const clearSpy = vi.spyOn(qc, "clear")
|
|
renderProbe(qc)
|
|
|
|
await waitFor(() => expect(screen.getByTestId("user").textContent).toBe(USER.email))
|
|
|
|
notifyUnauthorized()
|
|
|
|
await waitFor(() => expect(screen.getByTestId("user").textContent).toBe("none"))
|
|
expect(screen.getByTestId("project").textContent).toBe("none")
|
|
expect(clearSpy).toHaveBeenCalled()
|
|
})
|
|
})
|