@classytic/arc 2.10.8 → 2.11.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/{BaseController-DVNKvoX4.mjs → BaseController-JNV08qOT.mjs} +480 -442
- package/dist/{queryCachePlugin-Dumka73q.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
- package/dist/adapters/index.d.mts +2 -2
- package/dist/adapters/index.mjs +1 -1
- package/dist/{adapters-BXY4i-hw.mjs → adapters-D0tT2Tyo.mjs} +54 -0
- package/dist/audit/index.d.mts +1 -1
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.mjs +5 -5
- package/dist/{betterAuthOpenApi--rdY15Ld.mjs → betterAuthOpenApi-DwxtK3uG.mjs} +1 -1
- package/dist/cache/index.d.mts +3 -2
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +37 -27
- package/dist/cli/commands/init.mjs +46 -33
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/context/index.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -3
- package/dist/core-DXdSSFW-.mjs +1037 -0
- package/dist/createActionRouter-BwaSM0No.mjs +166 -0
- package/dist/{createApp-BwnEAO2h.mjs → createApp-DvNYEhpb.mjs} +75 -27
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-Dci0AYLT.mjs → elevation-DOFoxoDs.mjs} +1 -1
- package/dist/{errorHandler-CSxe7KIM.mjs → errorHandler-BQm8ZxTK.mjs} +1 -1
- package/dist/{eventPlugin-ByU4Cv0e.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
- package/dist/events/index.d.mts +3 -3
- package/dist/events/index.mjs +2 -2
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +2 -2
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +3 -3
- package/dist/idempotency/index.mjs +1 -1
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-C_Noptz-.d.mts → index-BYCqHCVu.d.mts} +2 -2
- package/dist/{index-BGbpGVyM.d.mts → index-Cm0vUrr_.d.mts} +699 -494
- package/dist/{index-BziRPS4H.d.mts → index-DAushRTt.d.mts} +29 -10
- package/dist/index-DsJ1MNfC.d.mts +1179 -0
- package/dist/{index-EqQN6p0W.d.mts → index-t8pLpPFW.d.mts} +11 -8
- package/dist/index.d.mts +6 -38
- package/dist/index.mjs +9 -9
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/streamline.d.mts +46 -5
- package/dist/integrations/streamline.mjs +50 -21
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +2 -154
- package/dist/integrations/websocket.mjs +292 -224
- package/dist/{keys-nWQGUTu1.mjs → keys-CARyUjiR.mjs} +2 -0
- package/dist/{loadResources-Bksk8ydA.mjs → loadResources-YNwKHvRA.mjs} +3 -1
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +1 -1
- package/dist/{openapi-DpNpqBmo.mjs → openapi-C0L9ar7m.mjs} +4 -4
- package/dist/org/index.d.mts +1 -1
- package/dist/permissions/index.d.mts +1 -1
- package/dist/permissions/index.mjs +2 -4
- package/dist/{permissions-wkqRwicB.mjs → permissions-B4vU9L0Q.mjs} +221 -3
- package/dist/{pipe-CGJxqDGx.mjs → pipe-DVoIheVC.mjs} +1 -1
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/plugins/index.d.mts +4 -4
- package/dist/plugins/index.mjs +10 -10
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +42 -24
- package/dist/presets/filesUpload.d.mts +1 -1
- package/dist/presets/filesUpload.mjs +3 -3
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +6 -0
- package/dist/presets/search.d.mts +1 -1
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-CrwOvuXI.mjs → presets-k604Lj99.mjs} +1 -1
- package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
- package/dist/{queryCachePlugin-ChLNZvFT.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
- package/dist/{redis-MXLp1oOf.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{resourceToTools-BhF3JV5p.mjs → resourceToTools--okX6QBr.mjs} +534 -420
- package/dist/routerShared-DeESFp4a.mjs +515 -0
- package/dist/schemaIR-BlG9bY7v.mjs +137 -0
- package/dist/scope/index.mjs +2 -2
- package/dist/testing/index.d.mts +367 -711
- package/dist/testing/index.mjs +637 -1434
- package/dist/{tracing-xqXzWeaf.d.mts → tracing-DokiEsuz.d.mts} +9 -4
- package/dist/types/index.d.mts +3 -3
- package/dist/types/index.mjs +1 -3
- package/dist/{types-CVdgPXBW.d.mts → types-CgikqKAj.d.mts} +118 -19
- package/dist/{types-CVKBssX5.d.mts → types-D9NqiYIw.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -968
- package/dist/utils/index.mjs +5 -6
- package/dist/utils-D3Yxnrwr.mjs +1639 -0
- package/dist/websocket-CyJ1VIFI.d.mts +186 -0
- package/package.json +7 -5
- package/skills/arc/SKILL.md +123 -38
- package/skills/arc/references/testing.md +212 -183
- package/dist/applyPermissionResult-QhV1Pa-g.mjs +0 -37
- package/dist/core-3MWJosCH.mjs +0 -1459
- package/dist/createActionRouter-C8UUB3Px.mjs +0 -249
- package/dist/errors-BI8kEKsO.d.mts +0 -140
- package/dist/fields-CTMWOUDt.mjs +0 -126
- package/dist/queryParser-NR__Qiju.mjs +0 -419
- package/dist/types-CDnTEpga.mjs +0 -27
- package/dist/utils-LMwVidKy.mjs +0 -947
- /package/dist/{HookSystem-BjFu7zf1.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
- /package/dist/{ResourceRegistry-CcN2LVrc.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
- /package/dist/{actionPermissions-TUVR3uiZ.mjs → actionPermissions-C8YYU92K.mjs} +0 -0
- /package/dist/{caching-3h93rkJM.mjs → caching-CheW3m-S.mjs} +0 -0
- /package/dist/{errorHandler-2ii4RIYr.d.mts → errorHandler-Co3lnVmJ.d.mts} +0 -0
- /package/dist/{errors-BqdUDja_.mjs → errors-D5c-5BJL.mjs} +0 -0
- /package/dist/{eventPlugin-D1ThQ1Pp.d.mts → eventPlugin-CUNjYYRY.d.mts} +0 -0
- /package/dist/{interface-B-pe8fhj.d.mts → interface-CkkWm5uR.d.mts} +0 -0
- /package/dist/{interface-yhyb_pLY.d.mts → interface-Da0r7Lna.d.mts} +0 -0
- /package/dist/{memory-DqI-449b.mjs → memory-DikHSvWa.mjs} +0 -0
- /package/dist/{metrics-TuOmguhi.mjs → metrics-Csh4nsvv.mjs} +0 -0
- /package/dist/{multipartBody-CUQGVlM_.mjs → multipartBody-CvTR1Un6.mjs} +0 -0
- /package/dist/{pluralize-CWP6MB39.mjs → pluralize-BneOJkpi.mjs} +0 -0
- /package/dist/{redis-stream-bkO88VHx.d.mts → redis-stream-CM8TXTix.d.mts} +0 -0
- /package/dist/{registry-B0Wl7uVV.mjs → registry-D63ee7fl.mjs} +0 -0
- /package/dist/{replyHelpers-BLojtuvR.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
- /package/dist/{requestContext-C38GskNt.mjs → requestContext-CfRkaxwf.mjs} +0 -0
- /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.mjs} +0 -0
- /package/dist/{sse-D8UeDwis.mjs → sse-V7aXc3bW.mjs} +0 -0
- /package/dist/{store-helpers-DYYUQbQN.mjs → store-helpers-BhrzxvyQ.mjs} +0 -0
- /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
- /package/dist/{types-D57iXYb8.mjs → types-DV9WDfeg.mjs} +0 -0
- /package/dist/{versioning-B6mimogM.mjs → versioning-CGPjkqAg.mjs} +0 -0
- /package/dist/{versioning-CeUXHfjw.d.mts → versioning-M9lNLhO8.d.mts} +0 -0
package/dist/testing/index.mjs
CHANGED
|
@@ -1,15 +1,255 @@
|
|
|
1
1
|
import { t as CRUD_OPERATIONS } from "../constants-BhY1OHoH.mjs";
|
|
2
|
-
import { n as applyFieldWritePermissions, t as applyFieldReadPermissions } from "../fields-CTMWOUDt.mjs";
|
|
3
2
|
import { runStorageContract } from "./storageContract.mjs";
|
|
4
3
|
import Fastify from "fastify";
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
//#region src/testing/authHelpers.ts
|
|
4
|
+
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
5
|
+
//#region src/testing/assertions.ts
|
|
8
6
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* expectArc — Arc-specific response assertions
|
|
8
|
+
*
|
|
9
|
+
* Wraps a Fastify `app.inject` response and exposes fluent assertions for
|
|
10
|
+
* the arc response envelope. Replaces the ~6 patterns repeated hundreds of
|
|
11
|
+
* times across the test suite:
|
|
12
|
+
*
|
|
13
|
+
* expect(res.statusCode).toBe(200);
|
|
14
|
+
* expect(JSON.parse(res.body).success).toBe(true);
|
|
15
|
+
* expect(JSON.parse(res.body).data.password).toBeUndefined();
|
|
16
|
+
*
|
|
17
|
+
* becomes
|
|
18
|
+
*
|
|
19
|
+
* expectArc(res).ok().hidesField('password');
|
|
20
|
+
*
|
|
21
|
+
* Every helper returns the assertion object so you can chain. `.body` /
|
|
22
|
+
* `.data` are lazy accessors — they parse once and cache, so repeated access
|
|
23
|
+
* is cheap.
|
|
24
|
+
*
|
|
25
|
+
* Assertions use `vitest`'s `expect` internally — import this only from
|
|
26
|
+
* test files or modules that run under vitest.
|
|
27
|
+
*/
|
|
28
|
+
function parseBody(response) {
|
|
29
|
+
if (response.body === "" || response.body === void 0 || response.body === null) return {};
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(response.body);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
throw new Error(`expectArc: response body is not valid JSON (statusCode=${response.statusCode}): ${err.message}\nBody: ${response.body.slice(0, 200)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function expectArc(response) {
|
|
37
|
+
let cachedBody = null;
|
|
38
|
+
const getBody = () => {
|
|
39
|
+
cachedBody ??= parseBody(response);
|
|
40
|
+
return cachedBody;
|
|
41
|
+
};
|
|
42
|
+
const assertion = {
|
|
43
|
+
response,
|
|
44
|
+
get body() {
|
|
45
|
+
return getBody();
|
|
46
|
+
},
|
|
47
|
+
get data() {
|
|
48
|
+
return getBody().data;
|
|
49
|
+
},
|
|
50
|
+
ok(status = 200) {
|
|
51
|
+
expect(response.statusCode, `expected 2xx (${status}) but got ${response.statusCode}. Body: ${response.body.slice(0, 200)}`).toBe(status);
|
|
52
|
+
expect(getBody().success).toBe(true);
|
|
53
|
+
return assertion;
|
|
54
|
+
},
|
|
55
|
+
failed(status) {
|
|
56
|
+
if (status !== void 0) expect(response.statusCode).toBe(status);
|
|
57
|
+
else expect(response.statusCode).toBeGreaterThanOrEqual(400);
|
|
58
|
+
expect(getBody().success).toBe(false);
|
|
59
|
+
return assertion;
|
|
60
|
+
},
|
|
61
|
+
unauthorized() {
|
|
62
|
+
return assertion.failed(401);
|
|
63
|
+
},
|
|
64
|
+
forbidden() {
|
|
65
|
+
return assertion.failed(403);
|
|
66
|
+
},
|
|
67
|
+
notFound() {
|
|
68
|
+
return assertion.failed(404);
|
|
69
|
+
},
|
|
70
|
+
validationError() {
|
|
71
|
+
return assertion.failed(400);
|
|
72
|
+
},
|
|
73
|
+
conflict() {
|
|
74
|
+
return assertion.failed(409);
|
|
75
|
+
},
|
|
76
|
+
hasData() {
|
|
77
|
+
expect(getBody().data, "expected body.data to be defined").toBeDefined();
|
|
78
|
+
return assertion;
|
|
79
|
+
},
|
|
80
|
+
hasStatus(status) {
|
|
81
|
+
expect(response.statusCode).toBe(status);
|
|
82
|
+
return assertion;
|
|
83
|
+
},
|
|
84
|
+
hidesField(field) {
|
|
85
|
+
const data = getBody().data;
|
|
86
|
+
expect(data, "expected body.data to be defined before field check").toBeDefined();
|
|
87
|
+
expect(data, `expected field '${field}' to be hidden from response.data`).not.toHaveProperty(field);
|
|
88
|
+
return assertion;
|
|
89
|
+
},
|
|
90
|
+
showsField(field) {
|
|
91
|
+
const data = getBody().data;
|
|
92
|
+
expect(data, "expected body.data to be defined before field check").toBeDefined();
|
|
93
|
+
expect(data, `expected field '${field}' on response.data`).toHaveProperty(field);
|
|
94
|
+
return assertion;
|
|
95
|
+
},
|
|
96
|
+
paginated(expected) {
|
|
97
|
+
const body = getBody();
|
|
98
|
+
expect(body.success).toBe(true);
|
|
99
|
+
const docs = body.docs ?? (Array.isArray(body.data) ? body.data : void 0);
|
|
100
|
+
expect(Array.isArray(docs), "expected `docs[]` (or `data[]`) on paginated response").toBe(true);
|
|
101
|
+
if (expected?.page !== void 0) expect(body.page).toBe(expected.page);
|
|
102
|
+
if (expected?.limit !== void 0) expect(body.limit).toBe(expected.limit);
|
|
103
|
+
if (expected?.total !== void 0) expect(body.total).toBe(expected.total);
|
|
104
|
+
if (expected?.hasNext !== void 0) expect(body.hasNext).toBe(expected.hasNext);
|
|
105
|
+
if (expected?.hasPrev !== void 0) expect(body.hasPrev).toBe(expected.hasPrev);
|
|
106
|
+
return assertion;
|
|
107
|
+
},
|
|
108
|
+
hasError(matcher) {
|
|
109
|
+
const body = getBody();
|
|
110
|
+
const errorField = body.error ?? body.message;
|
|
111
|
+
expect(errorField, "expected body.error or body.message to be set").toBeDefined();
|
|
112
|
+
if (typeof matcher === "string") expect(errorField).toBe(matcher);
|
|
113
|
+
else expect(errorField).toMatch(matcher);
|
|
114
|
+
return assertion;
|
|
115
|
+
},
|
|
116
|
+
hasMeta(key, value) {
|
|
117
|
+
const body = getBody();
|
|
118
|
+
const flat = body[key];
|
|
119
|
+
const nested = body.meta?.[key];
|
|
120
|
+
const resolved = flat !== void 0 ? flat : nested;
|
|
121
|
+
expect(resolved, `expected meta.${key} on body`).toBeDefined();
|
|
122
|
+
if (value !== void 0) expect(resolved).toEqual(value);
|
|
123
|
+
return assertion;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
return assertion;
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/testing/authSession.ts
|
|
130
|
+
function buildHeaders(token, orgId, extra) {
|
|
131
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
132
|
+
if (orgId) headers["x-organization-id"] = orgId;
|
|
133
|
+
if (extra) Object.assign(headers, extra);
|
|
134
|
+
return headers;
|
|
135
|
+
}
|
|
136
|
+
function freezeSession(session) {
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
...session,
|
|
139
|
+
headers: Object.freeze({ ...session.headers })
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function createProvider(deps) {
|
|
143
|
+
const registry = /* @__PURE__ */ new Map();
|
|
144
|
+
const build = (role, config) => {
|
|
145
|
+
const token = deps.mintToken(role, config);
|
|
146
|
+
const orgId = config.orgId ?? deps.defaultOrgId;
|
|
147
|
+
const headers = buildHeaders(token, orgId, config.extraHeaders);
|
|
148
|
+
const withExtra = (extra) => freezeSession({
|
|
149
|
+
role,
|
|
150
|
+
token,
|
|
151
|
+
orgId,
|
|
152
|
+
user: config.user,
|
|
153
|
+
headers: {
|
|
154
|
+
...headers,
|
|
155
|
+
...extra
|
|
156
|
+
},
|
|
157
|
+
withExtra
|
|
158
|
+
});
|
|
159
|
+
return freezeSession({
|
|
160
|
+
role,
|
|
161
|
+
token,
|
|
162
|
+
orgId,
|
|
163
|
+
user: config.user,
|
|
164
|
+
headers,
|
|
165
|
+
withExtra
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
register(role, config) {
|
|
170
|
+
if (!config.user && !config.token) throw new Error(`TestAuthProvider.register('${role}'): must supply either 'user' (JWT payload to sign) or 'token' (pre-signed bearer).`);
|
|
171
|
+
registry.set(role, config);
|
|
172
|
+
},
|
|
173
|
+
as(role) {
|
|
174
|
+
const config = registry.get(role);
|
|
175
|
+
if (!config) throw new Error(`TestAuthProvider.as('${role}'): unknown role. Registered: [${[...registry.keys()].join(", ") || "none"}]`);
|
|
176
|
+
return build(role, config);
|
|
177
|
+
},
|
|
178
|
+
anonymous() {
|
|
179
|
+
const withExtra = (extra) => freezeSession({
|
|
180
|
+
role: "anonymous",
|
|
181
|
+
token: "",
|
|
182
|
+
orgId: void 0,
|
|
183
|
+
user: void 0,
|
|
184
|
+
headers: { ...extra },
|
|
185
|
+
withExtra
|
|
186
|
+
});
|
|
187
|
+
return freezeSession({
|
|
188
|
+
role: "anonymous",
|
|
189
|
+
token: "",
|
|
190
|
+
orgId: void 0,
|
|
191
|
+
user: void 0,
|
|
192
|
+
headers: {},
|
|
193
|
+
withExtra
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
get roles() {
|
|
197
|
+
return [...registry.keys()];
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* JWT provider — signs tokens on-the-fly using `app.jwt.sign()`.
|
|
203
|
+
* Requires `@fastify/jwt` registered on the app.
|
|
204
|
+
*
|
|
205
|
+
* Accepts both `user` (payload to sign) and `token` (pre-signed) role configs,
|
|
206
|
+
* so the same provider handles mixed flows in a single test.
|
|
207
|
+
*/
|
|
208
|
+
function createJwtAuthProvider(app, opts = {}) {
|
|
209
|
+
return createProvider({
|
|
210
|
+
defaultOrgId: opts.defaultOrgId,
|
|
211
|
+
mintToken(role, config) {
|
|
212
|
+
if (config.token) return config.token;
|
|
213
|
+
if (!config.user) throw new Error(`[jwt] role '${role}' has neither 'user' nor 'token'`);
|
|
214
|
+
const jwt = app.jwt;
|
|
215
|
+
if (!jwt?.sign) throw new Error(`[jwt] app.jwt.sign() is unavailable — register @fastify/jwt before calling createJwtAuthProvider.`);
|
|
216
|
+
return jwt.sign(config.user);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Better Auth provider — uses pre-signed tokens (from signUp/signIn flows).
|
|
222
|
+
* No signing: role configs MUST carry `token`. A `user` alone will throw.
|
|
223
|
+
*/
|
|
224
|
+
function createBetterAuthProvider(opts = {}) {
|
|
225
|
+
return createProvider({
|
|
226
|
+
defaultOrgId: opts.defaultOrgId,
|
|
227
|
+
mintToken(role, config) {
|
|
228
|
+
if (!config.token) throw new Error(`[better-auth] role '${role}' requires a pre-signed 'token' (from signUp/signIn). JWT payloads ('user') are not supported by this provider.`);
|
|
229
|
+
return config.token;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Custom provider — plug in your own token minting logic. Useful for
|
|
235
|
+
* mocked external issuers, session-cookie flows, or fixtures that pre-mint.
|
|
236
|
+
*/
|
|
237
|
+
function createCustomAuthProvider(mintToken, opts = {}) {
|
|
238
|
+
return createProvider({
|
|
239
|
+
defaultOrgId: opts.defaultOrgId,
|
|
240
|
+
mintToken
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region src/testing/betterAuth.ts
|
|
245
|
+
const DEFAULT_BASE_PATH = "/api/auth";
|
|
246
|
+
/**
|
|
247
|
+
* Parse a JSON body safely. Returns null when empty or malformed — Better
|
|
248
|
+
* Auth endpoints occasionally emit empty 204 bodies (e.g. set-active) and
|
|
249
|
+
* tests shouldn't crash on the parse.
|
|
11
250
|
*/
|
|
12
251
|
function safeParseBody(body) {
|
|
252
|
+
if (!body) return null;
|
|
13
253
|
try {
|
|
14
254
|
return JSON.parse(body);
|
|
15
255
|
} catch {
|
|
@@ -17,712 +257,397 @@ function safeParseBody(body) {
|
|
|
17
257
|
}
|
|
18
258
|
}
|
|
19
259
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
260
|
+
* Extract a Better Auth session token from a response. Different versions
|
|
261
|
+
* return it under different keys (`token`, `session.token`, `data.token`)
|
|
262
|
+
* — check all three so the helper keeps working across minor-version
|
|
263
|
+
* bumps without a coordinated update.
|
|
264
|
+
*/
|
|
265
|
+
function extractToken(body) {
|
|
266
|
+
if (!body || typeof body !== "object") return null;
|
|
267
|
+
const obj = body;
|
|
268
|
+
if (typeof obj.token === "string") return obj.token;
|
|
269
|
+
const session = obj.session;
|
|
270
|
+
if (session && typeof session.token === "string") return session.token;
|
|
271
|
+
const data = obj.data;
|
|
272
|
+
if (data && typeof data.token === "string") return data.token;
|
|
273
|
+
if (data?.session && typeof data.session.token === "string") return data.session.token;
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Extract the user id from a response. Same tolerance story as
|
|
278
|
+
* `extractToken` — Better Auth has shuffled this field across versions.
|
|
279
|
+
*/
|
|
280
|
+
function extractUserId(body) {
|
|
281
|
+
if (!body || typeof body !== "object") return null;
|
|
282
|
+
const obj = body;
|
|
283
|
+
const userLike = obj.user ?? obj.data ?? obj;
|
|
284
|
+
if (!userLike) return null;
|
|
285
|
+
if (typeof userLike.id === "string") return userLike.id;
|
|
286
|
+
if (typeof userLike.userId === "string") return userLike.userId;
|
|
287
|
+
const nestedUser = userLike.user;
|
|
288
|
+
if (nestedUser && typeof nestedUser.id === "string") return nestedUser.id;
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
/** Same shape-tolerance for org ids. */
|
|
292
|
+
function extractOrgId(body) {
|
|
293
|
+
if (!body || typeof body !== "object") return null;
|
|
294
|
+
const obj = body;
|
|
295
|
+
if (typeof obj.id === "string") return obj.id;
|
|
296
|
+
const organization = obj.organization;
|
|
297
|
+
if (organization && typeof organization.id === "string") return organization.id;
|
|
298
|
+
const data = obj.data;
|
|
299
|
+
if (data && typeof data.id === "string") return data.id;
|
|
300
|
+
if (data) {
|
|
301
|
+
const nestedOrg = data.organization;
|
|
302
|
+
if (nestedOrg && typeof nestedOrg.id === "string") return nestedOrg.id;
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Stateless Better Auth helpers. Each function takes the app as a positional
|
|
308
|
+
* argument, so a single helper instance works across multiple test apps in
|
|
309
|
+
* the same suite.
|
|
24
310
|
*/
|
|
25
311
|
function createBetterAuthTestHelpers(options = {}) {
|
|
26
|
-
const basePath = options.basePath ??
|
|
312
|
+
const basePath = options.basePath ?? DEFAULT_BASE_PATH;
|
|
27
313
|
return {
|
|
28
|
-
async signUp(app,
|
|
314
|
+
async signUp(app, input) {
|
|
29
315
|
const res = await app.inject({
|
|
30
316
|
method: "POST",
|
|
31
317
|
url: `${basePath}/sign-up/email`,
|
|
32
|
-
payload:
|
|
318
|
+
payload: input,
|
|
319
|
+
headers: { "content-type": "application/json" }
|
|
33
320
|
});
|
|
34
|
-
const token = res.headers["set-auth-token"];
|
|
35
321
|
const body = safeParseBody(res.body);
|
|
322
|
+
const token = extractToken(body) ?? "";
|
|
323
|
+
const userId = extractUserId(body) ?? "";
|
|
36
324
|
return {
|
|
37
325
|
statusCode: res.statusCode,
|
|
38
|
-
token
|
|
39
|
-
|
|
326
|
+
token,
|
|
327
|
+
userId,
|
|
40
328
|
body
|
|
41
329
|
};
|
|
42
330
|
},
|
|
43
|
-
async signIn(app,
|
|
331
|
+
async signIn(app, input) {
|
|
44
332
|
const res = await app.inject({
|
|
45
333
|
method: "POST",
|
|
46
334
|
url: `${basePath}/sign-in/email`,
|
|
47
|
-
payload:
|
|
335
|
+
payload: input,
|
|
336
|
+
headers: { "content-type": "application/json" }
|
|
48
337
|
});
|
|
49
|
-
const token = res.headers["set-auth-token"];
|
|
50
338
|
const body = safeParseBody(res.body);
|
|
339
|
+
const token = extractToken(body) ?? "";
|
|
340
|
+
const userId = extractUserId(body) ?? "";
|
|
51
341
|
return {
|
|
52
342
|
statusCode: res.statusCode,
|
|
53
|
-
token
|
|
54
|
-
|
|
343
|
+
token,
|
|
344
|
+
userId,
|
|
55
345
|
body
|
|
56
346
|
};
|
|
57
347
|
},
|
|
58
|
-
async createOrg(app, token,
|
|
348
|
+
async createOrg(app, token, input) {
|
|
59
349
|
const res = await app.inject({
|
|
60
350
|
method: "POST",
|
|
61
351
|
url: `${basePath}/organization/create`,
|
|
62
|
-
|
|
63
|
-
|
|
352
|
+
payload: input,
|
|
353
|
+
headers: {
|
|
354
|
+
"content-type": "application/json",
|
|
355
|
+
authorization: `Bearer ${token}`
|
|
356
|
+
}
|
|
64
357
|
});
|
|
65
358
|
const body = safeParseBody(res.body);
|
|
359
|
+
const orgId = extractOrgId(body) ?? "";
|
|
66
360
|
return {
|
|
67
361
|
statusCode: res.statusCode,
|
|
68
|
-
orgId
|
|
362
|
+
orgId,
|
|
69
363
|
body
|
|
70
364
|
};
|
|
71
365
|
},
|
|
72
366
|
async setActiveOrg(app, token, orgId) {
|
|
73
|
-
|
|
367
|
+
return app.inject({
|
|
74
368
|
method: "POST",
|
|
75
369
|
url: `${basePath}/organization/set-active`,
|
|
76
|
-
|
|
77
|
-
|
|
370
|
+
payload: { organizationId: orgId },
|
|
371
|
+
headers: {
|
|
372
|
+
"content-type": "application/json",
|
|
373
|
+
authorization: `Bearer ${token}`
|
|
374
|
+
}
|
|
78
375
|
});
|
|
79
|
-
return {
|
|
80
|
-
statusCode: res.statusCode,
|
|
81
|
-
body: safeParseBody(res.body)
|
|
82
|
-
};
|
|
83
376
|
},
|
|
84
377
|
authHeaders(token, orgId) {
|
|
85
|
-
const
|
|
86
|
-
if (orgId)
|
|
87
|
-
return
|
|
378
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
379
|
+
if (orgId) headers["x-organization-id"] = orgId;
|
|
380
|
+
return headers;
|
|
88
381
|
}
|
|
89
382
|
};
|
|
90
383
|
}
|
|
91
384
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* },
|
|
110
|
-
* });
|
|
111
|
-
*
|
|
112
|
-
* // Use in tests:
|
|
113
|
-
* const res = await ctx.app.inject({
|
|
114
|
-
* method: 'GET',
|
|
115
|
-
* url: '/api/products',
|
|
116
|
-
* headers: auth.authHeaders(ctx.users.admin.token, ctx.orgId),
|
|
117
|
-
* });
|
|
118
|
-
*
|
|
119
|
-
* // Cleanup:
|
|
120
|
-
* await ctx.teardown();
|
|
121
|
-
* ```
|
|
385
|
+
* Composite setup for Better Auth apps. Replaces the pre-v2.11
|
|
386
|
+
* `setupBetterAuthOrg` with a tighter contract:
|
|
387
|
+
*
|
|
388
|
+
* 1. Accept an already-built `app` (caller owns its lifecycle — arc's
|
|
389
|
+
* `createTestApp` composes naturally, but any built Fastify works).
|
|
390
|
+
* 2. Sign up every user in order.
|
|
391
|
+
* 3. The creator user creates the org; orgId is captured.
|
|
392
|
+
* 4. Every non-creator user is added via the caller-supplied `addMember`
|
|
393
|
+
* (Better Auth's org-member API is app-specific, so arc doesn't
|
|
394
|
+
* hardcode it).
|
|
395
|
+
* 5. Set the active org on every user.
|
|
396
|
+
* 6. Register each user into a fresh `TestAuthProvider` — the 2.11
|
|
397
|
+
* `.as(key).headers` pattern works out of the box on the result.
|
|
398
|
+
*
|
|
399
|
+
* Exactly one user must be `isCreator: true`. Throws if zero or multiple
|
|
400
|
+
* creators are supplied (ambiguous ownership is a boot-time bug, not a
|
|
401
|
+
* runtime one).
|
|
122
402
|
*/
|
|
123
|
-
async function
|
|
124
|
-
const {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
password: userConfig.password,
|
|
135
|
-
name: userConfig.name
|
|
136
|
-
});
|
|
137
|
-
if (signup.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to sign up ${userConfig.email} (status ${signup.statusCode})`);
|
|
138
|
-
signups.set(userConfig.key, signup);
|
|
139
|
-
}
|
|
140
|
-
const creatorConfig = creators[0];
|
|
141
|
-
const creatorSignup = signups.get(creatorConfig.key);
|
|
142
|
-
const orgResult = await helpers.createOrg(app, creatorSignup.token, org);
|
|
143
|
-
if (orgResult.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to create org (status ${orgResult.statusCode})`);
|
|
144
|
-
const orgId = orgResult.orgId;
|
|
145
|
-
for (const userConfig of userConfigs) {
|
|
146
|
-
if (userConfig.isCreator) continue;
|
|
147
|
-
const result = await addMember({
|
|
148
|
-
organizationId: orgId,
|
|
149
|
-
userId: signups.get(userConfig.key).user?.id,
|
|
150
|
-
role: userConfig.role
|
|
403
|
+
async function setupBetterAuthTestApp(input) {
|
|
404
|
+
const { app, org, users, addMember, basePath } = input;
|
|
405
|
+
const creators = users.filter((u) => u.isCreator === true);
|
|
406
|
+
if (creators.length !== 1) throw new Error(`[arc-testing] setupBetterAuthTestApp: expected exactly one user with 'isCreator: true', got ${creators.length}. Every composite setup needs a single org owner to resolve ambiguous org membership.`);
|
|
407
|
+
const helpers = createBetterAuthTestHelpers({ basePath });
|
|
408
|
+
const signedUp = {};
|
|
409
|
+
for (const u of users) {
|
|
410
|
+
const res = await helpers.signUp(app, {
|
|
411
|
+
email: u.email,
|
|
412
|
+
password: u.password,
|
|
413
|
+
name: u.name
|
|
151
414
|
});
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const signup = signups.get(userConfig.key);
|
|
158
|
-
users[userConfig.key] = {
|
|
159
|
-
token: signup.token,
|
|
160
|
-
userId: signup.user?.id,
|
|
161
|
-
email: userConfig.email
|
|
415
|
+
if (res.statusCode >= 400 || !res.token || !res.userId) throw new Error(`[arc-testing] setupBetterAuthTestApp: signUp failed for '${u.key}' (${u.email}). statusCode=${res.statusCode}, token=${res.token ? "ok" : "missing"}, userId=${res.userId ? "ok" : "missing"}, body=${JSON.stringify(res.body).slice(0, 300)}`);
|
|
416
|
+
signedUp[u.key] = {
|
|
417
|
+
userId: res.userId,
|
|
418
|
+
token: res.token,
|
|
419
|
+
user: u
|
|
162
420
|
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
421
|
+
}
|
|
422
|
+
const creatorRec = signedUp[creators[0].key];
|
|
423
|
+
const orgRes = await helpers.createOrg(app, creatorRec.token, org);
|
|
424
|
+
if (orgRes.statusCode >= 400 || !orgRes.orgId) throw new Error(`[arc-testing] setupBetterAuthTestApp: createOrg failed. statusCode=${orgRes.statusCode}, orgId=${orgRes.orgId ? "ok" : "missing"}, body=${JSON.stringify(orgRes.body).slice(0, 300)}`);
|
|
425
|
+
const orgId = orgRes.orgId;
|
|
426
|
+
for (const u of users) {
|
|
427
|
+
if (u.isCreator === true) continue;
|
|
428
|
+
if (!addMember) continue;
|
|
429
|
+
const rec = signedUp[u.key];
|
|
430
|
+
const res = await addMember({
|
|
431
|
+
app,
|
|
432
|
+
creatorToken: creatorRec.token,
|
|
433
|
+
orgId,
|
|
434
|
+
userId: rec.userId,
|
|
435
|
+
role: u.role ?? "member"
|
|
167
436
|
});
|
|
168
|
-
|
|
169
|
-
users[userConfig.key] = {
|
|
170
|
-
token: login.token,
|
|
171
|
-
userId: signups.get(userConfig.key)?.user?.id,
|
|
172
|
-
email: userConfig.email
|
|
173
|
-
};
|
|
437
|
+
if (res.statusCode >= 400) throw new Error(`[arc-testing] setupBetterAuthTestApp: addMember failed for '${u.key}'. statusCode=${res.statusCode}, body=${res.body.slice(0, 300)}`);
|
|
174
438
|
}
|
|
175
|
-
const
|
|
439
|
+
for (const rec of Object.values(signedUp)) await helpers.setActiveOrg(app, rec.token, orgId);
|
|
440
|
+
const auth = createBetterAuthProvider({ defaultOrgId: orgId });
|
|
441
|
+
for (const [key, rec] of Object.entries(signedUp)) auth.register(key, {
|
|
442
|
+
token: rec.token,
|
|
443
|
+
orgId
|
|
444
|
+
});
|
|
445
|
+
return {
|
|
176
446
|
app,
|
|
177
447
|
orgId,
|
|
178
|
-
users,
|
|
448
|
+
users: Object.fromEntries(Object.entries(signedUp).map(([key, rec]) => [key, {
|
|
449
|
+
userId: rec.userId,
|
|
450
|
+
token: rec.token,
|
|
451
|
+
email: rec.user.email,
|
|
452
|
+
...rec.user.role ? { role: rec.user.role } : {}
|
|
453
|
+
}])),
|
|
454
|
+
auth,
|
|
179
455
|
async teardown() {
|
|
180
456
|
await app.close();
|
|
181
457
|
}
|
|
182
458
|
};
|
|
183
|
-
if (afterSetup) await afterSetup(ctx);
|
|
184
|
-
return ctx;
|
|
185
459
|
}
|
|
186
460
|
//#endregion
|
|
187
|
-
//#region src/testing/
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
var TestDatabase = class {
|
|
192
|
-
connection;
|
|
193
|
-
dbName;
|
|
194
|
-
constructor(dbName = `test_${Date.now()}`) {
|
|
195
|
-
this.dbName = dbName;
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* Connect to test database
|
|
199
|
-
*/
|
|
200
|
-
async connect(uri) {
|
|
201
|
-
const fullUri = `${uri || process.env.MONGO_TEST_URI || "mongodb://localhost:27017"}/${this.dbName}`;
|
|
202
|
-
this.connection = await mongoose.createConnection(fullUri).asPromise();
|
|
203
|
-
return this.connection;
|
|
204
|
-
}
|
|
205
|
-
/**
|
|
206
|
-
* Disconnect and cleanup
|
|
207
|
-
*/
|
|
208
|
-
async disconnect() {
|
|
209
|
-
if (this.connection) {
|
|
210
|
-
await this.connection.dropDatabase();
|
|
211
|
-
await this.connection.close();
|
|
212
|
-
this.connection = void 0;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Clear all collections
|
|
217
|
-
*/
|
|
218
|
-
async clear() {
|
|
219
|
-
if (!this.connection?.db) throw new Error("Database not connected");
|
|
220
|
-
const collections = await this.connection.db.collections();
|
|
221
|
-
await Promise.all(collections.map((collection) => collection.deleteMany({})));
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* Get connection
|
|
225
|
-
*/
|
|
226
|
-
getConnection() {
|
|
227
|
-
if (!this.connection) throw new Error("Database not connected");
|
|
228
|
-
return this.connection;
|
|
229
|
-
}
|
|
230
|
-
};
|
|
231
|
-
/**
|
|
232
|
-
* Higher-order function to wrap tests with database setup/teardown
|
|
233
|
-
*
|
|
234
|
-
* @example
|
|
235
|
-
* describe('Product Tests', () => {
|
|
236
|
-
* withTestDb(async (db) => {
|
|
237
|
-
* test('create product', async () => {
|
|
238
|
-
* const Product = db.getConnection().model('Product', schema);
|
|
239
|
-
* const product = await Product.create({ name: 'Test' });
|
|
240
|
-
* expect(product.name).toBe('Test');
|
|
241
|
-
* });
|
|
242
|
-
* });
|
|
243
|
-
* });
|
|
244
|
-
*/
|
|
245
|
-
function withTestDb(tests, options = {}) {
|
|
246
|
-
const db = new TestDatabase(options.dbName);
|
|
247
|
-
beforeAll(async () => {
|
|
248
|
-
await db.connect(options.uri);
|
|
249
|
-
});
|
|
250
|
-
afterAll(async () => {
|
|
251
|
-
await db.disconnect();
|
|
252
|
-
});
|
|
253
|
-
afterEach(async () => {
|
|
254
|
-
await db.clear();
|
|
255
|
-
});
|
|
256
|
-
tests(db);
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* Create test fixtures
|
|
260
|
-
*
|
|
261
|
-
* @example
|
|
262
|
-
* const fixtures = new TestFixtures(connection);
|
|
263
|
-
*
|
|
264
|
-
* await fixtures.load('products', [
|
|
265
|
-
* { name: 'Product 1', price: 100 },
|
|
266
|
-
* { name: 'Product 2', price: 200 },
|
|
267
|
-
* ]);
|
|
268
|
-
*
|
|
269
|
-
* const products = await fixtures.get('products');
|
|
270
|
-
*/
|
|
271
|
-
var TestFixtures = class {
|
|
272
|
-
fixtures = /* @__PURE__ */ new Map();
|
|
273
|
-
connection;
|
|
274
|
-
constructor(connection) {
|
|
275
|
-
this.connection = connection;
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Load fixtures into a collection
|
|
279
|
-
*/
|
|
280
|
-
async load(collectionName, data) {
|
|
281
|
-
const result = await this.connection.collection(collectionName).insertMany(data);
|
|
282
|
-
const insertedDocs = Object.values(result.insertedIds).map((id, index) => ({
|
|
283
|
-
...data[index],
|
|
284
|
-
_id: id
|
|
285
|
-
}));
|
|
286
|
-
this.fixtures.set(collectionName, insertedDocs);
|
|
287
|
-
return insertedDocs;
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* Get loaded fixtures
|
|
291
|
-
*/
|
|
292
|
-
get(collectionName) {
|
|
293
|
-
return this.fixtures.get(collectionName) || [];
|
|
294
|
-
}
|
|
295
|
-
/**
|
|
296
|
-
* Get first fixture
|
|
297
|
-
*/
|
|
298
|
-
getFirst(collectionName) {
|
|
299
|
-
return this.get(collectionName)[0] || null;
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* Clear all fixtures
|
|
303
|
-
*/
|
|
304
|
-
async clear() {
|
|
305
|
-
for (const collectionName of this.fixtures.keys()) {
|
|
306
|
-
const collection = this.connection.collection(collectionName);
|
|
307
|
-
const ids = this.fixtures.get(collectionName)?.map((item) => item._id) || [];
|
|
308
|
-
await collection.deleteMany({ _id: { $in: ids } });
|
|
309
|
-
}
|
|
310
|
-
this.fixtures.clear();
|
|
311
|
-
}
|
|
312
|
-
};
|
|
313
|
-
/**
|
|
314
|
-
* In-memory MongoDB for ultra-fast tests
|
|
315
|
-
*
|
|
316
|
-
* Requires: mongodb-memory-server
|
|
317
|
-
*
|
|
318
|
-
* @example
|
|
319
|
-
* import { InMemoryDatabase } from '@classytic/arc/testing';
|
|
320
|
-
*
|
|
321
|
-
* describe('Fast Tests', () => {
|
|
322
|
-
* const memoryDb = new InMemoryDatabase();
|
|
323
|
-
*
|
|
324
|
-
* beforeAll(async () => {
|
|
325
|
-
* await memoryDb.start();
|
|
326
|
-
* });
|
|
327
|
-
*
|
|
328
|
-
* afterAll(async () => {
|
|
329
|
-
* await memoryDb.stop();
|
|
330
|
-
* });
|
|
331
|
-
*
|
|
332
|
-
* test('create user', async () => {
|
|
333
|
-
* const uri = memoryDb.getUri();
|
|
334
|
-
* // Use uri for connection
|
|
335
|
-
* });
|
|
336
|
-
* });
|
|
337
|
-
*/
|
|
338
|
-
var InMemoryDatabase = class {
|
|
339
|
-
mongod;
|
|
340
|
-
uri;
|
|
341
|
-
/**
|
|
342
|
-
* Start in-memory MongoDB
|
|
343
|
-
*/
|
|
344
|
-
async start() {
|
|
345
|
-
try {
|
|
346
|
-
const { MongoMemoryServer } = await import("mongodb-memory-server");
|
|
347
|
-
this.mongod = await MongoMemoryServer.create();
|
|
348
|
-
const uri = this.mongod.getUri();
|
|
349
|
-
this.uri = uri;
|
|
350
|
-
return uri;
|
|
351
|
-
} catch {
|
|
352
|
-
throw new Error("mongodb-memory-server not installed. Install with: npm install -D mongodb-memory-server");
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
/**
|
|
356
|
-
* Stop in-memory MongoDB
|
|
357
|
-
*/
|
|
358
|
-
async stop() {
|
|
359
|
-
if (this.mongod) {
|
|
360
|
-
await this.mongod.stop();
|
|
361
|
-
this.mongod = void 0;
|
|
362
|
-
this.uri = void 0;
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
/**
|
|
366
|
-
* Get connection URI
|
|
367
|
-
*/
|
|
368
|
-
getUri() {
|
|
369
|
-
if (!this.uri) throw new Error("In-memory database not started");
|
|
370
|
-
return this.uri;
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
/**
|
|
374
|
-
* Database transaction helper for testing
|
|
375
|
-
*/
|
|
376
|
-
var TestTransaction = class {
|
|
377
|
-
session;
|
|
378
|
-
connection;
|
|
379
|
-
constructor(connection) {
|
|
380
|
-
this.connection = connection;
|
|
381
|
-
}
|
|
382
|
-
/**
|
|
383
|
-
* Start transaction
|
|
384
|
-
*/
|
|
385
|
-
async start() {
|
|
386
|
-
this.session = await this.connection.startSession();
|
|
387
|
-
this.session.startTransaction();
|
|
388
|
-
}
|
|
389
|
-
/**
|
|
390
|
-
* Commit transaction
|
|
391
|
-
*/
|
|
392
|
-
async commit() {
|
|
393
|
-
if (!this.session) throw new Error("Transaction not started");
|
|
394
|
-
await this.session.commitTransaction();
|
|
395
|
-
await this.session.endSession();
|
|
396
|
-
this.session = void 0;
|
|
397
|
-
}
|
|
398
|
-
/**
|
|
399
|
-
* Rollback transaction
|
|
400
|
-
*/
|
|
401
|
-
async rollback() {
|
|
402
|
-
if (!this.session) throw new Error("Transaction not started");
|
|
403
|
-
await this.session.abortTransaction();
|
|
404
|
-
await this.session.endSession();
|
|
405
|
-
this.session = void 0;
|
|
406
|
-
}
|
|
407
|
-
/**
|
|
408
|
-
* Get session
|
|
409
|
-
*/
|
|
410
|
-
getSession() {
|
|
411
|
-
if (!this.session) throw new Error("Transaction not started");
|
|
412
|
-
return this.session;
|
|
413
|
-
}
|
|
414
|
-
};
|
|
415
|
-
/**
|
|
416
|
-
* Seed data helper
|
|
417
|
-
*/
|
|
418
|
-
var TestSeeder = class {
|
|
419
|
-
connection;
|
|
420
|
-
constructor(connection) {
|
|
421
|
-
this.connection = connection;
|
|
422
|
-
}
|
|
423
|
-
/**
|
|
424
|
-
* Seed collection with data
|
|
425
|
-
*/
|
|
426
|
-
async seed(collectionName, generator, count = 10) {
|
|
427
|
-
const data = Array.from({ length: count }, () => generator()).flat();
|
|
428
|
-
const result = await this.connection.collection(collectionName).insertMany(data);
|
|
429
|
-
return Object.values(result.insertedIds).map((id, index) => ({
|
|
430
|
-
...data[index],
|
|
431
|
-
_id: id
|
|
432
|
-
}));
|
|
433
|
-
}
|
|
434
|
-
/**
|
|
435
|
-
* Clear collection
|
|
436
|
-
*/
|
|
437
|
-
async clear(collectionName) {
|
|
438
|
-
await this.connection.collection(collectionName).deleteMany({});
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* Clear all collections
|
|
442
|
-
*/
|
|
443
|
-
async clearAll() {
|
|
444
|
-
if (!this.connection.db) throw new Error("Database not connected");
|
|
445
|
-
const collections = await this.connection.db.collections();
|
|
446
|
-
await Promise.all(collections.map((collection) => collection.deleteMany({})));
|
|
447
|
-
}
|
|
448
|
-
};
|
|
449
|
-
/**
|
|
450
|
-
* Database snapshot helper for rollback testing
|
|
451
|
-
*/
|
|
452
|
-
var DatabaseSnapshot = class {
|
|
453
|
-
snapshots = /* @__PURE__ */ new Map();
|
|
454
|
-
connection;
|
|
455
|
-
constructor(connection) {
|
|
456
|
-
this.connection = connection;
|
|
457
|
-
}
|
|
458
|
-
/**
|
|
459
|
-
* Take snapshot of current database state
|
|
460
|
-
*/
|
|
461
|
-
async take() {
|
|
462
|
-
if (!this.connection.db) throw new Error("Database not connected");
|
|
463
|
-
const collections = await this.connection.db.collections();
|
|
464
|
-
for (const collection of collections) {
|
|
465
|
-
const data = await collection.find({}).toArray();
|
|
466
|
-
this.snapshots.set(collection.collectionName, data);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
/**
|
|
470
|
-
* Restore database to snapshot
|
|
471
|
-
*/
|
|
472
|
-
async restore() {
|
|
473
|
-
if (!this.connection.db) throw new Error("Database not connected");
|
|
474
|
-
const collections = await this.connection.db.collections();
|
|
475
|
-
await Promise.all(collections.map((collection) => collection.deleteMany({})));
|
|
476
|
-
for (const [collectionName, data] of this.snapshots.entries()) if (data.length > 0) await this.connection.collection(collectionName).insertMany(data);
|
|
477
|
-
}
|
|
478
|
-
/**
|
|
479
|
-
* Clear snapshot
|
|
480
|
-
*/
|
|
481
|
-
clear() {
|
|
482
|
-
this.snapshots.clear();
|
|
483
|
-
}
|
|
484
|
-
};
|
|
485
|
-
//#endregion
|
|
486
|
-
//#region src/testing/HttpTestHarness.ts
|
|
487
|
-
/**
|
|
488
|
-
* Create an auth provider for JWT-based apps.
|
|
489
|
-
*
|
|
490
|
-
* Generates JWT tokens on the fly using the app's JWT plugin.
|
|
491
|
-
*
|
|
492
|
-
* @example
|
|
493
|
-
* ```typescript
|
|
494
|
-
* const auth = createJwtAuthProvider({
|
|
495
|
-
* app,
|
|
496
|
-
* users: {
|
|
497
|
-
* admin: { payload: { id: '1', roles: ['admin'] }, organizationId: 'org1' },
|
|
498
|
-
* viewer: { payload: { id: '2', roles: ['viewer'] } },
|
|
499
|
-
* },
|
|
500
|
-
* adminRole: 'admin',
|
|
501
|
-
* });
|
|
502
|
-
* ```
|
|
503
|
-
*/
|
|
504
|
-
function createJwtAuthProvider(options) {
|
|
505
|
-
const { app, users, adminRole } = options;
|
|
461
|
+
//#region src/testing/fixtures.ts
|
|
462
|
+
function createTestFixtures() {
|
|
463
|
+
const registry = /* @__PURE__ */ new Map();
|
|
464
|
+
const tracked = [];
|
|
506
465
|
return {
|
|
507
|
-
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
const headers = { authorization: `Bearer ${app.jwt?.sign?.(user.payload) || "mock-token"}` };
|
|
511
|
-
if (user.organizationId) headers["x-organization-id"] = user.organizationId;
|
|
512
|
-
return headers;
|
|
466
|
+
register(name, factoryOrRegistration) {
|
|
467
|
+
const registration = typeof factoryOrRegistration === "function" ? { create: factoryOrRegistration } : factoryOrRegistration;
|
|
468
|
+
registry.set(name, registration);
|
|
513
469
|
},
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
* ```
|
|
534
|
-
*/
|
|
535
|
-
function createBetterAuthProvider(options) {
|
|
536
|
-
const { tokens, orgId, adminRole } = options;
|
|
537
|
-
return {
|
|
538
|
-
getHeaders(role) {
|
|
539
|
-
const token = tokens[role];
|
|
540
|
-
if (!token) throw new Error(`createBetterAuthProvider: No token for role '${role}'. Available: ${Object.keys(tokens).join(", ")}`);
|
|
541
|
-
return {
|
|
542
|
-
authorization: `Bearer ${token}`,
|
|
543
|
-
"x-organization-id": orgId
|
|
544
|
-
};
|
|
470
|
+
async create(name, data) {
|
|
471
|
+
const reg = registry.get(name);
|
|
472
|
+
if (!reg) throw new Error(`TestFixtures.create('${name}'): unknown factory. Registered: [${[...registry.keys()].join(", ") || "none"}]`);
|
|
473
|
+
const record = await reg.create(data ?? {});
|
|
474
|
+
tracked.push({
|
|
475
|
+
name,
|
|
476
|
+
record,
|
|
477
|
+
destroy: reg.destroy
|
|
478
|
+
});
|
|
479
|
+
return record;
|
|
480
|
+
},
|
|
481
|
+
async createMany(name, count, template) {
|
|
482
|
+
if (count < 0) throw new Error(`TestFixtures.createMany: count must be >= 0, got ${count}`);
|
|
483
|
+
const results = [];
|
|
484
|
+
for (let i = 0; i < count; i++) {
|
|
485
|
+
const record = await this.create(name, template);
|
|
486
|
+
results.push(record);
|
|
487
|
+
}
|
|
488
|
+
return results;
|
|
545
489
|
},
|
|
546
|
-
|
|
547
|
-
|
|
490
|
+
async clear() {
|
|
491
|
+
for (let i = tracked.length - 1; i >= 0; i--) {
|
|
492
|
+
const entry = tracked[i];
|
|
493
|
+
if (entry.destroy) await entry.destroy(entry.record).catch(() => {});
|
|
494
|
+
}
|
|
495
|
+
tracked.length = 0;
|
|
496
|
+
},
|
|
497
|
+
all(name) {
|
|
498
|
+
return tracked.filter((t) => t.name === name).map((t) => t.record);
|
|
499
|
+
},
|
|
500
|
+
get names() {
|
|
501
|
+
return [...registry.keys()];
|
|
502
|
+
}
|
|
548
503
|
};
|
|
549
504
|
}
|
|
505
|
+
//#endregion
|
|
506
|
+
//#region src/testing/HttpTestHarness.ts
|
|
550
507
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
* Supports deferred options via a getter function, which is essential
|
|
557
|
-
* when the app instance comes from async `beforeAll()` setup.
|
|
508
|
+
* An op is "protected" (should 401 without a token) unless the resource
|
|
509
|
+
* explicitly wired `allowPublic()` — that's the same rule arc's router uses
|
|
510
|
+
* via `requiresAuthentication`. Treats absent permission as public (matches
|
|
511
|
+
* the router's behaviour for historical reasons); the harness only emits the
|
|
512
|
+
* unauthenticated 401 assertion when the op is actually protected.
|
|
558
513
|
*/
|
|
514
|
+
function opRequiresAuth(resource, op) {
|
|
515
|
+
const check = resource.permissions?.[op];
|
|
516
|
+
if (!check) return false;
|
|
517
|
+
return check._isPublic !== true;
|
|
518
|
+
}
|
|
559
519
|
var HttpTestHarness = class {
|
|
560
520
|
resource;
|
|
561
521
|
optionsOrGetter;
|
|
562
|
-
eagerBaseUrl;
|
|
563
522
|
enabledRoutes;
|
|
564
|
-
|
|
523
|
+
/**
|
|
524
|
+
* Update verbs exercised by this harness instance. One entry for single-method
|
|
525
|
+
* resources (`"PATCH"` or `"PUT"`), two for `updateMethod: "both"` so both
|
|
526
|
+
* verbs are covered — the framework mounts both, and the harness should
|
|
527
|
+
* probe both.
|
|
528
|
+
*/
|
|
529
|
+
updateMethods;
|
|
565
530
|
constructor(resource, optionsOrGetter) {
|
|
566
531
|
this.resource = resource;
|
|
567
532
|
this.optionsOrGetter = optionsOrGetter;
|
|
568
|
-
if (typeof optionsOrGetter === "function") this.eagerBaseUrl = null;
|
|
569
|
-
else {
|
|
570
|
-
const apiPrefix = optionsOrGetter.apiPrefix ?? "/api";
|
|
571
|
-
this.eagerBaseUrl = `${apiPrefix}${resource.prefix}`;
|
|
572
|
-
}
|
|
573
533
|
const disabled = new Set(resource.disabledRoutes ?? []);
|
|
574
534
|
this.enabledRoutes = new Set(resource.disableDefaultRoutes ? [] : CRUD_OPERATIONS.filter((op) => !disabled.has(op)));
|
|
575
|
-
|
|
535
|
+
const um = resource.updateMethod;
|
|
536
|
+
this.updateMethods = um === "both" ? ["PATCH", "PUT"] : um === "PUT" ? ["PUT"] : ["PATCH"];
|
|
576
537
|
}
|
|
577
|
-
/** Resolve options (supports both direct and deferred) */
|
|
578
538
|
getOptions() {
|
|
579
539
|
return typeof this.optionsOrGetter === "function" ? this.optionsOrGetter() : this.optionsOrGetter;
|
|
580
540
|
}
|
|
581
|
-
/**
|
|
582
|
-
* Resolve the base URL for requests.
|
|
583
|
-
*
|
|
584
|
-
* - Eager mode: uses pre-computed baseUrl from constructor
|
|
585
|
-
* - Deferred mode: reads apiPrefix from the getter options at runtime
|
|
586
|
-
*
|
|
587
|
-
* Must only be called inside it()/afterAll() callbacks (after beforeAll has run).
|
|
588
|
-
*/
|
|
589
541
|
getBaseUrl() {
|
|
590
|
-
if (this.eagerBaseUrl !== null) return this.eagerBaseUrl;
|
|
591
542
|
return `${this.getOptions().apiPrefix ?? ""}${this.resource.prefix}`;
|
|
592
543
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
544
|
+
adminHeaders() {
|
|
545
|
+
const opts = this.getOptions();
|
|
546
|
+
return { ...opts.auth.as(opts.adminRole).headers };
|
|
547
|
+
}
|
|
596
548
|
runAll() {
|
|
597
549
|
this.runCrud();
|
|
598
550
|
this.runPermissions();
|
|
599
551
|
this.runValidation();
|
|
600
552
|
}
|
|
601
|
-
/**
|
|
602
|
-
* Run HTTP-level CRUD tests.
|
|
603
|
-
*
|
|
604
|
-
* Tests each enabled CRUD operation through app.inject():
|
|
605
|
-
* - POST (create) → 200/201 with { success: true, data }
|
|
606
|
-
* - GET (list) → 200 with array or paginated response
|
|
607
|
-
* - GET /:id → 200 with { success: true, data }
|
|
608
|
-
* - PATCH/PUT /:id → 200 with { success: true, data }
|
|
609
|
-
* - DELETE /:id → 200
|
|
610
|
-
* - GET /:id with non-existent ID → 404
|
|
611
|
-
*/
|
|
612
553
|
runCrud() {
|
|
613
|
-
const { resource, enabledRoutes,
|
|
554
|
+
const { resource, enabledRoutes, updateMethods } = this;
|
|
614
555
|
let createdId = null;
|
|
615
556
|
describe(`${resource.displayName} HTTP CRUD`, () => {
|
|
616
557
|
afterAll(async () => {
|
|
617
558
|
if (createdId && enabledRoutes.has("delete")) {
|
|
618
|
-
const { app
|
|
619
|
-
const baseUrl = this.getBaseUrl();
|
|
559
|
+
const { app } = this.getOptions();
|
|
620
560
|
await app.inject({
|
|
621
561
|
method: "DELETE",
|
|
622
|
-
url: `${
|
|
623
|
-
headers:
|
|
562
|
+
url: `${this.getBaseUrl()}/${createdId}`,
|
|
563
|
+
headers: this.adminHeaders()
|
|
624
564
|
});
|
|
625
565
|
}
|
|
626
566
|
});
|
|
627
567
|
if (enabledRoutes.has("create")) it("POST should create a resource", async () => {
|
|
628
|
-
const { app,
|
|
629
|
-
const baseUrl = this.getBaseUrl();
|
|
630
|
-
const adminHeaders = auth.getHeaders(auth.adminRole);
|
|
568
|
+
const { app, fixtures } = this.getOptions();
|
|
631
569
|
const res = await app.inject({
|
|
632
570
|
method: "POST",
|
|
633
|
-
url:
|
|
634
|
-
headers: adminHeaders,
|
|
571
|
+
url: this.getBaseUrl(),
|
|
572
|
+
headers: this.adminHeaders(),
|
|
635
573
|
payload: fixtures.valid
|
|
636
574
|
});
|
|
637
575
|
expect(res.statusCode).toBeLessThan(300);
|
|
638
576
|
const body = JSON.parse(res.body);
|
|
639
577
|
expect(body.success).toBe(true);
|
|
640
|
-
expect(body.data).toBeDefined();
|
|
641
|
-
expect(body.data._id).toBeDefined();
|
|
578
|
+
expect(body.data?._id).toBeDefined();
|
|
642
579
|
createdId = body.data._id;
|
|
643
580
|
});
|
|
644
581
|
if (enabledRoutes.has("list")) it("GET should list resources", async () => {
|
|
645
|
-
const { app
|
|
646
|
-
const baseUrl = this.getBaseUrl();
|
|
582
|
+
const { app } = this.getOptions();
|
|
647
583
|
const res = await app.inject({
|
|
648
584
|
method: "GET",
|
|
649
|
-
url:
|
|
650
|
-
headers:
|
|
585
|
+
url: this.getBaseUrl(),
|
|
586
|
+
headers: this.adminHeaders()
|
|
651
587
|
});
|
|
652
588
|
expect(res.statusCode).toBe(200);
|
|
653
589
|
const body = JSON.parse(res.body);
|
|
654
590
|
expect(body.success).toBe(true);
|
|
655
591
|
const list = body.data ?? body.docs;
|
|
656
|
-
expect(list).toBeDefined();
|
|
657
592
|
expect(Array.isArray(list)).toBe(true);
|
|
658
593
|
});
|
|
659
594
|
if (enabledRoutes.has("get")) {
|
|
660
595
|
it("GET /:id should return the resource", async () => {
|
|
661
596
|
if (!createdId) return;
|
|
662
|
-
const { app
|
|
663
|
-
const baseUrl = this.getBaseUrl();
|
|
597
|
+
const { app } = this.getOptions();
|
|
664
598
|
const res = await app.inject({
|
|
665
599
|
method: "GET",
|
|
666
|
-
url: `${
|
|
667
|
-
headers:
|
|
600
|
+
url: `${this.getBaseUrl()}/${createdId}`,
|
|
601
|
+
headers: this.adminHeaders()
|
|
668
602
|
});
|
|
669
603
|
expect(res.statusCode).toBe(200);
|
|
670
|
-
|
|
671
|
-
expect(body.success).toBe(true);
|
|
672
|
-
expect(body.data).toBeDefined();
|
|
673
|
-
expect(body.data._id).toBe(createdId);
|
|
604
|
+
expect(JSON.parse(res.body).data?._id).toBe(createdId);
|
|
674
605
|
});
|
|
675
606
|
it("GET /:id with non-existent ID should return 404", async () => {
|
|
676
|
-
const { app
|
|
677
|
-
const baseUrl = this.getBaseUrl();
|
|
607
|
+
const { app } = this.getOptions();
|
|
678
608
|
const res = await app.inject({
|
|
679
609
|
method: "GET",
|
|
680
|
-
url: `${
|
|
681
|
-
headers:
|
|
610
|
+
url: `${this.getBaseUrl()}/000000000000000000000000`,
|
|
611
|
+
headers: this.adminHeaders()
|
|
682
612
|
});
|
|
683
613
|
expect(res.statusCode).toBe(404);
|
|
684
614
|
expect(JSON.parse(res.body).success).toBe(false);
|
|
685
615
|
});
|
|
686
616
|
}
|
|
687
|
-
if (enabledRoutes.has("update")) {
|
|
688
|
-
it(`${
|
|
617
|
+
if (enabledRoutes.has("update")) for (const verb of updateMethods) {
|
|
618
|
+
it(`${verb} /:id should update the resource`, async () => {
|
|
689
619
|
if (!createdId) return;
|
|
690
|
-
const { app,
|
|
691
|
-
const
|
|
692
|
-
const updatePayload = fixtures.update || fixtures.valid;
|
|
620
|
+
const { app, fixtures } = this.getOptions();
|
|
621
|
+
const payload = fixtures.update ?? fixtures.valid;
|
|
693
622
|
const res = await app.inject({
|
|
694
|
-
method:
|
|
695
|
-
url: `${
|
|
696
|
-
headers:
|
|
697
|
-
payload
|
|
623
|
+
method: verb,
|
|
624
|
+
url: `${this.getBaseUrl()}/${createdId}`,
|
|
625
|
+
headers: this.adminHeaders(),
|
|
626
|
+
payload
|
|
698
627
|
});
|
|
699
628
|
expect(res.statusCode).toBe(200);
|
|
700
|
-
|
|
701
|
-
expect(body.success).toBe(true);
|
|
702
|
-
expect(body.data).toBeDefined();
|
|
629
|
+
expect(JSON.parse(res.body).success).toBe(true);
|
|
703
630
|
});
|
|
704
|
-
it(`${
|
|
705
|
-
const { app,
|
|
706
|
-
const
|
|
631
|
+
it(`${verb} /:id with non-existent ID should return 404`, async () => {
|
|
632
|
+
const { app, fixtures } = this.getOptions();
|
|
633
|
+
const payload = fixtures.update ?? fixtures.valid;
|
|
707
634
|
expect((await app.inject({
|
|
708
|
-
method:
|
|
709
|
-
url: `${
|
|
710
|
-
headers:
|
|
711
|
-
payload
|
|
635
|
+
method: verb,
|
|
636
|
+
url: `${this.getBaseUrl()}/000000000000000000000000`,
|
|
637
|
+
headers: this.adminHeaders(),
|
|
638
|
+
payload
|
|
712
639
|
})).statusCode).toBe(404);
|
|
713
640
|
});
|
|
714
641
|
}
|
|
715
642
|
if (enabledRoutes.has("delete")) {
|
|
716
643
|
it("DELETE /:id should delete the resource", async () => {
|
|
717
|
-
const { app,
|
|
718
|
-
const baseUrl = this.getBaseUrl();
|
|
719
|
-
const adminHeaders = auth.getHeaders(auth.adminRole);
|
|
644
|
+
const { app, fixtures } = this.getOptions();
|
|
720
645
|
let deleteId;
|
|
721
646
|
if (enabledRoutes.has("create")) {
|
|
722
647
|
const createRes = await app.inject({
|
|
723
648
|
method: "POST",
|
|
724
|
-
url:
|
|
725
|
-
headers: adminHeaders,
|
|
649
|
+
url: this.getBaseUrl(),
|
|
650
|
+
headers: this.adminHeaders(),
|
|
726
651
|
payload: fixtures.valid
|
|
727
652
|
});
|
|
728
653
|
deleteId = JSON.parse(createRes.body).data?._id;
|
|
@@ -730,124 +655,111 @@ var HttpTestHarness = class {
|
|
|
730
655
|
if (!deleteId) return;
|
|
731
656
|
expect((await app.inject({
|
|
732
657
|
method: "DELETE",
|
|
733
|
-
url: `${
|
|
734
|
-
headers: adminHeaders
|
|
658
|
+
url: `${this.getBaseUrl()}/${deleteId}`,
|
|
659
|
+
headers: this.adminHeaders()
|
|
735
660
|
})).statusCode).toBe(200);
|
|
736
661
|
if (enabledRoutes.has("get")) expect((await app.inject({
|
|
737
662
|
method: "GET",
|
|
738
|
-
url: `${
|
|
739
|
-
headers: adminHeaders
|
|
663
|
+
url: `${this.getBaseUrl()}/${deleteId}`,
|
|
664
|
+
headers: this.adminHeaders()
|
|
740
665
|
})).statusCode).toBe(404);
|
|
741
666
|
});
|
|
742
667
|
it("DELETE /:id with non-existent ID should return 404", async () => {
|
|
743
|
-
const { app
|
|
744
|
-
const baseUrl = this.getBaseUrl();
|
|
668
|
+
const { app } = this.getOptions();
|
|
745
669
|
expect((await app.inject({
|
|
746
670
|
method: "DELETE",
|
|
747
|
-
url: `${
|
|
748
|
-
headers:
|
|
671
|
+
url: `${this.getBaseUrl()}/000000000000000000000000`,
|
|
672
|
+
headers: this.adminHeaders()
|
|
749
673
|
})).statusCode).toBe(404);
|
|
750
674
|
});
|
|
751
675
|
}
|
|
752
676
|
});
|
|
753
677
|
}
|
|
754
|
-
/**
|
|
755
|
-
* Run permission tests.
|
|
756
|
-
*
|
|
757
|
-
* Tests that:
|
|
758
|
-
* - Unauthenticated requests return 401
|
|
759
|
-
* - Admin role gets 2xx for all operations
|
|
760
|
-
*/
|
|
761
678
|
runPermissions() {
|
|
762
|
-
const { resource, enabledRoutes,
|
|
679
|
+
const { resource, enabledRoutes, updateMethods } = this;
|
|
680
|
+
const protectedOps = {
|
|
681
|
+
list: opRequiresAuth(resource, "list"),
|
|
682
|
+
get: opRequiresAuth(resource, "get"),
|
|
683
|
+
create: opRequiresAuth(resource, "create"),
|
|
684
|
+
update: opRequiresAuth(resource, "update"),
|
|
685
|
+
delete: opRequiresAuth(resource, "delete")
|
|
686
|
+
};
|
|
763
687
|
describe(`${resource.displayName} HTTP Permissions`, () => {
|
|
764
|
-
if (enabledRoutes.has("list")) it("GET list without auth should return 401", async () => {
|
|
688
|
+
if (enabledRoutes.has("list") && protectedOps.list) it("GET list without auth should return 401", async () => {
|
|
765
689
|
const { app } = this.getOptions();
|
|
766
|
-
const baseUrl = this.getBaseUrl();
|
|
767
690
|
expect((await app.inject({
|
|
768
691
|
method: "GET",
|
|
769
|
-
url:
|
|
692
|
+
url: this.getBaseUrl()
|
|
770
693
|
})).statusCode).toBe(401);
|
|
771
694
|
});
|
|
772
|
-
if (enabledRoutes.has("get")) it("GET
|
|
695
|
+
if (enabledRoutes.has("get") && protectedOps.get) it("GET /:id without auth should return 401", async () => {
|
|
773
696
|
const { app } = this.getOptions();
|
|
774
|
-
const baseUrl = this.getBaseUrl();
|
|
775
697
|
expect((await app.inject({
|
|
776
698
|
method: "GET",
|
|
777
|
-
url: `${
|
|
699
|
+
url: `${this.getBaseUrl()}/000000000000000000000000`
|
|
778
700
|
})).statusCode).toBe(401);
|
|
779
701
|
});
|
|
780
|
-
if (enabledRoutes.has("create")) it("POST
|
|
702
|
+
if (enabledRoutes.has("create") && protectedOps.create) it("POST without auth should return 401", async () => {
|
|
781
703
|
const { app, fixtures } = this.getOptions();
|
|
782
|
-
const baseUrl = this.getBaseUrl();
|
|
783
704
|
expect((await app.inject({
|
|
784
705
|
method: "POST",
|
|
785
|
-
url:
|
|
706
|
+
url: this.getBaseUrl(),
|
|
786
707
|
payload: fixtures.valid
|
|
787
708
|
})).statusCode).toBe(401);
|
|
788
709
|
});
|
|
789
|
-
if (enabledRoutes.has("update")) it(`${
|
|
710
|
+
if (enabledRoutes.has("update") && protectedOps.update) for (const verb of updateMethods) it(`${verb} without auth should return 401`, async () => {
|
|
790
711
|
const { app, fixtures } = this.getOptions();
|
|
791
|
-
const
|
|
712
|
+
const payload = fixtures.update ?? fixtures.valid;
|
|
792
713
|
expect((await app.inject({
|
|
793
|
-
method:
|
|
794
|
-
url: `${
|
|
795
|
-
payload
|
|
714
|
+
method: verb,
|
|
715
|
+
url: `${this.getBaseUrl()}/000000000000000000000000`,
|
|
716
|
+
payload
|
|
796
717
|
})).statusCode).toBe(401);
|
|
797
718
|
});
|
|
798
|
-
if (enabledRoutes.has("delete")) it("DELETE
|
|
719
|
+
if (enabledRoutes.has("delete") && protectedOps.delete) it("DELETE without auth should return 401", async () => {
|
|
799
720
|
const { app } = this.getOptions();
|
|
800
|
-
const baseUrl = this.getBaseUrl();
|
|
801
721
|
expect((await app.inject({
|
|
802
722
|
method: "DELETE",
|
|
803
|
-
url: `${
|
|
723
|
+
url: `${this.getBaseUrl()}/000000000000000000000000`
|
|
804
724
|
})).statusCode).toBe(401);
|
|
805
725
|
});
|
|
806
726
|
if (enabledRoutes.has("list")) it("admin should access list endpoint", async () => {
|
|
807
|
-
const { app
|
|
808
|
-
const baseUrl = this.getBaseUrl();
|
|
727
|
+
const { app } = this.getOptions();
|
|
809
728
|
expect((await app.inject({
|
|
810
729
|
method: "GET",
|
|
811
|
-
url:
|
|
812
|
-
headers:
|
|
730
|
+
url: this.getBaseUrl(),
|
|
731
|
+
headers: this.adminHeaders()
|
|
813
732
|
})).statusCode).toBeLessThan(400);
|
|
814
733
|
});
|
|
815
734
|
if (enabledRoutes.has("create")) it("admin should access create endpoint", async () => {
|
|
816
|
-
const { app,
|
|
817
|
-
const baseUrl = this.getBaseUrl();
|
|
735
|
+
const { app, fixtures } = this.getOptions();
|
|
818
736
|
const res = await app.inject({
|
|
819
737
|
method: "POST",
|
|
820
|
-
url:
|
|
821
|
-
headers:
|
|
738
|
+
url: this.getBaseUrl(),
|
|
739
|
+
headers: this.adminHeaders(),
|
|
822
740
|
payload: fixtures.valid
|
|
823
741
|
});
|
|
824
742
|
expect(res.statusCode).toBeLessThan(400);
|
|
825
743
|
const body = JSON.parse(res.body);
|
|
826
744
|
if (body.data?._id && enabledRoutes.has("delete")) await app.inject({
|
|
827
745
|
method: "DELETE",
|
|
828
|
-
url: `${
|
|
829
|
-
headers:
|
|
746
|
+
url: `${this.getBaseUrl()}/${body.data._id}`,
|
|
747
|
+
headers: this.adminHeaders()
|
|
830
748
|
});
|
|
831
749
|
});
|
|
832
750
|
});
|
|
833
751
|
}
|
|
834
|
-
/**
|
|
835
|
-
* Run validation tests.
|
|
836
|
-
*
|
|
837
|
-
* Tests that invalid payloads return 400.
|
|
838
|
-
*/
|
|
839
752
|
runValidation() {
|
|
840
753
|
const { resource, enabledRoutes } = this;
|
|
841
754
|
if (!enabledRoutes.has("create")) return;
|
|
842
755
|
describe(`${resource.displayName} HTTP Validation`, () => {
|
|
843
|
-
it("POST with invalid payload should
|
|
844
|
-
const { app,
|
|
845
|
-
const baseUrl = this.getBaseUrl();
|
|
756
|
+
it("POST with invalid payload should be rejected", async () => {
|
|
757
|
+
const { app, fixtures } = this.getOptions();
|
|
846
758
|
if (!fixtures.invalid) return;
|
|
847
759
|
const res = await app.inject({
|
|
848
760
|
method: "POST",
|
|
849
|
-
url:
|
|
850
|
-
headers:
|
|
761
|
+
url: this.getBaseUrl(),
|
|
762
|
+
headers: this.adminHeaders(),
|
|
851
763
|
payload: fixtures.invalid
|
|
852
764
|
});
|
|
853
765
|
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
|
@@ -857,22 +769,8 @@ var HttpTestHarness = class {
|
|
|
857
769
|
}
|
|
858
770
|
};
|
|
859
771
|
/**
|
|
860
|
-
* Create an HTTP test harness
|
|
861
|
-
*
|
|
862
|
-
* Accepts options directly or as a getter function for deferred resolution.
|
|
863
|
-
*
|
|
864
|
-
* @example Deferred (recommended for async setup)
|
|
865
|
-
* ```typescript
|
|
866
|
-
* let ctx: TestContext;
|
|
867
|
-
* beforeAll(async () => { ctx = await setupTestOrg(); });
|
|
868
|
-
*
|
|
869
|
-
* createHttpTestHarness(jobResource, () => ({
|
|
870
|
-
* app: ctx.app,
|
|
871
|
-
* apiPrefix: '',
|
|
872
|
-
* fixtures: { valid: { title: 'Test' } },
|
|
873
|
-
* auth: createBetterAuthProvider({ ... }),
|
|
874
|
-
* })).runAll();
|
|
875
|
-
* ```
|
|
772
|
+
* Create an HTTP test harness. `optionsOrGetter` may be a plain object
|
|
773
|
+
* (for eager app setup) or a getter function (for async `beforeAll` apps).
|
|
876
774
|
*/
|
|
877
775
|
function createHttpTestHarness(resource, optionsOrGetter) {
|
|
878
776
|
return new HttpTestHarness(resource, optionsOrGetter);
|
|
@@ -1118,682 +1016,133 @@ function pickResource(value) {
|
|
|
1118
1016
|
for (const c of candidates) if (c && typeof c === "object" && typeof c.toPlugin === "function") return c;
|
|
1119
1017
|
}
|
|
1120
1018
|
//#endregion
|
|
1121
|
-
//#region src/testing/
|
|
1019
|
+
//#region src/testing/testApp.ts
|
|
1122
1020
|
/**
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
* Generates baseline tests for Arc resources automatically.
|
|
1126
|
-
* Tests CRUD operations + preset routes with minimal configuration.
|
|
1021
|
+
* createTestApp — test app factory for arc
|
|
1127
1022
|
*
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1023
|
+
* One call spins up a Fastify instance with arc's standard test defaults,
|
|
1024
|
+
* an in-memory MongoDB (optional), an auth provider (JWT / Better Auth / none),
|
|
1025
|
+
* and a fixture tracker attached to the result. Every piece is optional —
|
|
1026
|
+
* tests that just need a vanilla app skip the extras.
|
|
1131
1027
|
*
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
* },
|
|
1137
|
-
* });
|
|
1138
|
-
*
|
|
1139
|
-
* // Run all baseline tests (50+ auto-generated)
|
|
1140
|
-
* harness.runAll();
|
|
1141
|
-
*
|
|
1142
|
-
* // Or run specific test suites
|
|
1143
|
-
* harness.runPresets();
|
|
1144
|
-
* harness.runValidation();
|
|
1028
|
+
* const ctx = await createTestApp({
|
|
1029
|
+
* resources: [jobResource],
|
|
1030
|
+
* authMode: 'jwt',
|
|
1031
|
+
* });
|
|
1145
1032
|
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1033
|
+
* ctx.auth.register('admin', { user: { id: '1', roles: ['admin'] } });
|
|
1034
|
+
* const admin = ctx.auth.as('admin');
|
|
1035
|
+
* const res = await ctx.app.inject({ url: '/jobs', headers: admin.headers });
|
|
1036
|
+
*
|
|
1037
|
+
* afterAll(() => ctx.close());
|
|
1038
|
+
*
|
|
1039
|
+
* Scope — what this factory does AND doesn't do:
|
|
1040
|
+
* ✓ Starts in-memory Mongo (when `db: 'in-memory'`) and exposes `dbUri`
|
|
1041
|
+
* ✓ Optionally connects Mongoose to that URI via `connectMongoose: true`
|
|
1042
|
+
* ✓ Applies arc's standard test defaults for the Fastify instance
|
|
1043
|
+
* ✓ Applies the matching auth plugin for the chosen `authMode`
|
|
1044
|
+
* ✓ Registers every resource as a plugin (under its own `prefix`)
|
|
1045
|
+
* ✓ Tears everything down in the right order on `close()`
|
|
1046
|
+
* ✗ Does NOT thread `dbUri` into your resource adapters — adapter wiring
|
|
1047
|
+
* (mongokit/prisma/sqlitekit/custom) is app-level concern. Call
|
|
1048
|
+
* `mongoose.connect(ctx.dbUri)` (or use `connectMongoose: true`) before
|
|
1049
|
+
* importing resources whose models need the connection.
|
|
1148
1050
|
*/
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
constructor(resource, options) {
|
|
1158
|
-
this.resource = resource;
|
|
1159
|
-
this.fixtures = options.fixtures;
|
|
1160
|
-
this.setupFn = options.setupFn;
|
|
1161
|
-
this.teardownFn = options.teardownFn;
|
|
1162
|
-
this.mongoUri = options.mongoUri || process.env.MONGO_URI || "mongodb://localhost:27017/test";
|
|
1163
|
-
if (!resource.adapter) throw new Error(`TestHarness requires a resource with a database adapter`);
|
|
1164
|
-
if (resource.adapter.type !== "mongoose") throw new Error(`TestHarness currently only supports Mongoose adapters`);
|
|
1165
|
-
const model = resource.adapter.model;
|
|
1166
|
-
if (!model) throw new Error(`Mongoose adapter for ${resource.name} does not have a model`);
|
|
1167
|
-
this.Model = model;
|
|
1168
|
-
}
|
|
1169
|
-
/**
|
|
1170
|
-
* Run all baseline tests (schema, presets, field permissions, pipeline, events).
|
|
1171
|
-
*
|
|
1172
|
-
* For HTTP-level CRUD coverage (routes, auth, permissions), use
|
|
1173
|
-
* {@link HttpTestHarness} instead.
|
|
1174
|
-
*/
|
|
1175
|
-
runAll() {
|
|
1176
|
-
this.runValidation();
|
|
1177
|
-
this.runPresets();
|
|
1178
|
-
this.runFieldPermissions();
|
|
1179
|
-
this.runPipeline();
|
|
1180
|
-
this.runEvents();
|
|
1181
|
-
}
|
|
1182
|
-
/**
|
|
1183
|
-
* Run validation tests
|
|
1184
|
-
*
|
|
1185
|
-
* Tests schema validation, required fields, etc.
|
|
1186
|
-
*/
|
|
1187
|
-
runValidation() {
|
|
1188
|
-
const { resource, fixtures, Model } = this;
|
|
1189
|
-
describe(`${resource.displayName} Validation`, () => {
|
|
1190
|
-
beforeAll(async () => {
|
|
1191
|
-
await mongoose.connect(this.mongoUri);
|
|
1192
|
-
});
|
|
1193
|
-
afterAll(async () => {
|
|
1194
|
-
await mongoose.disconnect();
|
|
1195
|
-
});
|
|
1196
|
-
it("should reject empty document", async () => {
|
|
1197
|
-
await expect(Model.create({})).rejects.toThrow();
|
|
1198
|
-
});
|
|
1199
|
-
if (fixtures.invalid) it("should reject invalid data", async () => {
|
|
1200
|
-
await expect(Model.create(fixtures.invalid)).rejects.toThrow();
|
|
1201
|
-
});
|
|
1202
|
-
});
|
|
1203
|
-
}
|
|
1204
|
-
/**
|
|
1205
|
-
* Run preset-specific tests
|
|
1206
|
-
*
|
|
1207
|
-
* Auto-detects applied presets and tests their functionality:
|
|
1208
|
-
* - softDelete: deletedAt field, soft delete/restore
|
|
1209
|
-
* - slugLookup: slug generation
|
|
1210
|
-
* - tree: parent references, displayOrder
|
|
1211
|
-
* - multiTenant: organizationId requirement
|
|
1212
|
-
* - ownedByUser: userId requirement
|
|
1213
|
-
*/
|
|
1214
|
-
runPresets() {
|
|
1215
|
-
const { resource, fixtures, Model } = this;
|
|
1216
|
-
const presets = resource._appliedPresets || [];
|
|
1217
|
-
if (presets.length === 0) return;
|
|
1218
|
-
describe(`${resource.displayName} Preset Tests`, () => {
|
|
1219
|
-
beforeAll(async () => {
|
|
1220
|
-
await mongoose.connect(this.mongoUri);
|
|
1221
|
-
});
|
|
1222
|
-
afterAll(async () => {
|
|
1223
|
-
await mongoose.disconnect();
|
|
1224
|
-
});
|
|
1225
|
-
if (presets.includes("softDelete")) describe("Soft Delete", () => {
|
|
1226
|
-
let testDoc;
|
|
1227
|
-
beforeEach(async () => {
|
|
1228
|
-
testDoc = await Model.create(fixtures.valid);
|
|
1229
|
-
this._createdIds.push(testDoc._id);
|
|
1230
|
-
});
|
|
1231
|
-
it("should have deletedAt field", () => {
|
|
1232
|
-
expect(testDoc.deletedAt).toBeDefined();
|
|
1233
|
-
expect(testDoc.deletedAt).toBeNull();
|
|
1234
|
-
});
|
|
1235
|
-
it("should soft delete (set deletedAt)", async () => {
|
|
1236
|
-
await Model.findByIdAndUpdate(testDoc._id, { deletedAt: /* @__PURE__ */ new Date() });
|
|
1237
|
-
expect((await Model.findById(testDoc._id))?.deletedAt).not.toBeNull();
|
|
1238
|
-
});
|
|
1239
|
-
it("should restore (clear deletedAt)", async () => {
|
|
1240
|
-
await Model.findByIdAndUpdate(testDoc._id, { deletedAt: /* @__PURE__ */ new Date() });
|
|
1241
|
-
await Model.findByIdAndUpdate(testDoc._id, { deletedAt: null });
|
|
1242
|
-
expect((await Model.findById(testDoc._id))?.deletedAt).toBeNull();
|
|
1243
|
-
});
|
|
1244
|
-
});
|
|
1245
|
-
if (presets.includes("slugLookup")) describe("Slug Lookup", () => {
|
|
1246
|
-
it("should have slug field", async () => {
|
|
1247
|
-
const doc = await Model.create(fixtures.valid);
|
|
1248
|
-
this._createdIds.push(doc._id);
|
|
1249
|
-
expect(doc.slug).toBeDefined();
|
|
1250
|
-
});
|
|
1251
|
-
it("should generate slug from name", async () => {
|
|
1252
|
-
const doc = await Model.create({
|
|
1253
|
-
...fixtures.valid,
|
|
1254
|
-
name: "Test Slug Name"
|
|
1255
|
-
});
|
|
1256
|
-
this._createdIds.push(doc._id);
|
|
1257
|
-
expect(doc.slug).toMatch(/test-slug-name/i);
|
|
1258
|
-
});
|
|
1259
|
-
});
|
|
1260
|
-
if (presets.includes("tree")) describe("Tree Structure", () => {
|
|
1261
|
-
it("should allow parent reference", async () => {
|
|
1262
|
-
const parent = await Model.create(fixtures.valid);
|
|
1263
|
-
this._createdIds.push(parent._id);
|
|
1264
|
-
const child = await Model.create({
|
|
1265
|
-
...fixtures.valid,
|
|
1266
|
-
parent: parent._id
|
|
1267
|
-
});
|
|
1268
|
-
this._createdIds.push(child._id);
|
|
1269
|
-
expect(String(child.parent)).toEqual(String(parent._id));
|
|
1270
|
-
});
|
|
1271
|
-
it("should support displayOrder", async () => {
|
|
1272
|
-
const doc = await Model.create({
|
|
1273
|
-
...fixtures.valid,
|
|
1274
|
-
displayOrder: 5
|
|
1275
|
-
});
|
|
1276
|
-
this._createdIds.push(doc._id);
|
|
1277
|
-
expect(doc.displayOrder).toEqual(5);
|
|
1278
|
-
});
|
|
1279
|
-
});
|
|
1280
|
-
if (presets.includes("multiTenant")) describe("Multi-Tenant", () => {
|
|
1281
|
-
it("should require organizationId", async () => {
|
|
1282
|
-
const docWithoutOrg = { ...fixtures.valid };
|
|
1283
|
-
delete docWithoutOrg.organizationId;
|
|
1284
|
-
await expect(Model.create(docWithoutOrg)).rejects.toThrow();
|
|
1285
|
-
});
|
|
1286
|
-
});
|
|
1287
|
-
if (presets.includes("ownedByUser")) describe("Owned By User", () => {
|
|
1288
|
-
it("should require userId", async () => {
|
|
1289
|
-
const docWithoutUser = { ...fixtures.valid };
|
|
1290
|
-
delete docWithoutUser.userId;
|
|
1291
|
-
await expect(Model.create(docWithoutUser)).rejects.toThrow();
|
|
1292
|
-
});
|
|
1293
|
-
});
|
|
1294
|
-
});
|
|
1295
|
-
}
|
|
1296
|
-
/**
|
|
1297
|
-
* Run field-level permission tests
|
|
1298
|
-
*
|
|
1299
|
-
* Auto-generates tests for each field permission:
|
|
1300
|
-
* - hidden: field is stripped from responses
|
|
1301
|
-
* - visibleTo: field only shown to specified roles
|
|
1302
|
-
* - writableBy: field stripped from writes by non-privileged users
|
|
1303
|
-
* - redactFor: field shows redacted value for specified roles
|
|
1304
|
-
*/
|
|
1305
|
-
runFieldPermissions() {
|
|
1306
|
-
const { resource } = this;
|
|
1307
|
-
const fieldPerms = resource.fields;
|
|
1308
|
-
if (!fieldPerms || Object.keys(fieldPerms).length === 0) return;
|
|
1309
|
-
describe(`${resource.displayName} Field Permissions`, () => {
|
|
1310
|
-
for (const [field, rawPerm] of Object.entries(fieldPerms)) {
|
|
1311
|
-
const perm = rawPerm;
|
|
1312
|
-
switch (perm._type) {
|
|
1313
|
-
case "hidden":
|
|
1314
|
-
it(`should always hide field '${field}'`, () => {
|
|
1315
|
-
const result = applyFieldReadPermissions({
|
|
1316
|
-
[field]: "secret",
|
|
1317
|
-
otherField: "visible"
|
|
1318
|
-
}, fieldPerms, []);
|
|
1319
|
-
expect(result[field]).toBeUndefined();
|
|
1320
|
-
expect(result.otherField).toBe("visible");
|
|
1321
|
-
});
|
|
1322
|
-
it(`should strip hidden field '${field}' from writes`, () => {
|
|
1323
|
-
const { body: result } = applyFieldWritePermissions({
|
|
1324
|
-
[field]: "attempt",
|
|
1325
|
-
name: "test"
|
|
1326
|
-
}, fieldPerms, []);
|
|
1327
|
-
expect(result[field]).toBeUndefined();
|
|
1328
|
-
expect(result.name).toBe("test");
|
|
1329
|
-
});
|
|
1330
|
-
break;
|
|
1331
|
-
case "visibleTo":
|
|
1332
|
-
it(`should hide field '${field}' from non-privileged users`, () => {
|
|
1333
|
-
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, ["viewer"])[field]).toBeUndefined();
|
|
1334
|
-
});
|
|
1335
|
-
if (perm.roles && perm.roles.length > 0) {
|
|
1336
|
-
const allowedRole = perm.roles[0];
|
|
1337
|
-
it(`should show field '${field}' to roles: ${[...perm.roles].join(", ")}`, () => {
|
|
1338
|
-
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, [allowedRole])[field]).toBe("sensitive");
|
|
1339
|
-
});
|
|
1340
|
-
}
|
|
1341
|
-
break;
|
|
1342
|
-
case "writableBy":
|
|
1343
|
-
it(`should strip field '${field}' from writes by non-privileged users`, () => {
|
|
1344
|
-
const { body: result } = applyFieldWritePermissions({
|
|
1345
|
-
[field]: "new-value",
|
|
1346
|
-
name: "test"
|
|
1347
|
-
}, fieldPerms, ["viewer"]);
|
|
1348
|
-
expect(result[field]).toBeUndefined();
|
|
1349
|
-
expect(result.name).toBe("test");
|
|
1350
|
-
});
|
|
1351
|
-
if (perm.roles && perm.roles.length > 0) {
|
|
1352
|
-
const writeRole = perm.roles[0];
|
|
1353
|
-
it(`should allow writing field '${field}' by roles: ${[...perm.roles].join(", ")}`, () => {
|
|
1354
|
-
const { body: result } = applyFieldWritePermissions({ [field]: "new-value" }, fieldPerms, [writeRole]);
|
|
1355
|
-
expect(result[field]).toBe("new-value");
|
|
1356
|
-
});
|
|
1357
|
-
}
|
|
1358
|
-
break;
|
|
1359
|
-
case "redactFor":
|
|
1360
|
-
if (perm.roles && perm.roles.length > 0) {
|
|
1361
|
-
const redactRole = perm.roles[0];
|
|
1362
|
-
it(`should redact field '${field}' for roles: ${[...perm.roles].join(", ")}`, () => {
|
|
1363
|
-
expect(applyFieldReadPermissions({ [field]: "real-value" }, fieldPerms, [redactRole])[field]).toBe(perm.redactValue ?? "***");
|
|
1364
|
-
});
|
|
1365
|
-
}
|
|
1366
|
-
it(`should show real value of field '${field}' to non-redacted roles`, () => {
|
|
1367
|
-
expect(applyFieldReadPermissions({ [field]: "real-value" }, fieldPerms, ["unrelated-role"])[field]).toBe("real-value");
|
|
1368
|
-
});
|
|
1369
|
-
break;
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
});
|
|
1373
|
-
}
|
|
1374
|
-
/**
|
|
1375
|
-
* Run pipeline configuration tests
|
|
1376
|
-
*
|
|
1377
|
-
* Validates that pipeline steps are properly configured:
|
|
1378
|
-
* - All steps have names
|
|
1379
|
-
* - All steps have valid _type discriminants
|
|
1380
|
-
* - Operation filters (if set) use valid CRUD operation names
|
|
1381
|
-
*/
|
|
1382
|
-
runPipeline() {
|
|
1383
|
-
const { resource } = this;
|
|
1384
|
-
const pipe = resource.pipe;
|
|
1385
|
-
if (!pipe) return;
|
|
1386
|
-
const validOps = new Set(CRUD_OPERATIONS);
|
|
1387
|
-
describe(`${resource.displayName} Pipeline`, () => {
|
|
1388
|
-
const steps = collectPipelineSteps(pipe);
|
|
1389
|
-
it("should have at least one pipeline step", () => {
|
|
1390
|
-
expect(steps.length).toBeGreaterThan(0);
|
|
1391
|
-
});
|
|
1392
|
-
for (const step of steps) {
|
|
1393
|
-
it(`${step._type} '${step.name}' should have a valid type`, () => {
|
|
1394
|
-
expect([
|
|
1395
|
-
"guard",
|
|
1396
|
-
"transform",
|
|
1397
|
-
"interceptor"
|
|
1398
|
-
]).toContain(step._type);
|
|
1399
|
-
});
|
|
1400
|
-
it(`${step._type} '${step.name}' should have a name`, () => {
|
|
1401
|
-
expect(step.name).toBeTruthy();
|
|
1402
|
-
expect(typeof step.name).toBe("string");
|
|
1403
|
-
});
|
|
1404
|
-
it(`${step._type} '${step.name}' should have a handler function`, () => {
|
|
1405
|
-
expect(typeof step.handler).toBe("function");
|
|
1406
|
-
});
|
|
1407
|
-
if (step.operations?.length) it(`${step._type} '${step.name}' should target valid operations`, () => {
|
|
1408
|
-
for (const op of step.operations) expect(validOps.has(op)).toBe(true);
|
|
1409
|
-
});
|
|
1410
|
-
}
|
|
1411
|
-
});
|
|
1412
|
-
}
|
|
1413
|
-
/**
|
|
1414
|
-
* Run event definition tests
|
|
1415
|
-
*
|
|
1416
|
-
* Validates that events are properly defined:
|
|
1417
|
-
* - All events have handler functions
|
|
1418
|
-
* - Event names follow resource:action convention
|
|
1419
|
-
* - Schema definitions (if present) are valid objects
|
|
1420
|
-
*/
|
|
1421
|
-
runEvents() {
|
|
1422
|
-
const { resource } = this;
|
|
1423
|
-
const events = resource.events;
|
|
1424
|
-
if (!events || Object.keys(events).length === 0) return;
|
|
1425
|
-
describe(`${resource.displayName} Events`, () => {
|
|
1426
|
-
for (const [action, rawDef] of Object.entries(events)) {
|
|
1427
|
-
const def = rawDef;
|
|
1428
|
-
it(`event '${resource.name}:${action}' should have a handler function`, () => {
|
|
1429
|
-
expect(typeof def.handler).toBe("function");
|
|
1430
|
-
});
|
|
1431
|
-
it(`event '${resource.name}:${action}' should have a name`, () => {
|
|
1432
|
-
expect(def.name).toBeTruthy();
|
|
1433
|
-
expect(typeof def.name).toBe("string");
|
|
1434
|
-
});
|
|
1435
|
-
if (def.schema) it(`event '${resource.name}:${action}' schema should be an object`, () => {
|
|
1436
|
-
expect(typeof def.schema).toBe("object");
|
|
1437
|
-
expect(def.schema).not.toBeNull();
|
|
1438
|
-
});
|
|
1051
|
+
async function startInMemoryMongo() {
|
|
1052
|
+
try {
|
|
1053
|
+
const { MongoMemoryServer } = await import("mongodb-memory-server");
|
|
1054
|
+
const mongod = await MongoMemoryServer.create();
|
|
1055
|
+
return {
|
|
1056
|
+
uri: mongod.getUri(),
|
|
1057
|
+
async stop() {
|
|
1058
|
+
await mongod.stop();
|
|
1439
1059
|
}
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
};
|
|
1443
|
-
/**
|
|
1444
|
-
* Collect all pipeline steps from a PipelineConfig (flat array or per-operation map)
|
|
1445
|
-
*/
|
|
1446
|
-
function collectPipelineSteps(pipe) {
|
|
1447
|
-
if (Array.isArray(pipe)) return pipe;
|
|
1448
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1449
|
-
const steps = [];
|
|
1450
|
-
for (const opSteps of Object.values(pipe)) if (Array.isArray(opSteps)) for (const step of opSteps) {
|
|
1451
|
-
const key = `${step._type}:${step.name}`;
|
|
1452
|
-
if (!seen.has(key)) {
|
|
1453
|
-
seen.add(key);
|
|
1454
|
-
steps.push(step);
|
|
1455
|
-
}
|
|
1060
|
+
};
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
throw new Error(`createTestApp({ db: 'in-memory' }): mongodb-memory-server is required. Install with \`npm i -D mongodb-memory-server\`. Root cause: ${err.message}`);
|
|
1456
1063
|
}
|
|
1457
|
-
return steps;
|
|
1458
|
-
}
|
|
1459
|
-
/**
|
|
1460
|
-
* Create a test harness for an Arc resource
|
|
1461
|
-
*
|
|
1462
|
-
* @param resource - The Arc resource definition to test
|
|
1463
|
-
* @param options - Test harness configuration
|
|
1464
|
-
* @returns Test harness instance
|
|
1465
|
-
*
|
|
1466
|
-
* @example
|
|
1467
|
-
* import { createTestHarness } from '@classytic/arc/testing';
|
|
1468
|
-
*
|
|
1469
|
-
* const harness = createTestHarness(productResource, {
|
|
1470
|
-
* fixtures: {
|
|
1471
|
-
* valid: { name: 'Product', price: 100 },
|
|
1472
|
-
* update: { name: 'Updated' },
|
|
1473
|
-
* },
|
|
1474
|
-
* });
|
|
1475
|
-
*
|
|
1476
|
-
* harness.runAll(); // Generates 50+ baseline tests
|
|
1477
|
-
*/
|
|
1478
|
-
function createTestHarness(resource, options) {
|
|
1479
|
-
return new TestHarness(resource, options);
|
|
1480
1064
|
}
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
* const testContent = generateTestFile('product', {
|
|
1492
|
-
* presets: ['softDelete'],
|
|
1493
|
-
* modulePath: './modules/catalog',
|
|
1494
|
-
* });
|
|
1495
|
-
* fs.writeFileSync('product.test.js', testContent);
|
|
1496
|
-
*/
|
|
1497
|
-
function generateTestFile(resourceName, options = {}) {
|
|
1498
|
-
const { presets = [], modulePath = "." } = options;
|
|
1499
|
-
const className = resourceName.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
1500
|
-
const varName = className.charAt(0).toLowerCase() + className.slice(1);
|
|
1501
|
-
return `/**
|
|
1502
|
-
* ${className} Resource Tests
|
|
1503
|
-
*
|
|
1504
|
-
* Auto-generated baseline tests. Customize as needed.
|
|
1505
|
-
*/
|
|
1506
|
-
|
|
1507
|
-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
1508
|
-
import mongoose from 'mongoose';
|
|
1509
|
-
import { createTestHarness } from '@classytic/arc/testing';
|
|
1510
|
-
import ${varName}Resource from '${modulePath}/${resourceName}.resource.js';
|
|
1511
|
-
import ${className} from '${modulePath}/${resourceName}.model.js';
|
|
1512
|
-
|
|
1513
|
-
const MONGO_URI = process.env.MONGO_TEST_URI || 'mongodb://localhost:27017/${resourceName}-test';
|
|
1514
|
-
|
|
1515
|
-
// Test fixtures
|
|
1516
|
-
const fixtures = {
|
|
1517
|
-
valid: {
|
|
1518
|
-
name: 'Test ${className}',
|
|
1519
|
-
// Add required fields here
|
|
1520
|
-
},
|
|
1521
|
-
update: {
|
|
1522
|
-
name: 'Updated ${className}',
|
|
1523
|
-
},
|
|
1524
|
-
invalid: {
|
|
1525
|
-
// Empty or invalid data
|
|
1526
|
-
},
|
|
1527
|
-
};
|
|
1528
|
-
|
|
1529
|
-
// Create test harness
|
|
1530
|
-
const harness = createTestHarness(${varName}Resource, {
|
|
1531
|
-
fixtures,
|
|
1532
|
-
mongoUri: MONGO_URI,
|
|
1533
|
-
});
|
|
1534
|
-
|
|
1535
|
-
// Run all baseline tests
|
|
1536
|
-
harness.runAll();
|
|
1537
|
-
|
|
1538
|
-
// Custom tests
|
|
1539
|
-
describe('${className} Custom Tests', () => {
|
|
1540
|
-
let testId;
|
|
1541
|
-
|
|
1542
|
-
beforeAll(async () => {
|
|
1543
|
-
await mongoose.connect(MONGO_URI);
|
|
1544
|
-
});
|
|
1545
|
-
|
|
1546
|
-
afterAll(async () => {
|
|
1547
|
-
if (testId) {
|
|
1548
|
-
await ${className}.findByIdAndDelete(testId);
|
|
1549
|
-
}
|
|
1550
|
-
await mongoose.disconnect();
|
|
1551
|
-
});
|
|
1552
|
-
|
|
1553
|
-
// Add your custom tests here
|
|
1554
|
-
it('should pass custom validation', async () => {
|
|
1555
|
-
// Example: const doc = await ${className}.create(fixtures.valid);
|
|
1556
|
-
// testId = doc._id;
|
|
1557
|
-
// expect(doc.someField).toBe('expectedValue');
|
|
1558
|
-
expect(true).toBe(true);
|
|
1559
|
-
});
|
|
1560
|
-
});
|
|
1561
|
-
`;
|
|
1562
|
-
}
|
|
1563
|
-
/**
|
|
1564
|
-
* Run config-level tests for a resource (no DB required)
|
|
1565
|
-
*
|
|
1566
|
-
* Tests field permissions, pipeline configuration, and event definitions.
|
|
1567
|
-
* Works with any adapter — no Mongoose dependency.
|
|
1568
|
-
*
|
|
1569
|
-
* @param resource - The Arc resource definition to test
|
|
1570
|
-
*
|
|
1571
|
-
* @example
|
|
1572
|
-
* ```typescript
|
|
1573
|
-
* import { createConfigTestSuite } from '@classytic/arc/testing';
|
|
1574
|
-
* import productResource from './product.resource.js';
|
|
1575
|
-
*
|
|
1576
|
-
* // Generates field permission, pipeline, and event tests
|
|
1577
|
-
* createConfigTestSuite(productResource);
|
|
1578
|
-
* ```
|
|
1579
|
-
*/
|
|
1580
|
-
function createConfigTestSuite(resource) {
|
|
1581
|
-
const fieldPerms = resource.fields;
|
|
1582
|
-
const pipe = resource.pipe;
|
|
1583
|
-
const events = resource.events;
|
|
1584
|
-
if (fieldPerms && Object.keys(fieldPerms).length > 0) runFieldPermissionTests(resource.displayName, fieldPerms);
|
|
1585
|
-
if (pipe) runPipelineTests(resource.displayName, pipe);
|
|
1586
|
-
if (events && Object.keys(events).length > 0) runEventTests(resource.name, resource.displayName, events);
|
|
1587
|
-
if (resource.permissions && Object.keys(resource.permissions).length > 0) describe(`${resource.displayName} Permission Config`, () => {
|
|
1588
|
-
for (const op of CRUD_OPERATIONS) {
|
|
1589
|
-
const check = resource.permissions[op];
|
|
1590
|
-
if (check) it(`${op} permission should be a function`, () => {
|
|
1591
|
-
expect(typeof check).toBe("function");
|
|
1592
|
-
});
|
|
1593
|
-
}
|
|
1594
|
-
});
|
|
1595
|
-
}
|
|
1596
|
-
function runFieldPermissionTests(displayName, fieldPerms) {
|
|
1597
|
-
describe(`${displayName} Field Permissions`, () => {
|
|
1598
|
-
for (const [field, perm] of Object.entries(fieldPerms)) switch (perm._type) {
|
|
1599
|
-
case "hidden":
|
|
1600
|
-
it(`should always hide field '${field}'`, () => {
|
|
1601
|
-
expect(applyFieldReadPermissions({
|
|
1602
|
-
[field]: "secret",
|
|
1603
|
-
other: "visible"
|
|
1604
|
-
}, fieldPerms, [])[field]).toBeUndefined();
|
|
1605
|
-
});
|
|
1606
|
-
it(`should strip hidden field '${field}' from writes`, () => {
|
|
1607
|
-
const { body: result } = applyFieldWritePermissions({
|
|
1608
|
-
[field]: "attempt",
|
|
1609
|
-
name: "test"
|
|
1610
|
-
}, fieldPerms, []);
|
|
1611
|
-
expect(result[field]).toBeUndefined();
|
|
1612
|
-
});
|
|
1613
|
-
break;
|
|
1614
|
-
case "visibleTo":
|
|
1615
|
-
it(`should hide field '${field}' from non-privileged users`, () => {
|
|
1616
|
-
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, ["_no_role_"])[field]).toBeUndefined();
|
|
1617
|
-
});
|
|
1618
|
-
if (perm.roles && perm.roles.length > 0) {
|
|
1619
|
-
const allowedRole = perm.roles[0];
|
|
1620
|
-
it(`should show field '${field}' to roles: ${[...perm.roles].join(", ")}`, () => {
|
|
1621
|
-
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, [allowedRole])[field]).toBe("sensitive");
|
|
1622
|
-
});
|
|
1623
|
-
}
|
|
1624
|
-
break;
|
|
1625
|
-
case "writableBy":
|
|
1626
|
-
it(`should strip field '${field}' from writes by non-privileged users`, () => {
|
|
1627
|
-
const { body: result } = applyFieldWritePermissions({
|
|
1628
|
-
[field]: "v",
|
|
1629
|
-
name: "test"
|
|
1630
|
-
}, fieldPerms, ["_no_role_"]);
|
|
1631
|
-
expect(result[field]).toBeUndefined();
|
|
1632
|
-
});
|
|
1633
|
-
if (perm.roles && perm.roles.length > 0) {
|
|
1634
|
-
const writeRole = perm.roles[0];
|
|
1635
|
-
it(`should allow writing field '${field}' by roles: ${[...perm.roles].join(", ")}`, () => {
|
|
1636
|
-
const { body: result } = applyFieldWritePermissions({ [field]: "v" }, fieldPerms, [writeRole]);
|
|
1637
|
-
expect(result[field]).toBe("v");
|
|
1638
|
-
});
|
|
1639
|
-
}
|
|
1640
|
-
break;
|
|
1641
|
-
case "redactFor":
|
|
1642
|
-
if (perm.roles && perm.roles.length > 0) {
|
|
1643
|
-
const redactRole = perm.roles[0];
|
|
1644
|
-
it(`should redact field '${field}' for roles: ${[...perm.roles].join(", ")}`, () => {
|
|
1645
|
-
expect(applyFieldReadPermissions({ [field]: "real" }, fieldPerms, [redactRole])[field]).toBe(perm.redactValue ?? "***");
|
|
1646
|
-
});
|
|
1647
|
-
}
|
|
1648
|
-
it(`should show real value of field '${field}' to non-redacted roles`, () => {
|
|
1649
|
-
expect(applyFieldReadPermissions({ [field]: "real" }, fieldPerms, ["_other_"])[field]).toBe("real");
|
|
1650
|
-
});
|
|
1651
|
-
break;
|
|
1652
|
-
}
|
|
1653
|
-
});
|
|
1654
|
-
}
|
|
1655
|
-
function runPipelineTests(displayName, pipe) {
|
|
1656
|
-
const steps = collectPipelineSteps(pipe);
|
|
1657
|
-
if (steps.length === 0) return;
|
|
1658
|
-
const validOps = new Set(CRUD_OPERATIONS);
|
|
1659
|
-
describe(`${displayName} Pipeline`, () => {
|
|
1660
|
-
it("should have at least one pipeline step", () => {
|
|
1661
|
-
expect(steps.length).toBeGreaterThan(0);
|
|
1662
|
-
});
|
|
1663
|
-
for (const step of steps) {
|
|
1664
|
-
it(`${step._type} '${step.name}' should have a valid type`, () => {
|
|
1665
|
-
expect([
|
|
1666
|
-
"guard",
|
|
1667
|
-
"transform",
|
|
1668
|
-
"interceptor"
|
|
1669
|
-
]).toContain(step._type);
|
|
1670
|
-
});
|
|
1671
|
-
it(`${step._type} '${step.name}' should have a handler function`, () => {
|
|
1672
|
-
expect(typeof step.handler).toBe("function");
|
|
1673
|
-
});
|
|
1674
|
-
if (step.operations?.length) it(`${step._type} '${step.name}' should target valid operations`, () => {
|
|
1675
|
-
for (const op of step.operations) expect(validOps.has(op)).toBe(true);
|
|
1676
|
-
});
|
|
1677
|
-
}
|
|
1678
|
-
});
|
|
1679
|
-
}
|
|
1680
|
-
function runEventTests(resourceName, displayName, events) {
|
|
1681
|
-
describe(`${displayName} Events`, () => {
|
|
1682
|
-
for (const [action, def] of Object.entries(events)) {
|
|
1683
|
-
it(`event '${resourceName}:${action}' should have a handler function`, () => {
|
|
1684
|
-
expect(typeof def.handler).toBe("function");
|
|
1685
|
-
});
|
|
1686
|
-
it(`event '${resourceName}:${action}' should have a name`, () => {
|
|
1687
|
-
expect(def.name).toBeTruthy();
|
|
1688
|
-
});
|
|
1689
|
-
if (def.schema) it(`event '${resourceName}:${action}' schema should be an object`, () => {
|
|
1690
|
-
expect(typeof def.schema).toBe("object");
|
|
1691
|
-
expect(def.schema).not.toBeNull();
|
|
1692
|
-
});
|
|
1693
|
-
}
|
|
1694
|
-
});
|
|
1065
|
+
async function connectMongooseToUri(uri) {
|
|
1066
|
+
try {
|
|
1067
|
+
const mongoose = (await import("mongoose")).default;
|
|
1068
|
+
await mongoose.connect(uri);
|
|
1069
|
+
return { async disconnect() {
|
|
1070
|
+
await mongoose.disconnect();
|
|
1071
|
+
} };
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
throw new Error(`createTestApp({ connectMongoose: true }): failed to connect Mongoose to ${uri}. Root cause: ${err.message}`);
|
|
1074
|
+
}
|
|
1695
1075
|
}
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
* Testing Utilities - Test App Factory
|
|
1700
|
-
*
|
|
1701
|
-
* Create Fastify test instances with Arc configuration
|
|
1702
|
-
*/
|
|
1703
|
-
/**
|
|
1704
|
-
* Create a test application instance with optional in-memory MongoDB
|
|
1705
|
-
*
|
|
1706
|
-
* **Performance Boost**: Uses in-memory MongoDB by default for 10x faster tests.
|
|
1707
|
-
*
|
|
1708
|
-
* @example Basic usage with in-memory DB
|
|
1709
|
-
* ```typescript
|
|
1710
|
-
* import { createTestApp } from '@classytic/arc/testing';
|
|
1711
|
-
*
|
|
1712
|
-
* describe('API Tests', () => {
|
|
1713
|
-
* let testApp: TestAppResult;
|
|
1714
|
-
*
|
|
1715
|
-
* beforeAll(async () => {
|
|
1716
|
-
* testApp = await createTestApp({
|
|
1717
|
-
* auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
|
|
1718
|
-
* });
|
|
1719
|
-
* });
|
|
1720
|
-
*
|
|
1721
|
-
* afterAll(async () => {
|
|
1722
|
-
* await testApp.close(); // Cleans up DB and disconnects
|
|
1723
|
-
* });
|
|
1724
|
-
*
|
|
1725
|
-
* test('GET /health', async () => {
|
|
1726
|
-
* const response = await testApp.app.inject({
|
|
1727
|
-
* method: 'GET',
|
|
1728
|
-
* url: '/health',
|
|
1729
|
-
* });
|
|
1730
|
-
* expect(response.statusCode).toBe(200);
|
|
1731
|
-
* });
|
|
1732
|
-
* });
|
|
1733
|
-
* ```
|
|
1734
|
-
*
|
|
1735
|
-
* @example Using external MongoDB
|
|
1736
|
-
* ```typescript
|
|
1737
|
-
* const testApp = await createTestApp({
|
|
1738
|
-
* auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
|
|
1739
|
-
* useInMemoryDb: false,
|
|
1740
|
-
* mongoUri: 'mongodb://localhost:27017/test-db',
|
|
1741
|
-
* });
|
|
1742
|
-
* ```
|
|
1743
|
-
*
|
|
1744
|
-
* @example Accessing MongoDB URI for model connections
|
|
1745
|
-
* ```typescript
|
|
1746
|
-
* const testApp = await createTestApp({
|
|
1747
|
-
* auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
|
|
1748
|
-
* });
|
|
1749
|
-
* await mongoose.connect(testApp.mongoUri); // Connect your models
|
|
1750
|
-
* ```
|
|
1751
|
-
*/
|
|
1752
|
-
async function createTestApp(options = {}) {
|
|
1753
|
-
const { createApp } = await import("../createApp-BwnEAO2h.mjs").then((n) => n.r);
|
|
1754
|
-
const { useInMemoryDb = true, mongoUri: providedMongoUri, ...appOptions } = options;
|
|
1755
|
-
const defaultAuth = {
|
|
1076
|
+
function pickDefaultAuth(authMode, callerAuth) {
|
|
1077
|
+
if (callerAuth !== void 0) return callerAuth;
|
|
1078
|
+
if (authMode === "jwt") return {
|
|
1756
1079
|
type: "jwt",
|
|
1757
1080
|
jwt: { secret: "test-secret-32-chars-minimum-len" }
|
|
1758
1081
|
};
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1082
|
+
}
|
|
1083
|
+
async function createTestApp(options = {}) {
|
|
1084
|
+
const { createApp } = await import("../createApp-DvNYEhpb.mjs").then((n) => n.r);
|
|
1085
|
+
const { resources = [], db = "in-memory", connectMongoose = false, authMode = "jwt", defaultOrgId, plugins, auth: callerAuth, ...appOptions } = options;
|
|
1086
|
+
let dbHandle;
|
|
1087
|
+
let dbUri;
|
|
1088
|
+
if (db === "in-memory") {
|
|
1089
|
+
dbHandle = await startInMemoryMongo();
|
|
1090
|
+
dbUri = dbHandle.uri;
|
|
1091
|
+
} else if (db && typeof db === "object" && "uri" in db) dbUri = db.uri;
|
|
1092
|
+
let mongooseHandle;
|
|
1093
|
+
if (connectMongoose) {
|
|
1094
|
+
if (!dbUri) throw new Error(`createTestApp({ connectMongoose: true }): requires db: 'in-memory' or { uri }. Got db: ${JSON.stringify(db)}`);
|
|
1095
|
+
mongooseHandle = await connectMongooseToUri(dbUri);
|
|
1766
1096
|
}
|
|
1767
|
-
|
|
1097
|
+
if (authMode === "better-auth" && callerAuth === void 0) {
|
|
1098
|
+
if (dbHandle) await dbHandle.stop();
|
|
1099
|
+
if (mongooseHandle) await mongooseHandle.disconnect();
|
|
1100
|
+
throw new Error("createTestApp({ authMode: 'better-auth' }): you must also pass `auth: { type: 'better-auth', ... }`. Without it the app has no auth plugin registered and tests would silently bypass Better Auth middleware.");
|
|
1101
|
+
}
|
|
1102
|
+
const resolvedAuth = pickDefaultAuth(authMode, callerAuth);
|
|
1103
|
+
const testDefaults = {
|
|
1768
1104
|
preset: "testing",
|
|
1769
1105
|
logger: false,
|
|
1770
1106
|
helmet: false,
|
|
1771
1107
|
cors: false,
|
|
1772
1108
|
rateLimit: false,
|
|
1773
1109
|
underPressure: false,
|
|
1774
|
-
auth:
|
|
1775
|
-
|
|
1110
|
+
...resolvedAuth ? { auth: resolvedAuth } : {}
|
|
1111
|
+
};
|
|
1112
|
+
const mergedPlugins = async (fastify) => {
|
|
1113
|
+
if (plugins) await plugins(fastify);
|
|
1114
|
+
for (const resource of resources) await fastify.register(resource.toPlugin());
|
|
1115
|
+
};
|
|
1116
|
+
const app = await createApp({
|
|
1117
|
+
...testDefaults,
|
|
1118
|
+
...appOptions,
|
|
1119
|
+
plugins: mergedPlugins
|
|
1776
1120
|
});
|
|
1121
|
+
let auth;
|
|
1122
|
+
if (authMode === "jwt") auth = createJwtAuthProvider(app, { defaultOrgId });
|
|
1123
|
+
else if (authMode === "better-auth") auth = createBetterAuthProvider({ defaultOrgId });
|
|
1124
|
+
const fixtures = createTestFixtures();
|
|
1125
|
+
let closed = false;
|
|
1126
|
+
const close = async () => {
|
|
1127
|
+
if (closed) return;
|
|
1128
|
+
closed = true;
|
|
1129
|
+
await fixtures.clear().catch(() => {});
|
|
1130
|
+
await app.close();
|
|
1131
|
+
if (mongooseHandle) await mongooseHandle.disconnect();
|
|
1132
|
+
if (dbHandle) await dbHandle.stop();
|
|
1133
|
+
};
|
|
1777
1134
|
return {
|
|
1778
1135
|
app,
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
}
|
|
1136
|
+
auth,
|
|
1137
|
+
fixtures,
|
|
1138
|
+
dbUri,
|
|
1139
|
+
close
|
|
1784
1140
|
};
|
|
1785
1141
|
}
|
|
1786
1142
|
/**
|
|
1787
|
-
*
|
|
1788
|
-
*
|
|
1789
|
-
*
|
|
1790
|
-
*
|
|
1791
|
-
* @example
|
|
1792
|
-
* const app = createMinimalTestApp();
|
|
1793
|
-
* app.get('/test', async () => ({ success: true }));
|
|
1794
|
-
*
|
|
1795
|
-
* const response = await app.inject({ method: 'GET', url: '/test' });
|
|
1796
|
-
* expect(response.json()).toEqual({ success: true });
|
|
1143
|
+
* Minimal Fastify instance — no arc plugins, no auth, no db. Use when a test
|
|
1144
|
+
* needs bare Fastify (e.g. plugin unit tests that manually register their
|
|
1145
|
+
* dependencies).
|
|
1797
1146
|
*/
|
|
1798
1147
|
function createMinimalTestApp(options = {}) {
|
|
1799
1148
|
return Fastify({
|
|
@@ -1801,151 +1150,5 @@ function createMinimalTestApp(options = {}) {
|
|
|
1801
1150
|
...options
|
|
1802
1151
|
});
|
|
1803
1152
|
}
|
|
1804
|
-
/**
|
|
1805
|
-
* Test request builder for cleaner tests
|
|
1806
|
-
*
|
|
1807
|
-
* @example
|
|
1808
|
-
* const request = new TestRequestBuilder(app)
|
|
1809
|
-
* .get('/products')
|
|
1810
|
-
* .withAuth(mockUser)
|
|
1811
|
-
* .withQuery({ page: 1, limit: 10 });
|
|
1812
|
-
*
|
|
1813
|
-
* const response = await request.send();
|
|
1814
|
-
* expect(response.statusCode).toBe(200);
|
|
1815
|
-
*/
|
|
1816
|
-
var TestRequestBuilder = class {
|
|
1817
|
-
method = "GET";
|
|
1818
|
-
url = "/";
|
|
1819
|
-
body;
|
|
1820
|
-
query;
|
|
1821
|
-
headers = {};
|
|
1822
|
-
app;
|
|
1823
|
-
constructor(app) {
|
|
1824
|
-
this.app = app;
|
|
1825
|
-
}
|
|
1826
|
-
get(url) {
|
|
1827
|
-
this.method = "GET";
|
|
1828
|
-
this.url = url;
|
|
1829
|
-
return this;
|
|
1830
|
-
}
|
|
1831
|
-
post(url) {
|
|
1832
|
-
this.method = "POST";
|
|
1833
|
-
this.url = url;
|
|
1834
|
-
return this;
|
|
1835
|
-
}
|
|
1836
|
-
put(url) {
|
|
1837
|
-
this.method = "PUT";
|
|
1838
|
-
this.url = url;
|
|
1839
|
-
return this;
|
|
1840
|
-
}
|
|
1841
|
-
patch(url) {
|
|
1842
|
-
this.method = "PATCH";
|
|
1843
|
-
this.url = url;
|
|
1844
|
-
return this;
|
|
1845
|
-
}
|
|
1846
|
-
delete(url) {
|
|
1847
|
-
this.method = "DELETE";
|
|
1848
|
-
this.url = url;
|
|
1849
|
-
return this;
|
|
1850
|
-
}
|
|
1851
|
-
withBody(body) {
|
|
1852
|
-
this.body = body;
|
|
1853
|
-
return this;
|
|
1854
|
-
}
|
|
1855
|
-
withQuery(query) {
|
|
1856
|
-
this.query = query;
|
|
1857
|
-
return this;
|
|
1858
|
-
}
|
|
1859
|
-
withHeader(key, value) {
|
|
1860
|
-
this.headers[key] = value;
|
|
1861
|
-
return this;
|
|
1862
|
-
}
|
|
1863
|
-
withAuth(userOrHeaders) {
|
|
1864
|
-
if ("authorization" in userOrHeaders || "Authorization" in userOrHeaders) {
|
|
1865
|
-
for (const [key, value] of Object.entries(userOrHeaders)) if (typeof value === "string") this.headers[key] = value;
|
|
1866
|
-
} else {
|
|
1867
|
-
const token = this.app.jwt?.sign?.(userOrHeaders) || "mock-token";
|
|
1868
|
-
this.headers.Authorization = `Bearer ${token}`;
|
|
1869
|
-
}
|
|
1870
|
-
return this;
|
|
1871
|
-
}
|
|
1872
|
-
withContentType(type) {
|
|
1873
|
-
this.headers["Content-Type"] = type;
|
|
1874
|
-
return this;
|
|
1875
|
-
}
|
|
1876
|
-
async send() {
|
|
1877
|
-
return this.app.inject({
|
|
1878
|
-
method: this.method,
|
|
1879
|
-
url: this.url,
|
|
1880
|
-
payload: this.body,
|
|
1881
|
-
query: this.query,
|
|
1882
|
-
headers: this.headers
|
|
1883
|
-
});
|
|
1884
|
-
}
|
|
1885
|
-
};
|
|
1886
|
-
/**
|
|
1887
|
-
* Helper to create a test request builder
|
|
1888
|
-
*/
|
|
1889
|
-
function request(app) {
|
|
1890
|
-
return new TestRequestBuilder(app);
|
|
1891
|
-
}
|
|
1892
|
-
/**
|
|
1893
|
-
* Test helper for authentication
|
|
1894
|
-
*/
|
|
1895
|
-
function createTestAuth(app) {
|
|
1896
|
-
return {
|
|
1897
|
-
generateToken(user) {
|
|
1898
|
-
if (!app.jwt) throw new Error("JWT plugin not registered");
|
|
1899
|
-
return app.jwt.sign(user);
|
|
1900
|
-
},
|
|
1901
|
-
decodeToken(token) {
|
|
1902
|
-
if (!app.jwt) throw new Error("JWT plugin not registered");
|
|
1903
|
-
return app.jwt.decode(token);
|
|
1904
|
-
},
|
|
1905
|
-
async verifyToken(token) {
|
|
1906
|
-
if (!app.jwt) throw new Error("JWT plugin not registered");
|
|
1907
|
-
return app.jwt.verify(token);
|
|
1908
|
-
}
|
|
1909
|
-
};
|
|
1910
|
-
}
|
|
1911
|
-
/**
|
|
1912
|
-
* Snapshot testing helper for API responses
|
|
1913
|
-
*/
|
|
1914
|
-
function createSnapshotMatcher() {
|
|
1915
|
-
return { matchStructure(response, expected) {
|
|
1916
|
-
if (typeof response !== typeof expected) return false;
|
|
1917
|
-
if (Array.isArray(response) && Array.isArray(expected)) return response.length === expected.length;
|
|
1918
|
-
if (typeof response === "object" && response !== null && typeof expected === "object" && expected !== null) {
|
|
1919
|
-
const r = response;
|
|
1920
|
-
const e = expected;
|
|
1921
|
-
const responseKeys = Object.keys(r).sort();
|
|
1922
|
-
const expectedKeys = Object.keys(e).sort();
|
|
1923
|
-
if (JSON.stringify(responseKeys) !== JSON.stringify(expectedKeys)) return false;
|
|
1924
|
-
for (const key of responseKeys) if (!this.matchStructure(r[key], e[key])) return false;
|
|
1925
|
-
return true;
|
|
1926
|
-
}
|
|
1927
|
-
return true;
|
|
1928
|
-
} };
|
|
1929
|
-
}
|
|
1930
|
-
/**
|
|
1931
|
-
* Bulk test data loader
|
|
1932
|
-
*/
|
|
1933
|
-
var TestDataLoader = class {
|
|
1934
|
-
data = /* @__PURE__ */ new Map();
|
|
1935
|
-
/**
|
|
1936
|
-
* Load test data into database
|
|
1937
|
-
*/
|
|
1938
|
-
async load(collection, items) {
|
|
1939
|
-
this.data.set(collection, items);
|
|
1940
|
-
return items;
|
|
1941
|
-
}
|
|
1942
|
-
/**
|
|
1943
|
-
* Clear all loaded test data
|
|
1944
|
-
*/
|
|
1945
|
-
async cleanup() {
|
|
1946
|
-
for (const [_collection, _items] of this.data.entries());
|
|
1947
|
-
this.data.clear();
|
|
1948
|
-
}
|
|
1949
|
-
};
|
|
1950
1153
|
//#endregion
|
|
1951
|
-
export {
|
|
1154
|
+
export { HttpTestHarness, createBetterAuthProvider, createBetterAuthTestHelpers, createCustomAuthProvider, createDataFactory, createHttpTestHarness, createJwtAuthProvider, createMinimalTestApp, createMockController, createMockReply, createMockRepository, createMockRequest, createMockUser, createSpy, createTestApp, createTestFixtures, createTestTimer, expectArc, preloadResources, preloadResourcesAsync, runStorageContract, safeParseBody, setupBetterAuthTestApp, waitFor };
|