feat(web): Login/Register страницы, protected routes, logout
- 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).
This commit is contained in:
@@ -3,10 +3,12 @@ import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { AccountsPage } from "./AccountsPage"
|
||||
import { AuthProvider } from "@/auth/AuthContext"
|
||||
import { api } from "@/api/client"
|
||||
import { vi, beforeEach, test, expect } from "vitest"
|
||||
import type { Account } from "@/api/types"
|
||||
|
||||
const PROJECT_ID = "p1"
|
||||
const accounts: Account[] = [
|
||||
{ id: "acc1", provider: "selectel", comment: "Main" },
|
||||
{ id: "acc2", provider: "selectel", comment: "Backup" },
|
||||
@@ -16,14 +18,21 @@ function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={["/accounts"]}>
|
||||
<AccountsPage />
|
||||
</MemoryRouter>
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={["/accounts"]}>
|
||||
<AccountsPage />
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(api.auth, "me").mockResolvedValue({
|
||||
user: { id: "u1", email: "a@b.com" },
|
||||
project: { id: PROJECT_ID, name: "Default" },
|
||||
})
|
||||
vi.spyOn(api, "listAccounts").mockResolvedValue(accounts)
|
||||
})
|
||||
|
||||
@@ -55,7 +64,7 @@ test("форма создания вызывает api.createAccount с введ
|
||||
await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createSpy).toHaveBeenCalledWith({
|
||||
expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, {
|
||||
provider: "selectel",
|
||||
secret: "super-secret-token-123",
|
||||
comment: "New account",
|
||||
@@ -99,5 +108,5 @@ test("удаление учётки вызывает api.deleteAccount", async (
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /удалить.*main/i }))
|
||||
|
||||
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith("acc1"))
|
||||
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith(PROJECT_ID, "acc1"))
|
||||
})
|
||||
|
||||
@@ -3,20 +3,33 @@ import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { DomainDiffPage } from "./DomainDiffPage"
|
||||
import { AuthProvider } from "@/auth/AuthContext"
|
||||
import { api } from "@/api/client"
|
||||
import { vi } from "vitest"
|
||||
import { vi, beforeEach } from "vitest"
|
||||
|
||||
const PROJECT_ID = "p1"
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={["/domains/d1"]}>
|
||||
<Routes><Route path="/domains/:id" element={<DomainDiffPage />} /></Routes>
|
||||
</MemoryRouter>
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={["/domains/d1"]}>
|
||||
<Routes><Route path="/domains/:id" element={<DomainDiffPage />} /></Routes>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(api.auth, "me").mockResolvedValue({
|
||||
user: { id: "u1", email: "a@b.com" },
|
||||
project: { id: PROJECT_ID, name: "Default" },
|
||||
})
|
||||
})
|
||||
|
||||
test("apply sends applyPrunes=false by default, true only after opting in", async () => {
|
||||
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||
updates: [{ kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
|
||||
@@ -30,12 +43,12 @@ test("apply sends applyPrunes=false by default, true only after opting in", asyn
|
||||
const applyBtn = await screen.findByRole("button", { name: /apply/i })
|
||||
await user.click(applyBtn)
|
||||
await waitFor(() => expect(applySpy).toHaveBeenCalled())
|
||||
expect(applySpy.mock.calls[0][1]).toEqual({ applyUpdates: true, applyPrunes: false })
|
||||
expect(applySpy.mock.calls[0]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: false }])
|
||||
|
||||
// включить prune и применить снова
|
||||
const pruneToggle = screen.getByRole("checkbox", { name: /prune|удал/i })
|
||||
await user.click(pruneToggle)
|
||||
await user.click(screen.getByRole("button", { name: /apply/i }))
|
||||
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
|
||||
expect(applySpy.mock.calls[1][1]).toEqual({ applyUpdates: true, applyPrunes: true })
|
||||
expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true }])
|
||||
})
|
||||
|
||||
@@ -3,10 +3,12 @@ import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { DomainsPage } from "./DomainsPage"
|
||||
import { AuthProvider } from "@/auth/AuthContext"
|
||||
import { api } from "@/api/client"
|
||||
import { vi, beforeEach, test, expect } from "vitest"
|
||||
import type { Account, Domain, Template } from "@/api/types"
|
||||
|
||||
const PROJECT_ID = "p1"
|
||||
const accounts: Account[] = [
|
||||
{ id: "acc1", provider: "selectel", comment: "Main" },
|
||||
{ id: "acc2", provider: "cloudflare", comment: "Backup" },
|
||||
@@ -24,17 +26,24 @@ function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={["/domains"]}>
|
||||
<Routes>
|
||||
<Route path="/domains" element={<DomainsPage />} />
|
||||
<Route path="/domains/:id" element={<div>diff page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={["/domains"]}>
|
||||
<Routes>
|
||||
<Route path="/domains" element={<DomainsPage />} />
|
||||
<Route path="/domains/:id" element={<div>diff page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(api.auth, "me").mockResolvedValue({
|
||||
user: { id: "u1", email: "a@b.com" },
|
||||
project: { id: PROJECT_ID, name: "Default" },
|
||||
})
|
||||
vi.spyOn(api, "listDomains").mockResolvedValue(domains)
|
||||
vi.spyOn(api, "listAccounts").mockResolvedValue(accounts)
|
||||
vi.spyOn(api, "listTemplates").mockResolvedValue(templates)
|
||||
@@ -64,7 +73,7 @@ test("кнопка импорта вызывает api.importZones с выбра
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /импортировать зоны/i }))
|
||||
|
||||
await waitFor(() => expect(importSpy).toHaveBeenCalledWith("acc2"))
|
||||
await waitFor(() => expect(importSpy).toHaveBeenCalledWith(PROJECT_ID, "acc2"))
|
||||
})
|
||||
|
||||
test("привязка шаблона в строке домена вызывает api.setDomainTemplate", async () => {
|
||||
@@ -77,7 +86,7 @@ test("привязка шаблона в строке домена вызыва
|
||||
await user.click(screen.getByRole("combobox", { name: /example\.com\./i }))
|
||||
await user.click(await screen.findByRole("option", { name: /^standard$/i }))
|
||||
|
||||
await waitFor(() => expect(setTemplateSpy).toHaveBeenCalledWith("d1", "t1"))
|
||||
await waitFor(() => expect(setTemplateSpy).toHaveBeenCalledWith(PROJECT_ID, "d1", "t1"))
|
||||
})
|
||||
|
||||
test("ошибка привязки шаблона отображается пользователю", async () => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { LoginPage } from "./LoginPage"
|
||||
import { AuthProvider } from "@/auth/AuthContext"
|
||||
import { api, UnauthorizedError } from "@/api/client"
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<div>register page</div>} />
|
||||
<Route path="/domains" element={<div>domains page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(api.auth, "me").mockRejectedValue(new UnauthorizedError())
|
||||
})
|
||||
|
||||
describe("LoginPage", () => {
|
||||
it("ввод email+пароль и сабмит вызывает useAuth().login с введёнными данными", async () => {
|
||||
const loginSpy = vi.spyOn(api.auth, "login").mockResolvedValue({
|
||||
user: { id: "u1", email: "a@b.com" },
|
||||
project: { id: "p1", name: "Default" },
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), "a@b.com")
|
||||
await user.type(screen.getByLabelText(/пароль/i), "secret123")
|
||||
await user.click(screen.getByRole("button", { name: /войти/i }))
|
||||
|
||||
await waitFor(() => expect(loginSpy).toHaveBeenCalledWith("a@b.com", "secret123"))
|
||||
|
||||
expect(await screen.findByText("domains page")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("ошибка входа отображается пользователю через role=alert", async () => {
|
||||
vi.spyOn(api.auth, "login").mockRejectedValue(new Error("Неверный email или пароль"))
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), "a@b.com")
|
||||
await user.type(screen.getByLabelText(/пароль/i), "wrong-password")
|
||||
await user.click(screen.getByRole("button", { name: /войти/i }))
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Неверный email или пароль")
|
||||
})
|
||||
|
||||
it("содержит ссылку на регистрацию", async () => {
|
||||
renderPage()
|
||||
|
||||
const link = await screen.findByRole("link", { name: /зарегистрир/i })
|
||||
expect(link).toHaveAttribute("href", "/register")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useId, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { Link, Navigate } from "react-router-dom"
|
||||
import { KeyRound, Loader2, LogIn, SquareTerminal } from "lucide-react"
|
||||
import { useAuth } from "@/auth/AuthContext"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from "@/components/ui/field"
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().trim().min(1, "Укажите email").email("Некорректный email"),
|
||||
password: z.string().min(1, "Укажите пароль"),
|
||||
})
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>
|
||||
|
||||
export function LoginPage() {
|
||||
const { user, login } = useAuth()
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
const emailFieldId = useId()
|
||||
const passwordFieldId = useId()
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: { email: "", password: "" },
|
||||
})
|
||||
|
||||
// Already authenticated (fresh session on mount, or just logged in below) —
|
||||
// don't show the login form, go straight to the app.
|
||||
if (user) return <Navigate to="/domains" replace />
|
||||
|
||||
async function onSubmit(values: LoginForm) {
|
||||
setAuthError(null)
|
||||
try {
|
||||
await login(values.email, values.password)
|
||||
} catch {
|
||||
setAuthError("Неверный email или пароль")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-background px-4">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<SquareTerminal className="size-6 text-primary" strokeWidth={1.75} />
|
||||
<div className="flex flex-col leading-none">
|
||||
<span className="text-sm font-semibold tracking-tight">DNS Autoresolver</span>
|
||||
<span className="font-dns text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
console
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
noValidate
|
||||
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-5"
|
||||
>
|
||||
<FieldSet className="gap-3">
|
||||
<FieldGroup className="gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={emailFieldId}>Email</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={emailFieldId}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
aria-invalid={!!errors.email}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.email]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor={passwordFieldId}>Пароль</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={passwordFieldId}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••••••"
|
||||
aria-invalid={!!errors.password}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.password]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
{authError && (
|
||||
<FieldDescription role="alert" className="flex items-center gap-2 text-destructive">
|
||||
<KeyRound className="size-3.5 shrink-0" strokeWidth={1.75} />
|
||||
{authError}
|
||||
</FieldDescription>
|
||||
)}
|
||||
</FieldSet>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
) : (
|
||||
<LogIn className="size-4" strokeWidth={1.75} />
|
||||
)}
|
||||
Войти
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Нет учётной записи?{" "}
|
||||
<Link to="/register" className="text-primary underline-offset-4 hover:underline">
|
||||
Зарегистрироваться
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useId, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { Link, Navigate } from "react-router-dom"
|
||||
import { KeyRound, Loader2, SquareTerminal, UserPlus } from "lucide-react"
|
||||
import { useAuth } from "@/auth/AuthContext"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from "@/components/ui/field"
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().trim().min(1, "Укажите email").email("Некорректный email"),
|
||||
password: z.string().min(8, "Минимум 8 символов"),
|
||||
})
|
||||
|
||||
type RegisterForm = z.infer<typeof registerSchema>
|
||||
|
||||
export function RegisterPage() {
|
||||
const { user, register: registerUser } = useAuth()
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
const emailFieldId = useId()
|
||||
const passwordFieldId = useId()
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegisterForm>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: { email: "", password: "" },
|
||||
})
|
||||
|
||||
// Already authenticated — skip straight to the app instead of showing the
|
||||
// registration form again.
|
||||
if (user) return <Navigate to="/domains" replace />
|
||||
|
||||
async function onSubmit(values: RegisterForm) {
|
||||
setAuthError(null)
|
||||
try {
|
||||
await registerUser(values.email, values.password)
|
||||
} catch (err) {
|
||||
setAuthError(err instanceof Error ? err.message : "Не удалось зарегистрироваться")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-background px-4">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<SquareTerminal className="size-6 text-primary" strokeWidth={1.75} />
|
||||
<div className="flex flex-col leading-none">
|
||||
<span className="text-sm font-semibold tracking-tight">DNS Autoresolver</span>
|
||||
<span className="font-dns text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
console
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
noValidate
|
||||
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-5"
|
||||
>
|
||||
<FieldSet className="gap-3">
|
||||
<FieldGroup className="gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={emailFieldId}>Email</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={emailFieldId}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
aria-invalid={!!errors.email}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.email]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor={passwordFieldId}>Пароль</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={passwordFieldId}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••••••"
|
||||
aria-invalid={!!errors.password}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.password]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
{authError && (
|
||||
<FieldDescription role="alert" className="flex items-center gap-2 text-destructive">
|
||||
<KeyRound className="size-3.5 shrink-0" strokeWidth={1.75} />
|
||||
{authError}
|
||||
</FieldDescription>
|
||||
)}
|
||||
</FieldSet>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
) : (
|
||||
<UserPlus className="size-4" strokeWidth={1.75} />
|
||||
)}
|
||||
Зарегистрироваться
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Уже есть аккаунт?{" "}
|
||||
<Link to="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Войти
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { TemplatesPage } from "./TemplatesPage"
|
||||
import { AuthProvider } from "@/auth/AuthContext"
|
||||
import { api } from "@/api/client"
|
||||
import { vi, beforeEach, test, expect } from "vitest"
|
||||
import type { Template } from "@/api/types"
|
||||
|
||||
const PROJECT_ID = "p1"
|
||||
const templates: Template[] = [
|
||||
{
|
||||
id: "t1",
|
||||
@@ -21,14 +23,21 @@ function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={["/templates"]}>
|
||||
<TemplatesPage />
|
||||
</MemoryRouter>
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={["/templates"]}>
|
||||
<TemplatesPage />
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(api.auth, "me").mockResolvedValue({
|
||||
user: { id: "u1", email: "a@b.com" },
|
||||
project: { id: PROJECT_ID, name: "Default" },
|
||||
})
|
||||
vi.spyOn(api, "listTemplates").mockResolvedValue(templates)
|
||||
})
|
||||
|
||||
@@ -65,7 +74,7 @@ test("создание шаблона с записью вызывает api.cre
|
||||
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createSpy).toHaveBeenCalledWith({
|
||||
expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, {
|
||||
name: "New",
|
||||
records: [{ type: "A", name: "www", ttl: 3600, values: ["1.1.1.1"] }],
|
||||
}),
|
||||
@@ -88,7 +97,7 @@ test("редактирование шаблона вызывает api.updateTem
|
||||
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateSpy).toHaveBeenCalledWith("t1", {
|
||||
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, "t1", {
|
||||
name: "Standard v2",
|
||||
records: [{ type: "A", name: "@", ttl: 3600, values: ["1.2.3.4"] }],
|
||||
}),
|
||||
@@ -105,7 +114,7 @@ test("удаление шаблона вызывает api.deleteTemplate", asyn
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /удалить шаблон standard/i }))
|
||||
|
||||
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith("t1"))
|
||||
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith(PROJECT_ID, "t1"))
|
||||
})
|
||||
|
||||
test("ошибка создания шаблона отображается пользователю", async () => {
|
||||
|
||||
Reference in New Issue
Block a user