chore: add typed ESLint checks #18
Refs WealthLedger/main#18. Keep Biome; add no Prettier dependencies.
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
+6
-10
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user