Files
frontend/src/shared/api/resource-query.test.tsx
T
codex-bot f3725c202b
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m35s
test: await client settlement in late-response cases (WealthLedger/frontend#61) (#81)
2026-10-14 14:13:41 +03:00

218 lines
6.8 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { apiQueryClient, type paths, useResourceQuery } from "./index";
type Portfolio = NonNullable<paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
function resourceOptions(accountId: string) {
return apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId } } });
}
function usePrivateResource(accountId: string) {
return useResourceQuery({ ...resourceOptions(accountId), meta: { accessGroup: "brokerage" } });
}
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 once into the canonical cache without another import", 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, {
dataRevision: observations > 0 ? "new" : "old",
}),
);
},
),
);
const { queryClient, wrapper } = createQueryWrapper();
const { result } = renderHook(() => usePrivateResource("a"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await act(() => result.current.observe());
await waitFor(() => expect(result.current.meta?.dataRevision).toBe("new"));
expect(modes).toEqual([null, "observe"]);
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
expect(
queryClient.getQueryData(resourceOptions("a").queryKey),
).toMatchObject({ data: snapshot, meta: { dataRevision: "new" } });
});
it("keeps successful HTTP state distinct from missing or failed source data", async () => {
auth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(
resource(null, {
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
}),
),
),
);
const { result } = renderHook(() => usePrivateResource("a"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() =>
expect(result.current.meta?.lastAttempt.state).toBe("failed"),
);
expect(result.current.data).toBeUndefined();
expect(result.current.isSuccess).toBe(true);
expect(result.current.status).toBe("success");
expect(result.current.isPending).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.meta?.availability).toBe("initial-loading");
});
it("keeps the refetch envelope while exposing the refreshed resource payload", async () => {
auth();
let isReady = false;
const refreshed = resource(snapshot, { dataRevision: "ready" });
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(isReady ? refreshed : resource(null)),
),
);
const { result } = renderHook(() => usePrivateResource("a"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() =>
expect(result.current.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("keeps the observed snapshot when an older query finishes later", async () => {
auth();
const { promise: blocked, resolve: release }
= Promise.withResolvers<void>();
let isStarted = false;
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
async ({ request }) => {
if (!new URL(request.url).searchParams.has("sync")) {
isStarted = true;
await blocked;
return HttpResponse.json(resource({ ...snapshot, isStale: true }));
}
return HttpResponse.json(resource(snapshot));
},
),
);
const { result } = renderHook(() => usePrivateResource("a"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(isStarted).toBe(true));
// Capture the observation promise before releasing the old handler; the
// final assertion must follow its settlement, not the handler release.
let observation: Promise<unknown> = Promise.resolve();
await act(async () => {
observation = result.current.observe() ?? Promise.resolve();
await observation;
});
await act(async () => {
release();
await observation;
});
expect(result.current.data).toEqual(snapshot);
});
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: usePrivateResource("a"), b: usePrivateResource("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);
});
});