refactor: clarify frontend module ownership
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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 { 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 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(() => usePrivateResource("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(resourceOptions("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(() => usePrivateResource("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(() => usePrivateResource("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: 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);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,13 @@
|
||||
import {
|
||||
type QueryClient,
|
||||
type QueryFunctionContext,
|
||||
type QueryKey,
|
||||
useQueryClient,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { components } from "./generated/schema";
|
||||
import type { components, paths } from "./generated/schema";
|
||||
|
||||
import { useAuthenticatedQuery } from "./authenticated-query";
|
||||
import { getAuthenticatedSessionSnapshot } from "./client";
|
||||
@@ -23,6 +22,19 @@ type Resource = {
|
||||
meta: InvestmentResourceMeta;
|
||||
};
|
||||
|
||||
type ResourcePath = {
|
||||
[P in keyof paths]: paths[P]["get"] extends {
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- HTTP success response in the generated contract.
|
||||
responses: { 200: { content: { "application/json": Resource } } };
|
||||
} ? P : never;
|
||||
}[keyof paths];
|
||||
|
||||
type ResourceQueryKey = readonly [
|
||||
"get",
|
||||
ResourcePath,
|
||||
{ params?: { path?: object; query?: { sync?: "observe" } } }?,
|
||||
];
|
||||
|
||||
function invalidateResourceAccess(
|
||||
client: QueryClient,
|
||||
group: string,
|
||||
@@ -58,6 +70,17 @@ function invalidateResourceAccess(
|
||||
});
|
||||
}
|
||||
|
||||
// Keep endpoint and parameters intact. The generated queryFn accepts the
|
||||
// optional sync mode even for a cache key originally created without init.
|
||||
function observationKey<K extends ResourceQueryKey>(key: K): K {
|
||||
const [method, path, init] = key;
|
||||
|
||||
return [method, path, {
|
||||
...init,
|
||||
params: { ...init?.params, query: { ...init?.params?.query, sync: "observe" } },
|
||||
}] as unknown as K;
|
||||
}
|
||||
|
||||
function unwrapResourceResult<T extends Resource, E>(
|
||||
result: UseQueryResult<T, E>,
|
||||
) {
|
||||
@@ -86,7 +109,7 @@ function unwrapResourceResult<T extends Resource, E>(
|
||||
}
|
||||
|
||||
// Observation changes transport mode, never the generated cache identity.
|
||||
function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
|
||||
options: UseQueryOptions<T, E, T, K>,
|
||||
) {
|
||||
const client = useQueryClient();
|
||||
@@ -137,23 +160,7 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
return client.query({
|
||||
...options,
|
||||
queryFn: async (context: QueryFunctionContext<K>) => {
|
||||
const [method, path, input] = context.queryKey;
|
||||
|
||||
const init = input as
|
||||
undefined
|
||||
| { params?: { query?: Record<string, unknown> } };
|
||||
|
||||
const queryKey = [
|
||||
method,
|
||||
path,
|
||||
{
|
||||
...init,
|
||||
params: {
|
||||
...init?.params,
|
||||
query: { ...init?.params?.query, sync: "observe" },
|
||||
},
|
||||
},
|
||||
] as unknown as K;
|
||||
const queryKey = observationKey(context.queryKey);
|
||||
|
||||
const response = await queryFunction({ ...context, queryKey });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user