~james/core

core/web/src/lib/api/load.test.ts -rw-r--r-- 7.0 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ClientResponseError } from "#lib/api/client.ts";
import { createRequestCache, httpStatusFor, parallel, toHttpError } from "#lib/api/load.ts";

vi.mock("$app/env", () => ({ browser: true }));

const cre = (status: number, error: string, message?: string): ClientResponseError =>
	new ClientResponseError({ status, data: { error, message } });

describe("httpStatusFor", () => {
	it("passes through an in-band XRPC status", () => {
		expect(httpStatusFor(cre(404, "RecordNotFound"))).toBe(404);
	});

	it("prefers an in-band status over the error-name mapping", () => {
		// status 503 is in [400,599] so it wins even though the name maps to 404.
		expect(httpStatusFor(cre(503, "RecordNotFound"))).toBe(503);
	});

	it("maps the error name when the status is out of the 400-599 band", () => {
		expect(httpStatusFor(cre(200, "RecordNotFound"))).toBe(404);
		expect(httpStatusFor(cre(200, "InvalidRequest"))).toBe(400);
		expect(httpStatusFor(cre(200, "UpstreamFailed"))).toBe(502);
		expect(httpStatusFor(cre(200, "UpstreamGone"))).toBe(502);
		expect(httpStatusFor(cre(200, "InvalidRecord"))).toBe(502);
		expect(httpStatusFor(cre(200, "Overloaded"))).toBe(503);
		expect(httpStatusFor(cre(200, "SomethingElse"))).toBe(500);
	});

	it("maps any non-XRPC cause to 500", () => {
		expect(httpStatusFor(new Error("boom"))).toBe(500);
		expect(httpStatusFor("a string")).toBe(500);
		expect(httpStatusFor(undefined)).toBe(500);
		expect(httpStatusFor({ status: 404 })).toBe(500);
	});
});

describe("toHttpError", () => {
	it("throws a SvelteKit error with the mapped status and the XRPC description", () => {
		let thrown: unknown;
		try {
			toHttpError(cre(404, "RecordNotFound", "no such repo"));
		} catch (e) {
			thrown = e;
		}
		expect(thrown).toMatchObject({ status: 404, body: { message: "no such repo" } });
	});

	it("falls back to the error name when the description is absent", () => {
		let thrown: unknown;
		try {
			toHttpError(cre(200, "Overloaded"));
		} catch (e) {
			thrown = e;
		}
		expect(thrown).toMatchObject({ status: 503, body: { message: "Overloaded" } });
	});

	it("uses the fallback message + 500 for a non-XRPC cause", () => {
		let thrown: unknown;
		try {
			toHttpError(new Error("boom"), "could not load");
		} catch (e) {
			thrown = e;
		}
		expect(thrown).toMatchObject({ status: 500, body: { message: "could not load" } });
	});
});

describe("parallel", () => {
	it("preserves key/value pairing regardless of settle order", async () => {
		// deferred across a few microtasks (no real timer) so it settles after `fast`.
		const slow = Promise.resolve()
			.then(() => Promise.resolve())
			.then(() => "slow");
		const result = await parallel({ fast: Promise.resolve("fast"), slow });
		expect(result).toEqual({ fast: "fast", slow: "slow" });
	});

	it("resolves to an empty object for no tasks", async () => {
		await expect(parallel({})).resolves.toEqual({});
	});

	it("rejects if any input promise rejects", async () => {
		await expect(
			parallel({ a: Promise.resolve(1), b: Promise.reject(new Error("boom")) })
		).rejects.toThrow("boom");
	});
});

describe("createRequestCache", () => {
	it("invokes the loader once per key and shares the value", async () => {
		const cache = createRequestCache();
		const load = vi.fn(async () => 42);

		const first = await cache.run("k", load);
		const second = await cache.run("k", load);

		expect(first).toBe(42);
		expect(second).toBe(42);
		expect(load).toHaveBeenCalledTimes(1);
	});

	it("shares one in-flight promise for concurrent same-key calls", () => {
		const cache = createRequestCache();
		const load = vi.fn(async () => "v");

		const a = cache.run("k", load);
		const b = cache.run("k", load);

		expect(a).toBe(b);
		expect(load).toHaveBeenCalledTimes(1);
	});

	it("runs distinct keys independently", async () => {
		const cache = createRequestCache();
		const load = vi.fn(async (key: string) => key.toUpperCase());

		await cache.run("a", () => load("a"));
		await cache.run("b", () => load("b"));

		expect(load).toHaveBeenCalledTimes(2);
	});
});

describe("settle", () => {
	// the instant window lives in module state, so every case starts from a fresh module
	const fresh = async (): Promise<typeof import("#lib/api/load.ts").settle> =>
		(await import("#lib/api/load.ts")).settle;
	const freshEnd = async (): Promise<typeof import("#lib/api/load.ts").endNavigation> =>
		(await import("#lib/api/load.ts")).endNavigation;

	beforeEach(() => vi.useFakeTimers());
	afterEach(() => {
		vi.useRealTimers();
		vi.resetModules();
	});

	it("hands a promise that settled over as its value, so no boundary suspends on it", async () => {
		const settle = await fresh();
		const settled = await settle({ fast: Promise.resolve("v"), plain: 2 });

		expect(settled).toEqual({ fast: "v", plain: 2 });
	});

	it("keeps a promise that outlasts the window, its boundary paints the skeleton", async () => {
		const settle = await fresh();
		const slow = new Promise<string>((resolve) => setTimeout(() => resolve("late"), 400));
		const page = settle({ slow, fast: Promise.resolve("v") });

		await vi.advanceTimersByTimeAsync(200);

		const data = await page;
		expect(data.fast).toBe("v");
		expect(data.slow).toBe(slow);

		await vi.advanceTimersByTimeAsync(400);
		expect(await data.slow).toBe("late");
	});

	it("leaves the rejection on the promise, whoever awaits it owns the failure", async () => {
		const settle = await fresh();
		const broken = Promise.reject(new Error("boom"));
		const page = settle({ broken, fast: Promise.resolve("v") });

		await vi.advanceTimersByTimeAsync(200);

		const data = await page;
		expect(data.fast).toBe("v");
		await expect(data.broken).rejects.toThrow("boom");
	});

	it("opens a fresh window for the navigation after the last one committed", async () => {
		const settle = await fresh();
		const endNavigation = await freshEnd();
		await settle({ fast: Promise.resolve("v") });
		await vi.advanceTimersByTimeAsync(100);

		endNavigation();

		const late = new Promise<string>((resolve) => setTimeout(() => resolve("late"), 400));
		const page = settle({ late });

		await vi.advanceTimersByTimeAsync(200);
		expect((await page).late).toBe(late);
	});

	it("opens a fresh window for the navigation after an abandoned one", async () => {
		const settle = await fresh();
		await settle({ fast: Promise.resolve("v") });
		await vi.advanceTimersByTimeAsync(500);

		const late = new Promise<string>((resolve) => setTimeout(() => resolve("late"), 400));
		const page = settle({ late });

		await vi.advanceTimersByTimeAsync(200);
		expect((await page).late).toBe(late);
	});

	it("spends one window across the loads of a navigation", async () => {
		const settle = await fresh();
		await settle({ fast: Promise.resolve("v") });
		await vi.advanceTimersByTimeAsync(150);

		// 50ms of the first load's window is left
		const late = new Promise<string>((resolve) => setTimeout(() => resolve("late"), 400));
		const page = settle({ late });

		await vi.advanceTimersByTimeAsync(60);
		expect((await page).late).toBe(late);
	});
});