From 432cca299f3a9a9e45b2697527369ee95f2d0adc Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 9 Oct 2026 09:04:45 +0300 Subject: [PATCH 01/12] feat: align investment client with hourly sync --- .../api/useBrokerageAccounts.ts | 12 +- .../model/connection-boundary.ts | 1 - .../model/screen-sync.test.tsx | 738 +------- .../tbank-connection/model/screen-sync.ts | 270 +-- .../tbank-connection/model/sync.test.tsx | 148 -- .../AccountOperationsPage.test.tsx | 7 - .../ui/AccountPage/AccountPage.test.tsx | 22 - .../ui/AccountsPage/AccountsPage.test.tsx | 12 - .../accounts/ui/AccountsPage/AccountsPage.tsx | 1 + .../ui/AccountsPage/AccountsPage.vm.ts | 7 +- .../components/AccountsPageLoaded.view.tsx | 42 +- src/shared/api/generated/schema.d.ts | 862 ++++----- src/shared/api/index.ts | 2 - src/shared/api/resource-query.ts | 18 +- src/shared/api/spec/openapi.json | 1610 ++++++++--------- 15 files changed, 1244 insertions(+), 2508 deletions(-) delete mode 100644 src/features/tbank-connection/model/sync.test.tsx diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.ts b/src/entities/brokerage-account/api/useBrokerageAccounts.ts index 7ae6cec..2b8cb87 100644 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.ts +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.ts @@ -4,17 +4,25 @@ const brokerageAccountsQueryOptions = () => apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"); const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey; +const BROKERAGE_ACCOUNTS_STALE_TIME_MS = 1_800_000; type BrokerageAccount = NonNullable< paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"] >[number]; +type BrokerageAccountsResponse = paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]; + function useBrokerageAccounts() { - return useResourceQuery({ + const query = useResourceQuery({ meta: { accessGroup: "brokerage" }, ...brokerageAccountsQueryOptions(), - staleTime: 60_000, + staleTime: BROKERAGE_ACCOUNTS_STALE_TIME_MS, }); + + return { + ...query, + sync: query.sync as BrokerageAccountsResponse["sync"] | null, + }; } export { diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts index 22b86e8..61c9caa 100644 --- a/src/features/tbank-connection/model/connection-boundary.ts +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -13,7 +13,6 @@ function isInvestmentPath(path: string) { "/api/v1/brokerage-accounts", "/api/v1/stocks", "/api/v1/bonds", - "/api/v1/tbank-sync", ].some(root => path === root || path.startsWith(`${root}/`)); } diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx index be495f5..4dd8dab 100644 --- a/src/features/tbank-connection/model/screen-sync.test.tsx +++ b/src/features/tbank-connection/model/screen-sync.test.tsx @@ -3,14 +3,12 @@ import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; import { useBrokerageAccounts } from "@/entities/brokerage-account"; -import { useStockQuote } from "@/entities/stock"; -import { configureAuthClient, invalidateResourceAccess } from "@/shared/api"; +import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { useScreenSync } from "../index"; -import { commitTbankConnectionBoundary } from "./connection-boundary"; function auth() { configureAuthClient({ @@ -20,364 +18,21 @@ function auth() { }); } -describe("page-owned screen synchronization", () => { - it("keeps the current scope when an older sync acceptance arrives last", async () => { +describe("read-only screen synchronization", () => { + it("retries the resource with GET and never starts a sync POST", async () => { auth(); - - const { promise: blocked, resolve: release } - = Promise.withResolvers(); - - const posted: string[] = []; - const polled: string[] = []; + let gets = 0; server.use( - http.post("*/api/v1/tbank-sync", async ({ request }) => { - const scope = (await request.json()) as { - accountId: string; - screen: "account"; - }; + http.get("*/api/v1/brokerage-accounts", () => { + gets += 1; - posted.push(scope.accountId); - if (scope.accountId === "old") await blocked; - - return HttpResponse.json( - { id: scope.accountId, now: "2026-09-25T00:00:00Z", scope }, - { status: 202 }, - ); - }), - http.get("*/api/v1/tbank-sync/:id", ({ params }) => { - polled.push(String(params.id)); - - return HttpResponse.json({ - id: params.id, - state: "succeeded", - }); + return gets === 1 + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource([])); }), ); - const { rerender, result } = renderHook( - ({ accountId }) => useScreenSync({ accountId, screen: "account" }, []), - { - initialProps: { accountId: "old" }, - wrapper: createQueryWrapper().wrapper, - }, - ); - - let pending!: Promise; - act(() => { - pending = result.current.refresh(); - }); - await waitFor(() => expect(posted).toEqual(["old"])); - rerender({ accountId: "new" }); - await act(() => result.current.refresh()); - await waitFor(() => expect(polled).toEqual(["new"])); - await act(async () => { - release(); - await pending; - }); - expect(polled).toEqual(["new"]); - expect(posted).toEqual(["old", "new"]); - }); - it.each([false, true])( - "recovers a failed GET without forcing the source (cached=%s)", - async (cached) => { - auth(); - const modes: (null | string)[] = []; - let isUnavailable = !cached; - let posts = 0; - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - modes.push(new URL(request.url).searchParams.get("sync")); - - return isUnavailable - ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json( - resource([], { dataRevision: String(modes.length) }), - ); - }), - http.post("*/api/v1/tbank-sync", () => { - posts += 1; - - return new HttpResponse(null, { status: 500 }); - }), - ); - const { queryClient, wrapper } = createQueryWrapper(); - - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - const disabled = useStockQuote(""); - - return { - accounts, - disabled, - sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]), - }; - }, - { wrapper }, - ); - - if (cached) { - await waitFor(() => - // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. - expect(result.current.accounts.isSuccess).toBe(true), - ); - isUnavailable = true; - void result.current.accounts.refetch(); - } - - isUnavailable = false; - await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true)); - expect(posts).toBe(0); - expect(modes.every(mode => mode === null)).toBe(true); - expect(result.current.disabled.data).toBeUndefined(); - expect( - queryClient - .getQueryCache() - .findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }), - ).toHaveLength(1); - }, - ); - it.each([ - ["logout", true], - ["connection", true], - ["session", false], - ["connection", false], - ["access", false], - ] as const)( - "discards source recovery after %s changes (HTTP already pending=%s)", - async (boundary, pending) => { - auth(); - - const { promise: blocked, resolve: release } - = Promise.withResolvers(); - - let recoveryRequests = 0; - let isChanged = false; - - try { - server.use( - http.get("*/api/v1/brokerage-accounts", async () => { - if (isChanged) - return HttpResponse.json( - resource([], { dataRevision: "current" }), - ); - - if (recoveryRequests > 0) { - recoveryRequests += 1; - await blocked; - - return HttpResponse.json(resource([], { dataRevision: "old" })); - } - - recoveryRequests += 1; - - return HttpResponse.json( - resource(null, { - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: "failed", - state: "failed", - }, - }), - ); - }), - ); - const { queryClient, wrapper } = createQueryWrapper(); - const published: unknown[] = []; - - const unsubscribe = queryClient.getQueryCache().subscribe((event) => { - if (event.type === "updated" && event.action.type === "success") - published.push(event.query.state.data); - }); - - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; - }, - { wrapper }, - ); - - await waitFor(() => - expect(result.current.accounts.meta?.sync.state).toBe("failed"), - ); - vi.useFakeTimers(); - if (pending) - await act(() => vi.advanceTimersByTimeAsync(5000)); - expect(recoveryRequests).toBeGreaterThanOrEqual(1); - - await act(async () => { - isChanged = true; - - switch (boundary) { - case "access": { - invalidateResourceAccess(queryClient, "brokerage", { - code: "FORBIDDEN", - }); - - break; - } - - case "logout": { - configureAuthClient(); - queryClient.clear(); - - break; - } - - case "session": { - auth(); - queryClient.clear(); - - break; - } - - case "connection": { - commitTbankConnectionBoundary(queryClient, { - connected: true, - connectedAt: "2026-09-27T00:00:00Z", - }); - } - } - - release(); - if (!pending) await vi.advanceTimersByTimeAsync(5000); - }); - vi.useRealTimers(); - if (boundary === "connection" && !pending) - await waitFor(() => - // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. - expect(result.current.accounts.meta?.dataRevision).toBe("current"), - ); - expect(published).not.toContainEqual( - expect.objectContaining({ - meta: expect.objectContaining({ dataRevision: "old" }) as unknown, - }), - ); - unsubscribe(); - } - finally { - vi.useRealTimers(); - } - }, - ); - it.each([false, true])( - "recovers an initial GET failure without forced sync (status retry=%s)", - async (retryStatus) => { - auth(); - const modes: (null | string)[] = []; - let posts = 0; - let polls = 0; - - try { - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - modes.push(new URL(request.url).searchParams.get("sync")); - - return modes.length === 1 - ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json(resource([])); - }), - http.post("*/api/v1/tbank-sync", () => { - posts += 1; - - return HttpResponse.json({ id: "recover" }, { status: 202 }); - }), - http.get("*/api/v1/tbank-sync/:id", () => { - polls += 1; - - return retryStatus && polls === 1 - ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json({ - id: "recover", - state: "succeeded", - }); - }), - ); - const { queryClient, wrapper } = createQueryWrapper(); - - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - const disabled = useStockQuote(""); - - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]), - }; - }, - { wrapper }, - ); - - await waitFor( - () => expect(result.current.accounts.isSuccess).toBe(true), - { timeout: 3000 }, - ); - expect(result.current.accounts.data).toEqual([]); - - expect(modes).toEqual([null, null]); - expect(posts).toBe(0); - expect(polls).toBe(0); - expect( - queryClient - .getQueryCache() - .findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }), - ).toHaveLength(1); - } - finally { - vi.useRealTimers(); - } - }, - ); - it("posts only its scope once and observes completion without importing again", async () => { - auth(); - const modes: (null | string)[] = []; - const bodies: unknown[] = []; - let isCompleted = false; - let polls = 0; - - const meta = () => - resource([], { dataRevision: isCompleted ? "2" : "1" }).meta; - - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - modes.push(new URL(request.url).searchParams.get("sync")); - - return HttpResponse.json(resource([], meta())); - }), - http.post("*/api/v1/tbank-sync", async ({ request }) => { - bodies.push(await request.json()); - isCompleted = true; - - return HttpResponse.json( - { - id: "screen", - now: "2026-09-25T00:00:00Z", - scope: { screen: "accounts" }, - }, - { status: 202 }, - ); - }), - http.get("*/api/v1/tbank-sync/:id", () => { - polls++; - - return HttpResponse.json( - { - errorCode: null, - id: "screen", - now: "2026-09-25T00:00:00Z", - scope: { screen: "accounts" }, - state: "succeeded", - }, - ); - }), - ); - - const { result, unmount } = renderHook( + const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); @@ -389,382 +44,35 @@ describe("page-owned screen synchronization", () => { { wrapper: createQueryWrapper().wrapper }, ); + await waitFor(() => expect(gets).toBe(1)); + await act(async () => result.current.sync.retry()); await waitFor(() => expect(result.current.accounts.data).toEqual([])); - await act(async () => { - await Promise.all([ - result.current.sync.refresh(), - result.current.sync.refresh(), - ]); - }); - await waitFor(() => - expect(result.current.accounts.meta?.dataRevision).toBe("2"), - ); - expect(bodies).toEqual([{ screen: "accounts" }]); - expect(modes).toEqual([null, "observe"]); - vi.useFakeTimers(); - - try { - await act(() => vi.advanceTimersByTimeAsync(120_000)); - expect(polls).toBe(1); - - expect(modes).toEqual([null, "observe"]); - unmount(); - await vi.advanceTimersByTimeAsync(120_000); - expect(polls).toBe(1); - } - finally { - vi.useRealTimers(); - } - }); - it.each(["leave", "logout"])( - "publishes completed empty coverage while history runs, then stops on %s", - async (boundary) => { - vi.useFakeTimers(); - - try { - auth(); - let isCompleted = false; - let polls = 0; - const modes: (null | string)[] = []; - - const snapshot = () => - resource(isCompleted ? [] : null, { - dataRevision: isCompleted ? "covered" : "initial", - sync: { errorCode: null, id: "history", state: "running" }, - unavailableReason: isCompleted - ? undefined - : "CALENDAR_OR_ACCOUNT_HISTORY", - }); - - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - modes.push(new URL(request.url).searchParams.get("sync")); - - return HttpResponse.json(snapshot()); - }), - http.get("*/api/v1/tbank-sync/:id", () => { - polls++; - - return HttpResponse.json( - { - errorCode: null, - id: "history", - now: "2026-09-25T00:00:00Z", - scope: null, - state: "running", - }, - ); - }), - ); - - const { result, unmount } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; - }, - { wrapper: createQueryWrapper().wrapper }, - ); - - await vi.waitFor(() => expect(modes).toEqual([null, "observe"])); - expect(polls).toBe(1); - expect(result.current.sync.refreshing).toBe(true); - expect(result.current.accounts.data).toBeUndefined(); - - isCompleted = true; - await act(() => vi.advanceTimersByTimeAsync(5000)); - await vi.waitFor(() => - expect(result.current.accounts.data).toEqual([]), - ); - - expect(result.current.sync.refreshing).toBe(true); - expect(modes).toEqual([null, "observe", "observe"]); - - if (boundary === "leave") unmount(); - else act(() => configureAuthClient()); - - await act(() => vi.advanceTimersByTimeAsync(60_000)); - expect(polls).toBe(2); - expect(modes).toHaveLength(3); - } - finally { - vi.useRealTimers(); - } - }, - ); - - it("runs the next status poll and observe read only after five seconds", async () => { - auth(); - const modes: (null | string)[] = []; - const pollGaps: number[] = []; - let polls = 0; - let previousPollAt = 0; - - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - modes.push(new URL(request.url).searchParams.get("sync")); - - return HttpResponse.json( - resource([], { - sync: { errorCode: null, id: "history", state: "running" }, - }), - ); - }), - http.get("*/api/v1/tbank-sync/:id", () => { - const now = Date.now(); - if (previousPollAt) pollGaps.push(now - previousPollAt); - previousPollAt = now; - polls++; - - return HttpResponse.json({ - errorCode: null, - id: "history", - now: "2026-09-25T00:00:00Z", - scope: null, - state: "running", - }); - }), - ); - - vi.useFakeTimers(); - - try { - const { unmount } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; - }, - { wrapper: createQueryWrapper().wrapper }, - ); - - await vi.waitFor(() => expect(modes).toEqual([null, "observe"])); - expect(polls).toBe(1); - - await act(() => vi.advanceTimersByTimeAsync(2500)); - expect(polls).toBe(1); - expect(modes).toEqual([null, "observe"]); - - await act(() => vi.advanceTimersByTimeAsync(5000)); - await vi.waitFor(() => expect(polls).toBe(2)); - - expect(modes).toEqual([null, "observe", "observe"]); - expect(pollGaps).toHaveLength(1); - expect(pollGaps[0]).toBeGreaterThanOrEqual(5000); - - unmount(); - await act(() => vi.advanceTimersByTimeAsync(60_000)); - expect(polls).toBe(2); - expect(modes).toHaveLength(3); - } - finally { - vi.useRealTimers(); - } - }); - it("observes resources immediately when a status response turns terminal", async () => { - auth(); - const modes: (null | string)[] = []; - let isCompleted = false; - let polls = 0; - - const meta = () => - resource([], { - dataRevision: isCompleted ? "covered" : "initial", - sync: { errorCode: null, id: "history", state: "running" }, - }); - - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - modes.push(new URL(request.url).searchParams.get("sync")); - - return HttpResponse.json(meta()); - }), - http.get("*/api/v1/tbank-sync/:id", () => { - polls++; - - return HttpResponse.json({ - errorCode: null, - id: "history", - now: "2026-09-25T00:00:00Z", - scope: null, - state: isCompleted ? "succeeded" : "running", - }); - }), - ); - - vi.useFakeTimers(); - - try { - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; - }, - { wrapper: createQueryWrapper().wrapper }, - ); - - await vi.waitFor(() => expect(modes).toEqual([null, "observe"])); - expect(polls).toBe(1); - expect(result.current.sync.refreshing).toBe(true); - - isCompleted = true; - await act(() => vi.advanceTimersByTimeAsync(5000)); - await vi.waitFor(() => expect(modes).toEqual([null, "observe", "observe"])); - - expect(polls).toBe(2); - expect(result.current.accounts.meta?.dataRevision).toBe("covered"); - expect(result.current.sync.refreshing).toBe(false); - - await act(() => vi.advanceTimersByTimeAsync(60_000)); - expect(polls).toBe(2); - expect(modes).toHaveLength(3); - } - finally { - vi.useRealTimers(); - } + expect(gets).toBe(2); }); - it("clears private snapshots when status confirms access loss", async () => { + it("refreshes the currently open resources together", async () => { auth(); + let gets = 0; server.use( - http.get("*/api/v1/brokerage-accounts", () => - HttpResponse.json( - resource( + http.get("*/api/v1/brokerage-accounts", () => { + gets += 1; - [], - { - sync: { errorCode: null, id: "revoked", state: "running" }, - }, - ), - ), - ), - http.get("*/api/v1/tbank-sync/:id", () => - HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }), - ), + return HttpResponse.json(resource([])); + }), ); const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; + return useScreenSync({ screen: "accounts" }, [accounts]); }, { wrapper: createQueryWrapper().wrapper }, ); - await waitFor(() => expect(result.current.accounts.isError).toBe(true)); - expect(result.current.accounts.data).toBeUndefined(); - }); - it("observes a projection when only its dependency reports new coverage", async () => { - auth(); - let isObserved = false; - const modes: (null | string)[] = []; - - const dependency = resource( - - [], - { - dataRevision: isObserved ? "covered" : "initial", - resourceKey: "operations", - sync: { - errorCode: null, - id: "dependency", - state: isObserved ? "idle" : "running", - }, - }, - ).meta; - - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - const mode = new URL(request.url).searchParams.get("sync"); - modes.push(mode); - if (mode) isObserved = true; - - return HttpResponse.json( - resource( - isObserved - ? [] - : null, - { - dependencies: [ - { - ...dependency, - dataRevision: isObserved ? "covered" : "initial", - }, - ], - resourceKey: "projection", - }, - ), - ); - }), - http.get("*/api/v1/tbank-sync/:id", () => - - HttpResponse.json( - { - errorCode: null, - id: "dependency", - now: "2026-09-25T00:00:00Z", - scope: null, - state: "succeeded", - }, - ), - ), - ); - - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - useScreenSync({ screen: "accounts" }, [accounts]); - - return accounts; - }, - { wrapper: createQueryWrapper().wrapper }, - ); - - await waitFor(() => expect(result.current.data).toEqual([])); - - expect(modes).toEqual([null, "observe"]); - }); - - it("preserves a fresh snapshot and timestamp after a failed force", async () => { - auth(); - const saved = resource([], { lastSuccessfulAt: "2026-09-25T01:00:00Z" }); - server.use( - http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(saved)), - http.post("*/api/v1/tbank-sync", () => - HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), - ), - ); - - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; - }, - { wrapper: createQueryWrapper().wrapper }, - ); - - await waitFor(() => expect(result.current.accounts.data).toEqual([])); - await act(() => result.current.sync.refresh()); - await waitFor(() => expect(result.current.sync.transportError).toBe(true)); - expect(result.current.accounts.meta).toEqual(saved.meta); + await waitFor(() => expect(gets).toBe(1)); + await act(async () => result.current.refresh()); + await waitFor(() => expect(gets).toBe(2)); }); }); diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index 56b9893..5eed0b3 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -1,24 +1,8 @@ -import { - focusManager, - onlineManager, - type QueryKey, - useQueryClient, -} from "@tanstack/react-query"; +import type { QueryKey } from "@tanstack/react-query"; + import { useEffect, useRef } from "react"; -import { - ApiErrorCode, - apiErrorCode, - apiQueryClient, - invalidateResourceAccess, - type InvestmentResourceMeta, - isApiErrorCode, - type paths, - useAuthenticatedQueries, -} from "@/shared/api"; - -type InvestmentScreen - = paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"]; +import type { InvestmentResourceMeta } from "@/shared/api"; type ScreenResource = { isError: boolean; @@ -29,241 +13,43 @@ type ScreenResource = { refetch: () => Promise; }; -const STATUS_INTERVAL_MS = 5000; -const FIRST_SOURCE_RECOVERY_DELAY_MS = 5000; -const SECOND_SOURCE_RECOVERY_DELAY_MS = 15_000; +type ScreenScope = { + [key: string]: unknown; + screen: string; +}; -function requiresSourceRecovery(meta?: InvestmentResourceMeta): boolean { - if (!meta || meta.unavailableReason) return false; +const READ_INTERVAL_MS = 1_800_000; - return ["cancelled", "failed"].includes(meta.sync.state) - || meta.availability === "missing" - || meta.freshness === "stale" - || (meta.dependencies ?? []).some(dependency => - requiresSourceRecovery(dependency), - ); -} - -function sourceRecoveryDelay(attempt: number): number | undefined { - switch (attempt) { - case 0: { - return FIRST_SOURCE_RECOVERY_DELAY_MS; - } - - case 1: { - return SECOND_SOURCE_RECOVERY_DELAY_MS; - } - - default: { - return undefined; - } - } -} - -function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { - const mutation = apiQueryClient.useMutation("post", "/api/v1/tbank-sync", { - retry: false, - }); - - const client = useQueryClient(); - const deniedRef = useRef(new Set()); - const scopeKey = JSON.stringify(scope); - - const requestInFlightRef = useRef(null); - const lifecycleRecoveryRef = useRef(new Set()); - const sourceRecoveryRef = useRef(new Map()); - const sourceTimersRef = useRef(new Set>()); - - const isCurrentMutation - = JSON.stringify(mutation.variables?.body) === scopeKey; - - const observedRef = useRef(""); - - const ids = [ - ...new Set([ - ...(isCurrentMutation && mutation.data ? [mutation.data.id] : []), - ...resources - .flatMap(({ meta }) => - meta ? [meta, ...(meta.dependencies ?? [])] : [], - ) - .flatMap(meta => - meta.sync.id - && meta.sync.state === "running" - ? [meta.sync.id] - : [], - ), - ]), - ]; - - const statuses = useAuthenticatedQueries( - ids.map(id => ({ - ...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", { - params: { path: { id } }, - }), - refetchInterval: (query: { - state: { data?: { state: string }; error: unknown }; - }) => - !( - query.state.error - || (query.state.data && query.state.data.state !== "running") - ) && STATUS_INTERVAL_MS, - refetchOnMount: false, - refetchOnWindowFocus: false, - retry: false, - })), - ); +function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) { + const resourcesRef = useRef(resources); + useEffect(() => { + resourcesRef.current = resources; + }, [resources]); useEffect(() => { - for (const status of statuses) { - if (!status.error || deniedRef.current.has(status.error)) { - continue; - } - - deniedRef.current.add(status.error); - if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound)) - invalidateResourceAccess(client, "brokerage", status.error); - } - - if (statuses.every(status => !status.data)) return; - - const observation = JSON.stringify([ - scopeKey, - statuses.map(status => [ - status.data?.id, - status.data?.state, - status.dataUpdatedAt, - ]), - resources.map(resource => resource.queryKey), - ]); - - if (observedRef.current === observation) return; - observedRef.current = observation; - - const isCompleted = statuses.every( - status => status.data && status.data.state !== "running", - ); - - for (const resource of resources) - if (isCompleted || !resource.isFetching) - void resource.observe().catch(() => {}); - }, [client, statuses, resources, scopeKey]); - - useEffect(() => { - const timers = sourceTimersRef.current; - const attempts = sourceRecoveryRef.current; - - return () => { - for (const timer of timers) clearTimeout(timer); - timers.clear(); - attempts.clear(); - }; - }, [scopeKey]); - - useEffect(() => { - for (const resource of resources) { - if ( - !requiresSourceRecovery(resource.meta) - || resource.isFetching - ) continue; - - const key = JSON.stringify([scopeKey, resource.queryKey]); - const attempt = sourceRecoveryRef.current.get(key) ?? 0; - const delay = sourceRecoveryDelay(attempt); - if (delay === undefined) continue; - - sourceRecoveryRef.current.set(key, attempt + 1); - - const timer = setTimeout(() => { - sourceTimersRef.current.delete(timer); + const timer = setInterval(() => { + for (const resource of resourcesRef.current) void resource.refetch().catch(() => {}); - }, delay); + }, READ_INTERVAL_MS); - sourceTimersRef.current.add(timer); - } - }, [resources, scopeKey]); + return () => clearInterval(timer); + }, []); - useEffect(() => { - const recover = () => { - for (const resource of resources) { - if ( - !requiresSourceRecovery(resource.meta) - || resource.isFetching - ) continue; - - const key = JSON.stringify([scopeKey, resource.queryKey]); - if (lifecycleRecoveryRef.current.has(key)) continue; - - sourceRecoveryRef.current.delete(key); - lifecycleRecoveryRef.current.add(key); - void resource.refetch() - .catch(() => {}) - .finally(() => lifecycleRecoveryRef.current.delete(key)); - } - }; - - const unsubscribeFocus = focusManager.subscribe((focused) => { - if (focused) recover(); - }); - - const unsubscribeOnline = onlineManager.subscribe((online) => { - if (online) recover(); - }); - - return () => { - unsubscribeFocus(); - unsubscribeOnline(); - }; - }, [resources, scopeKey]); - - const isRefreshing - = (isCurrentMutation && mutation.isPending) - || statuses.some( - status => !status.isError && status.data?.state === "running", - ) - || resources.some(({ meta }) => { - const status = statuses[ids.indexOf(meta?.sync.id ?? "")]; - - return ( - !status?.isError - && (!status?.data || status.data.state === "running") - && meta?.sync.state === "running" - ); - }); + const isRefreshing = resources.some(resource => resource.isFetching); const refresh = async () => { - if (isRefreshing || requestInFlightRef.current === scopeKey) return; - requestInFlightRef.current = scopeKey; - - try { - await mutation.mutateAsync({ body: scope }); - } - catch { - /* - The mutation retains the transport error for presentation. - */ - } - finally { - if (requestInFlightRef.current === scopeKey) - requestInFlightRef.current - - = null; - } + if (isRefreshing) return; + await Promise.allSettled(resources.map(resource => resource.refetch())); }; // Retry failed HTTP reads without asking the source to import again. - // A source failure (or explicit refresh with no read error) still needs a sync. const retry = async () => { const failed = resources.filter( resource => resource.isError, ); - const failedStatuses = statuses.filter(status => status.isError); - if (failed.length > 0 || failedStatuses.length > 0) { - await Promise.allSettled([ - ...failed.map(resource => resource.observe()), - ...failedStatuses.map(status => status.refetch()), - ]); - } + if (failed.length > 0) + await Promise.allSettled(failed.map(resource => resource.observe())); else await refresh(); }; @@ -271,13 +57,9 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { refresh, refreshing: isRefreshing, retry, - sourceError: - statuses.some(status => status.data?.state === "failed") - || resources.some(({ meta }) => meta?.sync.state === "failed"), - transportError: - (isCurrentMutation && mutation.isError) - || statuses.some(status => status.isError), + sourceError: resources.some(({ meta }) => meta?.sync.state === "failed"), + transportError: resources.some(resource => resource.isError), }; } -export { type InvestmentScreen, useScreenSync }; +export { useScreenSync }; diff --git a/src/features/tbank-connection/model/sync.test.tsx b/src/features/tbank-connection/model/sync.test.tsx deleted file mode 100644 index 61ad44a..0000000 --- a/src/features/tbank-connection/model/sync.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; -import { http, HttpResponse } from "msw"; -import { describe, expect, it, vi } from "vitest"; - -import { useBrokerageAccounts } from "@/entities/brokerage-account"; -import { apiQueryClient, configureAuthClient } from "@/shared/api"; -import { createQueryWrapper } from "@/test/query"; -import { resource } from "@/test/resource"; -import { server } from "@/test/server"; - -import type { InvestmentScreen } from "./screen-sync"; - -import { useScreenSync } from "../index"; -import { commitTbankConnectionBoundary } from "./connection-boundary"; - -describe("sync connection and session boundaries", () => { - it.each(["connection", "session"])( - "keeps old status out of the cache after %s changes", - async (boundary) => { - let generation = 0; - configureAuthClient({ - accessToken: () => "synthetic", - clear: vi.fn(), - generation: () => generation, - refresh: () => Promise.resolve(true), - }); - - const { promise: blocked, resolve: release } - = Promise.withResolvers(); - - const accepted = { - id: "work", - now: "2026-09-25T12:00:00Z", - scope: { screen: "accounts" }, - }; - - let started = 0; - let isChanged = false; - server.use( - http.get("*/api/v1/brokerage-accounts", () => - HttpResponse.json(resource([], { - sync: { errorCode: null, id: "work", state: "running" }, - })), - ), - http.post("*/api/v1/tbank-sync", async () => { - started += 1; - await blocked; - - return HttpResponse.json(accepted, { status: 202 }); - }), - http.get("*/api/v1/tbank-sync/:id", async () => { - const now = isChanged ? "2026-09-25T12:01:00Z" : accepted.now; - started += 1; - await blocked; - - return HttpResponse.json( - { - ...accepted, - errorCode: null, - now, - state: "succeeded", - }, - ); - }), - ); - const { queryClient, wrapper } = createQueryWrapper(); - const published: string[] = []; - - const unsubscribe = queryClient.getQueryCache().subscribe((event) => { - if ( - (event.query.queryKey as readonly unknown[])[1] - !== "/api/v1/tbank-sync/{id}" - ) { - return; - } - - const data = event.query.state.data as undefined | { now?: string }; - if (data?.now) published.push(data.now); - }); - - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - useScreenSync({ screen: "accounts" }, [accounts]); - - return { - mutation: apiQueryClient.useMutation("post", "/api/v1/tbank-sync", { - retry: false, - }), - }; - }, - { wrapper }, - ); - - const screen: InvestmentScreen = { screen: "accounts" }; - let mutation!: Promise; - act(() => { - mutation = result.current.mutation.mutateAsync({ body: screen }); - }); - - // eslint-disable-next-line unicorn/prefer-await -- Handle rejection before changing the boundary and releasing the pending response. - const settled = mutation.catch(() => {}); - - await waitFor(() => expect(started).toBe(2)); - await act(async () => { - isChanged = true; - - if (boundary === "connection") - commitTbankConnectionBoundary( - - queryClient, - { - connected: false, - connectedAt: null, - }, - ); - else { - generation += 1; - queryClient.clear(); - } - - release(); - await blocked; - }); - await settled; - - const key = apiQueryClient.queryOptions( - "get", - "/api/v1/tbank-sync/{id}", - { params: { path: { id: "work" } } }, - ).queryKey; - - // A new request may start after a successful connection boundary; an old result cannot win it. - if (boundary === "session") - // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. - expect(queryClient.getQueryData(key)).toBeUndefined(); - else - await waitFor(() => - // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. - expect(queryClient.getQueryData(key)).toMatchObject({ - now: "2026-09-25T12:01:00Z", - }), - ); - expect(published).not.toContain(accepted.now); - unsubscribe(); - }, - ); -}); diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx index 7f443e9..6efda10 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx @@ -497,7 +497,6 @@ describe("AccountOperationsPage", () => { it("keeps UTC operation boundaries in GET and source sync requests", async () => { configureTestAuth(); const requested: Record[] = []; - const syncRequests: unknown[] = []; server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", @@ -518,11 +517,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () => ); }, ), - http.post("*/api/v1/tbank-sync", async ({ request }) => { - syncRequests.push(await request.json()); - - return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); - }), ); renderPage("?from=2024-02-29&to=2024-03-01&categories=trade"); await screen.findByRole("button", { name: "Сбросить" }); @@ -534,7 +528,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () => to: "2024-03-01T23:59:59.999Z", }, ]); - expect(syncRequests).toEqual([]); }); it("does not label incomplete empty operations as a confirmed empty period", async () => { diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index de60722..ea1bdcc 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -770,28 +770,6 @@ describe("AccountPage states", () => { let requestCount = 0; let resolveRetry: ((response: Response) => void) | undefined; server.use( - http.post("*/api/v1/tbank-sync", () => - HttpResponse.json( - { - id: "overview", - now: "2026-09-25T00:00:00Z", - scope: { accountId: "account-1", screen: "account" }, - }, - { status: 202 }, - ), - ), - http.get("*/api/v1/tbank-sync/:id", () => - - HttpResponse.json( - { - errorCode: null, - id: "overview", - now: "2026-09-25T00:00:00Z", - scope: { accountId: "account-1", screen: "account" }, - state: "succeeded", - }, - ), - ), http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { requestCount += 1; if (requestCount === 1) diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 3d9431a..ff69dd7 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx @@ -130,17 +130,6 @@ describe("AccountsPage", () => { }); it("keeps the cached account list quietly after a refresh error", async () => { - const requests: unknown[] = []; - server.use( - http.post("*/api/v1/tbank-sync", async ({ request }) => { - requests.push(await request.json()); - - return HttpResponse.json( - { code: "TBANK_UNAVAILABLE" }, - { status: 503 }, - ); - }), - ); let requestCount = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { @@ -169,7 +158,6 @@ describe("AccountsPage", () => { expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); expect(screen.getByText(account.name)).toBeInTheDocument(); - expect(requests).toEqual([]); }); it("keeps a cached empty account list visible after a refresh error", async () => { diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx index 241d6b6..40c24d5 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx @@ -15,6 +15,7 @@ const AccountsPage: FC = () => { return ( ); }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index 9f60141..b4e244d 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -4,15 +4,17 @@ import { useBrokerageAccounts, } from "@/entities/brokerage-account"; import { useScreenSync } from "@/features/tbank-connection"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; +import { ApiErrorCode, apiErrorCode, isApiErrorCode, type paths } from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; type AccountsPageViewModel - = | { accounts: BrokerageAccountCard[]; state: "loaded" } + = | { accounts: BrokerageAccountCard[]; state: "loaded"; sync: AccountsSync } | { code?: string; state: "error" } | { state: "empty" } | { state: "loading" }; +type AccountsSync = paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["sync"]; + function isAccountsAccessFailure(code?: string) { return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) || isApiErrorCode(code, ApiErrorCode.TbankNotConnected); @@ -50,6 +52,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel { return { accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies), state: "loaded" as const, + sync: accounts.sync, }; } diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx index 4e74c79..5e4fbef 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx @@ -1,6 +1,6 @@ import type { FC } from "react"; -import { Button, Stack, Text, Title } from "@mantine/core"; +import { Badge, Button, Stack, Text, Title } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -11,10 +11,35 @@ import { type AccountsPageLoadedProps = { accounts: BrokerageAccountCard[]; + sync: + | null + | undefined + | { + finishedAt: null | string; + sections: { + accountId?: string; + errorCode: null | string; + section: string; + }[]; + startedAt: string; + state: string; + }; }; const AccountsPageLoaded: FC = (props) => { const { t } = useTranslation(); + const sync = props.sync; + + const syncLabel = sync + ? new Intl.DateTimeFormat("ru-RU", { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(sync.finishedAt ?? sync.startedAt)) + : null; + + const syncColor = sync?.state === "failed" || sync?.state === "partial" + ? "red" + : "gray"; return ( @@ -24,6 +49,21 @@ const AccountsPageLoaded: FC = (props) => { {t("accounts.subtitle")} + + {sync && ( + + Последний обход: + {" "} + {syncLabel} + {" "} + + {sync.state} + + + )}