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.
269 lines
8.0 KiB
TypeScript
269 lines
8.0 KiB
TypeScript
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { HttpResponse, http } from "msw";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
|
import { configureAuthClient } from "@/shared/api";
|
|
import { createQueryWrapper } from "@/test/query";
|
|
import { resource } from "@/test/resource";
|
|
import { server } from "@/test/server";
|
|
import { useScreenSync } from "../index";
|
|
|
|
function auth() {
|
|
configureAuthClient({
|
|
accessToken: () => "synthetic",
|
|
refresh: async () => true,
|
|
clear: vi.fn(),
|
|
});
|
|
}
|
|
describe("page-owned screen synchronization", () => {
|
|
it("posts only its scope once and observes completion without importing again", async () => {
|
|
auth();
|
|
const modes: (string | null)[] = [];
|
|
const bodies: unknown[] = [];
|
|
let completed = false;
|
|
let polls = 0;
|
|
const meta = () =>
|
|
resource([], { stateVersion: completed ? "2" : "1" }).meta;
|
|
server.use(
|
|
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
|
modes.push(new URL(request.url).searchParams.get("sync"));
|
|
return HttpResponse.json(resource([], meta()));
|
|
}),
|
|
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
|
bodies.push(await request.json());
|
|
completed = true;
|
|
return HttpResponse.json(
|
|
{
|
|
id: "screen",
|
|
scope: { screen: "accounts" },
|
|
now: "2026-09-25T00:00:00Z",
|
|
},
|
|
{ status: 202 },
|
|
);
|
|
}),
|
|
http.get("*/api/v1/tbank-sync/:id", () => {
|
|
polls++;
|
|
return HttpResponse.json({
|
|
id: "screen",
|
|
scope: { screen: "accounts" },
|
|
now: "2026-09-25T00:00:00Z",
|
|
state: "succeeded",
|
|
errorCode: null,
|
|
progress: { total: 1, completed: 1, discovering: false },
|
|
resources: [meta()],
|
|
});
|
|
}),
|
|
);
|
|
const { result, unmount } = renderHook(
|
|
() => {
|
|
const accounts = useBrokerageAccounts();
|
|
return {
|
|
accounts,
|
|
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
|
};
|
|
},
|
|
{ wrapper: createQueryWrapper().wrapper },
|
|
);
|
|
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
|
|
await act(async () => {
|
|
await Promise.all([
|
|
result.current.sync.refresh(),
|
|
result.current.sync.refresh(),
|
|
]);
|
|
});
|
|
await waitFor(() =>
|
|
expect(result.current.accounts.meta?.stateVersion).toBe("2"),
|
|
);
|
|
expect(bodies).toEqual([{ screen: "accounts" }]);
|
|
expect(modes).toEqual([null, "observe"]);
|
|
vi.useFakeTimers();
|
|
try {
|
|
await act(() => vi.advanceTimersByTimeAsync(120000));
|
|
expect(polls).toBe(1);
|
|
expect(modes).toEqual([null, "observe"]);
|
|
unmount();
|
|
await vi.advanceTimersByTimeAsync(120000);
|
|
expect(polls).toBe(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
it.each(["leave", "logout"])(
|
|
"publishes completed empty coverage while history runs, then stops on %s",
|
|
async (boundary) => {
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
try {
|
|
auth();
|
|
let completed = false;
|
|
let polls = 0;
|
|
const modes: (string | null)[] = [];
|
|
const snapshot = () =>
|
|
resource(completed ? [] : null, {
|
|
stateVersion: completed ? "covered" : "initial",
|
|
sync: { id: "history", state: "running", errorCode: null },
|
|
});
|
|
server.use(
|
|
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
|
modes.push(new URL(request.url).searchParams.get("sync"));
|
|
return HttpResponse.json(snapshot());
|
|
}),
|
|
http.get("*/api/v1/tbank-sync/:id", () => {
|
|
polls++;
|
|
return HttpResponse.json({
|
|
id: "history",
|
|
scope: null,
|
|
now: "2026-09-25T00:00:00Z",
|
|
state: "running",
|
|
errorCode: null,
|
|
progress: {
|
|
total: 2,
|
|
completed: completed ? 1 : 0,
|
|
discovering: true,
|
|
},
|
|
resources: [snapshot().meta],
|
|
});
|
|
}),
|
|
);
|
|
const { result, unmount } = renderHook(
|
|
() => {
|
|
const accounts = useBrokerageAccounts();
|
|
return {
|
|
accounts,
|
|
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
|
};
|
|
},
|
|
{ wrapper: createQueryWrapper().wrapper },
|
|
);
|
|
await waitFor(() =>
|
|
expect(result.current.accounts.meta?.sync.id).toBe("history"),
|
|
);
|
|
await waitFor(() => expect(polls).toBeGreaterThan(0));
|
|
expect(result.current.sync.refreshing).toBe(true);
|
|
expect(result.current.accounts.data).toBeUndefined();
|
|
completed = true;
|
|
vi.setSystemTime(Date.now() + 61000);
|
|
await waitFor(() => expect(result.current.accounts.data).toEqual([]), {
|
|
timeout: 3000,
|
|
});
|
|
expect(result.current.sync.refreshing).toBe(true);
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
});
|
|
expect(modes).toEqual([null, "observe"]);
|
|
if (boundary === "leave") unmount();
|
|
else await act(() => configureAuthClient(null));
|
|
const stopped = polls;
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
});
|
|
expect(polls).toBe(stopped);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("clears private snapshots when status confirms access loss", async () => {
|
|
auth();
|
|
server.use(
|
|
http.get("*/api/v1/brokerage-accounts", () =>
|
|
HttpResponse.json(
|
|
resource([], {
|
|
sync: { id: "revoked", state: "running", errorCode: null },
|
|
}),
|
|
),
|
|
),
|
|
http.get("*/api/v1/tbank-sync/:id", () =>
|
|
HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }),
|
|
),
|
|
);
|
|
const { result } = renderHook(
|
|
() => {
|
|
const accounts = useBrokerageAccounts();
|
|
return {
|
|
accounts,
|
|
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
|
};
|
|
},
|
|
{ wrapper: createQueryWrapper().wrapper },
|
|
);
|
|
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
|
|
expect(result.current.accounts.data).toBeUndefined();
|
|
});
|
|
it("observes a projection when only its dependency reports new coverage", async () => {
|
|
auth();
|
|
let observed = false;
|
|
const modes: (string | null)[] = [];
|
|
const dependency = resource([], {
|
|
resourceKey: "operations",
|
|
stateVersion: observed ? "covered" : "initial",
|
|
sync: {
|
|
id: "dependency",
|
|
state: observed ? "idle" : "running",
|
|
errorCode: null,
|
|
},
|
|
}).meta;
|
|
server.use(
|
|
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
|
const mode = new URL(request.url).searchParams.get("sync");
|
|
modes.push(mode);
|
|
if (mode) observed = true;
|
|
return HttpResponse.json(
|
|
resource(observed ? [] : null, {
|
|
resourceKey: "projection",
|
|
dependencies: [
|
|
{ ...dependency, stateVersion: observed ? "covered" : "initial" },
|
|
],
|
|
}),
|
|
);
|
|
}),
|
|
http.get("*/api/v1/tbank-sync/:id", () =>
|
|
HttpResponse.json({
|
|
id: "dependency",
|
|
scope: null,
|
|
now: "2026-09-25T00:00:00Z",
|
|
state: "succeeded",
|
|
errorCode: null,
|
|
progress: { total: 1, completed: 1, discovering: false },
|
|
resources: [{ ...dependency, stateVersion: "covered" }],
|
|
}),
|
|
),
|
|
);
|
|
const { result } = renderHook(
|
|
() => {
|
|
const accounts = useBrokerageAccounts();
|
|
useScreenSync({ screen: "accounts" }, [accounts]);
|
|
return accounts;
|
|
},
|
|
{ wrapper: createQueryWrapper().wrapper },
|
|
);
|
|
await waitFor(() => expect(result.current.data).toEqual([]));
|
|
expect(modes).toEqual([null, "observe"]);
|
|
});
|
|
|
|
it("preserves a fresh snapshot and timestamp after a failed force", async () => {
|
|
auth();
|
|
const saved = resource([], { lastSuccessfulAt: "2026-09-25T01:00:00Z" });
|
|
server.use(
|
|
http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(saved)),
|
|
http.post("*/api/v1/tbank-sync", () =>
|
|
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
|
),
|
|
);
|
|
const { result } = renderHook(
|
|
() => {
|
|
const accounts = useBrokerageAccounts();
|
|
return {
|
|
accounts,
|
|
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
|
};
|
|
},
|
|
{ wrapper: createQueryWrapper().wrapper },
|
|
);
|
|
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
|
|
await act(() => result.current.sync.refresh());
|
|
await waitFor(() => expect(result.current.sync.transportError).toBe(true));
|
|
expect(result.current.accounts.meta).toEqual(saved.meta);
|
|
});
|
|
});
|