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:
2026-09-26 21:22:49 +03:00
parent b987fd8e78
commit 4e5f564fa1
81 changed files with 4714 additions and 1420 deletions
@@ -0,0 +1,144 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import {
type Portfolio,
portfolioQueryOptions,
usePortfolio,
} from "./usePortfolio";
const snapshot: Portfolio = {
positions: [],
totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }],
cashBalances: [],
structure: { currencies: [], unvaluedCategories: [] },
lastSuccessfulAt: "2026-08-14T00:00:00Z",
isStale: false,
};
function auth() {
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => true,
clear: vi.fn(),
});
}
describe("persistent resource query lifecycle", () => {
it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => {
auth();
const modes: (string | null)[] = [];
let observations = 0;
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
({ request }) => {
const mode = new URL(request.url).searchParams.get("sync");
modes.push(mode);
if (mode) observations += 1;
return HttpResponse.json(
resource(snapshot, {
stateVersion: observations > 1 ? "new" : "old",
}),
);
},
),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(() => usePortfolio("a"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await act(() => result.current.observe("new"));
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
expect(modes).toEqual([null, "observe", "observe"]);
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
expect(
queryClient.getQueryData(portfolioQueryOptions("a").queryKey),
).toMatchObject({ meta: { stateVersion: "new" }, data: snapshot });
});
it("does not convert missing or failed source data to empty financial success", async () => {
auth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(
resource(null, {
sync: {
id: "work",
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
),
),
);
const { result } = renderHook(() => usePortfolio("a"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
expect(result.current.data).toBeUndefined();
expect(result.current.isSuccess).toBe(false);
expect(result.current.meta?.availability).toBe("missing");
});
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
auth();
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
async ({ request }) => {
if (!new URL(request.url).searchParams.has("sync")) await blocked;
return HttpResponse.json(resource(snapshot));
},
),
);
const old = apiClient.GET(
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: "a" } } },
);
const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
params: { path: { accountId: "a" }, query: { sync: "observe" } },
});
release();
await rejected;
});
it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
auth();
let denied = false;
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
async ({ params }) => {
if (params.accountId === "b") await blocked;
if (params.accountId === "a" && denied)
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
return HttpResponse.json(resource(snapshot));
},
),
);
const { wrapper } = createQueryWrapper();
const { result } = renderHook(
() => ({ a: usePortfolio("a"), b: usePortfolio("b") }),
{ wrapper },
);
await waitFor(() => expect(result.current.a.data).toEqual(snapshot));
denied = true;
await act(() => result.current.a.refetch());
await waitFor(() => expect(result.current.a.data).toBeUndefined());
await act(async () => {
release();
await blocked;
});
expect(result.current.b.data).toBeUndefined();
expect(result.current.a.isError).toBe(true);
});
});