create-ailk 0.11.5 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.json +35 -2
- package/component-catalog.md +9 -6
- package/dist/add-module.js +13 -0
- package/dist/module-architecture.d.ts +8 -0
- package/dist/module-architecture.js +33 -0
- package/dist/paid-sections.d.ts +23 -0
- package/dist/paid-sections.js +119 -0
- package/dist/programmatic.js +8 -0
- package/package.json +3 -3
- package/templates/.env.example +69 -3
- package/templates/apps/api/.env.example +22 -0
- package/templates/apps/api/CLAUDE.md +19 -7
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/bin/spokes.ts +137 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
- package/templates/apps/api/src/config/index.ts +97 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
- package/templates/apps/api/src/lib/spoke-store.ts +131 -0
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
- package/templates/apps/api/src/routes/leads/index.ts +33 -3
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
- package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
- package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
- package/templates/apps/api/src/server.ts +48 -0
- package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
- package/templates/apps/api/src/services/default-tenant.ts +51 -28
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
- package/templates/apps/api/src/services/lead-routing.ts +13 -1
- package/templates/apps/api/src/services/project-listings.ts +209 -18
- package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
- package/templates/apps/api/src/services/slack-notify.ts +54 -3
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/layout.tsx +4 -0
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/jest.config.cjs +1 -1
- package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
- package/templates/apps/web/lib/listings.tsx +472 -0
- package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
- package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
- package/templates/apps/web/messages/en.json +10 -0
- package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
- package/templates/database/CHANGELOG.md +34 -0
- package/templates/database/__tests__/build.test.ts +131 -0
- package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
- package/templates/database/auth/schema.prisma +31 -0
- package/templates/database/config/schema.prisma +26 -0
- package/templates/database/inbox/schema.prisma +21 -0
- package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
- package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
- package/templates/database/package.json +3 -3
- package/versions.json +34 -34
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The listicle directory's page extension (#7521 D6, AC-5).
|
|
3
|
+
*
|
|
4
|
+
* Every dependency is injected — no network, no headers(), no paid package —
|
|
5
|
+
* so each case pins one decision: which path segment is the landing page,
|
|
6
|
+
* which links the cards get, when the extension makes NO read at all, and
|
|
7
|
+
* that a failed, slow or malformed read renders the page as today.
|
|
8
|
+
*/
|
|
9
|
+
import { render } from "@testing-library/react";
|
|
10
|
+
import type { ListingEntryPublic } from "@working-theory/validation";
|
|
11
|
+
import React from "react";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createListingExtension,
|
|
15
|
+
type ListingExtensionDeps,
|
|
16
|
+
type ListingUiModule,
|
|
17
|
+
} from "../listings";
|
|
18
|
+
|
|
19
|
+
function entry(slug: string, name: string): ListingEntryPublic {
|
|
20
|
+
return {
|
|
21
|
+
id: `prjl_${slug}`,
|
|
22
|
+
slug,
|
|
23
|
+
name,
|
|
24
|
+
summary: `${name} in one line.`,
|
|
25
|
+
url: `https://${slug}.example.com`,
|
|
26
|
+
logoMarkUrl: `https://${slug}.example.com/logo.png`,
|
|
27
|
+
featured: false,
|
|
28
|
+
status: "active",
|
|
29
|
+
windowEndsAt: "2099-01-01T00:00:00.000Z",
|
|
30
|
+
decidedAt: "2026-09-10T00:00:00.000Z",
|
|
31
|
+
waitlistCount: 3,
|
|
32
|
+
profile: [{ name: "stage", label: "Stage", value: "Live", kind: "select" }],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function directory(landingPage: string, entryType = "Organization") {
|
|
37
|
+
return {
|
|
38
|
+
landingPage,
|
|
39
|
+
entryType,
|
|
40
|
+
entries: [entry("acme", "Acme"), entry("globex", "Globex")],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const shelvesProps: Array<Record<string, unknown>> = [];
|
|
45
|
+
const detailProps: Array<Record<string, unknown>> = [];
|
|
46
|
+
const STUB_UI: ListingUiModule = {
|
|
47
|
+
ListingShelves: (props) => {
|
|
48
|
+
shelvesProps.push({ ...props });
|
|
49
|
+
return <div data-testid="stub-shelves" />;
|
|
50
|
+
},
|
|
51
|
+
ListingDetail: (props) => {
|
|
52
|
+
detailProps.push({ ...props });
|
|
53
|
+
return <div data-testid="stub-detail">{props.entry.name}</div>;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
let path: string | null;
|
|
58
|
+
let fetchDirectory: jest.Mock;
|
|
59
|
+
|
|
60
|
+
function extension(overrides: Partial<ListingExtensionDeps> = {}) {
|
|
61
|
+
return createListingExtension({
|
|
62
|
+
siteKey: () => "k",
|
|
63
|
+
pathname: async () => path,
|
|
64
|
+
locales: () => ["en", "fr"],
|
|
65
|
+
fetchDirectory,
|
|
66
|
+
loadUi: async () => STUB_UI,
|
|
67
|
+
translate: async () => (key: string) => `listings.${key}`,
|
|
68
|
+
baseUrl: () => "https://site.test",
|
|
69
|
+
...overrides,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ok = (body: unknown) => Promise.resolve({ ok: true, body });
|
|
74
|
+
|
|
75
|
+
function jsonLdOf(container: HTMLElement): Record<string, unknown>[] {
|
|
76
|
+
return [...container.querySelectorAll("script[type='application/ld+json']")].map(
|
|
77
|
+
(s) => JSON.parse(s.textContent ?? "null") as Record<string, unknown>,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
shelvesProps.length = 0;
|
|
83
|
+
detailProps.length = 0;
|
|
84
|
+
fetchDirectory = jest.fn((_key: string, landingPage: string) => ok(directory(landingPage)));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("appendix — the shelves on a landing page", () => {
|
|
88
|
+
it("renders the shelves with locale links and one ItemList JSON-LD block", async () => {
|
|
89
|
+
path = "/en/partners";
|
|
90
|
+
|
|
91
|
+
const node = await extension().appendix!({ type: "AboutPage", slug: "partners", locale: "en" });
|
|
92
|
+
const { container, getByTestId } = render(<>{node}</>);
|
|
93
|
+
|
|
94
|
+
expect(getByTestId("stub-shelves")).toBeTruthy();
|
|
95
|
+
expect(fetchDirectory).toHaveBeenCalledWith("k", "/partners", "en");
|
|
96
|
+
const hrefFor = shelvesProps[0]!["hrefFor"] as (e: { slug: string }) => string;
|
|
97
|
+
expect(hrefFor({ slug: "acme" })).toBe("/en/partners/acme");
|
|
98
|
+
expect(hrefFor({ slug: "globex" })).toBe("/en/partners/globex");
|
|
99
|
+
|
|
100
|
+
const blocks = jsonLdOf(container);
|
|
101
|
+
expect(blocks).toHaveLength(1);
|
|
102
|
+
expect(blocks[0]!["@type"]).toBe("ItemList");
|
|
103
|
+
expect(
|
|
104
|
+
(blocks[0]!["itemListElement"] as Array<{ url: string; position: number; name: string }>).map(
|
|
105
|
+
(item) => [item.position, item.url, item.name],
|
|
106
|
+
),
|
|
107
|
+
).toEqual([
|
|
108
|
+
[1, "https://site.test/en/partners/acme", "Acme"],
|
|
109
|
+
[2, "https://site.test/en/partners/globex", "Globex"],
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("an unprefixed default-locale path reads its whole path as the landing page", async () => {
|
|
114
|
+
path = "/tools";
|
|
115
|
+
|
|
116
|
+
render(<>{await extension().appendix!({ type: "WebPage", slug: "tools", locale: "en" })}</>);
|
|
117
|
+
|
|
118
|
+
expect(fetchDirectory).toHaveBeenCalledWith("k", "/tools", "en");
|
|
119
|
+
const hrefFor = shelvesProps[0]!["hrefFor"] as (e: { slug: string }) => string;
|
|
120
|
+
expect(hrefFor({ slug: "acme" })).toBe("/tools/acme");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("a non-default locale's links keep its prefix", async () => {
|
|
124
|
+
path = "/fr/partners";
|
|
125
|
+
|
|
126
|
+
render(<>{await extension().appendix!({ type: "AboutPage", slug: "partners", locale: "fr" })}</>);
|
|
127
|
+
|
|
128
|
+
const hrefFor = shelvesProps[0]!["hrefFor"] as (e: { slug: string }) => string;
|
|
129
|
+
expect(hrefFor({ slug: "acme" })).toBe("/fr/partners/acme");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("a page no live listicle names renders nothing", async () => {
|
|
133
|
+
path = "/en/about";
|
|
134
|
+
fetchDirectory.mockImplementation(() => ok(null));
|
|
135
|
+
|
|
136
|
+
expect(await extension().appendix!({ type: "AboutPage", slug: "about", locale: "en" })).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("a two-segment path never reaches appendix's read", async () => {
|
|
140
|
+
path = "/en/partners/acme";
|
|
141
|
+
|
|
142
|
+
expect(await extension().appendix!({ type: "AboutPage", slug: "acme", locale: "en" })).toBeNull();
|
|
143
|
+
expect(fetchDirectory).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("detailFallback — an entry at /<landingPage>/<slug>", () => {
|
|
148
|
+
it("renders ListingDetail for an accepted entry, with entry-type JSON-LD", async () => {
|
|
149
|
+
path = "/en/partners/acme";
|
|
150
|
+
|
|
151
|
+
const node = await extension().detailFallback!({ type: "Partner", slug: "acme", locale: "en" });
|
|
152
|
+
const { container, getByTestId } = render(<>{node}</>);
|
|
153
|
+
|
|
154
|
+
expect(getByTestId("stub-detail").textContent).toBe("Acme");
|
|
155
|
+
expect(fetchDirectory).toHaveBeenCalledWith("k", "/partners", "en");
|
|
156
|
+
const [ld] = jsonLdOf(container);
|
|
157
|
+
expect(ld).toEqual({
|
|
158
|
+
"@context": "https://schema.org",
|
|
159
|
+
"@type": "Organization",
|
|
160
|
+
"@id": "https://site.test/en/partners/acme",
|
|
161
|
+
name: "Acme",
|
|
162
|
+
description: "Acme in one line.",
|
|
163
|
+
url: "https://acme.example.com",
|
|
164
|
+
image: "https://acme.example.com/logo.png",
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("answers null for an unknown slug", async () => {
|
|
169
|
+
path = "/en/partners/nope";
|
|
170
|
+
|
|
171
|
+
expect(await extension().detailFallback!({ type: "Partner", slug: "nope", locale: "en" })).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("resolves samhenry's /projects/<slug> shape the same way", async () => {
|
|
175
|
+
path = "/en/projects/acme";
|
|
176
|
+
fetchDirectory.mockImplementation(() => ok(directory("/projects", "CreativeWork")));
|
|
177
|
+
|
|
178
|
+
const node = await extension().detailFallback!({ type: "Project", slug: "acme", locale: "en" });
|
|
179
|
+
const { container } = render(<>{node}</>);
|
|
180
|
+
|
|
181
|
+
expect(fetchDirectory).toHaveBeenCalledWith("k", "/projects", "en");
|
|
182
|
+
expect(jsonLdOf(container)[0]!["@type"]).toBe("CreativeWork");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("a one-segment path never reaches detailFallback's read", async () => {
|
|
186
|
+
path = "/en/partners";
|
|
187
|
+
|
|
188
|
+
expect(await extension().detailFallback!({ type: "Partner", slug: "partners", locale: "en" })).toBeNull();
|
|
189
|
+
expect(fetchDirectory).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it.each([
|
|
193
|
+
["a dotted segment", "/en/partners/a.b", "a.b"],
|
|
194
|
+
["a slug other than renderPage's", "/en/partners/acme", "acme-2"],
|
|
195
|
+
["an upper-case segment", "/en/Partners/acme", "acme"],
|
|
196
|
+
])("answers null with no read for %s", async (_label, at, slug) => {
|
|
197
|
+
path = at;
|
|
198
|
+
|
|
199
|
+
expect(await extension().detailFallback!({ type: "Partner", slug, locale: "en" })).toBeNull();
|
|
200
|
+
expect(fetchDirectory).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("detailMetadata titles the entry, and shares one read with detailFallback", async () => {
|
|
204
|
+
path = "/en/partners/acme";
|
|
205
|
+
const ext = extension();
|
|
206
|
+
|
|
207
|
+
const meta = await ext.detailMetadata!({ type: "Partner", slug: "acme", locale: "en" });
|
|
208
|
+
await ext.detailFallback!({ type: "Partner", slug: "acme", locale: "en" });
|
|
209
|
+
await ext.detailFallback!({ type: "Partner", slug: "acme", locale: "en" });
|
|
210
|
+
|
|
211
|
+
expect(meta).toEqual({ title: "Acme", description: "Acme in one line." });
|
|
212
|
+
expect(fetchDirectory).toHaveBeenCalledTimes(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("re-reads once the 30-second memo expires", async () => {
|
|
216
|
+
path = "/en/partners/acme";
|
|
217
|
+
let now = 1_000_000;
|
|
218
|
+
const ext = extension({ now: () => now });
|
|
219
|
+
|
|
220
|
+
await ext.detailFallback!({ type: "Partner", slug: "acme", locale: "en" });
|
|
221
|
+
now += 29_000;
|
|
222
|
+
await ext.detailFallback!({ type: "Partner", slug: "acme", locale: "en" });
|
|
223
|
+
expect(fetchDirectory).toHaveBeenCalledTimes(1);
|
|
224
|
+
now += 2_000;
|
|
225
|
+
await ext.detailFallback!({ type: "Partner", slug: "acme", locale: "en" });
|
|
226
|
+
expect(fetchDirectory).toHaveBeenCalledTimes(2);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("entryAt serves the [slug]/[detail] route from an explicit path", async () => {
|
|
230
|
+
const hubExists = jest.fn(async () => true);
|
|
231
|
+
const node = await extension().entryAt("/tools/acme", "acme", "en", hubExists);
|
|
232
|
+
const { getByTestId } = render(<>{node}</>);
|
|
233
|
+
|
|
234
|
+
expect(getByTestId("stub-detail").textContent).toBe("Acme");
|
|
235
|
+
expect(hubExists).toHaveBeenCalledTimes(1);
|
|
236
|
+
expect(fetchDirectory).toHaveBeenCalledWith("k", "/tools", "en");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("entryAt makes no read for a hub that is not a page (SEC-7521-D1)", async () => {
|
|
240
|
+
const ext = extension();
|
|
241
|
+
const missing = async () => false;
|
|
242
|
+
for (const hub of ["aaa", "bbb", "ccc"]) {
|
|
243
|
+
expect(await ext.entryAt(`/${hub}/acme`, "acme", "en", missing)).toBeNull();
|
|
244
|
+
expect(await ext.entryMetadataAt(`/${hub}/acme`, "acme", "en", missing)).toBeNull();
|
|
245
|
+
}
|
|
246
|
+
expect(fetchDirectory).toHaveBeenCalledTimes(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("a locale the site does not enable makes no read, whatever x-pathname says (SEC-7521-C1)", async () => {
|
|
250
|
+
// A dotted path such as /x1.y/services/a skips the middleware, so the
|
|
251
|
+
// client supplies x-pathname; the dotted segment arrives as the locale.
|
|
252
|
+
const ext = extension();
|
|
253
|
+
path = "/anything1/a";
|
|
254
|
+
expect(await ext.detailFallback!({ type: "Service", slug: "a", locale: "x1.y" })).toBeNull();
|
|
255
|
+
expect(await ext.detailMetadata!({ type: "Service", slug: "a", locale: "x1.y" })).toBeNull();
|
|
256
|
+
path = "/anything1";
|
|
257
|
+
expect(await ext.appendix!({ type: "LandingPage", slug: "anything1", locale: "x1.y" })).toBeNull();
|
|
258
|
+
expect(await ext.entryAt("/tools/acme", "acme", "x1.y", async () => true)).toBeNull();
|
|
259
|
+
expect(fetchDirectory).toHaveBeenCalledTimes(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("entryAt never probes the hub without a site key", async () => {
|
|
263
|
+
const hubExists = jest.fn(async () => true);
|
|
264
|
+
const ext = extension({ siteKey: () => undefined });
|
|
265
|
+
expect(await ext.entryAt("/tools/acme", "acme", "en", hubExists)).toBeNull();
|
|
266
|
+
expect(hubExists).not.toHaveBeenCalled();
|
|
267
|
+
expect(fetchDirectory).toHaveBeenCalledTimes(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("entryAt and entryMetadataAt answer null when the paid package fails to load", async () => {
|
|
271
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
272
|
+
const ext = extension({ loadUi: () => Promise.reject(new Error("chunk load failed")) });
|
|
273
|
+
const hub = async () => true;
|
|
274
|
+
expect(await ext.entryAt("/tools/acme", "acme", "en", hub)).toBeNull();
|
|
275
|
+
expect(await ext.entryMetadataAt("/tools/acme", "acme", "en", hub)).toBeNull();
|
|
276
|
+
warn.mockRestore();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("no read without a key or the paid package", () => {
|
|
281
|
+
it.each([
|
|
282
|
+
["no site key", { siteKey: () => undefined }],
|
|
283
|
+
["an empty site key", { siteKey: () => "" }],
|
|
284
|
+
["no paid package", { loadUi: async () => null }],
|
|
285
|
+
])("makes zero reads with %s", async (_label, overrides) => {
|
|
286
|
+
const ext = extension(overrides as Partial<ListingExtensionDeps>);
|
|
287
|
+
path = "/en/partners";
|
|
288
|
+
expect(await ext.appendix!({ type: "AboutPage", slug: "partners", locale: "en" })).toBeNull();
|
|
289
|
+
path = "/en/partners/acme";
|
|
290
|
+
expect(await ext.detailFallback!({ type: "Partner", slug: "acme", locale: "en" })).toBeNull();
|
|
291
|
+
expect(await ext.detailMetadata!({ type: "Partner", slug: "acme", locale: "en" })).toBeNull();
|
|
292
|
+
|
|
293
|
+
expect(fetchDirectory).toHaveBeenCalledTimes(0);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("a failed read renders the page as today", () => {
|
|
298
|
+
let warn: jest.SpyInstance;
|
|
299
|
+
beforeEach(() => {
|
|
300
|
+
path = "/en/partners";
|
|
301
|
+
warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
302
|
+
});
|
|
303
|
+
afterEach(() => {
|
|
304
|
+
warn.mockRestore();
|
|
305
|
+
jest.useRealTimers();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it.each([
|
|
309
|
+
["rejects", () => Promise.reject(new Error("ECONNREFUSED"))],
|
|
310
|
+
["answers 500", () => Promise.resolve({ ok: false, status: 500, body: {} })],
|
|
311
|
+
["answers a body failing the schema", () => ok({ landingPage: "/partners", entries: "nope" })],
|
|
312
|
+
])("yields null when the read %s, and logs without the key", async (_label, impl) => {
|
|
313
|
+
fetchDirectory.mockImplementation(impl);
|
|
314
|
+
|
|
315
|
+
expect(await extension().appendix!({ type: "AboutPage", slug: "partners", locale: "en" })).toBeNull();
|
|
316
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
317
|
+
expect(JSON.stringify(warn.mock.calls)).not.toContain('"k"');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("retries a failed read after 5 s, not the full 30 s memo", async () => {
|
|
321
|
+
fetchDirectory.mockImplementation(() => Promise.resolve({ ok: false, status: 429, body: {} }));
|
|
322
|
+
let now = 0;
|
|
323
|
+
const ext = extension({ now: () => now });
|
|
324
|
+
const appendix = () => ext.appendix!({ type: "AboutPage", slug: "partners", locale: "en" });
|
|
325
|
+
|
|
326
|
+
expect(await appendix()).toBeNull();
|
|
327
|
+
now += 4_000;
|
|
328
|
+
expect(await appendix()).toBeNull();
|
|
329
|
+
expect(fetchDirectory).toHaveBeenCalledTimes(1);
|
|
330
|
+
|
|
331
|
+
fetchDirectory.mockImplementation((_key: string, landingPage: string) => ok(directory(landingPage)));
|
|
332
|
+
now += 2_000;
|
|
333
|
+
expect(await appendix()).not.toBeNull();
|
|
334
|
+
expect(fetchDirectory).toHaveBeenCalledTimes(2);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("yields null when the read resolves after 2000 ms", async () => {
|
|
338
|
+
jest.useFakeTimers();
|
|
339
|
+
fetchDirectory.mockImplementation(
|
|
340
|
+
() => new Promise((resolve) => setTimeout(() => resolve({ ok: true, body: directory("/partners") }), 2500)),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const pending = extension().appendix!({ type: "AboutPage", slug: "partners", locale: "en" });
|
|
344
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
345
|
+
|
|
346
|
+
expect(await pending).toBeNull();
|
|
347
|
+
expect(warn).toHaveBeenCalledWith(
|
|
348
|
+
"[listings] directory read failed; rendering the page without it",
|
|
349
|
+
{ landingPage: "/partners", reason: "timeout" },
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
});
|