feat: show persisted investment data and sync state
Consume the provided resource-envelope API snapshot. Preserve financial snapshots during refresh, observe synchronization without importing, and fence late responses across account and connection changes.
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
@@ -12,9 +18,11 @@ import {
|
||||
} from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { createQueryWrapper, createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { AccountEventsPage } from "./AccountEventsPage";
|
||||
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
|
||||
|
||||
const Navigation: FC = () => {
|
||||
const location = useLocation();
|
||||
@@ -44,12 +52,14 @@ describe("Upcoming event date filters", () => {
|
||||
"*/api/v1/brokerage-accounts/:accountId/events",
|
||||
({ request }) => {
|
||||
searches.push(new URL(request.url).search);
|
||||
return HttpResponse.json({
|
||||
accountId,
|
||||
calculatedAt: "2099-01-01T00:00:00Z",
|
||||
events: [],
|
||||
incomeTotalsByCurrency: [],
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId,
|
||||
calculatedAt: "2099-01-01T00:00:00Z",
|
||||
events: [],
|
||||
incomeTotalsByCurrency: [],
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -110,3 +120,45 @@ describe("Upcoming event date filters", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the previous complete forecast and timestamp when its source refresh fails", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "synthetic",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
const events = [
|
||||
{
|
||||
date: "2026-10-01",
|
||||
displayName: "Synthetic coupon",
|
||||
type: "coupon",
|
||||
projectedAmount: { currency: "RUB", value: "123.45" },
|
||||
},
|
||||
];
|
||||
const snapshot = resource(
|
||||
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
|
||||
{ sync: { id: "failed", state: "failed", errorCode: "TBANK_UNAVAILABLE" } },
|
||||
);
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json(snapshot),
|
||||
),
|
||||
);
|
||||
const { wrapper: QueryWrapper } = createQueryWrapper();
|
||||
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryWrapper>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryWrapper>
|
||||
),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
state: "loaded",
|
||||
events,
|
||||
sourceError: true,
|
||||
refreshError: true,
|
||||
lastSuccessfulAt: snapshot.meta.lastSuccessfulAt,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
||||
import { AccountEventsPage } from "./AccountEventsPage";
|
||||
|
||||
const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
|
||||
vi.mock("@/features/tbank-connection", () => ({
|
||||
useScreenSync: () => ({
|
||||
refresh,
|
||||
refreshing: false,
|
||||
sourceError: false,
|
||||
transportError: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
|
||||
useBrokerageEvents: vi.fn(),
|
||||
@@ -24,12 +33,17 @@ function renderPage() {
|
||||
|
||||
describe("AccountEventsPage", () => {
|
||||
beforeEach(() => {
|
||||
refresh.mockClear();
|
||||
query.mockReturnValue({
|
||||
data: {
|
||||
accountId: "account",
|
||||
calculatedAt: "2026-08-14T00:00:00Z",
|
||||
events: [],
|
||||
},
|
||||
meta: {
|
||||
availability: "complete",
|
||||
sync: { state: "idle", id: null, errorCode: null },
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -55,6 +69,10 @@ describe("AccountEventsPage", () => {
|
||||
it("keeps the loading fallback when no result or account is available", () => {
|
||||
query.mockReturnValue({
|
||||
data: undefined,
|
||||
meta: {
|
||||
availability: "complete",
|
||||
sync: { state: "idle", id: null, errorCode: null },
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -99,6 +117,10 @@ describe("AccountEventsPage", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
availability: "complete",
|
||||
sync: { state: "idle", id: null, errorCode: null },
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -145,12 +167,16 @@ describe("AccountEventsPage", () => {
|
||||
</MantineProvider>,
|
||||
);
|
||||
screen.getByRole("button", { name: "Повторить" }).click();
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows a structural busy state before events are available", () => {
|
||||
query.mockReturnValue({
|
||||
data: undefined,
|
||||
meta: {
|
||||
availability: "complete",
|
||||
sync: { state: "idle", id: null, errorCode: null },
|
||||
},
|
||||
isError: false,
|
||||
isLoading: true,
|
||||
refetch: vi.fn(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
|
||||
type AccountEventsPageViewModel =
|
||||
| { state: "loading" }
|
||||
@@ -11,6 +12,9 @@ type AccountEventsPageViewModel =
|
||||
| {
|
||||
state: "loaded";
|
||||
events: BrokerageEvent[];
|
||||
sourceError?: boolean;
|
||||
lastSuccessfulAt?: string | null;
|
||||
partial?: boolean;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry: () => void;
|
||||
@@ -41,22 +45,34 @@ function useAccountEventsPageViewModel(
|
||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||
...(appliedTo ? { to: appliedTo } : {}),
|
||||
});
|
||||
const sync = useScreenSync(
|
||||
{
|
||||
screen: "events",
|
||||
accountId,
|
||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||
...(appliedTo ? { to: appliedTo } : {}),
|
||||
},
|
||||
[events],
|
||||
);
|
||||
if (events.isLoading) return { state: "loading" };
|
||||
if (events.isError && !events.data)
|
||||
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
|
||||
return {
|
||||
state: "error",
|
||||
retry: () => {
|
||||
void events.refetch();
|
||||
void sync.refresh();
|
||||
},
|
||||
};
|
||||
if (!events.data) return { state: "loading" };
|
||||
return {
|
||||
state: "loaded",
|
||||
events: events.data.events,
|
||||
refreshing: events.isFetching,
|
||||
refreshError: events.isError,
|
||||
sourceError: sync.sourceError,
|
||||
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
|
||||
partial: events.meta?.availability !== "complete",
|
||||
refreshing: events.isFetching || sync.refreshing,
|
||||
refreshError: events.isError || sync.sourceError || sync.transportError,
|
||||
retry: () => {
|
||||
void events.refetch();
|
||||
void sync.refresh();
|
||||
},
|
||||
draftFrom,
|
||||
draftTo,
|
||||
|
||||
+22
-4
@@ -11,6 +11,7 @@ import { DatePickerInput } from "@mantine/dates";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BrokerageEventCard } from "@/entities/brokerage-event";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
|
||||
|
||||
@@ -23,13 +24,30 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{props.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
<Button onClick={props.retry} disabled={props.refreshing} w="fit-content">
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
{props.partial ? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
) : null}
|
||||
{props.refreshing ? (
|
||||
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
|
||||
) : null}
|
||||
{props.refreshError ? (
|
||||
<Alert color="yellow">
|
||||
{t("accounts.refreshError")}
|
||||
<Alert color="yellow" role="status" aria-live="polite">
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "accounts.refreshError",
|
||||
)}
|
||||
<Button variant="subtle" onClick={props.retry}>
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
@@ -80,9 +98,9 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
) : !props.partial ? (
|
||||
<AccountEventsPageEmpty />
|
||||
)}
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user