218 lines
6.8 KiB
TypeScript
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);
|
|
});
|
|
});
|