@pplethai/cli 0.6.0 → 0.8.0
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/dist/bin.js +171 -22
- package/package.json +3 -2
- package/templates/golden-path/.dev.vars.example +19 -1
- package/templates/golden-path/CLAUDE.md +74 -7
- package/templates/golden-path/README.md +27 -2
- package/templates/golden-path/gitignore +3 -0
- package/templates/golden-path/overlay-mtime.mjs +30 -0
- package/templates/golden-path/package-lock.json +19 -1
- package/templates/golden-path/package.json +3 -2
- package/templates/golden-path/scripts/assert-dev-compiled-out.mjs +42 -0
- package/templates/golden-path/shared/api.ts +42 -2
- package/templates/golden-path/src/auth.ts +99 -7
- package/templates/golden-path/src/call-service.ts +140 -0
- package/templates/golden-path/src/dataset-query.ts +470 -0
- package/templates/golden-path/src/dataset-schema.ts +398 -0
- package/templates/golden-path/src/dev-call-service.ts +391 -0
- package/templates/golden-path/src/dev-personas.ts +105 -7
- package/templates/golden-path/src/dev-read-dataset.ts +429 -0
- package/templates/golden-path/src/dev-registry.ts +276 -0
- package/templates/golden-path/src/env.ts +17 -0
- package/templates/golden-path/src/identity-header.ts +17 -0
- package/templates/golden-path/src/index.ts +32 -1
- package/templates/golden-path/src/legal-identity.ts +180 -0
- package/templates/golden-path/src/levels.ts +132 -0
- package/templates/golden-path/src/read-dataset.ts +376 -0
- package/templates/golden-path/src/registry.ts +122 -0
- package/templates/golden-path/src/vite-env.d.ts +13 -0
- package/templates/golden-path/test/call-service.test.ts +178 -0
- package/templates/golden-path/test/dev-call-service.test.ts +279 -0
- package/templates/golden-path/test/dev-read-dataset.test.ts +270 -0
- package/templates/golden-path/test/dev-registry.test.ts +375 -0
- package/templates/golden-path/test/effective-sub.test.ts +101 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/entry.md +85 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/audience.json +3 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/named.json +3 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/offices.json +6 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/roster.json +3 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/summary.json +1 -0
- package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/entry.md +8 -0
- package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/mocks/responses.json +13 -0
- package/templates/golden-path/test/fixtures/overlay/shop/catalog/entry.md +8 -0
- package/templates/golden-path/test/fixtures/overlay/shop/catalog/mocks/responses.json +7 -0
- package/templates/golden-path/test/fixtures/overlay/shop/orders/entry.md +12 -0
- package/templates/golden-path/test/fixtures/overlay/shop/orders/mocks/responses.json +36 -0
- package/templates/golden-path/test/harness.ts +101 -16
- package/templates/golden-path/test/legal-identity.test.ts +323 -0
- package/templates/golden-path/test/membership.test.ts +105 -0
- package/templates/golden-path/test/read-dataset.test.ts +214 -0
- package/templates/golden-path/test/service-call.test.ts +166 -0
- package/templates/golden-path/vite.config.ts +2 -1
- package/templates/golden-path/vitest.config.ts +2 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Offline callService (issue #261, ADR-0026). Vitest never lets callService
|
|
2
|
+
// read `.pple/`. These tests feed the same matcher a fixture glob and read
|
|
3
|
+
// the status, headers, and body a Builder would see.
|
|
4
|
+
|
|
5
|
+
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { platformError } from "../src/call-service";
|
|
7
|
+
import { answerFromOverlay, matchMockCase, type OverlayCall } from "../src/dev-call-service";
|
|
8
|
+
|
|
9
|
+
const overlay: Record<string, unknown> = import.meta.glob("./fixtures/overlay/**/*", {
|
|
10
|
+
eager: true,
|
|
11
|
+
query: "?raw",
|
|
12
|
+
import: "default",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
const keys = Object.keys(overlay).join("\n");
|
|
17
|
+
expect(keys).toContain("shop/orders/mocks/responses.json");
|
|
18
|
+
expect(keys).toContain("shop/orders/entry.md");
|
|
19
|
+
expect(keys).toContain("platform/legal-identity/mocks/responses.json");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const cases = [
|
|
23
|
+
null,
|
|
24
|
+
{ method: "GET", path: "/items/:id", persona: "hq-staff", body: "hq" },
|
|
25
|
+
{ method: "GET", path: "/items/:id", persona: ["delegate", "platform-admin"], body: "listed" },
|
|
26
|
+
{ method: "GET", path: "items/:id", body: "open" },
|
|
27
|
+
{ method: "POST", path: "/", body: "root" },
|
|
28
|
+
{ path: "/files/*", body: "files" },
|
|
29
|
+
{ method: "GET", path: "/empty", persona: [], body: "no" },
|
|
30
|
+
{ method: "PUT", path: "/a/*/b", body: "bad" },
|
|
31
|
+
{ method: "DELETE", body: "any" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
describe("matchMockCase", () => {
|
|
35
|
+
it.each([
|
|
36
|
+
["first matching persona wins", "GET", "/items/1", "hq-staff", "hq"],
|
|
37
|
+
["a persona list matches", "GET", "/items/1", "delegate", "listed"],
|
|
38
|
+
["a persona the Builder added gets only an unscoped case", "GET", "/items/1", "intern", "open"],
|
|
39
|
+
["the query is not part of the match", "GET", "/items/1?limit=2", "hq-staff", "hq"],
|
|
40
|
+
["a pattern without a leading slash matches a slash tail", "GET", "/items/9", "unverified", "open"],
|
|
41
|
+
["a request without a leading slash matches a slash pattern", "get", "items/9", "hq-staff", "hq"],
|
|
42
|
+
["a param does not swallow a second segment", "GET", "/items/1/extra", "hq-staff", null],
|
|
43
|
+
["/ is the empty tail", "POST", "", "hq-staff", "root"],
|
|
44
|
+
["a slash tail is the same root", "POST", "/", "intern", "root"],
|
|
45
|
+
["a query on the root stays the root", "POST", "?dry=1", "hq-staff", "root"],
|
|
46
|
+
["the root does not match a longer tail", "POST", "/items", "hq-staff", null],
|
|
47
|
+
["a trailing star matches the rest of the tail", "POST", "/files/a/b", "intern", "files"],
|
|
48
|
+
["a trailing star does not match an empty rest", "GET", "/files", "hq-staff", null],
|
|
49
|
+
["an omitted path matches whatever is left", "DELETE", "/nope", "intern", "any"],
|
|
50
|
+
["an omitted path matches the root too", "DELETE", "", "hq-staff", "any"],
|
|
51
|
+
["an empty persona list matches nobody", "GET", "/empty", "hq-staff", null],
|
|
52
|
+
["a star that is not the last segment matches nothing", "PUT", "/a/x/b", "hq-staff", null],
|
|
53
|
+
] as const)("%s", (_name, method, path, persona, body) => {
|
|
54
|
+
const matched = matchMockCase(cases, method, path, persona);
|
|
55
|
+
expect(matched?.body ?? null).toBe(body);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns the earlier case when two would match", () => {
|
|
59
|
+
const matched = matchMockCase(
|
|
60
|
+
[
|
|
61
|
+
{ method: "GET", path: "/dup", body: "first" },
|
|
62
|
+
{ method: "GET", path: "/dup", body: "second" },
|
|
63
|
+
],
|
|
64
|
+
"GET",
|
|
65
|
+
"/dup",
|
|
66
|
+
"hq-staff",
|
|
67
|
+
);
|
|
68
|
+
expect(matched?.body).toBe("first");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("overlay fixture", () => {
|
|
73
|
+
it("matches method, path and persona, and marks the mock", async () => {
|
|
74
|
+
const res = await call({ entry: "shop/orders", path: "/items/7?limit=2", method: "GET", persona: "hq-staff", userToken: "t" });
|
|
75
|
+
expect(res.status).toBe(200);
|
|
76
|
+
expect(res.headers.get("x-pple-mock")).toBe("shop/orders");
|
|
77
|
+
expect(res.headers.get("x-pple-error")).toBeNull();
|
|
78
|
+
expect(res.headers.get("x-shop")).toBe("hq");
|
|
79
|
+
expect(platformError(res)).toBeNull();
|
|
80
|
+
expect(await res.json()).toEqual({ item: "hq" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("uses the first match, then an unscoped case for a persona the Builder added", async () => {
|
|
84
|
+
const listed = await call({ entry: "shop/orders", path: "/items/7", method: "GET", persona: "delegate", userToken: "t" });
|
|
85
|
+
expect(await listed.json()).toEqual({ item: "listed" });
|
|
86
|
+
|
|
87
|
+
const added = await call({ entry: "shop/orders", path: "/items/7", method: "GET", persona: "intern", userToken: "t" });
|
|
88
|
+
expect(added.headers.get("x-pple-mock")).toBe("shop/orders");
|
|
89
|
+
expect(await added.json()).toEqual({ item: "open" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("treats / as the entry root and sends a string body as text", async () => {
|
|
93
|
+
const root = await call({ entry: "shop/orders", path: "", method: "POST", persona: "hq-staff", userToken: "t" });
|
|
94
|
+
expect(root.status).toBe(201);
|
|
95
|
+
expect(root.headers.get("x-pple-mock")).toBe("shop/orders");
|
|
96
|
+
expect(await root.json()).toEqual({ created: true });
|
|
97
|
+
|
|
98
|
+
const longer = await call({ entry: "shop/orders", path: "/created", method: "POST", persona: "hq-staff", userToken: "t" });
|
|
99
|
+
expect(longer.status).toBe(404);
|
|
100
|
+
expect(longer.headers.get("x-pple-error")).toBe("no_mock");
|
|
101
|
+
|
|
102
|
+
const files = await call({ entry: "shop/orders", path: "/files/a/b", method: "GET", persona: "intern", userToken: "t" });
|
|
103
|
+
expect(files.headers.get("content-type")).toContain("text/plain");
|
|
104
|
+
expect(await files.text()).toBe("listed");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("answers a userToken none entry with no token", async () => {
|
|
108
|
+
const res = await call({ entry: "shop/catalog", path: "/search", method: "GET", persona: "hq-staff" });
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
expect(res.headers.get("x-pple-mock")).toBe("shop/catalog");
|
|
111
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("refuses a required entry that was called with no token", async () => {
|
|
115
|
+
for (const userToken of [undefined, "", " "]) {
|
|
116
|
+
const res = await call({ entry: "shop/orders", path: "/items/7", method: "GET", persona: "hq-staff", userToken });
|
|
117
|
+
expect(res.status).toBe(401);
|
|
118
|
+
expect(res.headers.get("x-pple-error")).toBe("user_token_invalid");
|
|
119
|
+
expect(res.headers.get("x-pple-mock")).toBeNull();
|
|
120
|
+
expect(platformError(res)).toBe("user_token_invalid");
|
|
121
|
+
expect(await res.json()).toEqual({ error: "user_token_invalid" });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("says when the entry is missing, and when the route is", async () => {
|
|
126
|
+
const missing = await call({ entry: "shop/missing", path: "/items/1", method: "GET", persona: "hq-staff", userToken: "t" });
|
|
127
|
+
expect(missing.status).toBe(404);
|
|
128
|
+
expect(missing.headers.get("x-pple-error")).toBe("no_mock");
|
|
129
|
+
expect(missing.headers.get("x-pple-mock")).toBeNull();
|
|
130
|
+
expect(platformError(missing)).toBeNull();
|
|
131
|
+
const missingBody = await missing.json<{ error: string; message: string }>();
|
|
132
|
+
expect(missingBody.error).toBe("no_mock");
|
|
133
|
+
expect(missingBody.message).toContain("pple registry");
|
|
134
|
+
expect(missingBody.message).toContain(".pple/registry/");
|
|
135
|
+
expect(missingBody.message).toMatch(/[\u0E00-\u0E7F]/);
|
|
136
|
+
|
|
137
|
+
const route = await call({ entry: "shop/orders", path: "/nope", method: "GET", persona: "hq-staff", userToken: "t" });
|
|
138
|
+
const routeBody = await route.json<{ error: string; message: string }>();
|
|
139
|
+
expect(route.status).toBe(404);
|
|
140
|
+
expect(route.headers.get("x-pple-error")).toBe("no_mock");
|
|
141
|
+
expect(routeBody.error).toBe("no_mock");
|
|
142
|
+
expect(routeBody.message).toContain("pple registry");
|
|
143
|
+
expect(routeBody.message).toContain("GET /nope");
|
|
144
|
+
expect(routeBody.message).toMatch(/[\u0E00-\u0E7F]/);
|
|
145
|
+
expect(routeBody.message).not.toContain(".pple/registry/");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("keeps a root mock off every other tail", async () => {
|
|
149
|
+
const root = await call({
|
|
150
|
+
entry: "platform/legal-identity",
|
|
151
|
+
path: "",
|
|
152
|
+
method: "POST",
|
|
153
|
+
persona: "kyc-verified-nonmember",
|
|
154
|
+
userToken: "t",
|
|
155
|
+
});
|
|
156
|
+
expect(await root.json()).toEqual({ source: "default" });
|
|
157
|
+
|
|
158
|
+
const member = await call({
|
|
159
|
+
entry: "platform/legal-identity",
|
|
160
|
+
path: "/",
|
|
161
|
+
method: "POST",
|
|
162
|
+
persona: "hq-staff",
|
|
163
|
+
userToken: "t",
|
|
164
|
+
});
|
|
165
|
+
expect(await member.json()).toEqual({ source: "membership" });
|
|
166
|
+
|
|
167
|
+
const other = await call({
|
|
168
|
+
entry: "platform/legal-identity",
|
|
169
|
+
path: "/extra",
|
|
170
|
+
method: "POST",
|
|
171
|
+
persona: "hq-staff",
|
|
172
|
+
userToken: "t",
|
|
173
|
+
});
|
|
174
|
+
expect(other.status).toBe(404);
|
|
175
|
+
expect(other.headers.get("x-pple-error")).toBe("no_mock");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("DEV_REGISTRY_ERROR", () => {
|
|
180
|
+
const statuses: [string, number][] = [
|
|
181
|
+
["invalid_app_key", 401],
|
|
182
|
+
["not_listed", 404],
|
|
183
|
+
["not_granted", 403],
|
|
184
|
+
["needs_beta", 403],
|
|
185
|
+
["user_token_invalid", 401],
|
|
186
|
+
["identity_unavailable", 503],
|
|
187
|
+
["provider_unavailable", 502],
|
|
188
|
+
["provider_timeout", 504],
|
|
189
|
+
["method_not_allowed", 405],
|
|
190
|
+
["not_found", 404],
|
|
191
|
+
["invalid_query", 400],
|
|
192
|
+
["level_not_met", 403],
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
it.each(statuses)("global %s is %i", async (code, status) => {
|
|
196
|
+
const res = await call({
|
|
197
|
+
entry: "shop/orders",
|
|
198
|
+
path: "/items/1",
|
|
199
|
+
method: "GET",
|
|
200
|
+
persona: "hq-staff",
|
|
201
|
+
userToken: "t",
|
|
202
|
+
devRegistryError: code,
|
|
203
|
+
});
|
|
204
|
+
expect(res.status).toBe(status);
|
|
205
|
+
expect(res.headers.get("x-pple-error")).toBe(code);
|
|
206
|
+
expect(res.headers.get("x-pple-mock")).toBeNull();
|
|
207
|
+
expect(platformError(res)).toBe(code);
|
|
208
|
+
expect(await res.json()).toEqual({ error: code });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("lets a per-entry code win over a global one, in either order", async () => {
|
|
212
|
+
for (const spec of ["not_granted, shop/orders=provider_timeout", "shop/orders = provider_timeout, not_granted"]) {
|
|
213
|
+
const orders = await call({
|
|
214
|
+
entry: "shop/orders",
|
|
215
|
+
path: "/items/1",
|
|
216
|
+
method: "GET",
|
|
217
|
+
persona: "hq-staff",
|
|
218
|
+
userToken: "t",
|
|
219
|
+
devRegistryError: spec,
|
|
220
|
+
});
|
|
221
|
+
expect(orders.status).toBe(504);
|
|
222
|
+
expect(orders.headers.get("x-pple-error")).toBe("provider_timeout");
|
|
223
|
+
|
|
224
|
+
const catalog = await call({
|
|
225
|
+
entry: "shop/catalog",
|
|
226
|
+
path: "/search",
|
|
227
|
+
method: "GET",
|
|
228
|
+
persona: "hq-staff",
|
|
229
|
+
devRegistryError: spec,
|
|
230
|
+
});
|
|
231
|
+
expect(catalog.status).toBe(403);
|
|
232
|
+
expect(await catalog.json()).toEqual({ error: "not_granted" });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("ignores an unknown code and still applies a global one", async () => {
|
|
237
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
238
|
+
try {
|
|
239
|
+
const mocked = await call({
|
|
240
|
+
entry: "shop/catalog",
|
|
241
|
+
path: "/search",
|
|
242
|
+
method: "GET",
|
|
243
|
+
persona: "hq-staff",
|
|
244
|
+
devRegistryError: "not_a_code",
|
|
245
|
+
});
|
|
246
|
+
expect(mocked.status).toBe(200);
|
|
247
|
+
expect(await mocked.json()).toEqual({ ok: true });
|
|
248
|
+
|
|
249
|
+
const forced = await call({
|
|
250
|
+
entry: "shop/orders",
|
|
251
|
+
path: "/items/1",
|
|
252
|
+
method: "GET",
|
|
253
|
+
persona: "hq-staff",
|
|
254
|
+
devRegistryError: "shop/orders=not_a_code, needs_beta",
|
|
255
|
+
});
|
|
256
|
+
expect(forced.status).toBe(403);
|
|
257
|
+
expect(forced.headers.get("x-pple-error")).toBe("needs_beta");
|
|
258
|
+
expect(warn.mock.calls.some((args) => String(args[0]).includes("not_a_code"))).toBe(true);
|
|
259
|
+
} finally {
|
|
260
|
+
warn.mockRestore();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("applies before the missing-token refusal", async () => {
|
|
265
|
+
const res = await call({
|
|
266
|
+
entry: "shop/orders",
|
|
267
|
+
path: "/items/1",
|
|
268
|
+
method: "GET",
|
|
269
|
+
persona: "hq-staff",
|
|
270
|
+
devRegistryError: "not_granted",
|
|
271
|
+
});
|
|
272
|
+
expect(res.status).toBe(403);
|
|
273
|
+
expect(await res.json()).toEqual({ error: "not_granted" });
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
function call(input: OverlayCall): Promise<Response> {
|
|
278
|
+
return Promise.resolve(answerFromOverlay(overlay, input));
|
|
279
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Offline readDataset (issue #270, ADR-0030). Vitest never lets readDataset
|
|
2
|
+
// read `.pple/`. These tests feed the overlay fixture glob and read the
|
|
3
|
+
// status, headers, and body a Builder would see.
|
|
4
|
+
|
|
5
|
+
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { platformError } from "../src/call-service";
|
|
7
|
+
import { answerOfflineDataset, type OfflineDatasetCall } from "../src/dev-read-dataset";
|
|
8
|
+
|
|
9
|
+
const overlay: Record<string, unknown> = import.meta.glob("./fixtures/overlay/**/*", {
|
|
10
|
+
eager: true,
|
|
11
|
+
query: "?raw",
|
|
12
|
+
import: "default",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const writtenAt: Record<string, unknown> = import.meta.glob("./fixtures/overlay/**/*", {
|
|
16
|
+
eager: true,
|
|
17
|
+
query: "?mtime",
|
|
18
|
+
import: "default",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
const keys = Object.keys(overlay).join("\n");
|
|
23
|
+
expect(keys).toContain("directory/branches/entry.md");
|
|
24
|
+
expect(keys).toContain("directory/branches/mocks/offices.json");
|
|
25
|
+
expect(keys).toContain("directory/branches/mocks/summary.json");
|
|
26
|
+
const offices = timeFor("mocks/offices.json");
|
|
27
|
+
expect(offices).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("offline readDataset", () => {
|
|
31
|
+
it("filters, sorts with id descending on a tie, and pages", async () => {
|
|
32
|
+
const filtered = await read({
|
|
33
|
+
objectType: "offices",
|
|
34
|
+
search: [
|
|
35
|
+
["province", "เชียงใหม่"],
|
|
36
|
+
["seats[gte]", "4"],
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
expect(filtered.status).toBe(200);
|
|
40
|
+
expect(filtered.headers.get("x-pple-mock")).toBe("directory/branches");
|
|
41
|
+
expect(filtered.headers.get("x-pple-error")).toBeNull();
|
|
42
|
+
expect(platformError(filtered)).toBeNull();
|
|
43
|
+
const filteredBody = await filtered.json<{ objects: { id: string }[]; next: null; sweptAt: string; snapshot: number }>();
|
|
44
|
+
expect(filteredBody.objects.map((object) => object.id)).toEqual(["branch-a", "branch-c"]);
|
|
45
|
+
expect(filteredBody.sweptAt).toBe(timeFor("mocks/offices.json"));
|
|
46
|
+
expect(filtered.headers.get("etag")).toBe(`"${filteredBody.snapshot}-${etagHash(filtered)}"`);
|
|
47
|
+
|
|
48
|
+
const descending = await read({ objectType: "offices", search: [["sort", "-seats"]] });
|
|
49
|
+
const descendingBody = await descending.json<{ objects: { id: string }[]; snapshot: number; sweptAt: string }>();
|
|
50
|
+
expect(descendingBody.objects.map((object) => object.id)).toEqual([
|
|
51
|
+
"branch-b",
|
|
52
|
+
"branch-c",
|
|
53
|
+
"branch-a",
|
|
54
|
+
"branch-d",
|
|
55
|
+
]);
|
|
56
|
+
expect(descendingBody.sweptAt).toBe(filteredBody.sweptAt);
|
|
57
|
+
expect(descendingBody.snapshot).toBe(filteredBody.snapshot);
|
|
58
|
+
|
|
59
|
+
const first = await read({ objectType: "offices", search: [["sort", "seats"], ["limit", "1"]] });
|
|
60
|
+
const firstBody = await first.json<{ objects: { id: string }[]; next: string | null; sweptAt: string }>();
|
|
61
|
+
expect(firstBody.objects.map((object) => object.id)).toEqual(["branch-d"]);
|
|
62
|
+
expect(firstBody.next).toEqual(expect.any(String));
|
|
63
|
+
expect(firstBody.sweptAt).toBe(timeFor("mocks/offices.json"));
|
|
64
|
+
|
|
65
|
+
const second = await read({
|
|
66
|
+
objectType: "offices",
|
|
67
|
+
search: [["sort", "seats"], ["limit", "1"], ["next", firstBody.next ?? ""]],
|
|
68
|
+
});
|
|
69
|
+
const secondBody = await second.json<{ objects: { id: string }[]; next: string | null; sweptAt: string }>();
|
|
70
|
+
expect(secondBody.objects.map((object) => object.id)).toEqual(["branch-a"]);
|
|
71
|
+
expect(secondBody.sweptAt).toBe(firstBody.sweptAt);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("uses the mock file's write time, not entry.md, when both are known", async () => {
|
|
75
|
+
const res = await read(
|
|
76
|
+
{ objectType: "offices" },
|
|
77
|
+
{
|
|
78
|
+
"/virtual/directory/branches/mocks/offices.json": "2024-05-01T00:00:00.000Z",
|
|
79
|
+
"/virtual/directory/branches/entry.md": "2020-01-01T00:00:00.000Z",
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
const body = await res.json<{ sweptAt: string }>();
|
|
83
|
+
expect(body.sweptAt).toBe("2024-05-01T00:00:00.000Z");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("looks an object up by id, and refuses a query on that lookup or on a singleton", async () => {
|
|
87
|
+
const found = await read({ objectType: "offices", id: "branch-b" });
|
|
88
|
+
expect(found.status).toBe(200);
|
|
89
|
+
expect(await found.json()).toMatchObject({
|
|
90
|
+
object: { id: "branch-b", province: "กรุงเทพ", seats: 9 },
|
|
91
|
+
sweptAt: timeFor("mocks/offices.json"),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const missing = await read({ objectType: "offices", id: "branch-z" });
|
|
95
|
+
expect(missing.status).toBe(404);
|
|
96
|
+
expect(missing.headers.get("x-pple-error")).toBe("not_found");
|
|
97
|
+
expect(missing.headers.get("x-pple-mock")).toBeNull();
|
|
98
|
+
expect(platformError(missing)).toBe("not_found");
|
|
99
|
+
|
|
100
|
+
const queried = await read({ objectType: "offices", id: "branch-b", search: [["limit", "1"]] });
|
|
101
|
+
expect(queried.status).toBe(400);
|
|
102
|
+
expect(queried.headers.get("x-pple-error")).toBe("invalid_query");
|
|
103
|
+
expect(platformError(queried)).toBe("invalid_query");
|
|
104
|
+
|
|
105
|
+
const summary = await read({ objectType: "summary", persona: "member", userToken: undefined });
|
|
106
|
+
expect(summary.status).toBe(200);
|
|
107
|
+
expect(summary.headers.get("x-pple-mock")).toBe("directory/branches");
|
|
108
|
+
expect(await summary.json()).toMatchObject({ object: { title: "สรุปสาขา" } });
|
|
109
|
+
|
|
110
|
+
const summaryQuery = await read({ objectType: "summary", search: [["limit", "1"]] });
|
|
111
|
+
expect(summaryQuery.status).toBe(400);
|
|
112
|
+
expect(summaryQuery.headers.get("x-pple-error")).toBe("invalid_query");
|
|
113
|
+
|
|
114
|
+
const summaryId = await read({ objectType: "summary", id: "1" });
|
|
115
|
+
expect(summaryId.status).toBe(404);
|
|
116
|
+
expect(summaryId.headers.get("x-pple-error")).toBe("not_found");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("refuses the member-only persona on an internal type and admits hq-staff", async () => {
|
|
120
|
+
for (const userToken of [undefined, "", " "]) {
|
|
121
|
+
const res = await read({ objectType: "offices", persona: "member", userToken });
|
|
122
|
+
expect(res.status).toBe(401);
|
|
123
|
+
expect(res.headers.get("x-pple-error")).toBe("user_token_invalid");
|
|
124
|
+
expect(platformError(res)).toBe("user_token_invalid");
|
|
125
|
+
expect(res.headers.get("x-pple-mock")).toBeNull();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const member = await read({ objectType: "roster", persona: "member", userToken: "t" });
|
|
129
|
+
expect(member.status).toBe(403);
|
|
130
|
+
expect(member.headers.get("x-pple-error")).toBe("level_not_met");
|
|
131
|
+
expect(member.headers.get("x-pple-mock")).toBeNull();
|
|
132
|
+
expect(platformError(member)).toBe("level_not_met");
|
|
133
|
+
expect(await member.json()).toEqual({ error: "level_not_met" });
|
|
134
|
+
|
|
135
|
+
const staff = await read({ objectType: "roster", persona: "hq-staff", userToken: "t" });
|
|
136
|
+
expect(staff.status).toBe(200);
|
|
137
|
+
expect(await staff.json()).toMatchObject({ objects: [{ id: "r1", province: "เชียงใหม่" }] });
|
|
138
|
+
|
|
139
|
+
const memberOffices = await read({ objectType: "offices", persona: "member", userToken: "t" });
|
|
140
|
+
expect(memberOffices.status).toBe(200);
|
|
141
|
+
const offices = await memberOffices.json<{ objects: { id: string }[] }>();
|
|
142
|
+
expect(offices.objects.map((object) => object.id)).toEqual(["branch-a", "branch-b", "branch-c", "branch-d"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("fails closed when subs are missing or swept", async () => {
|
|
146
|
+
const swept = await read({ objectType: "audience", userToken: "t" });
|
|
147
|
+
expect(swept.status).toBe(403);
|
|
148
|
+
expect(swept.headers.get("x-pple-error")).toBe("level_not_met");
|
|
149
|
+
|
|
150
|
+
const stripped = await read({ objectType: "named", userToken: "t" });
|
|
151
|
+
expect(stripped.status).toBe(403);
|
|
152
|
+
expect(stripped.headers.get("x-pple-error")).toBe("level_not_met");
|
|
153
|
+
|
|
154
|
+
const memberSwept = await read({ objectType: "audience", persona: "member", userToken: "t" });
|
|
155
|
+
expect(memberSwept.status).toBe(403);
|
|
156
|
+
expect(memberSwept.headers.get("x-pple-error")).toBe("level_not_met");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("refuses an invalid query the way production does", async () => {
|
|
160
|
+
const badValue = await read({ objectType: "offices", search: [["seats", "nope"]] });
|
|
161
|
+
expect(badValue.status).toBe(400);
|
|
162
|
+
expect(badValue.headers.get("x-pple-error")).toBe("invalid_query");
|
|
163
|
+
expect(platformError(badValue)).toBe("invalid_query");
|
|
164
|
+
expect(badValue.headers.get("x-pple-mock")).toBeNull();
|
|
165
|
+
|
|
166
|
+
const badField = await read({ objectType: "offices", search: [["city", "เชียงใหม่"]] });
|
|
167
|
+
expect(badField.headers.get("x-pple-error")).toBe("invalid_query");
|
|
168
|
+
|
|
169
|
+
const badLimit = await read({ objectType: "offices", search: [["limit", "0"]] });
|
|
170
|
+
expect(badLimit.headers.get("x-pple-error")).toBe("invalid_query");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("says no_mock for a missing entry or mock, and not_found for an unknown type", async () => {
|
|
174
|
+
const missing = await read({ entry: "directory/missing", objectType: "offices" });
|
|
175
|
+
expect(missing.status).toBe(404);
|
|
176
|
+
expect(missing.headers.get("x-pple-error")).toBe("no_mock");
|
|
177
|
+
expect(missing.headers.get("x-pple-mock")).toBeNull();
|
|
178
|
+
expect(platformError(missing)).toBeNull();
|
|
179
|
+
const missingBody = await missing.json<{ error: string; message: string }>();
|
|
180
|
+
expect(missingBody.error).toBe("no_mock");
|
|
181
|
+
expect(missingBody.message).toContain("pple registry");
|
|
182
|
+
expect(missingBody.message).toContain(".pple/registry/");
|
|
183
|
+
expect(missingBody.message).toMatch(/[\u0E00-\u0E7F]/);
|
|
184
|
+
|
|
185
|
+
const gap = await read({ objectType: "gap" });
|
|
186
|
+
expect(gap.status).toBe(404);
|
|
187
|
+
expect(gap.headers.get("x-pple-error")).toBe("no_mock");
|
|
188
|
+
const gapBody = await gap.json<{ error: string; message: string }>();
|
|
189
|
+
expect(gapBody.message).toContain("pple registry");
|
|
190
|
+
expect(gapBody.message).toContain("gap");
|
|
191
|
+
|
|
192
|
+
const unknown = await read({ objectType: "nope" });
|
|
193
|
+
expect(unknown.status).toBe(404);
|
|
194
|
+
expect(unknown.headers.get("x-pple-error")).toBe("not_found");
|
|
195
|
+
expect(platformError(unknown)).toBe("not_found");
|
|
196
|
+
expect(unknown.headers.get("x-pple-mock")).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("DEV_REGISTRY_ERROR", () => {
|
|
201
|
+
it("returns the platform refusal before the token and the level", async () => {
|
|
202
|
+
const res = await read({
|
|
203
|
+
objectType: "roster",
|
|
204
|
+
persona: "member",
|
|
205
|
+
devRegistryError: "not_granted",
|
|
206
|
+
});
|
|
207
|
+
expect(res.status).toBe(403);
|
|
208
|
+
expect(res.headers.get("x-pple-error")).toBe("not_granted");
|
|
209
|
+
expect(res.headers.get("x-pple-mock")).toBeNull();
|
|
210
|
+
expect(platformError(res)).toBe("not_granted");
|
|
211
|
+
expect(await res.json()).toEqual({ error: "not_granted" });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("lets a per-entry code win, and accepts a dataset refusal code", async () => {
|
|
215
|
+
const specific = await read({
|
|
216
|
+
objectType: "offices",
|
|
217
|
+
devRegistryError: "not_granted, directory/branches=needs_beta",
|
|
218
|
+
});
|
|
219
|
+
expect(specific.status).toBe(403);
|
|
220
|
+
expect(specific.headers.get("x-pple-error")).toBe("needs_beta");
|
|
221
|
+
|
|
222
|
+
const other = await read({
|
|
223
|
+
entry: "shop/orders",
|
|
224
|
+
objectType: "offices",
|
|
225
|
+
devRegistryError: "not_granted, directory/branches=needs_beta",
|
|
226
|
+
});
|
|
227
|
+
expect(other.headers.get("x-pple-error")).toBe("not_granted");
|
|
228
|
+
|
|
229
|
+
const level = await read({ objectType: "offices", devRegistryError: "level_not_met" });
|
|
230
|
+
expect(level.status).toBe(403);
|
|
231
|
+
expect(level.headers.get("x-pple-error")).toBe("level_not_met");
|
|
232
|
+
|
|
233
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
234
|
+
try {
|
|
235
|
+
const ignored = await read({
|
|
236
|
+
objectType: "summary",
|
|
237
|
+
devRegistryError: "directory/branches=not_a_code, not_listed",
|
|
238
|
+
});
|
|
239
|
+
expect(ignored.status).toBe(404);
|
|
240
|
+
expect(ignored.headers.get("x-pple-error")).toBe("not_listed");
|
|
241
|
+
} finally {
|
|
242
|
+
warn.mockRestore();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
function timeFor(suffix: string): string {
|
|
248
|
+
const key = Object.keys(writtenAt).find((file) => file.includes(suffix));
|
|
249
|
+
const value = key ? writtenAt[key] : undefined;
|
|
250
|
+
if (typeof value !== "string") throw new Error(`no mtime for ${suffix}`);
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function etagHash(response: Response): string {
|
|
255
|
+
const tag = response.headers.get("etag") ?? "";
|
|
256
|
+
const hash = tag.replaceAll('"', "").split("-")[1];
|
|
257
|
+
return hash ?? "";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function read(
|
|
261
|
+
input: Partial<OfflineDatasetCall> & { objectType: string },
|
|
262
|
+
times: Record<string, unknown> = writtenAt,
|
|
263
|
+
): Promise<Response> {
|
|
264
|
+
return Promise.resolve(answerOfflineDataset(overlay, times, {
|
|
265
|
+
entry: "directory/branches",
|
|
266
|
+
persona: "hq-staff",
|
|
267
|
+
userToken: "t",
|
|
268
|
+
...input,
|
|
269
|
+
}));
|
|
270
|
+
}
|