Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 8m54s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 9m59s
205 lines
6.2 KiB
TypeScript
205 lines
6.2 KiB
TypeScript
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { http, HttpResponse } 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 = {
|
|
cashBalances: [],
|
|
isStale: false,
|
|
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
|
positions: [],
|
|
structure: { currencies: [], unvaluedCategories: [] },
|
|
totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }],
|
|
};
|
|
|
|
function auth() {
|
|
configureAuthClient({
|
|
accessToken: () => "synthetic",
|
|
clear: vi.fn(),
|
|
refresh: () => Promise.resolve(true),
|
|
});
|
|
}
|
|
|
|
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: (null | string)[] = [];
|
|
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 { queryClient, wrapper } = 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({ data: snapshot, meta: { stateVersion: "new" } });
|
|
});
|
|
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: {
|
|
errorCode: "TBANK_UNAVAILABLE",
|
|
id: "work",
|
|
state: "failed",
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
|
|
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.status).toBe("pending");
|
|
expect(result.current.isPending).toBe(true);
|
|
expect(result.current.isLoading).toBe(false);
|
|
|
|
expect(result.current.transport).toEqual(
|
|
{
|
|
error: null,
|
|
fetchStatus: "idle",
|
|
status: "success",
|
|
},
|
|
);
|
|
expect(result.current.meta?.availability).toBe("missing");
|
|
});
|
|
it("keeps the refetch envelope while exposing the refreshed resource payload", async () => {
|
|
auth();
|
|
let isReady = false;
|
|
const refreshed = resource(snapshot, { stateVersion: "ready" });
|
|
server.use(
|
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
|
HttpResponse.json(isReady ? refreshed : resource(null)),
|
|
),
|
|
);
|
|
|
|
const { result } = renderHook(() => usePortfolio("a"), {
|
|
wrapper: createQueryWrapper().wrapper,
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(result.current.transport.status).toBe("success"),
|
|
);
|
|
await act(async () => {
|
|
const missing = await result.current.refetch();
|
|
expect(missing.status).toBe("success");
|
|
expect(missing.data).toEqual(resource(null));
|
|
expect(missing.data?.data).toBeNull();
|
|
isReady = true;
|
|
const loaded = await result.current.refetch();
|
|
expect(loaded.data).toEqual(refreshed);
|
|
expect(loaded.data?.data?.totalsByCurrency).toEqual(
|
|
snapshot.totalsByCurrency,
|
|
);
|
|
});
|
|
await waitFor(() => expect(result.current.data).toEqual(snapshot));
|
|
expect(result.current.meta).toEqual(refreshed.meta);
|
|
expect(result.current.status).toBe("success");
|
|
expect(result.current.isSuccess).toBe(true);
|
|
expect(result.current.isPending).toBe(false);
|
|
});
|
|
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
|
|
auth();
|
|
|
|
const { promise: blocked, resolve: release }
|
|
= Promise.withResolvers<void>();
|
|
|
|
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" } } },
|
|
);
|
|
|
|
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
|
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 isDenied = false;
|
|
|
|
const { promise: blocked, resolve: release }
|
|
= Promise.withResolvers<void>();
|
|
|
|
server.use(
|
|
http.get(
|
|
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
|
async ({ params }) => {
|
|
if (params.accountId === "b") await blocked;
|
|
|
|
return isDenied && params.accountId === "a"
|
|
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
|
|
: 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));
|
|
isDenied = 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);
|
|
});
|
|
});
|