feat: add removable reference feature vertical slice
This commit is contained in:
@@ -4,11 +4,12 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
|
||||
|
||||
import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
|
||||
import { createHttpClient } from "../../src/adapters/http/client.js";
|
||||
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
|
||||
|
||||
/** @type {number[]} */
|
||||
let responseStatuses = [];
|
||||
const server = setupServer(
|
||||
http.get("https://api.test/api/sample/resources", () => {
|
||||
http.get("https://api.test/api/entities", () => {
|
||||
const status = responseStatuses.shift() ?? 200;
|
||||
if (status === 401) {
|
||||
return HttpResponse.json(
|
||||
@@ -37,6 +38,11 @@ afterAll(() => server.close());
|
||||
|
||||
const clock = { now: () => 0, sleep: async () => {} };
|
||||
|
||||
/** @param {Parameters<typeof createHttpClient>[0]} options */
|
||||
function testClient(options) {
|
||||
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Partial<Parameters<typeof createExternalAuthSessionAdapter>[0]>} overrides
|
||||
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[0]}
|
||||
@@ -63,13 +69,13 @@ describe("bounded 401 session recovery", () => {
|
||||
recoverSession,
|
||||
notifyUnauthenticated: vi.fn(),
|
||||
}));
|
||||
const client = createHttpClient({
|
||||
const client = testClient({
|
||||
baseUrl: "https://api.test",
|
||||
authSession,
|
||||
clock,
|
||||
});
|
||||
|
||||
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
|
||||
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(recoverSession).toHaveBeenCalledTimes(1);
|
||||
@@ -83,13 +89,13 @@ describe("bounded 401 session recovery", () => {
|
||||
recoverSession: async () => "restored",
|
||||
notifyUnauthenticated,
|
||||
}));
|
||||
const client = createHttpClient({
|
||||
const client = testClient({
|
||||
baseUrl: "https://api.test",
|
||||
authSession,
|
||||
clock,
|
||||
});
|
||||
|
||||
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
|
||||
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "AUTH_REQUIRED" },
|
||||
});
|
||||
@@ -104,13 +110,13 @@ describe("bounded 401 session recovery", () => {
|
||||
recoverSession: async () => "restored",
|
||||
notifyUnauthenticated: vi.fn(),
|
||||
}));
|
||||
const client = createHttpClient({
|
||||
const client = testClient({
|
||||
baseUrl: "https://api.test",
|
||||
authSession: attachFailure,
|
||||
clock,
|
||||
});
|
||||
|
||||
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
|
||||
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "AUTH_INTEGRATION_FAILURE" },
|
||||
});
|
||||
|
||||
@@ -3,10 +3,11 @@ import { setupServer } from "msw/node";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { createHttpClient } from "../../src/adapters/http/client.js";
|
||||
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
|
||||
|
||||
let attempts = 0;
|
||||
const server = setupServer(
|
||||
http.get("https://api.test/api/sample/resources", () => {
|
||||
http.get("https://api.test/api/entities", () => {
|
||||
attempts += 1;
|
||||
if (attempts < 3) {
|
||||
return HttpResponse.json(
|
||||
@@ -34,16 +35,21 @@ const clock = {
|
||||
sleep: async () => {},
|
||||
};
|
||||
|
||||
/** @param {Parameters<typeof createHttpClient>[0]} options */
|
||||
function testClient(options) {
|
||||
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
|
||||
}
|
||||
|
||||
describe("shared HTTP client", () => {
|
||||
it("retries a safe request at most twice and returns validated data", async () => {
|
||||
const client = createHttpClient({
|
||||
const client = testClient({
|
||||
baseUrl: "https://api.test",
|
||||
clock,
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.execute("LIST_SAMPLE_RESOURCES", { routeId: "SAMPLE_RESOURCE_LIST" }),
|
||||
client.execute("LIST_ENTITIES", { routeId: "TEST_ROUTE" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: [{ id: "resource-1", displayName: "Example" }],
|
||||
@@ -55,12 +61,12 @@ describe("shared HTTP client", () => {
|
||||
it("rejects a non-JSON response without exposing its body", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://api.test/api/sample/resources",
|
||||
"https://api.test/api/entities",
|
||||
() => new HttpResponse("<secret>raw body</secret>", { status: 502 }),
|
||||
),
|
||||
);
|
||||
const client = createHttpClient({ baseUrl: "https://api.test", clock });
|
||||
const result = await client.execute("LIST_SAMPLE_RESOURCES");
|
||||
const client = testClient({ baseUrl: "https://api.test", clock });
|
||||
const result = await client.execute("LIST_ENTITIES");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
@@ -72,21 +78,21 @@ describe("shared HTTP client", () => {
|
||||
it("classifies malformed JSON and invalid payloads at the boundary", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://api.test/api/sample/resources",
|
||||
"https://api.test/api/entities",
|
||||
() =>
|
||||
new HttpResponse("{", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const client = createHttpClient({ baseUrl: "https://api.test", clock });
|
||||
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
|
||||
const client = testClient({ baseUrl: "https://api.test", clock });
|
||||
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_JSON" },
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://api.test/api/sample/resources", () =>
|
||||
http.get("https://api.test/api/entities", () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
data: [{ id: "resource-1", name: 42 }],
|
||||
@@ -94,7 +100,7 @@ describe("shared HTTP client", () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
|
||||
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SCHEMA_MISMATCH" },
|
||||
});
|
||||
@@ -102,7 +108,7 @@ describe("shared HTTP client", () => {
|
||||
|
||||
it("guards mapper exceptions as UNKNOWN_FAILURE", async () => {
|
||||
server.use(
|
||||
http.get("https://api.test/api/sample/resources", () =>
|
||||
http.get("https://api.test/api/entities", () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
data: [{ id: "resource-1", name: "Example" }],
|
||||
@@ -110,7 +116,7 @@ describe("shared HTTP client", () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
const client = createHttpClient({
|
||||
const client = testClient({
|
||||
baseUrl: "https://api.test",
|
||||
clock,
|
||||
mapPayload: () => {
|
||||
@@ -118,7 +124,7 @@ describe("shared HTTP client", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.execute("LIST_SAMPLE_RESOURCES");
|
||||
const result = await client.execute("LIST_ENTITIES");
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "UNKNOWN_FAILURE" },
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createHttpClient } from "../../src/adapters/http/client.js";
|
||||
import { queryKeys } from "../../src/contracts/query-keys.js";
|
||||
import {
|
||||
entityQueryKeys,
|
||||
TEST_HTTP_CONTRACT,
|
||||
} from "../helpers/http-contract-fixture.js";
|
||||
|
||||
/** @param {unknown} data */
|
||||
function successResponse(data) {
|
||||
@@ -40,6 +43,11 @@ function recordingScheduler() {
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {Parameters<typeof createHttpClient>[0]} options */
|
||||
function testClient(options) {
|
||||
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
|
||||
}
|
||||
|
||||
describe("HTTP operation execution contract", () => {
|
||||
it("sends parsed search/body values and aligns canonical query identity", async () => {
|
||||
const requests = /** @type {Request[]} */ ([]);
|
||||
@@ -51,7 +59,7 @@ describe("HTTP operation execution contract", () => {
|
||||
}
|
||||
return successResponse([]);
|
||||
});
|
||||
const client = createHttpClient({
|
||||
const client = testClient({
|
||||
baseUrl: "https://api.test",
|
||||
fetcher,
|
||||
clock: immediateClock(),
|
||||
@@ -60,21 +68,21 @@ describe("HTTP operation execution contract", () => {
|
||||
const filters = { tags: ["open", "new"], cursor: "a/b", limit: 5 };
|
||||
|
||||
await client.execute({
|
||||
operationId: "LIST_SAMPLE_RESOURCES",
|
||||
routeId: "SAMPLE_RESOURCE_LIST",
|
||||
operationId: "LIST_ENTITIES",
|
||||
routeId: "TEST_ROUTE",
|
||||
searchParams: filters,
|
||||
});
|
||||
await client.execute({
|
||||
operationId: "CREATE_SAMPLE_RESOURCE",
|
||||
routeId: "SAMPLE_RESOURCE_LIST",
|
||||
operationId: "CREATE_ENTITY",
|
||||
routeId: "TEST_ROUTE",
|
||||
body: { name: " Trimmed " },
|
||||
idempotencyKey: "logical-command",
|
||||
});
|
||||
|
||||
expect(requests[0].url).toBe(
|
||||
"https://api.test/api/sample/resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
|
||||
"https://api.test/api/entities?cursor=a%2Fb&limit=5&tags=open&tags=new",
|
||||
);
|
||||
expect(queryKeys.resource.list(filters).at(-1)).toEqual(filters);
|
||||
expect(entityQueryKeys.list(filters).at(-1)).toEqual(filters);
|
||||
await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" });
|
||||
expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command");
|
||||
expect(scheduler.setTimeout).toHaveBeenCalledTimes(2);
|
||||
@@ -84,7 +92,7 @@ describe("HTTP operation execution contract", () => {
|
||||
it("performs no fetch or timer work for invalid request input", async () => {
|
||||
const fetcher = vi.fn();
|
||||
const scheduler = recordingScheduler();
|
||||
const client = createHttpClient({
|
||||
const client = testClient({
|
||||
baseUrl: "https://api.test",
|
||||
fetcher,
|
||||
scheduler,
|
||||
@@ -92,8 +100,8 @@ describe("HTTP operation execution contract", () => {
|
||||
|
||||
await expect(
|
||||
client.execute({
|
||||
operationId: "CREATE_SAMPLE_RESOURCE",
|
||||
routeId: "SAMPLE_RESOURCE_LIST",
|
||||
operationId: "CREATE_ENTITY",
|
||||
routeId: "TEST_ROUTE",
|
||||
body: { name: " " },
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
@@ -114,7 +122,7 @@ describe("HTTP operation execution contract", () => {
|
||||
async (maxRetryAttempts, totalAttempts) => {
|
||||
const fetcher = vi.fn(async () => failureResponse(503));
|
||||
const scheduler = recordingScheduler();
|
||||
const client = createHttpClient({
|
||||
const client = testClient({
|
||||
baseUrl: "https://api.test",
|
||||
fetcher,
|
||||
clock: immediateClock(),
|
||||
@@ -124,8 +132,8 @@ describe("HTTP operation execution contract", () => {
|
||||
|
||||
await expect(
|
||||
client.execute({
|
||||
operationId: "LIST_SAMPLE_RESOURCES",
|
||||
routeId: "SAMPLE_RESOURCE_LIST",
|
||||
operationId: "LIST_ENTITIES",
|
||||
routeId: "TEST_ROUTE",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
@@ -149,15 +157,15 @@ describe("HTTP operation execution contract", () => {
|
||||
);
|
||||
}),
|
||||
);
|
||||
const client = createHttpClient({
|
||||
const client = testClient({
|
||||
baseUrl: "https://api.test",
|
||||
fetcher,
|
||||
scheduler,
|
||||
maxRetryAttempts: 0,
|
||||
});
|
||||
const timeoutResult = client.execute({
|
||||
operationId: "LIST_SAMPLE_RESOURCES",
|
||||
routeId: "SAMPLE_RESOURCE_LIST",
|
||||
operationId: "LIST_ENTITIES",
|
||||
routeId: "TEST_ROUTE",
|
||||
});
|
||||
await vi.waitFor(() => expect(scheduler.callbacks).toHaveLength(1));
|
||||
scheduler.callbacks[0]();
|
||||
@@ -170,8 +178,8 @@ describe("HTTP operation execution contract", () => {
|
||||
const add = vi.spyOn(caller.signal, "addEventListener");
|
||||
const remove = vi.spyOn(caller.signal, "removeEventListener");
|
||||
const abortResult = client.execute({
|
||||
operationId: "LIST_SAMPLE_RESOURCES",
|
||||
routeId: "SAMPLE_RESOURCE_LIST",
|
||||
operationId: "LIST_ENTITIES",
|
||||
routeId: "TEST_ROUTE",
|
||||
signal: caller.signal,
|
||||
});
|
||||
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
|
||||
@@ -196,11 +204,11 @@ describe("HTTP operation execution contract", () => {
|
||||
retry: "never",
|
||||
requestSource: "none",
|
||||
requestSchema: "unused",
|
||||
responseSchema: "SampleResourcePayload",
|
||||
responseSchema: "EntityPayload",
|
||||
owner: "test",
|
||||
});
|
||||
const unsafeFetch = vi.fn(async () => failureResponse(503));
|
||||
const unsafeClient = createHttpClient({
|
||||
const unsafeClient = testClient({
|
||||
baseUrl: "https://api.test",
|
||||
fetcher: unsafeFetch,
|
||||
clock: immediateClock(),
|
||||
@@ -213,14 +221,14 @@ describe("HTTP operation execution contract", () => {
|
||||
expect(unsafeFetch).toHaveBeenCalledOnce();
|
||||
|
||||
const statusFetch = vi.fn(async () => failureResponse(500));
|
||||
const statusClient = createHttpClient({
|
||||
const statusClient = testClient({
|
||||
baseUrl: "https://api.test",
|
||||
fetcher: statusFetch,
|
||||
clock: immediateClock(),
|
||||
});
|
||||
await statusClient.execute({
|
||||
operationId: "LIST_SAMPLE_RESOURCES",
|
||||
routeId: "SAMPLE_RESOURCE_LIST",
|
||||
operationId: "LIST_ENTITIES",
|
||||
routeId: "TEST_ROUTE",
|
||||
});
|
||||
expect(statusFetch).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -228,15 +236,15 @@ describe("HTTP operation execution contract", () => {
|
||||
successResponse([{ id: "one", name: 42 }]),
|
||||
);
|
||||
const schemaScheduler = recordingScheduler();
|
||||
const schemaClient = createHttpClient({
|
||||
const schemaClient = testClient({
|
||||
baseUrl: "https://api.test",
|
||||
fetcher: schemaFetch,
|
||||
clock: immediateClock(),
|
||||
scheduler: schemaScheduler,
|
||||
});
|
||||
await schemaClient.execute({
|
||||
operationId: "LIST_SAMPLE_RESOURCES",
|
||||
routeId: "SAMPLE_RESOURCE_LIST",
|
||||
operationId: "LIST_ENTITIES",
|
||||
routeId: "TEST_ROUTE",
|
||||
});
|
||||
expect(schemaFetch).toHaveBeenCalledOnce();
|
||||
expect(schemaScheduler.clearTimeout).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { createHttpClient } from "../../src/adapters/http/client.js";
|
||||
import {
|
||||
createQueryCacheAdapter,
|
||||
createQueryClient,
|
||||
} from "../../src/adapters/query-cache/tanstack-query-cache.js";
|
||||
import { queryKeys } from "../../src/contracts/query-keys.js";
|
||||
import { createSampleFacade } from "../../src/sample/contract-fixture/sample-facade.js";
|
||||
|
||||
const server = setupServer(
|
||||
http.get("https://api.test/api/sample/resources", () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
data: [{ id: "resource-1", name: "Example" }],
|
||||
meta: { requestId: "request-1", traceId: "trace-1" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("sample vertical contract fixture", () => {
|
||||
it("traverses API, schema, mapper, application facade, and cache", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
const cache = createQueryCacheAdapter(queryClient);
|
||||
const facade = createSampleFacade({
|
||||
http: createHttpClient({
|
||||
baseUrl: "https://api.test",
|
||||
clock: { now: () => 0, sleep: async () => {} },
|
||||
}),
|
||||
cache,
|
||||
});
|
||||
|
||||
await expect(facade.listResources()).resolves.toEqual({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
resourceId: "resource-1",
|
||||
title: "Example",
|
||||
createdAtLabel: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(cache.read(queryKeys.resource.list({}))).toMatchObject({
|
||||
ok: true,
|
||||
value: [{ id: "resource-1", displayName: "Example" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user