chore: add typed ESLint checks #18

Refs WealthLedger/main#18. Keep Biome; add no Prettier dependencies.
This commit is contained in:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 95fdc8759e
commit 57630102e4
38 changed files with 2914 additions and 155 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
export { AuthProvider, useAuth } from "./model/auth";
export { AuthProvider } from "./model/auth";
export { useAuth } from "./model/auth-context";
export { AuthPageLayout } from "./ui/AuthPageLayout/AuthPageLayout";
export { LoginForm } from "./ui/LoginForm";
export { RegisterForm } from "./ui/RegisterForm";
+23
View File
@@ -0,0 +1,23 @@
import { createContext, useContext } from "react";
import type { paths } from "@/shared/api";
type User =
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
type AuthContextValue = {
user: User | null;
accessToken: string | null;
ready: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<boolean>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error("AuthProvider is required");
return context;
}
export { AuthContext, type User, useAuth };
+2 -1
View File
@@ -7,7 +7,8 @@ import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AuthProvider, useAuth } from "./auth";
import { AuthProvider } from "./auth";
import { useAuth } from "./auth-context";
const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
params: { query: { includeArchived: "false" } },
+5 -29
View File
@@ -1,32 +1,13 @@
import { useQueryClient } from "@tanstack/react-query";
import type { FC, PropsWithChildren } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiClient, configureAuthClient, type paths } from "@/shared/api";
import { AuthContext, type User } from "./auth-context";
type User =
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
type Authentication =
paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
type Credentials =
paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"];
type AuthContextValue = {
user: User | null;
accessToken: string | null;
ready: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<boolean>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
async function request(
path: "/api/v1/auth/login" | "/api/v1/auth/register",
email: string,
@@ -35,8 +16,9 @@ async function request(
const { data, error } = await apiClient.POST(path, {
body: { email, password },
} satisfies { body: Credentials });
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error;
return data as Authentication;
return data;
}
const AuthProvider: FC<PropsWithChildren> = (props) => {
@@ -165,10 +147,4 @@ const AuthProvider: FC<PropsWithChildren> = (props) => {
);
};
function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error("AuthProvider is required");
return context;
}
export { AuthProvider, useAuth };
export { AuthProvider };
@@ -27,8 +27,6 @@ describe("AuthForm", () => {
fireEvent.click(screen.getByRole("button", { name: "Регистрация" }));
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
await waitFor(() =>
expect(screen.getByText("Пароли не совпадают")).toBeInTheDocument(),
);
expect(await screen.findByText("Пароли не совпадают")).toBeInTheDocument();
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
import type { FC } from "react";
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../model/auth";
import { useAuth } from "../model/auth-context";
import { AuthForm } from "./AuthForm/AuthForm";
const LoginForm: FC = () => {
@@ -14,7 +14,7 @@ const LoginForm: FC = () => {
<AuthForm
onSubmit={async (email, password) => {
await login(email, password);
navigate(returnTo, { replace: true });
await navigate(returnTo, { replace: true });
}}
/>
);
+2 -2
View File
@@ -1,6 +1,6 @@
import type { FC } from "react";
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../model/auth";
import { useAuth } from "../model/auth-context";
import { AuthForm } from "./AuthForm/AuthForm";
const RegisterForm: FC = () => {
@@ -15,7 +15,7 @@ const RegisterForm: FC = () => {
register
onSubmit={async (email, password) => {
await register(email, password);
navigate(returnTo, { replace: true });
await navigate(returnTo, { replace: true });
}}
/>
);
@@ -18,6 +18,57 @@ function auth() {
});
}
describe("page-owned screen synchronization", () => {
it("ignores a late sync acceptance after the account scope changes", async () => {
auth();
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
const posted: string[] = [];
const polled: string[] = [];
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
const scope = (await request.json()) as {
screen: "account";
accountId: string;
};
posted.push(scope.accountId);
await blocked;
return HttpResponse.json(
{ id: scope.accountId, scope, now: "2026-09-25T00:00:00Z" },
{ status: 202 },
);
}),
http.get("*/api/v1/tbank-sync/:id", ({ params }) => {
polled.push(String(params.id));
return HttpResponse.json({
id: params.id,
state: "succeeded",
resources: [],
});
}),
);
const { result, rerender } = renderHook(
({ accountId }) => useScreenSync({ screen: "account", accountId }, []),
{
initialProps: { accountId: "old" },
wrapper: createQueryWrapper().wrapper,
},
);
let pending!: Promise<void>;
act(() => {
pending = result.current.refresh();
});
await waitFor(() => expect(posted).toEqual(["old"]));
rerender({ accountId: "new" });
await act(async () => {
release();
await pending;
});
await act(() => result.current.refresh());
await waitFor(() => expect(polled).toEqual(["new"]));
expect(posted).toEqual(["old", "new"]);
});
it.each([false, true])(
"retries a failed GET without forcing the source (cached=%s)",
async (cached) => {
@@ -155,7 +206,7 @@ describe("page-owned screen synchronization", () => {
);
expect(published).not.toContainEqual(
expect.objectContaining({
meta: expect.objectContaining({ stateVersion: "old" }),
meta: expect.objectContaining({ stateVersion: "old" }) as unknown,
}),
);
unsubscribe();
@@ -354,7 +405,7 @@ describe("page-owned screen synchronization", () => {
});
expect(modes).toEqual([null, "observe"]);
if (boundary === "leave") unmount();
else await act(() => configureAuthClient(null));
else act(() => configureAuthClient(null));
const stopped = polls;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1100));
@@ -1,5 +1,5 @@
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
apiErrorCode,
apiQueryClient,
@@ -33,7 +33,9 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
} | null>(null);
const requestInFlight = useRef<string | null>(null);
const currentScope = useRef(scopeKey);
currentScope.current = scopeKey;
useLayoutEffect(() => {
currentScope.current = scopeKey;
}, [scopeKey]);
const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
const observed = useRef(new Map<string, string>());
const ids = [
@@ -56,7 +56,10 @@ describe("sync connection and session boundaries", () => {
const { wrapper, queryClient } = createQueryWrapper();
const published: string[] = [];
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (event.query.queryKey[1] === "/api/v1/tbank-sync/{id}") {
if (
(event.query.queryKey as readonly unknown[])[1] ===
"/api/v1/tbank-sync/{id}"
) {
const data = event.query.state.data as { now?: string } | undefined;
if (data?.now) published.push(data.now);
}
@@ -73,6 +76,7 @@ describe("sync connection and session boundaries", () => {
act(() => {
mutation = result.current.mutation.mutateAsync({ body: screen });
});
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
const discarded = expect(mutation).rejects.toMatchObject({
name: "AbortError",
});
@@ -163,11 +163,9 @@ describe("TbankConnectionSection", () => {
});
fireEvent.click(screen.getByRole("button", { name: "Подключить" }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Заменить token" }),
).toBeInTheDocument(),
);
expect(
await screen.findByRole("button", { name: "Заменить token" }),
).toBeInTheDocument();
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
{ id: "bank-card-1" },
@@ -224,11 +222,9 @@ describe("TbankConnectionSection", () => {
);
fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Подключить" }),
).toBeInTheDocument(),
);
expect(
await screen.findByRole("button", { name: "Подключить" }),
).toBeInTheDocument();
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
{ id: "bank-card-1" },