Compare commits
3
Commits
1336be8c4f
...
dd24ad65a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd24ad65a7 | ||
|
|
5796d2d789 | ||
|
|
a56edf2b37 |
@@ -3,8 +3,9 @@ import type { FC, PropsWithChildren } from "react";
|
||||
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
|
||||
import * as sharedApi from "@/shared/api";
|
||||
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
@@ -398,4 +399,23 @@ describe("AuthProvider", () => {
|
||||
expect(result.current.cards.data?.[0]?.id).toBe("b-card"),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers auth handlers once until unmount", async () => {
|
||||
const configure = vi.spyOn(sharedApi, "configureAuthClient");
|
||||
onTestFinished(() => configure.mockRestore());
|
||||
|
||||
const { rerender, result, unmount } = renderHook(useAuth, {
|
||||
wrapper: createAuthWrapper(createTestQueryClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||
const registrations = configure.mock.calls.length;
|
||||
|
||||
rerender();
|
||||
expect(configure).toHaveBeenCalledTimes(registrations);
|
||||
|
||||
unmount();
|
||||
expect(configure).toHaveBeenCalledTimes(registrations + 1);
|
||||
expect(configure).toHaveBeenLastCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,7 +161,8 @@ function useAuthProviderVm() {
|
||||
refresh,
|
||||
unavailable: () => unavailableRef.current,
|
||||
});
|
||||
});
|
||||
// `user` tracks access-token changes, which configureAuthClient publishes as session state.
|
||||
}, [clearSession, refresh, user]);
|
||||
useEffect(() => () => configureAuthClient(), []);
|
||||
|
||||
const authContextValue = {
|
||||
|
||||
@@ -151,6 +151,20 @@ function loadedProps(nextPortfolio: Portfolio = portfolio) {
|
||||
}
|
||||
|
||||
describe("AccountPage states", () => {
|
||||
it("shows the loaded portfolio source sync status", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(resource(portfolio, { freshness: "stale" })),
|
||||
),
|
||||
);
|
||||
renderAccountRoute(createTestQueryClient());
|
||||
|
||||
expect(
|
||||
await screen.findByText("Данные требуют обновления"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Данные на 14\.08\.2026/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(
|
||||
["account-1", "all"].flatMap(accountId =>
|
||||
["STOCK", "BOND"].flatMap(type =>
|
||||
|
||||
@@ -31,6 +31,7 @@ const AccountPage: FC = () => {
|
||||
positionSections={viewModel.positionSections}
|
||||
recentEvents={viewModel.recentEvents}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
source={viewModel.source}
|
||||
unrealizedReturns={viewModel.unrealizedReturns}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -59,8 +59,7 @@ type AccountPageViewModel
|
||||
| { state: "loading" };
|
||||
|
||||
function isPortfolioAccessFailure(code?: string) {
|
||||
return isApiErrorCode(code, ApiErrorCode.Forbidden)
|
||||
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|
||||
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|
||||
|| isApiErrorCode(code, ApiErrorCode.NotFound)
|
||||
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
|
||||
}
|
||||
|
||||
@@ -285,7 +285,11 @@ describe("apiClient transient read recovery", () => {
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requests += 1;
|
||||
|
||||
return HttpResponse.json({ code: "FORBIDDEN" }, { status });
|
||||
// Any non-transient code; status alone must prevent the retry.
|
||||
return HttpResponse.json(
|
||||
{ code: "INTERNAL_SERVER_ERROR", status },
|
||||
{ status },
|
||||
);
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
|
||||
@@ -166,7 +166,6 @@ function isNetworkFailure(error: unknown): boolean {
|
||||
|
||||
function isPermanentCode(code?: string): boolean {
|
||||
return [
|
||||
ApiErrorCode.Forbidden,
|
||||
ApiErrorCode.InvalidTbankToken,
|
||||
ApiErrorCode.NotFound,
|
||||
ApiErrorCode.TbankNotConnected,
|
||||
|
||||
Vendored
+5
-2
@@ -731,8 +731,11 @@ export interface components {
|
||||
ProblemDetailsDto: {
|
||||
/** @example 401 */
|
||||
status: number;
|
||||
/** @example UNAUTHORIZED */
|
||||
code: string;
|
||||
/**
|
||||
* @example UNAUTHORIZED
|
||||
* @enum {string}
|
||||
*/
|
||||
code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR" | "NOT_FOUND" | "UNAUTHORIZED" | "UNSUPPORTED_MEDIA_TYPE" | "VALIDATION_ERROR" | "EMAIL_ALREADY_REGISTERED" | "INVALID_CREDENTIALS" | "INVALID_OPERATIONS_CURSOR" | "BOND_HISTORY_START_UNAVAILABLE" | "BOND_TICKER_AMBIGUOUS" | "STOCK_HISTORY_START_UNAVAILABLE" | "STOCK_TICKER_AMBIGUOUS" | "INVALID_TBANK_TOKEN" | "TBANK_NOT_CONNECTED" | "TBANK_UNAVAILABLE" | "BANK_CARD_ARCHIVED" | "BANK_DEPENDENT_REFUNDS" | "BANK_DUPLICATE_CATEGORY" | "BANK_IMMUTABLE_OPERATION" | "BANK_INTENT_CONFLICT" | "BANK_INVALID_DATE" | "BANK_INVALID_INPUT" | "BANK_INVALID_MONEY" | "BANK_INVALID_REFUND" | "BANK_NOT_FOUND" | "BANK_OPENING_DATE_CONFLICT" | "BANK_OPERATION_DELETED" | "BANK_REFUND_LIMIT" | "BANK_VERSION_CONFLICT" | "BANK_WRITE_CONFLICT" | "MOEX_UNAVAILABLE" | "NCR_UNAVAILABLE" | "INVALID_EVENTS_PERIOD" | "INVALID_INCOME_PERIOD";
|
||||
/**
|
||||
* @example [
|
||||
* {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { components } from "./generated/schema";
|
||||
|
||||
enum ApiErrorCode {
|
||||
BankDependentRefunds = "BANK_DEPENDENT_REFUNDS",
|
||||
BankIntentConflict = "BANK_INTENT_CONFLICT",
|
||||
@@ -6,7 +8,6 @@ enum ApiErrorCode {
|
||||
BankVersionConflict = "BANK_VERSION_CONFLICT",
|
||||
BankWriteConflict = "BANK_WRITE_CONFLICT",
|
||||
BondTickerAmbiguous = "BOND_TICKER_AMBIGUOUS",
|
||||
Forbidden = "FORBIDDEN",
|
||||
InvalidOperationsCursor = "INVALID_OPERATIONS_CURSOR",
|
||||
InvalidTbankToken = "INVALID_TBANK_TOKEN",
|
||||
NotFound = "NOT_FOUND",
|
||||
@@ -16,6 +17,16 @@ enum ApiErrorCode {
|
||||
Unauthorized = "UNAUTHORIZED",
|
||||
}
|
||||
|
||||
/**
|
||||
Compile-time drift guard: collapses to `never` when any enum member is absent
|
||||
from the generated ProblemDetails contract, so every call site fails to build.
|
||||
*/
|
||||
type ContractApiErrorCode
|
||||
= Exclude<
|
||||
`${ApiErrorCode}`,
|
||||
components["schemas"]["ProblemDetailsDto"]["code"]
|
||||
> extends never ? ApiErrorCode : never;
|
||||
|
||||
function apiErrorCode(error: unknown): string | undefined {
|
||||
return typeof error === "object" && error !== null && "code" in error
|
||||
? String(error.code)
|
||||
@@ -23,7 +34,10 @@ function apiErrorCode(error: unknown): string | undefined {
|
||||
}
|
||||
|
||||
// Incoming codes are an open wire string; unknown values retain their fallback.
|
||||
function isApiErrorCode(value: unknown, expected: ApiErrorCode): boolean {
|
||||
function isApiErrorCode(
|
||||
value: unknown,
|
||||
expected: ContractApiErrorCode,
|
||||
): boolean {
|
||||
return value === expected;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ function invalidateResourceAccess(
|
||||
const errorCode = apiErrorCode(error);
|
||||
if (
|
||||
[
|
||||
ApiErrorCode.Forbidden,
|
||||
ApiErrorCode.InvalidTbankToken,
|
||||
ApiErrorCode.NotFound,
|
||||
ApiErrorCode.TbankNotConnected,
|
||||
|
||||
@@ -5204,7 +5204,44 @@
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"example": "UNAUTHORIZED"
|
||||
"example": "UNAUTHORIZED",
|
||||
"enum": [
|
||||
"BAD_REQUEST",
|
||||
"INTERNAL_SERVER_ERROR",
|
||||
"NOT_FOUND",
|
||||
"UNAUTHORIZED",
|
||||
"UNSUPPORTED_MEDIA_TYPE",
|
||||
"VALIDATION_ERROR",
|
||||
"EMAIL_ALREADY_REGISTERED",
|
||||
"INVALID_CREDENTIALS",
|
||||
"INVALID_OPERATIONS_CURSOR",
|
||||
"BOND_HISTORY_START_UNAVAILABLE",
|
||||
"BOND_TICKER_AMBIGUOUS",
|
||||
"STOCK_HISTORY_START_UNAVAILABLE",
|
||||
"STOCK_TICKER_AMBIGUOUS",
|
||||
"INVALID_TBANK_TOKEN",
|
||||
"TBANK_NOT_CONNECTED",
|
||||
"TBANK_UNAVAILABLE",
|
||||
"BANK_CARD_ARCHIVED",
|
||||
"BANK_DEPENDENT_REFUNDS",
|
||||
"BANK_DUPLICATE_CATEGORY",
|
||||
"BANK_IMMUTABLE_OPERATION",
|
||||
"BANK_INTENT_CONFLICT",
|
||||
"BANK_INVALID_DATE",
|
||||
"BANK_INVALID_INPUT",
|
||||
"BANK_INVALID_MONEY",
|
||||
"BANK_INVALID_REFUND",
|
||||
"BANK_NOT_FOUND",
|
||||
"BANK_OPENING_DATE_CONFLICT",
|
||||
"BANK_OPERATION_DELETED",
|
||||
"BANK_REFUND_LIMIT",
|
||||
"BANK_VERSION_CONFLICT",
|
||||
"BANK_WRITE_CONFLICT",
|
||||
"MOEX_UNAVAILABLE",
|
||||
"NCR_UNAVAILABLE",
|
||||
"INVALID_EVENTS_PERIOD",
|
||||
"INVALID_INCOME_PERIOD"
|
||||
]
|
||||
},
|
||||
"errors": {
|
||||
"type": "array",
|
||||
|
||||
Reference in New Issue
Block a user