feat: add removable reference feature vertical slice
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.js";
|
||||
import type {
|
||||
ReferenceFeatureInput,
|
||||
ReferenceResult,
|
||||
} from "../../../src/features/reference-feature/application/reference-feature-api.js";
|
||||
import { REFERENCE_FEATURE_ID } from "../../../src/features/reference-feature/contracts/reference-feature-contract.js";
|
||||
import type { ReferenceResourceView } from "../../../src/features/reference-feature/contracts/reference-mapper.js";
|
||||
import { createFailure } from "../../../src/contracts/errors.js";
|
||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.js";
|
||||
import { AppRouter } from "../../../src/presentation/routes/app-router.js";
|
||||
import { createTestApplication } from "../../helpers/create-test-application.js";
|
||||
|
||||
function renderReference(
|
||||
input: ReferenceFeatureInput,
|
||||
url = "/examples/reference-resources?limit=5",
|
||||
) {
|
||||
window.history.pushState({}, "", url);
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session: createDemoSessionAdapter("authenticated"),
|
||||
featureInputs: { [REFERENCE_FEATURE_ID]: input },
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function inputWith(
|
||||
overrides: Partial<ReferenceFeatureInput> = {},
|
||||
): ReferenceFeatureInput {
|
||||
return {
|
||||
listResources: async () => ({ ok: true, value: [] }),
|
||||
createResource: async ({ name }) => ({
|
||||
ok: true,
|
||||
value: {
|
||||
resourceId: "created",
|
||||
title: name,
|
||||
createdAtLabel: null,
|
||||
},
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reference feature page states", () => {
|
||||
it("renders loading, success and empty states through the installed route", async () => {
|
||||
let resolveList:
|
||||
| ((result: ReferenceResult<readonly ReferenceResourceView[]>) => void)
|
||||
| undefined;
|
||||
const pending = new Promise<
|
||||
ReferenceResult<readonly ReferenceResourceView[]>
|
||||
>((resolve) => {
|
||||
resolveList = resolve;
|
||||
});
|
||||
const loaded = renderReference(
|
||||
inputWith({ listResources: async () => pending }),
|
||||
);
|
||||
expect(await screen.findByLabelText("불러오는 중")).toBeVisible();
|
||||
resolveList?.({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
resourceId: "reference-1",
|
||||
title: "Loaded",
|
||||
createdAtLabel: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await screen.findByText("Loaded")).toBeVisible();
|
||||
loaded.unmount();
|
||||
|
||||
renderReference(inputWith(), "/examples/reference-resources?limit=10");
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "표시할 항목이 없습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("rejects invalid URL input before the feature application input", async () => {
|
||||
const listResources = vi.fn();
|
||||
renderReference(
|
||||
inputWith({ listResources }),
|
||||
"/examples/reference-resources?limit=invalid",
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "올바르지 않은 주소입니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(listResources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders backend forbidden even when the client access hint allowed entry", async () => {
|
||||
renderReference(
|
||||
inputWith({
|
||||
listResources: async () => ({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"FORBIDDEN",
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
0,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"이 작업을 수행할 권한이 없습니다.",
|
||||
);
|
||||
});
|
||||
|
||||
it("deduplicates optimistic create and rolls back a conflict", async () => {
|
||||
const user = userEvent.setup();
|
||||
let finish:
|
||||
| ((result: ReferenceResult<ReferenceResourceView>) => void)
|
||||
| undefined;
|
||||
const createResource = vi.fn(
|
||||
() =>
|
||||
new Promise<ReferenceResult<ReferenceResourceView>>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
renderReference(
|
||||
inputWith({
|
||||
listResources: async () => ({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
resourceId: "existing",
|
||||
title: "Existing",
|
||||
createdAtLabel: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
createResource,
|
||||
}),
|
||||
);
|
||||
await screen.findByText("Existing");
|
||||
await user.type(screen.getByLabelText("새 항목 이름"), "Conflicting");
|
||||
const submit = screen.getByRole("button", { name: "추가" });
|
||||
await user.dblClick(submit);
|
||||
|
||||
await waitFor(() => expect(createResource).toHaveBeenCalledOnce());
|
||||
expect(await screen.findByText("Conflicting")).toHaveAttribute(
|
||||
"data-optimistic",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByText("mutation-pending")).toBeVisible();
|
||||
|
||||
finish?.({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"CONFLICT",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
),
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "충돌 해결" }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText("Conflicting")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "충돌 해결" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("mutation-conflict")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
|
||||
const user = userEvent.setup();
|
||||
const listResources = vi
|
||||
.fn<ReferenceFeatureInput["listResources"]>()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
resourceId: "existing",
|
||||
title: "Existing",
|
||||
createdAtLabel: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
0,
|
||||
),
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
resourceId: "recovered",
|
||||
title: "Recovered",
|
||||
createdAtLabel: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
renderReference(inputWith({ listResources }));
|
||||
await screen.findByText("Existing");
|
||||
await user.type(screen.getByLabelText("새 항목 이름"), "Created");
|
||||
await user.click(screen.getByRole("button", { name: "추가" }));
|
||||
|
||||
expect(await screen.findByText("stale-degraded")).toBeVisible();
|
||||
expect(screen.getByText("Existing")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(await screen.findByText("Recovered")).toBeVisible();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("stale-degraded")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(listResources).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user