feat(web): клиент API, токен и экран входа, хуки запросов (#21)
Типы ответов хаба, клиент с разбором ошибок (AuthError, HubDownError, ApiError), хранилище токена с подпиской, экран входа с проверкой токена до сохранения, QueryClient с выходом на экран входа при 401, хуки запросов. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bad6c73371
commit
51c2772dcc
50
web/src/api/client.test.ts
Normal file
50
web/src/api/client.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearToken, setToken } from "../auth/token.ts";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { api, ApiError, AuthError, checkToken, HubDownError } from "./client.ts";
|
||||
|
||||
describe("api", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
|
||||
it("шлёт токен и параметры, пустые параметры выбрасывает", async () => {
|
||||
const fetch = mockFetch(() => json(200, { issues: [] }));
|
||||
await api.get("/issues", { sprint: "S 2", q: "", epic: null, limit: 50 });
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toBe("/issues?sprint=S+2&limit=50");
|
||||
expect((init!.headers as Record<string, string>).Authorization).toBe("Bearer tok");
|
||||
expect(init!.cache).toBe("no-store");
|
||||
});
|
||||
|
||||
it("PUT отправляет JSON", async () => {
|
||||
const fetch = mockFetch((_m, _u, body) => json(200, body));
|
||||
expect(await api.put("/issues/A-1/note", { rest_text: "x" })).toEqual({ rest_text: "x" });
|
||||
expect((fetch.mock.calls[0][1]!.headers as Record<string, string>)["Content-Type"]).toBe("application/json; charset=utf-8");
|
||||
});
|
||||
|
||||
it("401 — AuthError, 5xx и сеть — HubDownError, прочие 4xx — ApiError с detail", async () => {
|
||||
mockFetch(() => json(401, { error: "unauthorized" }));
|
||||
await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError);
|
||||
mockFetch(() => json(503, {}));
|
||||
await expect(api.get("/board")).rejects.toBeInstanceOf(HubDownError);
|
||||
mockFetch(() => { throw new TypeError("Failed to fetch"); });
|
||||
await expect(api.get("/board")).rejects.toBeInstanceOf(HubDownError);
|
||||
mockFetch(() => json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" }));
|
||||
const e = await api.put("/issues/A-1/note", { x: 1 }).catch((err: unknown) => err);
|
||||
expect(e).toBeInstanceOf(ApiError);
|
||||
expect(e).toMatchObject({ status: 422, error: "bad_note", detail: "неизвестные поля заметки: x" });
|
||||
});
|
||||
|
||||
it("без токена — AuthError без запроса", async () => {
|
||||
clearToken();
|
||||
const fetch = mockFetch(() => json(200, {}));
|
||||
await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checkToken проверяет переданный токен, не сохранённый", async () => {
|
||||
const fetch = mockFetch(() => json(200, { kind_labels: {} }));
|
||||
await checkToken("candidate");
|
||||
expect((fetch.mock.calls[0][1]!.headers as Record<string, string>).Authorization).toBe("Bearer candidate");
|
||||
expect(fetch.mock.calls[0][0]).toBe("/settings");
|
||||
});
|
||||
});
|
||||
58
web/src/api/client.ts
Normal file
58
web/src/api/client.ts
Normal file
@ -0,0 +1,58 @@
|
||||
// единственный клиент HTTP API хаба: тот же хост, клиентский токен в Authorization, ответы — JSON.
|
||||
// Ошибки: AuthError — 401 или нет токена; HubDownError — сеть или 5xx; ApiError — прочие 4xx с {error, detail}
|
||||
import { getToken } from "../auth/token.ts";
|
||||
import type { HubSettings } from "./types.ts";
|
||||
|
||||
export class AuthError extends Error {}
|
||||
export class HubDownError extends Error {}
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly error: string;
|
||||
readonly detail: string | null;
|
||||
constructor(status: number, error: string, detail: string | null) {
|
||||
super(detail ? `${error}: ${detail}` : error);
|
||||
this.status = status;
|
||||
this.error = error;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
export type Params = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
function withParams(path: string, params?: Params): string {
|
||||
if (!params) return path;
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) if (v !== null && v !== undefined && v !== "") q.set(k, String(v));
|
||||
const s = q.toString();
|
||||
return s ? `${path}?${s}` : path;
|
||||
}
|
||||
|
||||
async function call<T>(method: string, path: string, body?: unknown, token = getToken()): Promise<T> {
|
||||
if (!token) throw new AuthError("нет токена");
|
||||
const headers: Record<string, string> = { Authorization: "Bearer " + token, Accept: "application/json" };
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json; charset=utf-8";
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), cache: "no-store" });
|
||||
} catch {
|
||||
throw new HubDownError("хаб не отвечает");
|
||||
}
|
||||
if (resp.status === 401) throw new AuthError("токен не подошёл");
|
||||
let data: unknown = null;
|
||||
try { data = await resp.json(); } catch { /* тело не JSON */ }
|
||||
if (resp.status >= 500) throw new HubDownError(`хаб ответил ${resp.status}`);
|
||||
if (!resp.ok) {
|
||||
const d = (data ?? {}) as { error?: string; detail?: unknown };
|
||||
throw new ApiError(resp.status, d.error ?? "http", d.detail === undefined || d.detail === null ? null : String(d.detail));
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, params?: Params) => call<T>("GET", withParams(path, params)),
|
||||
post: <T>(path: string, body: unknown = {}) => call<T>("POST", path, body),
|
||||
put: <T>(path: string, body: unknown) => call<T>("PUT", path, body),
|
||||
};
|
||||
|
||||
// проверка токена до сохранения: экран входа
|
||||
export const checkToken = (token: string) => call<HubSettings>("GET", "/settings", undefined, token);
|
||||
46
web/src/api/queries.ts
Normal file
46
web/src/api/queries.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "./client.ts";
|
||||
import type { BoardSnapshot, HubSettings, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts";
|
||||
|
||||
export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q"] as const;
|
||||
export type IssueFilters = Partial<Record<(typeof ISSUE_FILTER_NAMES)[number], string>>;
|
||||
export const ISSUE_PAGE = 50;
|
||||
|
||||
export const qk = {
|
||||
settings: ["settings"] as const,
|
||||
board: ["board"] as const,
|
||||
issues: (f: IssueFilters) => ["issues", f] as const,
|
||||
issue: (key: string) => ["issue", key] as const,
|
||||
};
|
||||
|
||||
export const useSettings = () =>
|
||||
useQuery({ queryKey: qk.settings, queryFn: () => api.get<HubSettings>("/settings"), staleTime: Infinity });
|
||||
|
||||
export const useBoard = () => useQuery({ queryKey: qk.board, queryFn: () => api.get<BoardSnapshot>("/board") });
|
||||
|
||||
export const useIssue = (key: string) =>
|
||||
useQuery({ queryKey: qk.issue(key), queryFn: () => api.get<IssueDetail>(`/issues/${encodeURIComponent(key)}`) });
|
||||
|
||||
// список порциями по ISSUE_PAGE: следующая порция есть, пока последняя полная
|
||||
export const useIssues = (filters: IssueFilters) =>
|
||||
useInfiniteQuery({
|
||||
queryKey: qk.issues(filters),
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.get<{ issues: IssueRow[] }>("/issues", { ...filters, limit: ISSUE_PAGE, offset: pageParam }).then((r) => r.issues),
|
||||
getNextPageParam: (last, all) => (last.length < ISSUE_PAGE ? undefined : all.length * ISSUE_PAGE),
|
||||
});
|
||||
|
||||
// сохранение заметки: key фиксируется при создании мутации — ответ пишется в карточку той задачи, для которой отправлен
|
||||
export function useSaveNote(key: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (fields: NoteFields) =>
|
||||
api.put<Note>(`/issues/${encodeURIComponent(key)}/note`, { ...fields, updated_by: "web" }),
|
||||
onSuccess: (note) => {
|
||||
qc.setQueryData<IssueDetail>(qk.issue(key), (d) => (d ? { ...d, note } : d));
|
||||
void qc.invalidateQueries({ queryKey: ["issues"] });
|
||||
void qc.invalidateQueries({ queryKey: qk.board });
|
||||
},
|
||||
});
|
||||
}
|
||||
13
web/src/api/queryClient.ts
Normal file
13
web/src/api/queryClient.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { clearToken, getToken } from "../auth/token.ts";
|
||||
import { AuthError } from "./client.ts";
|
||||
|
||||
// 401 в любом запросе или действии — экран входа с пояснением «токен не подошёл»
|
||||
export function makeQueryClient(): QueryClient {
|
||||
const onError = (e: unknown) => { if (e instanceof AuthError) clearToken(getToken() ? "bad" : "none"); };
|
||||
return new QueryClient({
|
||||
queryCache: new QueryCache({ onError }),
|
||||
mutationCache: new MutationCache({ onError }),
|
||||
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, staleTime: 30_000 } },
|
||||
});
|
||||
}
|
||||
54
web/src/api/types.ts
Normal file
54
web/src/api/types.ts
Normal file
@ -0,0 +1,54 @@
|
||||
// ответы HTTP API хаба (openspec/specs/hub-api); поля — как отдаёт хаб, без переименований
|
||||
|
||||
export interface BoardIssue {
|
||||
key: string; summary: string | null; issue_type: string | null; status: string | null; priority: string | null;
|
||||
assignee: string | null; sprint: string | null; duedate: string | null; planned_start: string | null;
|
||||
planned_end: string | null; epic_key: string | null; labels: string[]; neighbor: boolean;
|
||||
fact_status: string | null; work_kind: string | null;
|
||||
}
|
||||
export interface BoardLink { source: string; target: string; link_type: string }
|
||||
export interface SyncRun { started_at: string; finished_at: string; issues_count: number }
|
||||
export interface BoardSnapshot { generated_at: string; last_sync: SyncRun | null; issues: BoardIssue[]; links: BoardLink[] }
|
||||
|
||||
// словари настроек: kind_labels — вид работы → метка Jira, link_types — логический тип → имя типа связи Jira,
|
||||
// status_labels — статус по факту → метка Jira
|
||||
export interface HubSettings {
|
||||
kind_labels?: Record<string, string>;
|
||||
link_types?: Record<string, string>;
|
||||
status_labels?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IssueRow {
|
||||
key: string; id: string | null; issue_type: string | null; summary: string | null; description: string | null;
|
||||
status: string | null; priority: string | null; assignee: string | null; reporter: string | null;
|
||||
duedate: string | null; planned_start: string | null; planned_end: string | null; epic_key: string | null;
|
||||
labels: string[]; sprint: string | null; created: string | null; jira_updated: string | null; fetched_at: string;
|
||||
neighbor: boolean; fact_status?: string | null; work_kind?: string | null;
|
||||
}
|
||||
|
||||
export const NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] as const;
|
||||
export type NoteField = (typeof NOTE_FIELDS)[number];
|
||||
export type NoteFields = Partial<Record<NoteField, string | null>>;
|
||||
export type Note = Record<NoteField, string | null> & { key: string; updated_at: string; updated_by: string };
|
||||
|
||||
export interface IssueCommand {
|
||||
id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null;
|
||||
}
|
||||
export interface IssueDetail extends IssueRow {
|
||||
links_out: { target_key: string; link_type: string }[];
|
||||
links_in: { source_key: string; link_type: string }[];
|
||||
note: Note | null;
|
||||
commands: IssueCommand[];
|
||||
}
|
||||
|
||||
export type BatchStatus = "queued" | "running" | "done" | "cancelled";
|
||||
export interface BatchCommand {
|
||||
id: number; seq: number; type: string; status: string; error: string | null; finished_at: string | null;
|
||||
}
|
||||
export interface Batch {
|
||||
id: number; title: string; author: string; created_at: string; cancelled_at: string | null; status: BatchStatus;
|
||||
counts: Record<string, number>; remaining_seconds: number; commands?: BatchCommand[];
|
||||
}
|
||||
export interface AgentView { online: boolean; last_seen_at: string | null; warning: string | null }
|
||||
export interface CreatedBatch { id: number; estimate_seconds: number; count: number }
|
||||
9
web/src/auth/AuthGate.tsx
Normal file
9
web/src/auth/AuthGate.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { LoginScreen } from "./LoginScreen.tsx";
|
||||
import { useAuth } from "./useAuth.ts";
|
||||
|
||||
// пока нет токена — только экран входа: страниц и их фоновых запросов нет (M-6)
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { token } = useAuth();
|
||||
return token ? <>{children}</> : <LoginScreen />;
|
||||
}
|
||||
65
web/src/auth/LoginScreen.test.tsx
Normal file
65
web/src/auth/LoginScreen.test.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { renderWithProviders } from "../test/render.tsx";
|
||||
import { AuthGate } from "./AuthGate.tsx";
|
||||
import { clearToken, getAuth } from "./token.ts";
|
||||
|
||||
const app = <AuthGate><p>страница приложения</p></AuthGate>;
|
||||
|
||||
describe("вход", () => {
|
||||
beforeEach(() => clearToken());
|
||||
|
||||
it("без токена — экран входа, страница не видна", () => {
|
||||
renderWithProviders(app);
|
||||
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("страница приложения")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/uv run jira-hub tokens/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("верный токен сохраняется и открывает приложение", async () => {
|
||||
const fetch = mockFetch(() => json(200, {}));
|
||||
renderWithProviders(app);
|
||||
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "good-token");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||||
expect(await screen.findByText("страница приложения")).toBeInTheDocument();
|
||||
expect(localStorage.getItem("jira-hub-token")).toBe("good-token");
|
||||
expect(fetch.mock.calls[0][0]).toBe("/settings");
|
||||
});
|
||||
|
||||
it("неверный токен не сохраняется, поле не стирается", async () => {
|
||||
mockFetch(() => json(401, { error: "unauthorized" }));
|
||||
renderWithProviders(app);
|
||||
const input = screen.getByLabelText("Клиентский токен хаба");
|
||||
await userEvent.type(input, "wrong");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||||
expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument();
|
||||
expect(input).toHaveValue("wrong");
|
||||
expect(localStorage.getItem("jira-hub-token")).toBeNull();
|
||||
});
|
||||
|
||||
it("кириллица или пробел — «недопустимые символы», запроса нет", async () => {
|
||||
const fetch = mockFetch(() => json(200, {}));
|
||||
renderWithProviders(app);
|
||||
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "ток ен");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||||
expect(screen.getByText(/недопустимые символы/)).toBeInTheDocument();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("хаб не отвечает — своё сообщение, не «токен не подошёл»", async () => {
|
||||
mockFetch(() => { throw new TypeError("Failed to fetch"); });
|
||||
renderWithProviders(app);
|
||||
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "tok");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||||
expect(await screen.findByText(/Хаб не отвечает/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("после 401 в приложении экран входа поясняет причину", async () => {
|
||||
clearToken("bad");
|
||||
renderWithProviders(app);
|
||||
await waitFor(() => expect(getAuth().reason).toBe("bad"));
|
||||
expect(screen.getByText(/Токен не подошёл/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
48
web/src/auth/LoginScreen.tsx
Normal file
48
web/src/auth/LoginScreen.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { AuthError, checkToken } from "../api/client.ts";
|
||||
import { setToken, TOKEN_RE } from "./token.ts";
|
||||
import { useAuth } from "./useAuth.ts";
|
||||
|
||||
const BAD_TOKEN = "Токен не подошёл. Проверьте его по uv run jira-hub tokens: токен могли пересоздать.";
|
||||
const BAD_CHARS = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы.";
|
||||
const HUB_DOWN = "Хаб не отвечает: проверьте, что jira-hub запущен, и попробуйте снова.";
|
||||
|
||||
export function LoginScreen() {
|
||||
const { reason } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const [value, setValue] = useState("");
|
||||
const [error, setError] = useState(reason === "bad" ? BAD_TOKEN : "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(ev: FormEvent) {
|
||||
ev.preventDefault();
|
||||
const t = value.trim();
|
||||
if (!t) return;
|
||||
if (!TOKEN_RE.test(t)) { setError(BAD_CHARS); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await checkToken(t);
|
||||
qc.clear();
|
||||
setToken(t);
|
||||
} catch (e) {
|
||||
setError(e instanceof AuthError ? BAD_TOKEN : HUB_DOWN);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<h2>Вход в хаб</h2>
|
||||
<p>Приложению нужен клиентский токен хаба. Его показывает команда <code>uv run jira-hub tokens</code> в каталоге
|
||||
jira-hub на этом Mac. Токен сохранится в этом браузере.</p>
|
||||
{error && <p className="err" role="alert">{error}</p>}
|
||||
<form onSubmit={submit}>
|
||||
<input type="password" autoComplete="off" spellCheck={false} aria-label="Клиентский токен хаба"
|
||||
placeholder="клиентский токен" value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
<button className="btn" type="submit" disabled={busy}>Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
web/src/auth/token.test.ts
Normal file
40
web/src/auth/token.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("token", () => {
|
||||
beforeEach(() => { vi.resetModules(); localStorage.clear(); });
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
it("читает токен, сохранённый прежней доской", async () => {
|
||||
localStorage.setItem("jira-hub-token", "abc");
|
||||
const t = await import("./token.ts");
|
||||
expect(t.getToken()).toBe("abc");
|
||||
});
|
||||
|
||||
it("setToken и clearToken пишут в localStorage и оповещают подписчиков", async () => {
|
||||
const t = await import("./token.ts");
|
||||
const fn = vi.fn();
|
||||
t.subscribe(fn);
|
||||
t.setToken("xyz");
|
||||
expect(localStorage.getItem("jira-hub-token")).toBe("xyz");
|
||||
t.clearToken("bad");
|
||||
expect(t.getAuth()).toEqual({ token: "", reason: "bad" });
|
||||
expect(localStorage.getItem("jira-hub-token")).toBeNull();
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("закрытое хранилище: токен живёт в памяти, исключений нет", async () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||||
const t = await import("./token.ts");
|
||||
expect(t.getToken()).toBe("");
|
||||
t.setToken("mem");
|
||||
expect(t.getToken()).toBe("mem");
|
||||
});
|
||||
|
||||
it("TOKEN_RE пропускает только печатный ASCII без пробелов", async () => {
|
||||
const { TOKEN_RE } = await import("./token.ts");
|
||||
expect(TOKEN_RE.test("Ab-_9~")).toBe(true);
|
||||
expect(TOKEN_RE.test("ab c")).toBe(false);
|
||||
expect(TOKEN_RE.test("токен")).toBe(false);
|
||||
});
|
||||
});
|
||||
33
web/src/auth/token.ts
Normal file
33
web/src/auth/token.ts
Normal file
@ -0,0 +1,33 @@
|
||||
// клиентский токен хаба: localStorage["jira-hub-token"] (тот же ключ, что у прежней доски) и копия в памяти на случай
|
||||
// закрытого хранилища. reason — почему показан экран входа: "bad" — токен не подошёл (ответ 401)
|
||||
export const TOKEN_KEY = "jira-hub-token";
|
||||
export const TOKEN_RE = /^[\x21-\x7e]+$/; // печатный ASCII без пробелов: иначе fetch бросит TypeError на заголовке
|
||||
|
||||
export type LoginReason = "none" | "bad";
|
||||
export interface AuthSnapshot { token: string; reason: LoginReason }
|
||||
|
||||
const readStored = (): string => {
|
||||
try { return localStorage.getItem(TOKEN_KEY) ?? ""; } catch { return ""; }
|
||||
};
|
||||
|
||||
let state: AuthSnapshot = { token: readStored(), reason: "none" };
|
||||
const listeners = new Set<() => void>();
|
||||
const emit = (next: AuthSnapshot) => { state = next; listeners.forEach((l) => l()); };
|
||||
|
||||
export const getAuth = (): AuthSnapshot => state;
|
||||
export const getToken = (): string => state.token;
|
||||
|
||||
export function setToken(token: string): void {
|
||||
try { localStorage.setItem(TOKEN_KEY, token); } catch { /* закрытое хранилище: токен только в памяти */ }
|
||||
emit({ token, reason: "none" });
|
||||
}
|
||||
|
||||
export function clearToken(reason: LoginReason = "none"): void {
|
||||
try { localStorage.removeItem(TOKEN_KEY); } catch { /* закрытое хранилище */ }
|
||||
emit({ token: "", reason });
|
||||
}
|
||||
|
||||
export function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
4
web/src/auth/useAuth.ts
Normal file
4
web/src/auth/useAuth.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { getAuth, subscribe, type AuthSnapshot } from "./token.ts";
|
||||
|
||||
export const useAuth = (): AuthSnapshot => useSyncExternalStore(subscribe, getAuth);
|
||||
16
web/src/test/fetchMock.ts
Normal file
16
web/src/test/fetchMock.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// подмена fetch: handler получает метод, URL (относительно http://hub.test) и разобранное тело
|
||||
export function mockFetch(handler: (method: string, url: URL, body: unknown) => Response | Promise<Response>) {
|
||||
const fn = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const url = new URL(String(input), "http://hub.test");
|
||||
const body = typeof init.body === "string" ? JSON.parse(init.body) : undefined;
|
||||
return handler(init.method ?? "GET", url, body);
|
||||
});
|
||||
vi.stubGlobal("fetch", fn);
|
||||
return fn;
|
||||
}
|
||||
13
web/src/test/render.tsx
Normal file
13
web/src/test/render.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||
import { makeQueryClient } from "../api/queryClient.ts";
|
||||
|
||||
// компонент под QueryClient и data-роутером в памяти: path — шаблон маршрута (для useParams), route — открытый адрес
|
||||
export function renderWithProviders(ui: ReactElement, { route = "/", path = "*" }: { route?: string; path?: string } = {}) {
|
||||
const client = makeQueryClient();
|
||||
const router = createMemoryRouter([{ path, element: ui }, { path: "*", element: <p>другая страница</p> }],
|
||||
{ initialEntries: [route] });
|
||||
return { client, router, ...render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>) };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user