@classytic/arc 2.3.0 → 2.4.2
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/README.md +187 -18
- package/bin/arc.js +11 -3
- package/dist/BaseController-CkM5dUh_.mjs +1031 -0
- package/dist/{EventTransport-BkUDYZEb.d.mts → EventTransport-wc5hSLik.d.mts} +1 -1
- package/dist/{HookSystem-BsGV-j2l.mjs → HookSystem-COkyWztM.mjs} +2 -3
- package/dist/{ResourceRegistry-7Ic20ZMw.mjs → ResourceRegistry-DeCIFlix.mjs} +8 -5
- package/dist/adapters/index.d.mts +3 -5
- package/dist/adapters/index.mjs +2 -3
- package/dist/{prisma-DJbMt3yf.mjs → adapters-DTC4Ug66.mjs} +45 -12
- package/dist/audit/index.d.mts +4 -7
- package/dist/audit/index.mjs +2 -29
- package/dist/audit/mongodb.d.mts +1 -4
- package/dist/audit/mongodb.mjs +2 -3
- package/dist/auth/index.d.mts +7 -9
- package/dist/auth/index.mjs +65 -63
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/auth/redis-session.mjs +1 -2
- package/dist/{betterAuthOpenApi-DjWDddNc.mjs → betterAuthOpenApi-lz0IRbXJ.mjs} +4 -6
- package/dist/cache/index.d.mts +23 -23
- package/dist/cache/index.mjs +4 -6
- package/dist/{caching-GSDJcA6-.mjs → caching-BSXB-Xr7.mjs} +2 -24
- package/dist/chunk-BpYLSNr0.mjs +14 -0
- package/dist/circuitBreaker-BOBOpN2w.mjs +284 -0
- package/dist/circuitBreaker-JP2GdJ4b.d.mts +206 -0
- package/dist/cli/commands/describe.mjs +24 -7
- package/dist/cli/commands/docs.mjs +6 -7
- package/dist/cli/commands/doctor.d.mts +10 -0
- package/dist/cli/commands/doctor.mjs +156 -0
- package/dist/cli/commands/generate.mjs +66 -17
- package/dist/cli/commands/init.mjs +315 -45
- package/dist/cli/commands/introspect.mjs +2 -4
- package/dist/cli/index.d.mts +1 -10
- package/dist/cli/index.mjs +4 -153
- package/dist/{constants-DdXFXQtN.mjs → constants-Cxde4rpC.mjs} +1 -2
- package/dist/core/index.d.mts +3 -5
- package/dist/core/index.mjs +5 -4
- package/dist/core-C1XCMtqM.mjs +185 -0
- package/dist/{createApp-CgKOPhA4.mjs → createApp-ByWNRsZj.mjs} +64 -35
- package/dist/{defineResource-DWbpJYtm.mjs → defineResource-D9aY5Cy6.mjs} +108 -1157
- package/dist/discovery/index.mjs +37 -5
- package/dist/docs/index.d.mts +6 -9
- package/dist/docs/index.mjs +3 -21
- package/dist/dynamic/index.d.mts +93 -0
- package/dist/dynamic/index.mjs +122 -0
- package/dist/{elevation-DSTbVvYj.mjs → elevation-BEdACOLB.mjs} +5 -36
- package/dist/{elevation-DGo5shaX.d.mts → elevation-Ca_yveIO.d.mts} +41 -7
- package/dist/{errorHandler-C3GY3_ow.mjs → errorHandler--zp54tGc.mjs} +3 -5
- package/dist/errorHandler-Do4vVQ1f.d.mts +139 -0
- package/dist/{errors-DBANPbGr.mjs → errors-rxhfP7Hf.mjs} +1 -2
- package/dist/{eventPlugin-BEOvaDqo.mjs → eventPlugin-Ba00swHF.mjs} +25 -27
- package/dist/{eventPlugin-H6wDDjGO.d.mts → eventPlugin-iGrSEmwJ.d.mts} +105 -5
- package/dist/events/index.d.mts +72 -7
- package/dist/events/index.mjs +216 -4
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +19 -7
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +3 -4
- package/dist/factory/index.d.mts +23 -9
- package/dist/factory/index.mjs +48 -3
- package/dist/{fields-Bi_AVKSo.d.mts → fields-DFwdaWCq.d.mts} +1 -1
- package/dist/{fields-CTd_CrKr.mjs → fields-ipsbIRPK.mjs} +1 -2
- package/dist/hooks/index.d.mts +1 -3
- package/dist/hooks/index.mjs +2 -3
- package/dist/idempotency/index.d.mts +5 -5
- package/dist/idempotency/index.mjs +3 -7
- package/dist/idempotency/mongodb.d.mts +1 -1
- package/dist/idempotency/mongodb.mjs +4 -5
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +2 -5
- package/dist/{fastifyAdapter-6b_eRDBw.d.mts → index-BL8CaQih.d.mts} +56 -57
- package/dist/index-Diqcm14c.d.mts +369 -0
- package/dist/{prisma-Dy5S5F5i.d.mts → index-yhxyjqNb.d.mts} +4 -5
- package/dist/index.d.mts +100 -105
- package/dist/index.mjs +85 -58
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +8 -4
- package/dist/integrations/index.d.mts +4 -2
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs.d.mts +2 -2
- package/dist/integrations/jobs.mjs +63 -14
- package/dist/integrations/mcp/index.d.mts +219 -0
- package/dist/integrations/mcp/index.mjs +572 -0
- package/dist/integrations/mcp/testing.d.mts +53 -0
- package/dist/integrations/mcp/testing.mjs +104 -0
- package/dist/integrations/streamline.mjs +39 -19
- package/dist/integrations/webhooks.d.mts +56 -0
- package/dist/integrations/webhooks.mjs +139 -0
- package/dist/integrations/websocket-redis.d.mts +46 -0
- package/dist/integrations/websocket-redis.mjs +50 -0
- package/dist/integrations/websocket.d.mts +68 -2
- package/dist/integrations/websocket.mjs +96 -13
- package/dist/{interface-CSNjltAc.d.mts → interface-B4awm1RJ.d.mts} +2 -2
- package/dist/interface-DGmPxakH.d.mts +2213 -0
- package/dist/{keys-DhqDRxv3.mjs → keys-qcD-TVJl.mjs} +3 -4
- package/dist/{logger-ByrvQWZO.mjs → logger-Dz3j1ItV.mjs} +2 -4
- package/dist/{memory-B2v7KrCB.mjs → memory-Cb_7iy9e.mjs} +2 -4
- package/dist/metrics-Csh4nsvv.mjs +224 -0
- package/dist/migrations/index.d.mts +113 -44
- package/dist/migrations/index.mjs +84 -102
- package/dist/{mongodb-DNKEExbf.mjs → mongodb-BuQ7fNTg.mjs} +1 -4
- package/dist/{mongodb-ClykrfGo.d.mts → mongodb-CUpYfxfD.d.mts} +2 -3
- package/dist/{mongodb-Dg8O_gvd.d.mts → mongodb-bga9AbkD.d.mts} +2 -2
- package/dist/{openapi-9nB_kiuR.mjs → openapi-CBmZ6EQN.mjs} +4 -21
- package/dist/org/index.d.mts +12 -14
- package/dist/org/index.mjs +92 -119
- package/dist/org/types.d.mts +2 -2
- package/dist/org/types.mjs +1 -1
- package/dist/permissions/index.d.mts +4 -278
- package/dist/permissions/index.mjs +4 -579
- package/dist/permissions-CA5zg0yK.mjs +751 -0
- package/dist/plugins/index.d.mts +104 -107
- package/dist/plugins/index.mjs +203 -313
- package/dist/plugins/response-cache.mjs +4 -69
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +24 -11
- package/dist/{pluralize-CM-jZg7p.mjs → pluralize-CcT6qF0a.mjs} +12 -13
- package/dist/policies/index.d.mts +2 -2
- package/dist/policies/index.mjs +80 -83
- package/dist/presets/index.d.mts +26 -19
- package/dist/presets/index.mjs +2 -142
- package/dist/presets/multiTenant.d.mts +1 -4
- package/dist/presets/multiTenant.mjs +4 -6
- package/dist/presets-C9QXJV1u.mjs +422 -0
- package/dist/{queryCachePlugin-B6R0d4av.mjs → queryCachePlugin-ClosZdNS.mjs} +6 -27
- package/dist/{queryCachePlugin-Q6SYuHZ6.d.mts → queryCachePlugin-DcmETvcB.d.mts} +3 -3
- package/dist/queryParser-CgCtsjti.mjs +352 -0
- package/dist/{redis-UwjEp8Ea.d.mts → redis-CQ5YxMC5.d.mts} +2 -2
- package/dist/{redis-stream-CBg0upHI.d.mts → redis-stream-BW9UKLZM.d.mts} +9 -2
- package/dist/registry/index.d.mts +1 -4
- package/dist/registry/index.mjs +3 -4
- package/dist/{introspectionPlugin-B3JkrjwU.mjs → registry-I-ogLgL9.mjs} +1 -8
- package/dist/{requestContext-xi6OKBL-.mjs → requestContext-DYtmNpm5.mjs} +1 -3
- package/dist/resourceToTools-PMFE8HIv.mjs +533 -0
- package/dist/rpc/index.d.mts +90 -0
- package/dist/rpc/index.mjs +248 -0
- package/dist/{schemaConverter-Dtg0Kt9T.mjs → schemaConverter-DjzHpFam.mjs} +1 -2
- package/dist/schemas/index.d.mts +30 -30
- package/dist/schemas/index.mjs +2 -4
- package/dist/scope/index.d.mts +13 -2
- package/dist/scope/index.mjs +18 -5
- package/dist/{sessionManager-D_iEHjQl.d.mts → sessionManager-wbkYj2HL.d.mts} +2 -2
- package/dist/{sse-DkqQ1uxb.mjs → sse-BkViJPlT.mjs} +4 -25
- package/dist/testing/index.d.mts +551 -567
- package/dist/testing/index.mjs +1744 -1799
- package/dist/{tracing-8CEbhF0w.d.mts → tracing-bz_U4EM1.d.mts} +6 -1
- package/dist/{typeGuards-DwxA1t_L.mjs → typeGuards-Cj5Rgvlg.mjs} +1 -2
- package/dist/types/index.d.mts +4 -946
- package/dist/types/index.mjs +2 -4
- package/dist/types-BJmgxNbF.d.mts +275 -0
- package/dist/{types-RLkFVgaw.d.mts → types-BNUccdcf.d.mts} +2 -2
- package/dist/{types-Beqn1Un7.mjs → types-C6TQjtdi.mjs} +30 -2
- package/dist/{types-tKwaViYB.d.mts → types-Dt0-AI6E.d.mts} +68 -27
- package/dist/{types-DelU6kln.mjs → types-ZUu_h0jp.mjs} +1 -2
- package/dist/utils/index.d.mts +254 -351
- package/dist/utils/index.mjs +7 -6
- package/dist/utils-Dc0WhlIl.mjs +594 -0
- package/dist/versioning-BzfeHmhj.mjs +37 -0
- package/package.json +44 -10
- package/skills/arc/SKILL.md +518 -0
- package/skills/arc/references/auth.md +250 -0
- package/skills/arc/references/events.md +272 -0
- package/skills/arc/references/integrations.md +385 -0
- package/skills/arc/references/mcp.md +431 -0
- package/skills/arc/references/production.md +610 -0
- package/skills/arc/references/testing.md +183 -0
- package/dist/audited-CGdLiSlE.mjs +0 -140
- package/dist/chunk-C7Uep-_p.mjs +0 -20
- package/dist/circuitBreaker-CSS2VvL6.mjs +0 -1109
- package/dist/errorHandler-CW3OOeYq.d.mts +0 -72
- package/dist/interface-BtdYtQUA.d.mts +0 -1114
- package/dist/presets-BTeYbw7h.d.mts +0 -57
- package/dist/presets-CeFtfDR8.mjs +0 -119
- /package/dist/{errors-DAWRdiYP.d.mts → errors-CPpvPHT0.d.mts} +0 -0
- /package/dist/{externalPaths-SyPF2tgK.d.mts → externalPaths-DpO-s7r8.d.mts} +0 -0
- /package/dist/{interface-DTbsvIWe.d.mts → interface-D_BWALyZ.d.mts} +0 -0
package/dist/testing/index.mjs
CHANGED
|
@@ -1,2008 +1,1953 @@
|
|
|
1
|
-
import { t as CRUD_OPERATIONS } from "../constants-
|
|
2
|
-
import { n as applyFieldWritePermissions, t as applyFieldReadPermissions } from "../fields-
|
|
1
|
+
import { t as CRUD_OPERATIONS } from "../constants-Cxde4rpC.mjs";
|
|
2
|
+
import { n as applyFieldWritePermissions, t as applyFieldReadPermissions } from "../fields-ipsbIRPK.mjs";
|
|
3
3
|
import Fastify from "fastify";
|
|
4
|
+
import mongoose from "mongoose";
|
|
4
5
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//#region src/testing/TestHarness.ts
|
|
6
|
+
//#region src/testing/authHelpers.ts
|
|
8
7
|
/**
|
|
9
|
-
*
|
|
8
|
+
* Safely parse a JSON response body.
|
|
9
|
+
* Returns null if parsing fails.
|
|
10
|
+
*/
|
|
11
|
+
function safeParseBody(body) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(body);
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create stateless Better Auth test helpers.
|
|
10
20
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
21
|
+
* All methods take the app instance as a parameter, making them
|
|
22
|
+
* safe to use across multiple test suites.
|
|
23
|
+
*/
|
|
24
|
+
function createBetterAuthTestHelpers(options = {}) {
|
|
25
|
+
const basePath = options.basePath ?? "/api/auth";
|
|
26
|
+
return {
|
|
27
|
+
async signUp(app, data) {
|
|
28
|
+
const res = await app.inject({
|
|
29
|
+
method: "POST",
|
|
30
|
+
url: `${basePath}/sign-up/email`,
|
|
31
|
+
payload: data
|
|
32
|
+
});
|
|
33
|
+
const token = res.headers["set-auth-token"];
|
|
34
|
+
const body = safeParseBody(res.body);
|
|
35
|
+
return {
|
|
36
|
+
statusCode: res.statusCode,
|
|
37
|
+
token: token || "",
|
|
38
|
+
user: body?.user || body,
|
|
39
|
+
body
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
async signIn(app, data) {
|
|
43
|
+
const res = await app.inject({
|
|
44
|
+
method: "POST",
|
|
45
|
+
url: `${basePath}/sign-in/email`,
|
|
46
|
+
payload: data
|
|
47
|
+
});
|
|
48
|
+
const token = res.headers["set-auth-token"];
|
|
49
|
+
const body = safeParseBody(res.body);
|
|
50
|
+
return {
|
|
51
|
+
statusCode: res.statusCode,
|
|
52
|
+
token: token || "",
|
|
53
|
+
user: body?.user || body,
|
|
54
|
+
body
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
async createOrg(app, token, data) {
|
|
58
|
+
const res = await app.inject({
|
|
59
|
+
method: "POST",
|
|
60
|
+
url: `${basePath}/organization/create`,
|
|
61
|
+
headers: { authorization: `Bearer ${token}` },
|
|
62
|
+
payload: data
|
|
63
|
+
});
|
|
64
|
+
const body = safeParseBody(res.body);
|
|
65
|
+
return {
|
|
66
|
+
statusCode: res.statusCode,
|
|
67
|
+
orgId: body?.id,
|
|
68
|
+
body
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
async setActiveOrg(app, token, orgId) {
|
|
72
|
+
const res = await app.inject({
|
|
73
|
+
method: "POST",
|
|
74
|
+
url: `${basePath}/organization/set-active`,
|
|
75
|
+
headers: { authorization: `Bearer ${token}` },
|
|
76
|
+
payload: { organizationId: orgId }
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
statusCode: res.statusCode,
|
|
80
|
+
body: safeParseBody(res.body)
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
authHeaders(token, orgId) {
|
|
84
|
+
const h = { authorization: `Bearer ${token}` };
|
|
85
|
+
if (orgId) h["x-organization-id"] = orgId;
|
|
86
|
+
return h;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Set up a complete test organization with users.
|
|
13
92
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* import productResource from './product.resource.js';
|
|
93
|
+
* Creates the app, signs up users, creates an org, adds members,
|
|
94
|
+
* and returns a context object with tokens and a teardown function.
|
|
17
95
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```typescript
|
|
98
|
+
* const ctx = await setupBetterAuthOrg({
|
|
99
|
+
* createApp: () => createAppInstance(),
|
|
100
|
+
* org: { name: 'Test Corp', slug: 'test-corp' },
|
|
101
|
+
* users: [
|
|
102
|
+
* { key: 'admin', email: 'admin@test.com', password: 'pass', name: 'Admin', role: 'admin', isCreator: true },
|
|
103
|
+
* { key: 'member', email: 'user@test.com', password: 'pass', name: 'User', role: 'member' },
|
|
104
|
+
* ],
|
|
105
|
+
* addMember: async (data) => {
|
|
106
|
+
* await auth.api.addMember({ body: data });
|
|
107
|
+
* return { statusCode: 200 };
|
|
22
108
|
* },
|
|
23
109
|
* });
|
|
24
110
|
*
|
|
25
|
-
* //
|
|
26
|
-
*
|
|
111
|
+
* // Use in tests:
|
|
112
|
+
* const res = await ctx.app.inject({
|
|
113
|
+
* method: 'GET',
|
|
114
|
+
* url: '/api/products',
|
|
115
|
+
* headers: auth.authHeaders(ctx.users.admin.token, ctx.orgId),
|
|
116
|
+
* });
|
|
27
117
|
*
|
|
28
|
-
* //
|
|
29
|
-
*
|
|
30
|
-
*
|
|
118
|
+
* // Cleanup:
|
|
119
|
+
* await ctx.teardown();
|
|
120
|
+
* ```
|
|
31
121
|
*/
|
|
122
|
+
async function setupBetterAuthOrg(options) {
|
|
123
|
+
const { createApp, org, users: userConfigs, addMember, afterSetup, authHelpers: helpersOptions } = options;
|
|
124
|
+
const helpers = createBetterAuthTestHelpers(helpersOptions);
|
|
125
|
+
const creators = userConfigs.filter((u) => u.isCreator);
|
|
126
|
+
if (creators.length !== 1) throw new Error(`setupBetterAuthOrg: Exactly one user must have isCreator: true (found ${creators.length})`);
|
|
127
|
+
const app = await createApp();
|
|
128
|
+
await app.ready();
|
|
129
|
+
const signups = /* @__PURE__ */ new Map();
|
|
130
|
+
for (const userConfig of userConfigs) {
|
|
131
|
+
const signup = await helpers.signUp(app, {
|
|
132
|
+
email: userConfig.email,
|
|
133
|
+
password: userConfig.password,
|
|
134
|
+
name: userConfig.name
|
|
135
|
+
});
|
|
136
|
+
if (signup.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to sign up ${userConfig.email} (status ${signup.statusCode})`);
|
|
137
|
+
signups.set(userConfig.key, signup);
|
|
138
|
+
}
|
|
139
|
+
const creatorConfig = creators[0];
|
|
140
|
+
const creatorSignup = signups.get(creatorConfig.key);
|
|
141
|
+
const orgResult = await helpers.createOrg(app, creatorSignup.token, org);
|
|
142
|
+
if (orgResult.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to create org (status ${orgResult.statusCode})`);
|
|
143
|
+
const orgId = orgResult.orgId;
|
|
144
|
+
for (const userConfig of userConfigs) {
|
|
145
|
+
if (userConfig.isCreator) continue;
|
|
146
|
+
const result = await addMember({
|
|
147
|
+
organizationId: orgId,
|
|
148
|
+
userId: signups.get(userConfig.key).user?.id,
|
|
149
|
+
role: userConfig.role
|
|
150
|
+
});
|
|
151
|
+
if (result.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to add member ${userConfig.email} (status ${result.statusCode})`);
|
|
152
|
+
}
|
|
153
|
+
await helpers.setActiveOrg(app, creatorSignup.token, orgId);
|
|
154
|
+
const users = {};
|
|
155
|
+
for (const userConfig of userConfigs) if (userConfig.isCreator) {
|
|
156
|
+
const signup = signups.get(userConfig.key);
|
|
157
|
+
users[userConfig.key] = {
|
|
158
|
+
token: signup.token,
|
|
159
|
+
userId: signup.user?.id,
|
|
160
|
+
email: userConfig.email
|
|
161
|
+
};
|
|
162
|
+
} else {
|
|
163
|
+
const login = await helpers.signIn(app, {
|
|
164
|
+
email: userConfig.email,
|
|
165
|
+
password: userConfig.password
|
|
166
|
+
});
|
|
167
|
+
await helpers.setActiveOrg(app, login.token, orgId);
|
|
168
|
+
users[userConfig.key] = {
|
|
169
|
+
token: login.token,
|
|
170
|
+
userId: signups.get(userConfig.key)?.user?.id,
|
|
171
|
+
email: userConfig.email
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const ctx = {
|
|
175
|
+
app,
|
|
176
|
+
orgId,
|
|
177
|
+
users,
|
|
178
|
+
async teardown() {
|
|
179
|
+
await app.close();
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
if (afterSetup) await afterSetup(ctx);
|
|
183
|
+
return ctx;
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/testing/dbHelpers.ts
|
|
32
187
|
/**
|
|
33
|
-
* Test
|
|
34
|
-
*
|
|
35
|
-
* Provides automatic test generation for:
|
|
36
|
-
* - CRUD operations (create, read, update, delete)
|
|
37
|
-
* - Schema validation
|
|
38
|
-
* - Preset-specific functionality (softDelete, slugLookup, tree, etc.)
|
|
188
|
+
* Test database manager
|
|
39
189
|
*/
|
|
40
|
-
var
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
mongoUri;
|
|
46
|
-
_createdIds = [];
|
|
47
|
-
Model;
|
|
48
|
-
constructor(resource, options) {
|
|
49
|
-
this.resource = resource;
|
|
50
|
-
this.fixtures = options.fixtures;
|
|
51
|
-
this.setupFn = options.setupFn;
|
|
52
|
-
this.teardownFn = options.teardownFn;
|
|
53
|
-
this.mongoUri = options.mongoUri || process.env.MONGO_URI || "mongodb://localhost:27017/test";
|
|
54
|
-
if (!resource.adapter) throw new Error(`TestHarness requires a resource with a database adapter`);
|
|
55
|
-
if (resource.adapter.type !== "mongoose") throw new Error(`TestHarness currently only supports Mongoose adapters`);
|
|
56
|
-
const model = resource.adapter.model;
|
|
57
|
-
if (!model) throw new Error(`Mongoose adapter for ${resource.name} does not have a model`);
|
|
58
|
-
this.Model = model;
|
|
190
|
+
var TestDatabase = class {
|
|
191
|
+
connection;
|
|
192
|
+
dbName;
|
|
193
|
+
constructor(dbName = `test_${Date.now()}`) {
|
|
194
|
+
this.dbName = dbName;
|
|
59
195
|
}
|
|
60
196
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* Executes CRUD, validation, and preset tests
|
|
197
|
+
* Connect to test database
|
|
64
198
|
*/
|
|
65
|
-
|
|
66
|
-
this.
|
|
67
|
-
this.
|
|
68
|
-
this.
|
|
69
|
-
this.runFieldPermissions();
|
|
70
|
-
this.runPipeline();
|
|
71
|
-
this.runEvents();
|
|
199
|
+
async connect(uri) {
|
|
200
|
+
const fullUri = `${uri || process.env.MONGO_TEST_URI || "mongodb://localhost:27017"}/${this.dbName}`;
|
|
201
|
+
this.connection = await mongoose.createConnection(fullUri).asPromise();
|
|
202
|
+
return this.connection;
|
|
72
203
|
}
|
|
73
204
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* Tests: create, read (list + getById), update, delete
|
|
77
|
-
*
|
|
78
|
-
* @deprecated Use `HttpTestHarness.runCrud()` for HTTP-level CRUD tests.
|
|
79
|
-
* This method tests Mongoose models directly and does not exercise
|
|
80
|
-
* HTTP routes, authentication, permissions, or the Arc pipeline.
|
|
205
|
+
* Disconnect and cleanup
|
|
81
206
|
*/
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
});
|
|
89
|
-
afterAll(async () => {
|
|
90
|
-
if (this._createdIds.length > 0) await Model.deleteMany({ _id: { $in: this._createdIds } });
|
|
91
|
-
if (this.teardownFn) await this.teardownFn();
|
|
92
|
-
await mongoose.disconnect();
|
|
93
|
-
});
|
|
94
|
-
describe("Create", () => {
|
|
95
|
-
it("should create a new document with valid data", async () => {
|
|
96
|
-
const doc = await Model.create(fixtures.valid);
|
|
97
|
-
this._createdIds.push(doc._id);
|
|
98
|
-
expect(doc).toBeDefined();
|
|
99
|
-
expect(doc._id).toBeDefined();
|
|
100
|
-
for (const [key, value] of Object.entries(fixtures.valid)) if (typeof value !== "object") expect(doc[key]).toEqual(value);
|
|
101
|
-
});
|
|
102
|
-
it("should have timestamps", async () => {
|
|
103
|
-
const doc = await Model.findById(this._createdIds[0]);
|
|
104
|
-
expect(doc).toBeDefined();
|
|
105
|
-
expect(doc.createdAt).toBeDefined();
|
|
106
|
-
expect(doc.updatedAt).toBeDefined();
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
describe("Read", () => {
|
|
110
|
-
it("should find document by ID", async () => {
|
|
111
|
-
expect(await Model.findById(this._createdIds[0])).toBeDefined();
|
|
112
|
-
});
|
|
113
|
-
it("should list documents", async () => {
|
|
114
|
-
const docs = await Model.find({});
|
|
115
|
-
expect(Array.isArray(docs)).toBe(true);
|
|
116
|
-
expect(docs.length).toBeGreaterThan(0);
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
describe("Update", () => {
|
|
120
|
-
it("should update document", async () => {
|
|
121
|
-
const updateData = fixtures.update || { updatedAt: /* @__PURE__ */ new Date() };
|
|
122
|
-
expect(await Model.findByIdAndUpdate(this._createdIds[0], updateData, { new: true })).toBeDefined();
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
describe("Delete", () => {
|
|
126
|
-
it("should delete document", async () => {
|
|
127
|
-
const toDelete = await Model.create(fixtures.valid);
|
|
128
|
-
await Model.findByIdAndDelete(toDelete._id);
|
|
129
|
-
expect(await Model.findById(toDelete._id)).toBeNull();
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
});
|
|
207
|
+
async disconnect() {
|
|
208
|
+
if (this.connection) {
|
|
209
|
+
await this.connection.dropDatabase();
|
|
210
|
+
await this.connection.close();
|
|
211
|
+
this.connection = void 0;
|
|
212
|
+
}
|
|
133
213
|
}
|
|
134
214
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* Tests schema validation, required fields, etc.
|
|
215
|
+
* Clear all collections
|
|
138
216
|
*/
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
await mongoose.connect(this.mongoUri);
|
|
144
|
-
});
|
|
145
|
-
afterAll(async () => {
|
|
146
|
-
await mongoose.disconnect();
|
|
147
|
-
});
|
|
148
|
-
it("should reject empty document", async () => {
|
|
149
|
-
await expect(Model.create({})).rejects.toThrow();
|
|
150
|
-
});
|
|
151
|
-
if (fixtures.invalid) it("should reject invalid data", async () => {
|
|
152
|
-
await expect(Model.create(fixtures.invalid)).rejects.toThrow();
|
|
153
|
-
});
|
|
154
|
-
});
|
|
217
|
+
async clear() {
|
|
218
|
+
if (!this.connection?.db) throw new Error("Database not connected");
|
|
219
|
+
const collections = await this.connection.db.collections();
|
|
220
|
+
await Promise.all(collections.map((collection) => collection.deleteMany({})));
|
|
155
221
|
}
|
|
156
222
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* Auto-detects applied presets and tests their functionality:
|
|
160
|
-
* - softDelete: deletedAt field, soft delete/restore
|
|
161
|
-
* - slugLookup: slug generation
|
|
162
|
-
* - tree: parent references, displayOrder
|
|
163
|
-
* - multiTenant: organizationId requirement
|
|
164
|
-
* - ownedByUser: userId requirement
|
|
223
|
+
* Get connection
|
|
165
224
|
*/
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const child = await Model.create({
|
|
217
|
-
...fixtures.valid,
|
|
218
|
-
parent: parent._id
|
|
219
|
-
});
|
|
220
|
-
this._createdIds.push(child._id);
|
|
221
|
-
expect(child.parent.toString()).toEqual(parent._id.toString());
|
|
222
|
-
});
|
|
223
|
-
it("should support displayOrder", async () => {
|
|
224
|
-
const doc = await Model.create({
|
|
225
|
-
...fixtures.valid,
|
|
226
|
-
displayOrder: 5
|
|
227
|
-
});
|
|
228
|
-
this._createdIds.push(doc._id);
|
|
229
|
-
expect(doc.displayOrder).toEqual(5);
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
if (presets.includes("multiTenant")) describe("Multi-Tenant", () => {
|
|
233
|
-
it("should require organizationId", async () => {
|
|
234
|
-
const docWithoutOrg = { ...fixtures.valid };
|
|
235
|
-
delete docWithoutOrg.organizationId;
|
|
236
|
-
await expect(Model.create(docWithoutOrg)).rejects.toThrow();
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
if (presets.includes("ownedByUser")) describe("Owned By User", () => {
|
|
240
|
-
it("should require userId", async () => {
|
|
241
|
-
const docWithoutUser = { ...fixtures.valid };
|
|
242
|
-
delete docWithoutUser.userId;
|
|
243
|
-
await expect(Model.create(docWithoutUser)).rejects.toThrow();
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
});
|
|
225
|
+
getConnection() {
|
|
226
|
+
if (!this.connection) throw new Error("Database not connected");
|
|
227
|
+
return this.connection;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* Higher-order function to wrap tests with database setup/teardown
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* describe('Product Tests', () => {
|
|
235
|
+
* withTestDb(async (db) => {
|
|
236
|
+
* test('create product', async () => {
|
|
237
|
+
* const Product = db.getConnection().model('Product', schema);
|
|
238
|
+
* const product = await Product.create({ name: 'Test' });
|
|
239
|
+
* expect(product.name).toBe('Test');
|
|
240
|
+
* });
|
|
241
|
+
* });
|
|
242
|
+
* });
|
|
243
|
+
*/
|
|
244
|
+
function withTestDb(tests, options = {}) {
|
|
245
|
+
const db = new TestDatabase(options.dbName);
|
|
246
|
+
beforeAll(async () => {
|
|
247
|
+
await db.connect(options.uri);
|
|
248
|
+
});
|
|
249
|
+
afterAll(async () => {
|
|
250
|
+
await db.disconnect();
|
|
251
|
+
});
|
|
252
|
+
afterEach(async () => {
|
|
253
|
+
await db.clear();
|
|
254
|
+
});
|
|
255
|
+
tests(db);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Create test fixtures
|
|
259
|
+
*
|
|
260
|
+
* @example
|
|
261
|
+
* const fixtures = new TestFixtures(connection);
|
|
262
|
+
*
|
|
263
|
+
* await fixtures.load('products', [
|
|
264
|
+
* { name: 'Product 1', price: 100 },
|
|
265
|
+
* { name: 'Product 2', price: 200 },
|
|
266
|
+
* ]);
|
|
267
|
+
*
|
|
268
|
+
* const products = await fixtures.get('products');
|
|
269
|
+
*/
|
|
270
|
+
var TestFixtures = class {
|
|
271
|
+
fixtures = /* @__PURE__ */ new Map();
|
|
272
|
+
connection;
|
|
273
|
+
constructor(connection) {
|
|
274
|
+
this.connection = connection;
|
|
247
275
|
}
|
|
248
276
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* Auto-generates tests for each field permission:
|
|
252
|
-
* - hidden: field is stripped from responses
|
|
253
|
-
* - visibleTo: field only shown to specified roles
|
|
254
|
-
* - writableBy: field stripped from writes by non-privileged users
|
|
255
|
-
* - redactFor: field shows redacted value for specified roles
|
|
277
|
+
* Load fixtures into a collection
|
|
256
278
|
*/
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const result = applyFieldReadPermissions({
|
|
266
|
-
[field]: "secret",
|
|
267
|
-
otherField: "visible"
|
|
268
|
-
}, fieldPerms, []);
|
|
269
|
-
expect(result[field]).toBeUndefined();
|
|
270
|
-
expect(result.otherField).toBe("visible");
|
|
271
|
-
});
|
|
272
|
-
it(`should strip hidden field '${field}' from writes`, () => {
|
|
273
|
-
const result = applyFieldWritePermissions({
|
|
274
|
-
[field]: "attempt",
|
|
275
|
-
name: "test"
|
|
276
|
-
}, fieldPerms, []);
|
|
277
|
-
expect(result[field]).toBeUndefined();
|
|
278
|
-
expect(result.name).toBe("test");
|
|
279
|
-
});
|
|
280
|
-
break;
|
|
281
|
-
case "visibleTo":
|
|
282
|
-
it(`should hide field '${field}' from non-privileged users`, () => {
|
|
283
|
-
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, ["viewer"])[field]).toBeUndefined();
|
|
284
|
-
});
|
|
285
|
-
if (perm.roles && perm.roles.length > 0) {
|
|
286
|
-
const allowedRole = perm.roles[0];
|
|
287
|
-
it(`should show field '${field}' to roles: ${[...perm.roles].join(", ")}`, () => {
|
|
288
|
-
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, [allowedRole])[field]).toBe("sensitive");
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
break;
|
|
292
|
-
case "writableBy":
|
|
293
|
-
it(`should strip field '${field}' from writes by non-privileged users`, () => {
|
|
294
|
-
const result = applyFieldWritePermissions({
|
|
295
|
-
[field]: "new-value",
|
|
296
|
-
name: "test"
|
|
297
|
-
}, fieldPerms, ["viewer"]);
|
|
298
|
-
expect(result[field]).toBeUndefined();
|
|
299
|
-
expect(result.name).toBe("test");
|
|
300
|
-
});
|
|
301
|
-
if (perm.roles && perm.roles.length > 0) {
|
|
302
|
-
const writeRole = perm.roles[0];
|
|
303
|
-
it(`should allow writing field '${field}' by roles: ${[...perm.roles].join(", ")}`, () => {
|
|
304
|
-
expect(applyFieldWritePermissions({ [field]: "new-value" }, fieldPerms, [writeRole])[field]).toBe("new-value");
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
break;
|
|
308
|
-
case "redactFor":
|
|
309
|
-
if (perm.roles && perm.roles.length > 0) {
|
|
310
|
-
const redactRole = perm.roles[0];
|
|
311
|
-
it(`should redact field '${field}' for roles: ${[...perm.roles].join(", ")}`, () => {
|
|
312
|
-
expect(applyFieldReadPermissions({ [field]: "real-value" }, fieldPerms, [redactRole])[field]).toBe(perm.redactValue ?? "***");
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
it(`should show real value of field '${field}' to non-redacted roles`, () => {
|
|
316
|
-
expect(applyFieldReadPermissions({ [field]: "real-value" }, fieldPerms, ["unrelated-role"])[field]).toBe("real-value");
|
|
317
|
-
});
|
|
318
|
-
break;
|
|
319
|
-
}
|
|
320
|
-
});
|
|
279
|
+
async load(collectionName, data) {
|
|
280
|
+
const result = await this.connection.collection(collectionName).insertMany(data);
|
|
281
|
+
const insertedDocs = Object.values(result.insertedIds).map((id, index) => ({
|
|
282
|
+
...data[index],
|
|
283
|
+
_id: id
|
|
284
|
+
}));
|
|
285
|
+
this.fixtures.set(collectionName, insertedDocs);
|
|
286
|
+
return insertedDocs;
|
|
321
287
|
}
|
|
322
288
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* Validates that pipeline steps are properly configured:
|
|
326
|
-
* - All steps have names
|
|
327
|
-
* - All steps have valid _type discriminants
|
|
328
|
-
* - Operation filters (if set) use valid CRUD operation names
|
|
289
|
+
* Get loaded fixtures
|
|
329
290
|
*/
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const pipe = resource.pipe;
|
|
333
|
-
if (!pipe) return;
|
|
334
|
-
const validOps = new Set(CRUD_OPERATIONS);
|
|
335
|
-
describe(`${resource.displayName} Pipeline`, () => {
|
|
336
|
-
const steps = collectPipelineSteps(pipe);
|
|
337
|
-
it("should have at least one pipeline step", () => {
|
|
338
|
-
expect(steps.length).toBeGreaterThan(0);
|
|
339
|
-
});
|
|
340
|
-
for (const step of steps) {
|
|
341
|
-
it(`${step._type} '${step.name}' should have a valid type`, () => {
|
|
342
|
-
expect([
|
|
343
|
-
"guard",
|
|
344
|
-
"transform",
|
|
345
|
-
"interceptor"
|
|
346
|
-
]).toContain(step._type);
|
|
347
|
-
});
|
|
348
|
-
it(`${step._type} '${step.name}' should have a name`, () => {
|
|
349
|
-
expect(step.name).toBeTruthy();
|
|
350
|
-
expect(typeof step.name).toBe("string");
|
|
351
|
-
});
|
|
352
|
-
it(`${step._type} '${step.name}' should have a handler function`, () => {
|
|
353
|
-
expect(typeof step.handler).toBe("function");
|
|
354
|
-
});
|
|
355
|
-
if (step.operations?.length) it(`${step._type} '${step.name}' should target valid operations`, () => {
|
|
356
|
-
for (const op of step.operations) expect(validOps.has(op)).toBe(true);
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
});
|
|
291
|
+
get(collectionName) {
|
|
292
|
+
return this.fixtures.get(collectionName) || [];
|
|
360
293
|
}
|
|
361
294
|
/**
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
* Validates that events are properly defined:
|
|
365
|
-
* - All events have handler functions
|
|
366
|
-
* - Event names follow resource:action convention
|
|
367
|
-
* - Schema definitions (if present) are valid objects
|
|
295
|
+
* Get first fixture
|
|
368
296
|
*/
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const events = resource.events;
|
|
372
|
-
if (!events || Object.keys(events).length === 0) return;
|
|
373
|
-
describe(`${resource.displayName} Events`, () => {
|
|
374
|
-
for (const [action, def] of Object.entries(events)) {
|
|
375
|
-
it(`event '${resource.name}:${action}' should have a handler function`, () => {
|
|
376
|
-
expect(typeof def.handler).toBe("function");
|
|
377
|
-
});
|
|
378
|
-
it(`event '${resource.name}:${action}' should have a name`, () => {
|
|
379
|
-
expect(def.name).toBeTruthy();
|
|
380
|
-
expect(typeof def.name).toBe("string");
|
|
381
|
-
});
|
|
382
|
-
if (def.schema) it(`event '${resource.name}:${action}' schema should be an object`, () => {
|
|
383
|
-
expect(typeof def.schema).toBe("object");
|
|
384
|
-
expect(def.schema).not.toBeNull();
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
});
|
|
297
|
+
getFirst(collectionName) {
|
|
298
|
+
return this.get(collectionName)[0] || null;
|
|
388
299
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
for (const opSteps of Object.values(pipe)) if (Array.isArray(opSteps)) for (const step of opSteps) {
|
|
398
|
-
const key = `${step._type}:${step.name}`;
|
|
399
|
-
if (!seen.has(key)) {
|
|
400
|
-
seen.add(key);
|
|
401
|
-
steps.push(step);
|
|
300
|
+
/**
|
|
301
|
+
* Clear all fixtures
|
|
302
|
+
*/
|
|
303
|
+
async clear() {
|
|
304
|
+
for (const collectionName of this.fixtures.keys()) {
|
|
305
|
+
const collection = this.connection.collection(collectionName);
|
|
306
|
+
const ids = this.fixtures.get(collectionName)?.map((item) => item._id) || [];
|
|
307
|
+
await collection.deleteMany({ _id: { $in: ids } });
|
|
402
308
|
}
|
|
309
|
+
this.fixtures.clear();
|
|
403
310
|
}
|
|
404
|
-
|
|
405
|
-
}
|
|
311
|
+
};
|
|
406
312
|
/**
|
|
407
|
-
*
|
|
313
|
+
* In-memory MongoDB for ultra-fast tests
|
|
408
314
|
*
|
|
409
|
-
*
|
|
410
|
-
* @param options - Test harness configuration
|
|
411
|
-
* @returns Test harness instance
|
|
315
|
+
* Requires: mongodb-memory-server
|
|
412
316
|
*
|
|
413
317
|
* @example
|
|
414
|
-
* import {
|
|
415
|
-
*
|
|
416
|
-
* const harness = createTestHarness(productResource, {
|
|
417
|
-
* fixtures: {
|
|
418
|
-
* valid: { name: 'Product', price: 100 },
|
|
419
|
-
* update: { name: 'Updated' },
|
|
420
|
-
* },
|
|
421
|
-
* });
|
|
318
|
+
* import { InMemoryDatabase } from '@classytic/arc/testing';
|
|
422
319
|
*
|
|
423
|
-
*
|
|
424
|
-
|
|
425
|
-
function createTestHarness(resource, options) {
|
|
426
|
-
return new TestHarness(resource, options);
|
|
427
|
-
}
|
|
428
|
-
/**
|
|
429
|
-
* Generate test file content for a resource
|
|
320
|
+
* describe('Fast Tests', () => {
|
|
321
|
+
* const memoryDb = new InMemoryDatabase();
|
|
430
322
|
*
|
|
431
|
-
*
|
|
323
|
+
* beforeAll(async () => {
|
|
324
|
+
* await memoryDb.start();
|
|
325
|
+
* });
|
|
432
326
|
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
327
|
+
* afterAll(async () => {
|
|
328
|
+
* await memoryDb.stop();
|
|
329
|
+
* });
|
|
436
330
|
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
331
|
+
* test('create user', async () => {
|
|
332
|
+
* const uri = memoryDb.getUri();
|
|
333
|
+
* // Use uri for connection
|
|
334
|
+
* });
|
|
441
335
|
* });
|
|
442
|
-
* fs.writeFileSync('product.test.js', testContent);
|
|
443
336
|
*/
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
337
|
+
var InMemoryDatabase = class {
|
|
338
|
+
mongod;
|
|
339
|
+
uri;
|
|
340
|
+
/**
|
|
341
|
+
* Start in-memory MongoDB
|
|
342
|
+
*/
|
|
343
|
+
async start() {
|
|
344
|
+
try {
|
|
345
|
+
const { MongoMemoryServer } = await import("mongodb-memory-server");
|
|
346
|
+
this.mongod = await MongoMemoryServer.create();
|
|
347
|
+
const uri = this.mongod.getUri();
|
|
348
|
+
this.uri = uri;
|
|
349
|
+
return uri;
|
|
350
|
+
} catch {
|
|
351
|
+
throw new Error("mongodb-memory-server not installed. Install with: npm install -D mongodb-memory-server");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Stop in-memory MongoDB
|
|
356
|
+
*/
|
|
357
|
+
async stop() {
|
|
358
|
+
if (this.mongod) {
|
|
359
|
+
await this.mongod.stop();
|
|
360
|
+
this.mongod = void 0;
|
|
361
|
+
this.uri = void 0;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Get connection URI
|
|
366
|
+
*/
|
|
367
|
+
getUri() {
|
|
368
|
+
if (!this.uri) throw new Error("In-memory database not started");
|
|
369
|
+
return this.uri;
|
|
370
|
+
}
|
|
474
371
|
};
|
|
475
|
-
|
|
476
|
-
// Create test harness
|
|
477
|
-
const harness = createTestHarness(${varName}Resource, {
|
|
478
|
-
fixtures,
|
|
479
|
-
mongoUri: MONGO_URI,
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
// Run all baseline tests
|
|
483
|
-
harness.runAll();
|
|
484
|
-
|
|
485
|
-
// Custom tests
|
|
486
|
-
describe('${className} Custom Tests', () => {
|
|
487
|
-
let testId;
|
|
488
|
-
|
|
489
|
-
beforeAll(async () => {
|
|
490
|
-
await mongoose.connect(MONGO_URI);
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
afterAll(async () => {
|
|
494
|
-
if (testId) {
|
|
495
|
-
await ${className}.findByIdAndDelete(testId);
|
|
496
|
-
}
|
|
497
|
-
await mongoose.disconnect();
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
// Add your custom tests here
|
|
501
|
-
it('should pass custom validation', async () => {
|
|
502
|
-
// Example: const doc = await ${className}.create(fixtures.valid);
|
|
503
|
-
// testId = doc._id;
|
|
504
|
-
// expect(doc.someField).toBe('expectedValue');
|
|
505
|
-
expect(true).toBe(true);
|
|
506
|
-
});
|
|
507
|
-
});
|
|
508
|
-
`;
|
|
509
|
-
}
|
|
510
372
|
/**
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
* Tests field permissions, pipeline configuration, and event definitions.
|
|
514
|
-
* Works with any adapter — no Mongoose dependency.
|
|
515
|
-
*
|
|
516
|
-
* @param resource - The Arc resource definition to test
|
|
517
|
-
*
|
|
518
|
-
* @example
|
|
519
|
-
* ```typescript
|
|
520
|
-
* import { createConfigTestSuite } from '@classytic/arc/testing';
|
|
521
|
-
* import productResource from './product.resource.js';
|
|
522
|
-
*
|
|
523
|
-
* // Generates field permission, pipeline, and event tests
|
|
524
|
-
* createConfigTestSuite(productResource);
|
|
525
|
-
* ```
|
|
373
|
+
* Database transaction helper for testing
|
|
526
374
|
*/
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const pipe = resource.pipe;
|
|
530
|
-
const events = resource.events;
|
|
531
|
-
if (fieldPerms && Object.keys(fieldPerms).length > 0) runFieldPermissionTests(resource.displayName, fieldPerms);
|
|
532
|
-
if (pipe) runPipelineTests(resource.displayName, pipe);
|
|
533
|
-
if (events && Object.keys(events).length > 0) runEventTests(resource.name, resource.displayName, events);
|
|
534
|
-
if (resource.permissions && Object.keys(resource.permissions).length > 0) describe(`${resource.displayName} Permission Config`, () => {
|
|
535
|
-
for (const op of CRUD_OPERATIONS) {
|
|
536
|
-
const check = resource.permissions[op];
|
|
537
|
-
if (check) it(`${op} permission should be a function`, () => {
|
|
538
|
-
expect(typeof check).toBe("function");
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
function runFieldPermissionTests(displayName, fieldPerms) {
|
|
544
|
-
describe(`${displayName} Field Permissions`, () => {
|
|
545
|
-
for (const [field, perm] of Object.entries(fieldPerms)) switch (perm._type) {
|
|
546
|
-
case "hidden":
|
|
547
|
-
it(`should always hide field '${field}'`, () => {
|
|
548
|
-
expect(applyFieldReadPermissions({
|
|
549
|
-
[field]: "secret",
|
|
550
|
-
other: "visible"
|
|
551
|
-
}, fieldPerms, [])[field]).toBeUndefined();
|
|
552
|
-
});
|
|
553
|
-
it(`should strip hidden field '${field}' from writes`, () => {
|
|
554
|
-
expect(applyFieldWritePermissions({
|
|
555
|
-
[field]: "attempt",
|
|
556
|
-
name: "test"
|
|
557
|
-
}, fieldPerms, [])[field]).toBeUndefined();
|
|
558
|
-
});
|
|
559
|
-
break;
|
|
560
|
-
case "visibleTo":
|
|
561
|
-
it(`should hide field '${field}' from non-privileged users`, () => {
|
|
562
|
-
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, ["_no_role_"])[field]).toBeUndefined();
|
|
563
|
-
});
|
|
564
|
-
if (perm.roles && perm.roles.length > 0) {
|
|
565
|
-
const allowedRole = perm.roles[0];
|
|
566
|
-
it(`should show field '${field}' to roles: ${[...perm.roles].join(", ")}`, () => {
|
|
567
|
-
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, [allowedRole])[field]).toBe("sensitive");
|
|
568
|
-
});
|
|
569
|
-
}
|
|
570
|
-
break;
|
|
571
|
-
case "writableBy":
|
|
572
|
-
it(`should strip field '${field}' from writes by non-privileged users`, () => {
|
|
573
|
-
expect(applyFieldWritePermissions({
|
|
574
|
-
[field]: "v",
|
|
575
|
-
name: "test"
|
|
576
|
-
}, fieldPerms, ["_no_role_"])[field]).toBeUndefined();
|
|
577
|
-
});
|
|
578
|
-
if (perm.roles && perm.roles.length > 0) {
|
|
579
|
-
const writeRole = perm.roles[0];
|
|
580
|
-
it(`should allow writing field '${field}' by roles: ${[...perm.roles].join(", ")}`, () => {
|
|
581
|
-
expect(applyFieldWritePermissions({ [field]: "v" }, fieldPerms, [writeRole])[field]).toBe("v");
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
break;
|
|
585
|
-
case "redactFor":
|
|
586
|
-
if (perm.roles && perm.roles.length > 0) {
|
|
587
|
-
const redactRole = perm.roles[0];
|
|
588
|
-
it(`should redact field '${field}' for roles: ${[...perm.roles].join(", ")}`, () => {
|
|
589
|
-
expect(applyFieldReadPermissions({ [field]: "real" }, fieldPerms, [redactRole])[field]).toBe(perm.redactValue ?? "***");
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
it(`should show real value of field '${field}' to non-redacted roles`, () => {
|
|
593
|
-
expect(applyFieldReadPermissions({ [field]: "real" }, fieldPerms, ["_other_"])[field]).toBe("real");
|
|
594
|
-
});
|
|
595
|
-
break;
|
|
596
|
-
}
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
|
-
function runPipelineTests(displayName, pipe) {
|
|
600
|
-
const steps = collectPipelineSteps(pipe);
|
|
601
|
-
if (steps.length === 0) return;
|
|
602
|
-
const validOps = new Set(CRUD_OPERATIONS);
|
|
603
|
-
describe(`${displayName} Pipeline`, () => {
|
|
604
|
-
it("should have at least one pipeline step", () => {
|
|
605
|
-
expect(steps.length).toBeGreaterThan(0);
|
|
606
|
-
});
|
|
607
|
-
for (const step of steps) {
|
|
608
|
-
it(`${step._type} '${step.name}' should have a valid type`, () => {
|
|
609
|
-
expect([
|
|
610
|
-
"guard",
|
|
611
|
-
"transform",
|
|
612
|
-
"interceptor"
|
|
613
|
-
]).toContain(step._type);
|
|
614
|
-
});
|
|
615
|
-
it(`${step._type} '${step.name}' should have a handler function`, () => {
|
|
616
|
-
expect(typeof step.handler).toBe("function");
|
|
617
|
-
});
|
|
618
|
-
if (step.operations?.length) it(`${step._type} '${step.name}' should target valid operations`, () => {
|
|
619
|
-
for (const op of step.operations) expect(validOps.has(op)).toBe(true);
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
function runEventTests(resourceName, displayName, events) {
|
|
625
|
-
describe(`${displayName} Events`, () => {
|
|
626
|
-
for (const [action, def] of Object.entries(events)) {
|
|
627
|
-
it(`event '${resourceName}:${action}' should have a handler function`, () => {
|
|
628
|
-
expect(typeof def.handler).toBe("function");
|
|
629
|
-
});
|
|
630
|
-
it(`event '${resourceName}:${action}' should have a name`, () => {
|
|
631
|
-
expect(def.name).toBeTruthy();
|
|
632
|
-
});
|
|
633
|
-
if (def.schema) it(`event '${resourceName}:${action}' schema should be an object`, () => {
|
|
634
|
-
expect(typeof def.schema).toBe("object");
|
|
635
|
-
expect(def.schema).not.toBeNull();
|
|
636
|
-
});
|
|
637
|
-
}
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
//#endregion
|
|
642
|
-
//#region src/testing/dbHelpers.ts
|
|
643
|
-
/**
|
|
644
|
-
* Testing Utilities - Database Helpers
|
|
645
|
-
*
|
|
646
|
-
* Utilities for managing test databases and fixtures
|
|
647
|
-
*/
|
|
648
|
-
/**
|
|
649
|
-
* Test database manager
|
|
650
|
-
*/
|
|
651
|
-
var TestDatabase = class {
|
|
375
|
+
var TestTransaction = class {
|
|
376
|
+
session;
|
|
652
377
|
connection;
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
this.dbName = dbName;
|
|
378
|
+
constructor(connection) {
|
|
379
|
+
this.connection = connection;
|
|
656
380
|
}
|
|
657
381
|
/**
|
|
658
|
-
*
|
|
382
|
+
* Start transaction
|
|
659
383
|
*/
|
|
660
|
-
async
|
|
661
|
-
|
|
662
|
-
this.
|
|
663
|
-
return this.connection;
|
|
384
|
+
async start() {
|
|
385
|
+
this.session = await this.connection.startSession();
|
|
386
|
+
this.session.startTransaction();
|
|
664
387
|
}
|
|
665
388
|
/**
|
|
666
|
-
*
|
|
389
|
+
* Commit transaction
|
|
667
390
|
*/
|
|
668
|
-
async
|
|
669
|
-
if (this.
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
}
|
|
391
|
+
async commit() {
|
|
392
|
+
if (!this.session) throw new Error("Transaction not started");
|
|
393
|
+
await this.session.commitTransaction();
|
|
394
|
+
await this.session.endSession();
|
|
395
|
+
this.session = void 0;
|
|
674
396
|
}
|
|
675
397
|
/**
|
|
676
|
-
*
|
|
398
|
+
* Rollback transaction
|
|
677
399
|
*/
|
|
678
|
-
async
|
|
679
|
-
if (!this.
|
|
680
|
-
|
|
681
|
-
await
|
|
400
|
+
async rollback() {
|
|
401
|
+
if (!this.session) throw new Error("Transaction not started");
|
|
402
|
+
await this.session.abortTransaction();
|
|
403
|
+
await this.session.endSession();
|
|
404
|
+
this.session = void 0;
|
|
682
405
|
}
|
|
683
406
|
/**
|
|
684
|
-
* Get
|
|
407
|
+
* Get session
|
|
685
408
|
*/
|
|
686
|
-
|
|
687
|
-
if (!this.
|
|
688
|
-
return this.
|
|
409
|
+
getSession() {
|
|
410
|
+
if (!this.session) throw new Error("Transaction not started");
|
|
411
|
+
return this.session;
|
|
689
412
|
}
|
|
690
413
|
};
|
|
691
414
|
/**
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
* @example
|
|
695
|
-
* describe('Product Tests', () => {
|
|
696
|
-
* withTestDb(async (db) => {
|
|
697
|
-
* test('create product', async () => {
|
|
698
|
-
* const Product = db.getConnection().model('Product', schema);
|
|
699
|
-
* const product = await Product.create({ name: 'Test' });
|
|
700
|
-
* expect(product.name).toBe('Test');
|
|
701
|
-
* });
|
|
702
|
-
* });
|
|
703
|
-
* });
|
|
704
|
-
*/
|
|
705
|
-
function withTestDb(tests, options = {}) {
|
|
706
|
-
const db = new TestDatabase(options.dbName);
|
|
707
|
-
beforeAll(async () => {
|
|
708
|
-
await db.connect(options.uri);
|
|
709
|
-
});
|
|
710
|
-
afterAll(async () => {
|
|
711
|
-
await db.disconnect();
|
|
712
|
-
});
|
|
713
|
-
afterEach(async () => {
|
|
714
|
-
await db.clear();
|
|
715
|
-
});
|
|
716
|
-
tests(db);
|
|
717
|
-
}
|
|
718
|
-
/**
|
|
719
|
-
* Create test fixtures
|
|
720
|
-
*
|
|
721
|
-
* @example
|
|
722
|
-
* const fixtures = new TestFixtures(connection);
|
|
723
|
-
*
|
|
724
|
-
* await fixtures.load('products', [
|
|
725
|
-
* { name: 'Product 1', price: 100 },
|
|
726
|
-
* { name: 'Product 2', price: 200 },
|
|
727
|
-
* ]);
|
|
728
|
-
*
|
|
729
|
-
* const products = await fixtures.get('products');
|
|
415
|
+
* Seed data helper
|
|
730
416
|
*/
|
|
731
|
-
var
|
|
732
|
-
fixtures = /* @__PURE__ */ new Map();
|
|
417
|
+
var TestSeeder = class {
|
|
733
418
|
connection;
|
|
734
419
|
constructor(connection) {
|
|
735
420
|
this.connection = connection;
|
|
736
421
|
}
|
|
737
422
|
/**
|
|
738
|
-
*
|
|
423
|
+
* Seed collection with data
|
|
739
424
|
*/
|
|
740
|
-
async
|
|
425
|
+
async seed(collectionName, generator, count = 10) {
|
|
426
|
+
const data = Array.from({ length: count }, () => generator()).flat();
|
|
741
427
|
const result = await this.connection.collection(collectionName).insertMany(data);
|
|
742
|
-
|
|
428
|
+
return Object.values(result.insertedIds).map((id, index) => ({
|
|
743
429
|
...data[index],
|
|
744
430
|
_id: id
|
|
745
431
|
}));
|
|
746
|
-
this.fixtures.set(collectionName, insertedDocs);
|
|
747
|
-
return insertedDocs;
|
|
748
432
|
}
|
|
749
433
|
/**
|
|
750
|
-
*
|
|
434
|
+
* Clear collection
|
|
751
435
|
*/
|
|
752
|
-
|
|
753
|
-
|
|
436
|
+
async clear(collectionName) {
|
|
437
|
+
await this.connection.collection(collectionName).deleteMany({});
|
|
754
438
|
}
|
|
755
439
|
/**
|
|
756
|
-
*
|
|
440
|
+
* Clear all collections
|
|
757
441
|
*/
|
|
758
|
-
|
|
759
|
-
|
|
442
|
+
async clearAll() {
|
|
443
|
+
if (!this.connection.db) throw new Error("Database not connected");
|
|
444
|
+
const collections = await this.connection.db.collections();
|
|
445
|
+
await Promise.all(collections.map((collection) => collection.deleteMany({})));
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
/**
|
|
449
|
+
* Database snapshot helper for rollback testing
|
|
450
|
+
*/
|
|
451
|
+
var DatabaseSnapshot = class {
|
|
452
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
453
|
+
connection;
|
|
454
|
+
constructor(connection) {
|
|
455
|
+
this.connection = connection;
|
|
760
456
|
}
|
|
761
457
|
/**
|
|
762
|
-
*
|
|
458
|
+
* Take snapshot of current database state
|
|
763
459
|
*/
|
|
764
|
-
async
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
await collection.
|
|
460
|
+
async take() {
|
|
461
|
+
if (!this.connection.db) throw new Error("Database not connected");
|
|
462
|
+
const collections = await this.connection.db.collections();
|
|
463
|
+
for (const collection of collections) {
|
|
464
|
+
const data = await collection.find({}).toArray();
|
|
465
|
+
this.snapshots.set(collection.collectionName, data);
|
|
769
466
|
}
|
|
770
|
-
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Restore database to snapshot
|
|
470
|
+
*/
|
|
471
|
+
async restore() {
|
|
472
|
+
if (!this.connection.db) throw new Error("Database not connected");
|
|
473
|
+
const collections = await this.connection.db.collections();
|
|
474
|
+
await Promise.all(collections.map((collection) => collection.deleteMany({})));
|
|
475
|
+
for (const [collectionName, data] of this.snapshots.entries()) if (data.length > 0) await this.connection.collection(collectionName).insertMany(data);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Clear snapshot
|
|
479
|
+
*/
|
|
480
|
+
clear() {
|
|
481
|
+
this.snapshots.clear();
|
|
771
482
|
}
|
|
772
483
|
};
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region src/testing/HttpTestHarness.ts
|
|
773
486
|
/**
|
|
774
|
-
*
|
|
487
|
+
* Create an auth provider for JWT-based apps.
|
|
775
488
|
*
|
|
776
|
-
*
|
|
489
|
+
* Generates JWT tokens on the fly using the app's JWT plugin.
|
|
777
490
|
*
|
|
778
491
|
* @example
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
* afterAll(async () => {
|
|
789
|
-
* await memoryDb.stop();
|
|
790
|
-
* });
|
|
791
|
-
*
|
|
792
|
-
* test('create user', async () => {
|
|
793
|
-
* const uri = memoryDb.getUri();
|
|
794
|
-
* // Use uri for connection
|
|
795
|
-
* });
|
|
492
|
+
* ```typescript
|
|
493
|
+
* const auth = createJwtAuthProvider({
|
|
494
|
+
* app,
|
|
495
|
+
* users: {
|
|
496
|
+
* admin: { payload: { id: '1', roles: ['admin'] }, organizationId: 'org1' },
|
|
497
|
+
* viewer: { payload: { id: '2', roles: ['viewer'] } },
|
|
498
|
+
* },
|
|
499
|
+
* adminRole: 'admin',
|
|
796
500
|
* });
|
|
501
|
+
* ```
|
|
797
502
|
*/
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
throw new Error("mongodb-memory-server not installed. Install with: npm install -D mongodb-memory-server");
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
/**
|
|
816
|
-
* Stop in-memory MongoDB
|
|
817
|
-
*/
|
|
818
|
-
async stop() {
|
|
819
|
-
if (this.mongod) {
|
|
820
|
-
await this.mongod.stop();
|
|
821
|
-
this.mongod = void 0;
|
|
822
|
-
this.uri = void 0;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
/**
|
|
826
|
-
* Get connection URI
|
|
827
|
-
*/
|
|
828
|
-
getUri() {
|
|
829
|
-
if (!this.uri) throw new Error("In-memory database not started");
|
|
830
|
-
return this.uri;
|
|
831
|
-
}
|
|
832
|
-
};
|
|
503
|
+
function createJwtAuthProvider(options) {
|
|
504
|
+
const { app, users, adminRole } = options;
|
|
505
|
+
return {
|
|
506
|
+
getHeaders(role) {
|
|
507
|
+
const user = users[role];
|
|
508
|
+
if (!user) throw new Error(`createJwtAuthProvider: Unknown role '${role}'. Available: ${Object.keys(users).join(", ")}`);
|
|
509
|
+
const headers = { authorization: `Bearer ${app.jwt?.sign?.(user.payload) || "mock-token"}` };
|
|
510
|
+
if (user.organizationId) headers["x-organization-id"] = user.organizationId;
|
|
511
|
+
return headers;
|
|
512
|
+
},
|
|
513
|
+
availableRoles: Object.keys(users),
|
|
514
|
+
adminRole
|
|
515
|
+
};
|
|
516
|
+
}
|
|
833
517
|
/**
|
|
834
|
-
*
|
|
518
|
+
* Create an auth provider for Better Auth apps.
|
|
519
|
+
*
|
|
520
|
+
* Uses pre-existing tokens (from signUp/signIn) rather than generating them.
|
|
521
|
+
*
|
|
522
|
+
* @example
|
|
523
|
+
* ```typescript
|
|
524
|
+
* const auth = createBetterAuthProvider({
|
|
525
|
+
* tokens: {
|
|
526
|
+
* admin: ctx.users.admin.token,
|
|
527
|
+
* member: ctx.users.member.token,
|
|
528
|
+
* },
|
|
529
|
+
* orgId: ctx.orgId,
|
|
530
|
+
* adminRole: 'admin',
|
|
531
|
+
* });
|
|
532
|
+
* ```
|
|
835
533
|
*/
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
*/
|
|
852
|
-
async commit() {
|
|
853
|
-
if (!this.session) throw new Error("Transaction not started");
|
|
854
|
-
await this.session.commitTransaction();
|
|
855
|
-
await this.session.endSession();
|
|
856
|
-
this.session = void 0;
|
|
857
|
-
}
|
|
858
|
-
/**
|
|
859
|
-
* Rollback transaction
|
|
860
|
-
*/
|
|
861
|
-
async rollback() {
|
|
862
|
-
if (!this.session) throw new Error("Transaction not started");
|
|
863
|
-
await this.session.abortTransaction();
|
|
864
|
-
await this.session.endSession();
|
|
865
|
-
this.session = void 0;
|
|
866
|
-
}
|
|
867
|
-
/**
|
|
868
|
-
* Get session
|
|
869
|
-
*/
|
|
870
|
-
getSession() {
|
|
871
|
-
if (!this.session) throw new Error("Transaction not started");
|
|
872
|
-
return this.session;
|
|
873
|
-
}
|
|
874
|
-
};
|
|
534
|
+
function createBetterAuthProvider(options) {
|
|
535
|
+
const { tokens, orgId, adminRole } = options;
|
|
536
|
+
return {
|
|
537
|
+
getHeaders(role) {
|
|
538
|
+
const token = tokens[role];
|
|
539
|
+
if (!token) throw new Error(`createBetterAuthProvider: No token for role '${role}'. Available: ${Object.keys(tokens).join(", ")}`);
|
|
540
|
+
return {
|
|
541
|
+
authorization: `Bearer ${token}`,
|
|
542
|
+
"x-organization-id": orgId
|
|
543
|
+
};
|
|
544
|
+
},
|
|
545
|
+
availableRoles: Object.keys(tokens),
|
|
546
|
+
adminRole
|
|
547
|
+
};
|
|
548
|
+
}
|
|
875
549
|
/**
|
|
876
|
-
*
|
|
550
|
+
* HTTP-level test harness for Arc resources.
|
|
551
|
+
*
|
|
552
|
+
* Generates tests that exercise the full HTTP lifecycle:
|
|
553
|
+
* routes, auth, permissions, pipeline, and response envelope.
|
|
554
|
+
*
|
|
555
|
+
* Supports deferred options via a getter function, which is essential
|
|
556
|
+
* when the app instance comes from async `beforeAll()` setup.
|
|
877
557
|
*/
|
|
878
|
-
var
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
}));
|
|
893
|
-
}
|
|
894
|
-
/**
|
|
895
|
-
* Clear collection
|
|
896
|
-
*/
|
|
897
|
-
async clear(collectionName) {
|
|
898
|
-
await this.connection.collection(collectionName).deleteMany({});
|
|
899
|
-
}
|
|
900
|
-
/**
|
|
901
|
-
* Clear all collections
|
|
902
|
-
*/
|
|
903
|
-
async clearAll() {
|
|
904
|
-
if (!this.connection.db) throw new Error("Database not connected");
|
|
905
|
-
const collections = await this.connection.db.collections();
|
|
906
|
-
await Promise.all(collections.map((collection) => collection.deleteMany({})));
|
|
558
|
+
var HttpTestHarness = class {
|
|
559
|
+
resource;
|
|
560
|
+
optionsOrGetter;
|
|
561
|
+
eagerBaseUrl;
|
|
562
|
+
enabledRoutes;
|
|
563
|
+
updateMethod;
|
|
564
|
+
constructor(resource, optionsOrGetter) {
|
|
565
|
+
this.resource = resource;
|
|
566
|
+
this.optionsOrGetter = optionsOrGetter;
|
|
567
|
+
if (typeof optionsOrGetter === "function") this.eagerBaseUrl = null;
|
|
568
|
+
else this.eagerBaseUrl = `${optionsOrGetter.apiPrefix ?? "/api"}${resource.prefix}`;
|
|
569
|
+
const disabled = new Set(resource.disabledRoutes ?? []);
|
|
570
|
+
this.enabledRoutes = new Set(resource.disableDefaultRoutes ? [] : CRUD_OPERATIONS.filter((op) => !disabled.has(op)));
|
|
571
|
+
this.updateMethod = resource.updateMethod === "PUT" ? "PUT" : "PATCH";
|
|
907
572
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
*/
|
|
912
|
-
var DatabaseSnapshot = class {
|
|
913
|
-
snapshots = /* @__PURE__ */ new Map();
|
|
914
|
-
connection;
|
|
915
|
-
constructor(connection) {
|
|
916
|
-
this.connection = connection;
|
|
573
|
+
/** Resolve options (supports both direct and deferred) */
|
|
574
|
+
getOptions() {
|
|
575
|
+
return typeof this.optionsOrGetter === "function" ? this.optionsOrGetter() : this.optionsOrGetter;
|
|
917
576
|
}
|
|
918
577
|
/**
|
|
919
|
-
*
|
|
578
|
+
* Resolve the base URL for requests.
|
|
579
|
+
*
|
|
580
|
+
* - Eager mode: uses pre-computed baseUrl from constructor
|
|
581
|
+
* - Deferred mode: reads apiPrefix from the getter options at runtime
|
|
582
|
+
*
|
|
583
|
+
* Must only be called inside it()/afterAll() callbacks (after beforeAll has run).
|
|
920
584
|
*/
|
|
921
|
-
|
|
922
|
-
if (
|
|
923
|
-
|
|
924
|
-
for (const collection of collections) {
|
|
925
|
-
const data = await collection.find({}).toArray();
|
|
926
|
-
this.snapshots.set(collection.collectionName, data);
|
|
927
|
-
}
|
|
585
|
+
getBaseUrl() {
|
|
586
|
+
if (this.eagerBaseUrl !== null) return this.eagerBaseUrl;
|
|
587
|
+
return `${this.getOptions().apiPrefix ?? ""}${this.resource.prefix}`;
|
|
928
588
|
}
|
|
929
589
|
/**
|
|
930
|
-
*
|
|
590
|
+
* Run all test suites: CRUD + permissions + validation
|
|
931
591
|
*/
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
for (const [collectionName, data] of this.snapshots.entries()) if (data.length > 0) await this.connection.collection(collectionName).insertMany(data);
|
|
592
|
+
runAll() {
|
|
593
|
+
this.runCrud();
|
|
594
|
+
this.runPermissions();
|
|
595
|
+
this.runValidation();
|
|
937
596
|
}
|
|
938
597
|
/**
|
|
939
|
-
*
|
|
598
|
+
* Run HTTP-level CRUD tests.
|
|
599
|
+
*
|
|
600
|
+
* Tests each enabled CRUD operation through app.inject():
|
|
601
|
+
* - POST (create) → 200/201 with { success: true, data }
|
|
602
|
+
* - GET (list) → 200 with array or paginated response
|
|
603
|
+
* - GET /:id → 200 with { success: true, data }
|
|
604
|
+
* - PATCH/PUT /:id → 200 with { success: true, data }
|
|
605
|
+
* - DELETE /:id → 200
|
|
606
|
+
* - GET /:id with non-existent ID → 404
|
|
940
607
|
*/
|
|
941
|
-
|
|
942
|
-
this
|
|
943
|
-
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
608
|
+
runCrud() {
|
|
609
|
+
const { resource, enabledRoutes, updateMethod } = this;
|
|
610
|
+
let createdId = null;
|
|
611
|
+
describe(`${resource.displayName} HTTP CRUD`, () => {
|
|
612
|
+
afterAll(async () => {
|
|
613
|
+
if (createdId && enabledRoutes.has("delete")) {
|
|
614
|
+
const { app, auth } = this.getOptions();
|
|
615
|
+
const baseUrl = this.getBaseUrl();
|
|
616
|
+
await app.inject({
|
|
617
|
+
method: "DELETE",
|
|
618
|
+
url: `${baseUrl}/${createdId}`,
|
|
619
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
if (enabledRoutes.has("create")) it("POST should create a resource", async () => {
|
|
624
|
+
const { app, auth, fixtures } = this.getOptions();
|
|
625
|
+
const baseUrl = this.getBaseUrl();
|
|
626
|
+
const adminHeaders = auth.getHeaders(auth.adminRole);
|
|
627
|
+
const res = await app.inject({
|
|
628
|
+
method: "POST",
|
|
629
|
+
url: baseUrl,
|
|
630
|
+
headers: adminHeaders,
|
|
631
|
+
payload: fixtures.valid
|
|
632
|
+
});
|
|
633
|
+
expect(res.statusCode).toBeLessThan(300);
|
|
634
|
+
const body = JSON.parse(res.body);
|
|
635
|
+
expect(body.success).toBe(true);
|
|
636
|
+
expect(body.data).toBeDefined();
|
|
637
|
+
expect(body.data._id).toBeDefined();
|
|
638
|
+
createdId = body.data._id;
|
|
639
|
+
});
|
|
640
|
+
if (enabledRoutes.has("list")) it("GET should list resources", async () => {
|
|
641
|
+
const { app, auth } = this.getOptions();
|
|
642
|
+
const baseUrl = this.getBaseUrl();
|
|
643
|
+
const res = await app.inject({
|
|
644
|
+
method: "GET",
|
|
645
|
+
url: baseUrl,
|
|
646
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
647
|
+
});
|
|
648
|
+
expect(res.statusCode).toBe(200);
|
|
649
|
+
const body = JSON.parse(res.body);
|
|
650
|
+
expect(body.success).toBe(true);
|
|
651
|
+
const list = body.data ?? body.docs;
|
|
652
|
+
expect(list).toBeDefined();
|
|
653
|
+
expect(Array.isArray(list)).toBe(true);
|
|
654
|
+
});
|
|
655
|
+
if (enabledRoutes.has("get")) {
|
|
656
|
+
it("GET /:id should return the resource", async () => {
|
|
657
|
+
if (!createdId) return;
|
|
658
|
+
const { app, auth } = this.getOptions();
|
|
659
|
+
const baseUrl = this.getBaseUrl();
|
|
660
|
+
const res = await app.inject({
|
|
661
|
+
method: "GET",
|
|
662
|
+
url: `${baseUrl}/${createdId}`,
|
|
663
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
664
|
+
});
|
|
665
|
+
expect(res.statusCode).toBe(200);
|
|
666
|
+
const body = JSON.parse(res.body);
|
|
667
|
+
expect(body.success).toBe(true);
|
|
668
|
+
expect(body.data).toBeDefined();
|
|
669
|
+
expect(body.data._id).toBe(createdId);
|
|
670
|
+
});
|
|
671
|
+
it("GET /:id with non-existent ID should return 404", async () => {
|
|
672
|
+
const { app, auth } = this.getOptions();
|
|
673
|
+
const baseUrl = this.getBaseUrl();
|
|
674
|
+
const res = await app.inject({
|
|
675
|
+
method: "GET",
|
|
676
|
+
url: `${baseUrl}/000000000000000000000000`,
|
|
677
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
678
|
+
});
|
|
679
|
+
expect(res.statusCode).toBe(404);
|
|
680
|
+
expect(JSON.parse(res.body).success).toBe(false);
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
if (enabledRoutes.has("update")) {
|
|
684
|
+
it(`${updateMethod} /:id should update the resource`, async () => {
|
|
685
|
+
if (!createdId) return;
|
|
686
|
+
const { app, auth, fixtures } = this.getOptions();
|
|
687
|
+
const baseUrl = this.getBaseUrl();
|
|
688
|
+
const updatePayload = fixtures.update || fixtures.valid;
|
|
689
|
+
const res = await app.inject({
|
|
690
|
+
method: updateMethod,
|
|
691
|
+
url: `${baseUrl}/${createdId}`,
|
|
692
|
+
headers: auth.getHeaders(auth.adminRole),
|
|
693
|
+
payload: updatePayload
|
|
694
|
+
});
|
|
695
|
+
expect(res.statusCode).toBe(200);
|
|
696
|
+
const body = JSON.parse(res.body);
|
|
697
|
+
expect(body.success).toBe(true);
|
|
698
|
+
expect(body.data).toBeDefined();
|
|
699
|
+
});
|
|
700
|
+
it(`${updateMethod} /:id with non-existent ID should return 404`, async () => {
|
|
701
|
+
const { app, auth, fixtures } = this.getOptions();
|
|
702
|
+
const baseUrl = this.getBaseUrl();
|
|
703
|
+
expect((await app.inject({
|
|
704
|
+
method: updateMethod,
|
|
705
|
+
url: `${baseUrl}/000000000000000000000000`,
|
|
706
|
+
headers: auth.getHeaders(auth.adminRole),
|
|
707
|
+
payload: fixtures.update || fixtures.valid
|
|
708
|
+
})).statusCode).toBe(404);
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
if (enabledRoutes.has("delete")) {
|
|
712
|
+
it("DELETE /:id should delete the resource", async () => {
|
|
713
|
+
const { app, auth, fixtures } = this.getOptions();
|
|
714
|
+
const baseUrl = this.getBaseUrl();
|
|
715
|
+
const adminHeaders = auth.getHeaders(auth.adminRole);
|
|
716
|
+
let deleteId;
|
|
717
|
+
if (enabledRoutes.has("create")) {
|
|
718
|
+
const createRes = await app.inject({
|
|
719
|
+
method: "POST",
|
|
720
|
+
url: baseUrl,
|
|
721
|
+
headers: adminHeaders,
|
|
722
|
+
payload: fixtures.valid
|
|
723
|
+
});
|
|
724
|
+
deleteId = JSON.parse(createRes.body).data?._id;
|
|
725
|
+
}
|
|
726
|
+
if (!deleteId) return;
|
|
727
|
+
expect((await app.inject({
|
|
728
|
+
method: "DELETE",
|
|
729
|
+
url: `${baseUrl}/${deleteId}`,
|
|
730
|
+
headers: adminHeaders
|
|
731
|
+
})).statusCode).toBe(200);
|
|
732
|
+
if (enabledRoutes.has("get")) expect((await app.inject({
|
|
733
|
+
method: "GET",
|
|
734
|
+
url: `${baseUrl}/${deleteId}`,
|
|
735
|
+
headers: adminHeaders
|
|
736
|
+
})).statusCode).toBe(404);
|
|
737
|
+
});
|
|
738
|
+
it("DELETE /:id with non-existent ID should return 404", async () => {
|
|
739
|
+
const { app, auth } = this.getOptions();
|
|
740
|
+
const baseUrl = this.getBaseUrl();
|
|
741
|
+
expect((await app.inject({
|
|
742
|
+
method: "DELETE",
|
|
743
|
+
url: `${baseUrl}/000000000000000000000000`,
|
|
744
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
745
|
+
})).statusCode).toBe(404);
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Run permission tests.
|
|
752
|
+
*
|
|
753
|
+
* Tests that:
|
|
754
|
+
* - Unauthenticated requests return 401
|
|
755
|
+
* - Admin role gets 2xx for all operations
|
|
756
|
+
*/
|
|
757
|
+
runPermissions() {
|
|
758
|
+
const { resource, enabledRoutes, updateMethod } = this;
|
|
759
|
+
describe(`${resource.displayName} HTTP Permissions`, () => {
|
|
760
|
+
if (enabledRoutes.has("list")) it("GET list without auth should return 401", async () => {
|
|
761
|
+
const { app } = this.getOptions();
|
|
762
|
+
const baseUrl = this.getBaseUrl();
|
|
763
|
+
expect((await app.inject({
|
|
764
|
+
method: "GET",
|
|
765
|
+
url: baseUrl
|
|
766
|
+
})).statusCode).toBe(401);
|
|
767
|
+
});
|
|
768
|
+
if (enabledRoutes.has("get")) it("GET get without auth should return 401", async () => {
|
|
769
|
+
const { app } = this.getOptions();
|
|
770
|
+
const baseUrl = this.getBaseUrl();
|
|
771
|
+
expect((await app.inject({
|
|
772
|
+
method: "GET",
|
|
773
|
+
url: `${baseUrl}/000000000000000000000000`
|
|
774
|
+
})).statusCode).toBe(401);
|
|
775
|
+
});
|
|
776
|
+
if (enabledRoutes.has("create")) it("POST create without auth should return 401", async () => {
|
|
777
|
+
const { app, fixtures } = this.getOptions();
|
|
778
|
+
const baseUrl = this.getBaseUrl();
|
|
779
|
+
expect((await app.inject({
|
|
780
|
+
method: "POST",
|
|
781
|
+
url: baseUrl,
|
|
782
|
+
payload: fixtures.valid
|
|
783
|
+
})).statusCode).toBe(401);
|
|
784
|
+
});
|
|
785
|
+
if (enabledRoutes.has("update")) it(`${updateMethod} update without auth should return 401`, async () => {
|
|
786
|
+
const { app, fixtures } = this.getOptions();
|
|
787
|
+
const baseUrl = this.getBaseUrl();
|
|
788
|
+
expect((await app.inject({
|
|
789
|
+
method: updateMethod,
|
|
790
|
+
url: `${baseUrl}/000000000000000000000000`,
|
|
791
|
+
payload: fixtures.update || fixtures.valid
|
|
792
|
+
})).statusCode).toBe(401);
|
|
793
|
+
});
|
|
794
|
+
if (enabledRoutes.has("delete")) it("DELETE delete without auth should return 401", async () => {
|
|
795
|
+
const { app } = this.getOptions();
|
|
796
|
+
const baseUrl = this.getBaseUrl();
|
|
797
|
+
expect((await app.inject({
|
|
798
|
+
method: "DELETE",
|
|
799
|
+
url: `${baseUrl}/000000000000000000000000`
|
|
800
|
+
})).statusCode).toBe(401);
|
|
801
|
+
});
|
|
802
|
+
if (enabledRoutes.has("list")) it("admin should access list endpoint", async () => {
|
|
803
|
+
const { app, auth } = this.getOptions();
|
|
804
|
+
const baseUrl = this.getBaseUrl();
|
|
805
|
+
expect((await app.inject({
|
|
806
|
+
method: "GET",
|
|
807
|
+
url: baseUrl,
|
|
808
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
809
|
+
})).statusCode).toBeLessThan(400);
|
|
810
|
+
});
|
|
811
|
+
if (enabledRoutes.has("create")) it("admin should access create endpoint", async () => {
|
|
812
|
+
const { app, auth, fixtures } = this.getOptions();
|
|
813
|
+
const baseUrl = this.getBaseUrl();
|
|
814
|
+
const res = await app.inject({
|
|
815
|
+
method: "POST",
|
|
816
|
+
url: baseUrl,
|
|
817
|
+
headers: auth.getHeaders(auth.adminRole),
|
|
818
|
+
payload: fixtures.valid
|
|
819
|
+
});
|
|
820
|
+
expect(res.statusCode).toBeLessThan(400);
|
|
821
|
+
const body = JSON.parse(res.body);
|
|
822
|
+
if (body.data?._id && enabledRoutes.has("delete")) await app.inject({
|
|
823
|
+
method: "DELETE",
|
|
824
|
+
url: `${baseUrl}/${body.data._id}`,
|
|
825
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Run validation tests.
|
|
832
|
+
*
|
|
833
|
+
* Tests that invalid payloads return 400.
|
|
834
|
+
*/
|
|
835
|
+
runValidation() {
|
|
836
|
+
const { resource, enabledRoutes } = this;
|
|
837
|
+
if (!enabledRoutes.has("create")) return;
|
|
838
|
+
describe(`${resource.displayName} HTTP Validation`, () => {
|
|
839
|
+
it("POST with invalid payload should not return 2xx", async () => {
|
|
840
|
+
const { app, auth, fixtures } = this.getOptions();
|
|
841
|
+
const baseUrl = this.getBaseUrl();
|
|
842
|
+
if (!fixtures.invalid) return;
|
|
843
|
+
const res = await app.inject({
|
|
844
|
+
method: "POST",
|
|
845
|
+
url: baseUrl,
|
|
846
|
+
headers: auth.getHeaders(auth.adminRole),
|
|
847
|
+
payload: fixtures.invalid
|
|
848
|
+
});
|
|
849
|
+
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
|
850
|
+
expect(JSON.parse(res.body).success).toBe(false);
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
/**
|
|
856
|
+
* Create an HTTP test harness for an Arc resource.
|
|
964
857
|
*
|
|
965
|
-
*
|
|
966
|
-
* testApp = await createTestApp({
|
|
967
|
-
* auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
|
|
968
|
-
* });
|
|
969
|
-
* });
|
|
858
|
+
* Accepts options directly or as a getter function for deferred resolution.
|
|
970
859
|
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
860
|
+
* @example Deferred (recommended for async setup)
|
|
861
|
+
* ```typescript
|
|
862
|
+
* let ctx: TestContext;
|
|
863
|
+
* beforeAll(async () => { ctx = await setupTestOrg(); });
|
|
974
864
|
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
*
|
|
979
|
-
*
|
|
980
|
-
*
|
|
981
|
-
* });
|
|
982
|
-
* });
|
|
865
|
+
* createHttpTestHarness(jobResource, () => ({
|
|
866
|
+
* app: ctx.app,
|
|
867
|
+
* apiPrefix: '',
|
|
868
|
+
* fixtures: { valid: { title: 'Test' } },
|
|
869
|
+
* auth: createBetterAuthProvider({ ... }),
|
|
870
|
+
* })).runAll();
|
|
983
871
|
* ```
|
|
872
|
+
*/
|
|
873
|
+
function createHttpTestHarness(resource, optionsOrGetter) {
|
|
874
|
+
return new HttpTestHarness(resource, optionsOrGetter);
|
|
875
|
+
}
|
|
876
|
+
//#endregion
|
|
877
|
+
//#region src/testing/mocks.ts
|
|
878
|
+
/**
|
|
879
|
+
* Testing Utilities - Mock Factories
|
|
984
880
|
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
*
|
|
990
|
-
* mongoUri: 'mongodb://localhost:27017/test-db',
|
|
991
|
-
* });
|
|
992
|
-
* ```
|
|
881
|
+
* Create mock repositories, controllers, and services for testing.
|
|
882
|
+
* Uses Vitest for mocking (compatible with Jest API).
|
|
883
|
+
*/
|
|
884
|
+
/**
|
|
885
|
+
* Create a mock repository for testing
|
|
993
886
|
*
|
|
994
|
-
* @example
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
*
|
|
887
|
+
* @example
|
|
888
|
+
* const mockRepo = createMockRepository<Product>({
|
|
889
|
+
* getById: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }),
|
|
890
|
+
* create: vi.fn().mockImplementation(data => Promise.resolve({ id: '1', ...data })),
|
|
998
891
|
* });
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
892
|
+
*
|
|
893
|
+
* await mockRepo.getById('1'); // Returns mocked product
|
|
1001
894
|
*/
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
895
|
+
function createMockRepository(overrides = {}) {
|
|
896
|
+
return {
|
|
897
|
+
getAll: vi.fn().mockResolvedValue({
|
|
898
|
+
docs: [],
|
|
899
|
+
total: 0,
|
|
900
|
+
page: 1,
|
|
901
|
+
limit: 20,
|
|
902
|
+
pages: 0,
|
|
903
|
+
hasNext: false,
|
|
904
|
+
hasPrev: false
|
|
905
|
+
}),
|
|
906
|
+
getById: vi.fn().mockResolvedValue(null),
|
|
907
|
+
create: vi.fn().mockImplementation((data) => Promise.resolve({
|
|
908
|
+
_id: "mock-id",
|
|
909
|
+
...data
|
|
910
|
+
})),
|
|
911
|
+
update: vi.fn().mockImplementation((_id, data) => Promise.resolve({
|
|
912
|
+
_id: "mock-id",
|
|
913
|
+
...data
|
|
914
|
+
})),
|
|
915
|
+
delete: vi.fn().mockResolvedValue({
|
|
916
|
+
success: true,
|
|
917
|
+
message: "Deleted"
|
|
918
|
+
}),
|
|
919
|
+
getBySlug: vi.fn().mockResolvedValue(null),
|
|
920
|
+
getDeleted: vi.fn().mockResolvedValue([]),
|
|
921
|
+
restore: vi.fn().mockResolvedValue(null),
|
|
922
|
+
getTree: vi.fn().mockResolvedValue([]),
|
|
923
|
+
getChildren: vi.fn().mockResolvedValue([]),
|
|
924
|
+
...overrides
|
|
1008
925
|
};
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
} catch (err) {
|
|
1015
|
-
console.warn("[createTestApp] Failed to start in-memory MongoDB:", err.message, "\nFalling back to external MongoDB or no DB connection.");
|
|
1016
|
-
}
|
|
1017
|
-
const app = await createApp({
|
|
1018
|
-
preset: "testing",
|
|
1019
|
-
logger: false,
|
|
1020
|
-
helmet: false,
|
|
1021
|
-
cors: false,
|
|
1022
|
-
rateLimit: false,
|
|
1023
|
-
underPressure: false,
|
|
1024
|
-
auth: defaultAuth,
|
|
1025
|
-
...appOptions
|
|
1026
|
-
});
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Create a mock user for authentication testing
|
|
929
|
+
*/
|
|
930
|
+
function createMockUser(overrides = {}) {
|
|
1027
931
|
return {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
932
|
+
_id: "mock-user-id",
|
|
933
|
+
id: "mock-user-id",
|
|
934
|
+
email: "test@example.com",
|
|
935
|
+
roles: ["user"],
|
|
936
|
+
organizationId: null,
|
|
937
|
+
...overrides
|
|
1034
938
|
};
|
|
1035
939
|
}
|
|
1036
940
|
/**
|
|
1037
|
-
* Create a
|
|
1038
|
-
*
|
|
1039
|
-
* Use when you don't need Arc's full plugin stack
|
|
1040
|
-
*
|
|
1041
|
-
* @example
|
|
1042
|
-
* const app = createMinimalTestApp();
|
|
1043
|
-
* app.get('/test', async () => ({ success: true }));
|
|
1044
|
-
*
|
|
1045
|
-
* const response = await app.inject({ method: 'GET', url: '/test' });
|
|
1046
|
-
* expect(response.json()).toEqual({ success: true });
|
|
941
|
+
* Create a mock Fastify request
|
|
1047
942
|
*/
|
|
1048
|
-
function
|
|
1049
|
-
return
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
943
|
+
function createMockRequest(overrides = {}) {
|
|
944
|
+
return {
|
|
945
|
+
body: {},
|
|
946
|
+
params: {},
|
|
947
|
+
query: {},
|
|
948
|
+
headers: {},
|
|
949
|
+
user: createMockUser(),
|
|
950
|
+
context: {},
|
|
951
|
+
log: {
|
|
952
|
+
info: vi.fn(),
|
|
953
|
+
warn: vi.fn(),
|
|
954
|
+
error: vi.fn(),
|
|
955
|
+
debug: vi.fn()
|
|
956
|
+
},
|
|
957
|
+
...overrides
|
|
958
|
+
};
|
|
1053
959
|
}
|
|
1054
960
|
/**
|
|
1055
|
-
*
|
|
961
|
+
* Create a mock Fastify reply
|
|
962
|
+
*/
|
|
963
|
+
function createMockReply() {
|
|
964
|
+
return {
|
|
965
|
+
code: vi.fn().mockReturnThis(),
|
|
966
|
+
send: vi.fn().mockReturnThis(),
|
|
967
|
+
header: vi.fn().mockReturnThis(),
|
|
968
|
+
headers: vi.fn().mockReturnThis(),
|
|
969
|
+
status: vi.fn().mockReturnThis(),
|
|
970
|
+
type: vi.fn().mockReturnThis(),
|
|
971
|
+
redirect: vi.fn().mockReturnThis(),
|
|
972
|
+
callNotFound: vi.fn().mockReturnThis(),
|
|
973
|
+
sent: false
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Create a mock controller for testing
|
|
978
|
+
*/
|
|
979
|
+
function createMockController(repository) {
|
|
980
|
+
return {
|
|
981
|
+
repository,
|
|
982
|
+
list: vi.fn(),
|
|
983
|
+
get: vi.fn(),
|
|
984
|
+
create: vi.fn(),
|
|
985
|
+
update: vi.fn(),
|
|
986
|
+
delete: vi.fn()
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Create mock data factory
|
|
1056
991
|
*
|
|
1057
992
|
* @example
|
|
1058
|
-
* const
|
|
1059
|
-
* .
|
|
1060
|
-
* .
|
|
1061
|
-
*
|
|
993
|
+
* const productFactory = createDataFactory<Product>({
|
|
994
|
+
* name: () => faker.commerce.productName(),
|
|
995
|
+
* price: () => faker.number.int({ min: 10, max: 1000 }),
|
|
996
|
+
* sku: (i) => `SKU-${i}`,
|
|
997
|
+
* });
|
|
1062
998
|
*
|
|
1063
|
-
* const
|
|
1064
|
-
*
|
|
999
|
+
* const product = productFactory.build();
|
|
1000
|
+
* const products = productFactory.buildMany(10);
|
|
1065
1001
|
*/
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
this.url = url;
|
|
1084
|
-
return this;
|
|
1085
|
-
}
|
|
1086
|
-
put(url) {
|
|
1087
|
-
this.method = "PUT";
|
|
1088
|
-
this.url = url;
|
|
1089
|
-
return this;
|
|
1090
|
-
}
|
|
1091
|
-
patch(url) {
|
|
1092
|
-
this.method = "PATCH";
|
|
1093
|
-
this.url = url;
|
|
1094
|
-
return this;
|
|
1095
|
-
}
|
|
1096
|
-
delete(url) {
|
|
1097
|
-
this.method = "DELETE";
|
|
1098
|
-
this.url = url;
|
|
1099
|
-
return this;
|
|
1100
|
-
}
|
|
1101
|
-
withBody(body) {
|
|
1102
|
-
this.body = body;
|
|
1103
|
-
return this;
|
|
1104
|
-
}
|
|
1105
|
-
withQuery(query) {
|
|
1106
|
-
this.query = query;
|
|
1107
|
-
return this;
|
|
1108
|
-
}
|
|
1109
|
-
withHeader(key, value) {
|
|
1110
|
-
this.headers[key] = value;
|
|
1111
|
-
return this;
|
|
1112
|
-
}
|
|
1113
|
-
withAuth(userOrHeaders) {
|
|
1114
|
-
if ("authorization" in userOrHeaders || "Authorization" in userOrHeaders) {
|
|
1115
|
-
for (const [key, value] of Object.entries(userOrHeaders)) if (typeof value === "string") this.headers[key] = value;
|
|
1116
|
-
} else {
|
|
1117
|
-
const token = this.app.jwt?.sign?.(userOrHeaders) || "mock-token";
|
|
1118
|
-
this.headers["Authorization"] = `Bearer ${token}`;
|
|
1002
|
+
function createDataFactory(template) {
|
|
1003
|
+
let counter = 0;
|
|
1004
|
+
return {
|
|
1005
|
+
build(overrides = {}) {
|
|
1006
|
+
const index = counter++;
|
|
1007
|
+
const data = {};
|
|
1008
|
+
for (const [key, generator] of Object.entries(template)) data[key] = generator(index);
|
|
1009
|
+
return {
|
|
1010
|
+
...data,
|
|
1011
|
+
...overrides
|
|
1012
|
+
};
|
|
1013
|
+
},
|
|
1014
|
+
buildMany(count, overrides = {}) {
|
|
1015
|
+
return Array.from({ length: count }, () => this.build(overrides));
|
|
1016
|
+
},
|
|
1017
|
+
reset() {
|
|
1018
|
+
counter = 0;
|
|
1119
1019
|
}
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
withContentType(type) {
|
|
1123
|
-
this.headers["Content-Type"] = type;
|
|
1124
|
-
return this;
|
|
1125
|
-
}
|
|
1126
|
-
async send() {
|
|
1127
|
-
return this.app.inject({
|
|
1128
|
-
method: this.method,
|
|
1129
|
-
url: this.url,
|
|
1130
|
-
payload: this.body,
|
|
1131
|
-
query: this.query,
|
|
1132
|
-
headers: this.headers
|
|
1133
|
-
});
|
|
1134
|
-
}
|
|
1135
|
-
};
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1136
1022
|
/**
|
|
1137
|
-
*
|
|
1023
|
+
* Create a spy that tracks function calls
|
|
1024
|
+
*
|
|
1025
|
+
* Useful for testing side effects without full mocking
|
|
1026
|
+
*/
|
|
1027
|
+
function createSpy(_name = "spy") {
|
|
1028
|
+
const calls = [];
|
|
1029
|
+
const spy = vi.fn((...args) => {
|
|
1030
|
+
calls.push(args);
|
|
1031
|
+
});
|
|
1032
|
+
spy.getCalls = () => calls;
|
|
1033
|
+
spy.getLastCall = () => calls[calls.length - 1] || [];
|
|
1034
|
+
return spy;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Wait for a condition to be true
|
|
1038
|
+
*
|
|
1039
|
+
* Useful for async testing
|
|
1138
1040
|
*/
|
|
1139
|
-
function
|
|
1140
|
-
|
|
1041
|
+
async function waitFor(condition, options = {}) {
|
|
1042
|
+
const { timeout = 5e3, interval = 100 } = options;
|
|
1043
|
+
const startTime = Date.now();
|
|
1044
|
+
while (Date.now() - startTime < timeout) {
|
|
1045
|
+
if (await condition()) return;
|
|
1046
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
1047
|
+
}
|
|
1048
|
+
throw new Error(`Timeout waiting for condition after ${timeout}ms`);
|
|
1141
1049
|
}
|
|
1142
1050
|
/**
|
|
1143
|
-
*
|
|
1051
|
+
* Create a test timer that can be controlled
|
|
1144
1052
|
*/
|
|
1145
|
-
function
|
|
1053
|
+
function createTestTimer() {
|
|
1054
|
+
let time = Date.now();
|
|
1146
1055
|
return {
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1056
|
+
now: () => time,
|
|
1057
|
+
advance: (ms) => {
|
|
1058
|
+
time += ms;
|
|
1150
1059
|
},
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
return app.jwt.decode(token);
|
|
1060
|
+
set: (timestamp) => {
|
|
1061
|
+
time = timestamp;
|
|
1154
1062
|
},
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
return app.jwt.verify(token);
|
|
1063
|
+
reset: () => {
|
|
1064
|
+
time = Date.now();
|
|
1158
1065
|
}
|
|
1159
1066
|
};
|
|
1160
1067
|
}
|
|
1068
|
+
//#endregion
|
|
1069
|
+
//#region src/testing/TestHarness.ts
|
|
1161
1070
|
/**
|
|
1162
|
-
*
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
*
|
|
1071
|
+
* Resource Test Harness
|
|
1072
|
+
*
|
|
1073
|
+
* Generates baseline tests for Arc resources automatically.
|
|
1074
|
+
* Tests CRUD operations + preset routes with minimal configuration.
|
|
1075
|
+
*
|
|
1076
|
+
* @example
|
|
1077
|
+
* import { createTestHarness } from '@classytic/arc/testing';
|
|
1078
|
+
* import productResource from './product.resource.js';
|
|
1079
|
+
*
|
|
1080
|
+
* const harness = createTestHarness(productResource, {
|
|
1081
|
+
* fixtures: {
|
|
1082
|
+
* valid: { name: 'Test Product', price: 100 },
|
|
1083
|
+
* update: { name: 'Updated Product' },
|
|
1084
|
+
* },
|
|
1085
|
+
* });
|
|
1086
|
+
*
|
|
1087
|
+
* // Run all baseline tests (50+ auto-generated)
|
|
1088
|
+
* harness.runAll();
|
|
1089
|
+
*
|
|
1090
|
+
* // Or run specific test suites
|
|
1091
|
+
* harness.runCrud();
|
|
1092
|
+
* harness.runPresets();
|
|
1180
1093
|
*/
|
|
1181
|
-
var
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1094
|
+
var TestHarness = class {
|
|
1095
|
+
resource;
|
|
1096
|
+
Model;
|
|
1097
|
+
fixtures;
|
|
1098
|
+
setupFn;
|
|
1099
|
+
teardownFn;
|
|
1100
|
+
mongoUri;
|
|
1101
|
+
_createdIds = [];
|
|
1102
|
+
constructor(resource, options) {
|
|
1103
|
+
this.resource = resource;
|
|
1104
|
+
this.fixtures = options.fixtures;
|
|
1105
|
+
this.setupFn = options.setupFn;
|
|
1106
|
+
this.teardownFn = options.teardownFn;
|
|
1107
|
+
this.mongoUri = options.mongoUri || process.env.MONGO_URI || "mongodb://localhost:27017/test";
|
|
1108
|
+
if (!resource.adapter) throw new Error(`TestHarness requires a resource with a database adapter`);
|
|
1109
|
+
if (resource.adapter.type !== "mongoose") throw new Error(`TestHarness currently only supports Mongoose adapters`);
|
|
1110
|
+
const model = resource.adapter.model;
|
|
1111
|
+
if (!model) throw new Error(`Mongoose adapter for ${resource.name} does not have a model`);
|
|
1112
|
+
this.Model = model;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Run all baseline tests
|
|
1116
|
+
*
|
|
1117
|
+
* Executes CRUD, validation, and preset tests
|
|
1118
|
+
*/
|
|
1119
|
+
runAll() {
|
|
1120
|
+
this.runCrud();
|
|
1121
|
+
this.runValidation();
|
|
1122
|
+
this.runPresets();
|
|
1123
|
+
this.runFieldPermissions();
|
|
1124
|
+
this.runPipeline();
|
|
1125
|
+
this.runEvents();
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Run CRUD operation tests (model-level)
|
|
1129
|
+
*
|
|
1130
|
+
* Tests: create, read (list + getById), update, delete
|
|
1131
|
+
*
|
|
1132
|
+
* @deprecated Use `HttpTestHarness.runCrud()` for HTTP-level CRUD tests.
|
|
1133
|
+
* This method tests Mongoose models directly and does not exercise
|
|
1134
|
+
* HTTP routes, authentication, permissions, or the Arc pipeline.
|
|
1135
|
+
*/
|
|
1136
|
+
runCrud() {
|
|
1137
|
+
const { resource, fixtures, Model } = this;
|
|
1138
|
+
describe(`${resource.displayName} CRUD Operations`, () => {
|
|
1139
|
+
beforeAll(async () => {
|
|
1140
|
+
await mongoose.connect(this.mongoUri);
|
|
1141
|
+
if (this.setupFn) await this.setupFn();
|
|
1142
|
+
});
|
|
1143
|
+
afterAll(async () => {
|
|
1144
|
+
if (this._createdIds.length > 0) await Model.deleteMany({ _id: { $in: this._createdIds } });
|
|
1145
|
+
if (this.teardownFn) await this.teardownFn();
|
|
1146
|
+
await mongoose.disconnect();
|
|
1147
|
+
});
|
|
1148
|
+
describe("Create", () => {
|
|
1149
|
+
it("should create a new document with valid data", async () => {
|
|
1150
|
+
const doc = await Model.create(fixtures.valid);
|
|
1151
|
+
this._createdIds.push(doc._id);
|
|
1152
|
+
expect(doc).toBeDefined();
|
|
1153
|
+
expect(doc._id).toBeDefined();
|
|
1154
|
+
for (const [key, value] of Object.entries(fixtures.valid)) if (typeof value !== "object") expect(doc[key]).toEqual(value);
|
|
1155
|
+
});
|
|
1156
|
+
it("should have timestamps", async () => {
|
|
1157
|
+
const doc = await Model.findById(this._createdIds[0]);
|
|
1158
|
+
expect(doc).toBeDefined();
|
|
1159
|
+
expect(doc?.createdAt).toBeDefined();
|
|
1160
|
+
expect(doc?.updatedAt).toBeDefined();
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
describe("Read", () => {
|
|
1164
|
+
it("should find document by ID", async () => {
|
|
1165
|
+
expect(await Model.findById(this._createdIds[0])).toBeDefined();
|
|
1166
|
+
});
|
|
1167
|
+
it("should list documents", async () => {
|
|
1168
|
+
const docs = await Model.find({});
|
|
1169
|
+
expect(Array.isArray(docs)).toBe(true);
|
|
1170
|
+
expect(docs.length).toBeGreaterThan(0);
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
describe("Update", () => {
|
|
1174
|
+
it("should update document", async () => {
|
|
1175
|
+
const updateData = fixtures.update || { updatedAt: /* @__PURE__ */ new Date() };
|
|
1176
|
+
expect(await Model.findByIdAndUpdate(this._createdIds[0], updateData, { new: true })).toBeDefined();
|
|
1177
|
+
});
|
|
1178
|
+
});
|
|
1179
|
+
describe("Delete", () => {
|
|
1180
|
+
it("should delete document", async () => {
|
|
1181
|
+
const toDelete = await Model.create(fixtures.valid);
|
|
1182
|
+
await Model.findByIdAndDelete(toDelete._id);
|
|
1183
|
+
expect(await Model.findById(toDelete._id)).toBeNull();
|
|
1184
|
+
});
|
|
1185
|
+
});
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Run validation tests
|
|
1190
|
+
*
|
|
1191
|
+
* Tests schema validation, required fields, etc.
|
|
1192
|
+
*/
|
|
1193
|
+
runValidation() {
|
|
1194
|
+
const { resource, fixtures, Model } = this;
|
|
1195
|
+
describe(`${resource.displayName} Validation`, () => {
|
|
1196
|
+
beforeAll(async () => {
|
|
1197
|
+
await mongoose.connect(this.mongoUri);
|
|
1198
|
+
});
|
|
1199
|
+
afterAll(async () => {
|
|
1200
|
+
await mongoose.disconnect();
|
|
1201
|
+
});
|
|
1202
|
+
it("should reject empty document", async () => {
|
|
1203
|
+
await expect(Model.create({})).rejects.toThrow();
|
|
1204
|
+
});
|
|
1205
|
+
if (fixtures.invalid) it("should reject invalid data", async () => {
|
|
1206
|
+
await expect(Model.create(fixtures.invalid)).rejects.toThrow();
|
|
1207
|
+
});
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Run preset-specific tests
|
|
1212
|
+
*
|
|
1213
|
+
* Auto-detects applied presets and tests their functionality:
|
|
1214
|
+
* - softDelete: deletedAt field, soft delete/restore
|
|
1215
|
+
* - slugLookup: slug generation
|
|
1216
|
+
* - tree: parent references, displayOrder
|
|
1217
|
+
* - multiTenant: organizationId requirement
|
|
1218
|
+
* - ownedByUser: userId requirement
|
|
1219
|
+
*/
|
|
1220
|
+
runPresets() {
|
|
1221
|
+
const { resource, fixtures, Model } = this;
|
|
1222
|
+
const presets = resource._appliedPresets || [];
|
|
1223
|
+
if (presets.length === 0) return;
|
|
1224
|
+
describe(`${resource.displayName} Preset Tests`, () => {
|
|
1225
|
+
beforeAll(async () => {
|
|
1226
|
+
await mongoose.connect(this.mongoUri);
|
|
1227
|
+
});
|
|
1228
|
+
afterAll(async () => {
|
|
1229
|
+
await mongoose.disconnect();
|
|
1230
|
+
});
|
|
1231
|
+
if (presets.includes("softDelete")) describe("Soft Delete", () => {
|
|
1232
|
+
let testDoc;
|
|
1233
|
+
beforeEach(async () => {
|
|
1234
|
+
testDoc = await Model.create(fixtures.valid);
|
|
1235
|
+
this._createdIds.push(testDoc._id);
|
|
1236
|
+
});
|
|
1237
|
+
it("should have deletedAt field", () => {
|
|
1238
|
+
expect(testDoc.deletedAt).toBeDefined();
|
|
1239
|
+
expect(testDoc.deletedAt).toBeNull();
|
|
1240
|
+
});
|
|
1241
|
+
it("should soft delete (set deletedAt)", async () => {
|
|
1242
|
+
await Model.findByIdAndUpdate(testDoc._id, { deletedAt: /* @__PURE__ */ new Date() });
|
|
1243
|
+
expect((await Model.findById(testDoc._id))?.deletedAt).not.toBeNull();
|
|
1244
|
+
});
|
|
1245
|
+
it("should restore (clear deletedAt)", async () => {
|
|
1246
|
+
await Model.findByIdAndUpdate(testDoc._id, { deletedAt: /* @__PURE__ */ new Date() });
|
|
1247
|
+
await Model.findByIdAndUpdate(testDoc._id, { deletedAt: null });
|
|
1248
|
+
expect((await Model.findById(testDoc._id))?.deletedAt).toBeNull();
|
|
1249
|
+
});
|
|
1250
|
+
});
|
|
1251
|
+
if (presets.includes("slugLookup")) describe("Slug Lookup", () => {
|
|
1252
|
+
it("should have slug field", async () => {
|
|
1253
|
+
const doc = await Model.create(fixtures.valid);
|
|
1254
|
+
this._createdIds.push(doc._id);
|
|
1255
|
+
expect(doc.slug).toBeDefined();
|
|
1256
|
+
});
|
|
1257
|
+
it("should generate slug from name", async () => {
|
|
1258
|
+
const doc = await Model.create({
|
|
1259
|
+
...fixtures.valid,
|
|
1260
|
+
name: "Test Slug Name"
|
|
1261
|
+
});
|
|
1262
|
+
this._createdIds.push(doc._id);
|
|
1263
|
+
expect(doc.slug).toMatch(/test-slug-name/i);
|
|
1264
|
+
});
|
|
1265
|
+
});
|
|
1266
|
+
if (presets.includes("tree")) describe("Tree Structure", () => {
|
|
1267
|
+
it("should allow parent reference", async () => {
|
|
1268
|
+
const parent = await Model.create(fixtures.valid);
|
|
1269
|
+
this._createdIds.push(parent._id);
|
|
1270
|
+
const child = await Model.create({
|
|
1271
|
+
...fixtures.valid,
|
|
1272
|
+
parent: parent._id
|
|
1273
|
+
});
|
|
1274
|
+
this._createdIds.push(child._id);
|
|
1275
|
+
expect(String(child.parent)).toEqual(String(parent._id));
|
|
1276
|
+
});
|
|
1277
|
+
it("should support displayOrder", async () => {
|
|
1278
|
+
const doc = await Model.create({
|
|
1279
|
+
...fixtures.valid,
|
|
1280
|
+
displayOrder: 5
|
|
1281
|
+
});
|
|
1282
|
+
this._createdIds.push(doc._id);
|
|
1283
|
+
expect(doc.displayOrder).toEqual(5);
|
|
1284
|
+
});
|
|
1285
|
+
});
|
|
1286
|
+
if (presets.includes("multiTenant")) describe("Multi-Tenant", () => {
|
|
1287
|
+
it("should require organizationId", async () => {
|
|
1288
|
+
const docWithoutOrg = { ...fixtures.valid };
|
|
1289
|
+
delete docWithoutOrg.organizationId;
|
|
1290
|
+
await expect(Model.create(docWithoutOrg)).rejects.toThrow();
|
|
1291
|
+
});
|
|
1292
|
+
});
|
|
1293
|
+
if (presets.includes("ownedByUser")) describe("Owned By User", () => {
|
|
1294
|
+
it("should require userId", async () => {
|
|
1295
|
+
const docWithoutUser = { ...fixtures.valid };
|
|
1296
|
+
delete docWithoutUser.userId;
|
|
1297
|
+
await expect(Model.create(docWithoutUser)).rejects.toThrow();
|
|
1298
|
+
});
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
1186
1301
|
}
|
|
1187
1302
|
/**
|
|
1188
|
-
*
|
|
1303
|
+
* Run field-level permission tests
|
|
1304
|
+
*
|
|
1305
|
+
* Auto-generates tests for each field permission:
|
|
1306
|
+
* - hidden: field is stripped from responses
|
|
1307
|
+
* - visibleTo: field only shown to specified roles
|
|
1308
|
+
* - writableBy: field stripped from writes by non-privileged users
|
|
1309
|
+
* - redactFor: field shows redacted value for specified roles
|
|
1189
1310
|
*/
|
|
1190
|
-
|
|
1191
|
-
this
|
|
1192
|
-
|
|
1311
|
+
runFieldPermissions() {
|
|
1312
|
+
const { resource } = this;
|
|
1313
|
+
const fieldPerms = resource.fields;
|
|
1314
|
+
if (!fieldPerms || Object.keys(fieldPerms).length === 0) return;
|
|
1315
|
+
describe(`${resource.displayName} Field Permissions`, () => {
|
|
1316
|
+
for (const [field, rawPerm] of Object.entries(fieldPerms)) {
|
|
1317
|
+
const perm = rawPerm;
|
|
1318
|
+
switch (perm._type) {
|
|
1319
|
+
case "hidden":
|
|
1320
|
+
it(`should always hide field '${field}'`, () => {
|
|
1321
|
+
const result = applyFieldReadPermissions({
|
|
1322
|
+
[field]: "secret",
|
|
1323
|
+
otherField: "visible"
|
|
1324
|
+
}, fieldPerms, []);
|
|
1325
|
+
expect(result[field]).toBeUndefined();
|
|
1326
|
+
expect(result.otherField).toBe("visible");
|
|
1327
|
+
});
|
|
1328
|
+
it(`should strip hidden field '${field}' from writes`, () => {
|
|
1329
|
+
const result = applyFieldWritePermissions({
|
|
1330
|
+
[field]: "attempt",
|
|
1331
|
+
name: "test"
|
|
1332
|
+
}, fieldPerms, []);
|
|
1333
|
+
expect(result[field]).toBeUndefined();
|
|
1334
|
+
expect(result.name).toBe("test");
|
|
1335
|
+
});
|
|
1336
|
+
break;
|
|
1337
|
+
case "visibleTo":
|
|
1338
|
+
it(`should hide field '${field}' from non-privileged users`, () => {
|
|
1339
|
+
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, ["viewer"])[field]).toBeUndefined();
|
|
1340
|
+
});
|
|
1341
|
+
if (perm.roles && perm.roles.length > 0) {
|
|
1342
|
+
const allowedRole = perm.roles[0];
|
|
1343
|
+
it(`should show field '${field}' to roles: ${[...perm.roles].join(", ")}`, () => {
|
|
1344
|
+
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, [allowedRole])[field]).toBe("sensitive");
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
break;
|
|
1348
|
+
case "writableBy":
|
|
1349
|
+
it(`should strip field '${field}' from writes by non-privileged users`, () => {
|
|
1350
|
+
const result = applyFieldWritePermissions({
|
|
1351
|
+
[field]: "new-value",
|
|
1352
|
+
name: "test"
|
|
1353
|
+
}, fieldPerms, ["viewer"]);
|
|
1354
|
+
expect(result[field]).toBeUndefined();
|
|
1355
|
+
expect(result.name).toBe("test");
|
|
1356
|
+
});
|
|
1357
|
+
if (perm.roles && perm.roles.length > 0) {
|
|
1358
|
+
const writeRole = perm.roles[0];
|
|
1359
|
+
it(`should allow writing field '${field}' by roles: ${[...perm.roles].join(", ")}`, () => {
|
|
1360
|
+
expect(applyFieldWritePermissions({ [field]: "new-value" }, fieldPerms, [writeRole])[field]).toBe("new-value");
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
break;
|
|
1364
|
+
case "redactFor":
|
|
1365
|
+
if (perm.roles && perm.roles.length > 0) {
|
|
1366
|
+
const redactRole = perm.roles[0];
|
|
1367
|
+
it(`should redact field '${field}' for roles: ${[...perm.roles].join(", ")}`, () => {
|
|
1368
|
+
expect(applyFieldReadPermissions({ [field]: "real-value" }, fieldPerms, [redactRole])[field]).toBe(perm.redactValue ?? "***");
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
it(`should show real value of field '${field}' to non-redacted roles`, () => {
|
|
1372
|
+
expect(applyFieldReadPermissions({ [field]: "real-value" }, fieldPerms, ["unrelated-role"])[field]).toBe("real-value");
|
|
1373
|
+
});
|
|
1374
|
+
break;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1193
1378
|
}
|
|
1194
1379
|
/**
|
|
1195
|
-
*
|
|
1380
|
+
* Run pipeline configuration tests
|
|
1381
|
+
*
|
|
1382
|
+
* Validates that pipeline steps are properly configured:
|
|
1383
|
+
* - All steps have names
|
|
1384
|
+
* - All steps have valid _type discriminants
|
|
1385
|
+
* - Operation filters (if set) use valid CRUD operation names
|
|
1196
1386
|
*/
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1387
|
+
runPipeline() {
|
|
1388
|
+
const { resource } = this;
|
|
1389
|
+
const pipe = resource.pipe;
|
|
1390
|
+
if (!pipe) return;
|
|
1391
|
+
const validOps = new Set(CRUD_OPERATIONS);
|
|
1392
|
+
describe(`${resource.displayName} Pipeline`, () => {
|
|
1393
|
+
const steps = collectPipelineSteps(pipe);
|
|
1394
|
+
it("should have at least one pipeline step", () => {
|
|
1395
|
+
expect(steps.length).toBeGreaterThan(0);
|
|
1396
|
+
});
|
|
1397
|
+
for (const step of steps) {
|
|
1398
|
+
it(`${step._type} '${step.name}' should have a valid type`, () => {
|
|
1399
|
+
expect([
|
|
1400
|
+
"guard",
|
|
1401
|
+
"transform",
|
|
1402
|
+
"interceptor"
|
|
1403
|
+
]).toContain(step._type);
|
|
1404
|
+
});
|
|
1405
|
+
it(`${step._type} '${step.name}' should have a name`, () => {
|
|
1406
|
+
expect(step.name).toBeTruthy();
|
|
1407
|
+
expect(typeof step.name).toBe("string");
|
|
1408
|
+
});
|
|
1409
|
+
it(`${step._type} '${step.name}' should have a handler function`, () => {
|
|
1410
|
+
expect(typeof step.handler).toBe("function");
|
|
1411
|
+
});
|
|
1412
|
+
if (step.operations?.length) it(`${step._type} '${step.name}' should target valid operations`, () => {
|
|
1413
|
+
for (const op of step.operations) expect(validOps.has(op)).toBe(true);
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Run event definition tests
|
|
1420
|
+
*
|
|
1421
|
+
* Validates that events are properly defined:
|
|
1422
|
+
* - All events have handler functions
|
|
1423
|
+
* - Event names follow resource:action convention
|
|
1424
|
+
* - Schema definitions (if present) are valid objects
|
|
1425
|
+
*/
|
|
1426
|
+
runEvents() {
|
|
1427
|
+
const { resource } = this;
|
|
1428
|
+
const events = resource.events;
|
|
1429
|
+
if (!events || Object.keys(events).length === 0) return;
|
|
1430
|
+
describe(`${resource.displayName} Events`, () => {
|
|
1431
|
+
for (const [action, rawDef] of Object.entries(events)) {
|
|
1432
|
+
const def = rawDef;
|
|
1433
|
+
it(`event '${resource.name}:${action}' should have a handler function`, () => {
|
|
1434
|
+
expect(typeof def.handler).toBe("function");
|
|
1435
|
+
});
|
|
1436
|
+
it(`event '${resource.name}:${action}' should have a name`, () => {
|
|
1437
|
+
expect(def.name).toBeTruthy();
|
|
1438
|
+
expect(typeof def.name).toBe("string");
|
|
1439
|
+
});
|
|
1440
|
+
if (def.schema) it(`event '${resource.name}:${action}' schema should be an object`, () => {
|
|
1441
|
+
expect(typeof def.schema).toBe("object");
|
|
1442
|
+
expect(def.schema).not.toBeNull();
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1200
1446
|
}
|
|
1201
1447
|
};
|
|
1202
|
-
|
|
1203
|
-
//#endregion
|
|
1204
|
-
//#region src/testing/mocks.ts
|
|
1205
|
-
/**
|
|
1206
|
-
* Testing Utilities - Mock Factories
|
|
1207
|
-
*
|
|
1208
|
-
* Create mock repositories, controllers, and services for testing.
|
|
1209
|
-
* Uses Vitest for mocking (compatible with Jest API).
|
|
1210
|
-
*/
|
|
1211
|
-
/**
|
|
1212
|
-
* Create a mock repository for testing
|
|
1213
|
-
*
|
|
1214
|
-
* @example
|
|
1215
|
-
* const mockRepo = createMockRepository<Product>({
|
|
1216
|
-
* getById: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }),
|
|
1217
|
-
* create: vi.fn().mockImplementation(data => Promise.resolve({ id: '1', ...data })),
|
|
1218
|
-
* });
|
|
1219
|
-
*
|
|
1220
|
-
* await mockRepo.getById('1'); // Returns mocked product
|
|
1221
|
-
*/
|
|
1222
|
-
function createMockRepository(overrides = {}) {
|
|
1223
|
-
return {
|
|
1224
|
-
getAll: vi.fn().mockResolvedValue({
|
|
1225
|
-
docs: [],
|
|
1226
|
-
total: 0,
|
|
1227
|
-
page: 1,
|
|
1228
|
-
limit: 20,
|
|
1229
|
-
pages: 0,
|
|
1230
|
-
hasNext: false,
|
|
1231
|
-
hasPrev: false
|
|
1232
|
-
}),
|
|
1233
|
-
getById: vi.fn().mockResolvedValue(null),
|
|
1234
|
-
create: vi.fn().mockImplementation((data) => Promise.resolve({
|
|
1235
|
-
_id: "mock-id",
|
|
1236
|
-
...data
|
|
1237
|
-
})),
|
|
1238
|
-
update: vi.fn().mockImplementation((_id, data) => Promise.resolve({
|
|
1239
|
-
_id: "mock-id",
|
|
1240
|
-
...data
|
|
1241
|
-
})),
|
|
1242
|
-
delete: vi.fn().mockResolvedValue({
|
|
1243
|
-
success: true,
|
|
1244
|
-
message: "Deleted"
|
|
1245
|
-
}),
|
|
1246
|
-
getBySlug: vi.fn().mockResolvedValue(null),
|
|
1247
|
-
getDeleted: vi.fn().mockResolvedValue([]),
|
|
1248
|
-
restore: vi.fn().mockResolvedValue(null),
|
|
1249
|
-
getTree: vi.fn().mockResolvedValue([]),
|
|
1250
|
-
getChildren: vi.fn().mockResolvedValue([]),
|
|
1251
|
-
...overrides
|
|
1252
|
-
};
|
|
1253
|
-
}
|
|
1254
|
-
/**
|
|
1255
|
-
* Create a mock user for authentication testing
|
|
1256
|
-
*/
|
|
1257
|
-
function createMockUser(overrides = {}) {
|
|
1258
|
-
return {
|
|
1259
|
-
_id: "mock-user-id",
|
|
1260
|
-
id: "mock-user-id",
|
|
1261
|
-
email: "test@example.com",
|
|
1262
|
-
roles: ["user"],
|
|
1263
|
-
organizationId: null,
|
|
1264
|
-
...overrides
|
|
1265
|
-
};
|
|
1266
|
-
}
|
|
1267
|
-
/**
|
|
1268
|
-
* Create a mock Fastify request
|
|
1269
|
-
*/
|
|
1270
|
-
function createMockRequest(overrides = {}) {
|
|
1271
|
-
return {
|
|
1272
|
-
body: {},
|
|
1273
|
-
params: {},
|
|
1274
|
-
query: {},
|
|
1275
|
-
headers: {},
|
|
1276
|
-
user: createMockUser(),
|
|
1277
|
-
context: {},
|
|
1278
|
-
log: {
|
|
1279
|
-
info: vi.fn(),
|
|
1280
|
-
warn: vi.fn(),
|
|
1281
|
-
error: vi.fn(),
|
|
1282
|
-
debug: vi.fn()
|
|
1283
|
-
},
|
|
1284
|
-
...overrides
|
|
1285
|
-
};
|
|
1286
|
-
}
|
|
1287
|
-
/**
|
|
1288
|
-
* Create a mock Fastify reply
|
|
1289
|
-
*/
|
|
1290
|
-
function createMockReply() {
|
|
1291
|
-
return {
|
|
1292
|
-
code: vi.fn().mockReturnThis(),
|
|
1293
|
-
send: vi.fn().mockReturnThis(),
|
|
1294
|
-
header: vi.fn().mockReturnThis(),
|
|
1295
|
-
headers: vi.fn().mockReturnThis(),
|
|
1296
|
-
status: vi.fn().mockReturnThis(),
|
|
1297
|
-
type: vi.fn().mockReturnThis(),
|
|
1298
|
-
redirect: vi.fn().mockReturnThis(),
|
|
1299
|
-
callNotFound: vi.fn().mockReturnThis(),
|
|
1300
|
-
sent: false
|
|
1301
|
-
};
|
|
1302
|
-
}
|
|
1303
|
-
/**
|
|
1304
|
-
* Create a mock controller for testing
|
|
1305
|
-
*/
|
|
1306
|
-
function createMockController(repository) {
|
|
1307
|
-
return {
|
|
1308
|
-
repository,
|
|
1309
|
-
list: vi.fn(),
|
|
1310
|
-
get: vi.fn(),
|
|
1311
|
-
create: vi.fn(),
|
|
1312
|
-
update: vi.fn(),
|
|
1313
|
-
delete: vi.fn()
|
|
1314
|
-
};
|
|
1315
|
-
}
|
|
1316
1448
|
/**
|
|
1317
|
-
*
|
|
1318
|
-
*
|
|
1319
|
-
* @example
|
|
1320
|
-
* const productFactory = createDataFactory<Product>({
|
|
1321
|
-
* name: () => faker.commerce.productName(),
|
|
1322
|
-
* price: () => faker.number.int({ min: 10, max: 1000 }),
|
|
1323
|
-
* sku: (i) => `SKU-${i}`,
|
|
1324
|
-
* });
|
|
1325
|
-
*
|
|
1326
|
-
* const product = productFactory.build();
|
|
1327
|
-
* const products = productFactory.buildMany(10);
|
|
1449
|
+
* Collect all pipeline steps from a PipelineConfig (flat array or per-operation map)
|
|
1328
1450
|
*/
|
|
1329
|
-
function
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
...overrides
|
|
1339
|
-
};
|
|
1340
|
-
},
|
|
1341
|
-
buildMany(count, overrides = {}) {
|
|
1342
|
-
return Array.from({ length: count }, () => this.build(overrides));
|
|
1343
|
-
},
|
|
1344
|
-
reset() {
|
|
1345
|
-
counter = 0;
|
|
1451
|
+
function collectPipelineSteps(pipe) {
|
|
1452
|
+
if (Array.isArray(pipe)) return pipe;
|
|
1453
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1454
|
+
const steps = [];
|
|
1455
|
+
for (const opSteps of Object.values(pipe)) if (Array.isArray(opSteps)) for (const step of opSteps) {
|
|
1456
|
+
const key = `${step._type}:${step.name}`;
|
|
1457
|
+
if (!seen.has(key)) {
|
|
1458
|
+
seen.add(key);
|
|
1459
|
+
steps.push(step);
|
|
1346
1460
|
}
|
|
1347
|
-
}
|
|
1461
|
+
}
|
|
1462
|
+
return steps;
|
|
1348
1463
|
}
|
|
1349
1464
|
/**
|
|
1350
|
-
* Create a
|
|
1465
|
+
* Create a test harness for an Arc resource
|
|
1351
1466
|
*
|
|
1352
|
-
*
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
const calls = [];
|
|
1356
|
-
const spy = vi.fn((...args) => {
|
|
1357
|
-
calls.push(args);
|
|
1358
|
-
});
|
|
1359
|
-
spy.getCalls = () => calls;
|
|
1360
|
-
spy.getLastCall = () => calls[calls.length - 1] || [];
|
|
1361
|
-
return spy;
|
|
1362
|
-
}
|
|
1363
|
-
/**
|
|
1364
|
-
* Wait for a condition to be true
|
|
1467
|
+
* @param resource - The Arc resource definition to test
|
|
1468
|
+
* @param options - Test harness configuration
|
|
1469
|
+
* @returns Test harness instance
|
|
1365
1470
|
*
|
|
1366
|
-
*
|
|
1471
|
+
* @example
|
|
1472
|
+
* import { createTestHarness } from '@classytic/arc/testing';
|
|
1473
|
+
*
|
|
1474
|
+
* const harness = createTestHarness(productResource, {
|
|
1475
|
+
* fixtures: {
|
|
1476
|
+
* valid: { name: 'Product', price: 100 },
|
|
1477
|
+
* update: { name: 'Updated' },
|
|
1478
|
+
* },
|
|
1479
|
+
* });
|
|
1480
|
+
*
|
|
1481
|
+
* harness.runAll(); // Generates 50+ baseline tests
|
|
1367
1482
|
*/
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
const startTime = Date.now();
|
|
1371
|
-
while (Date.now() - startTime < timeout) {
|
|
1372
|
-
if (await condition()) return;
|
|
1373
|
-
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
1374
|
-
}
|
|
1375
|
-
throw new Error(`Timeout waiting for condition after ${timeout}ms`);
|
|
1483
|
+
function createTestHarness(resource, options) {
|
|
1484
|
+
return new TestHarness(resource, options);
|
|
1376
1485
|
}
|
|
1377
1486
|
/**
|
|
1378
|
-
*
|
|
1487
|
+
* Generate test file content for a resource
|
|
1488
|
+
*
|
|
1489
|
+
* Useful for scaffolding new resource tests via CLI
|
|
1490
|
+
*
|
|
1491
|
+
* @param resourceName - Resource name in kebab-case (e.g., 'product')
|
|
1492
|
+
* @param options - Generation options
|
|
1493
|
+
* @returns Complete test file content as string
|
|
1494
|
+
*
|
|
1495
|
+
* @example
|
|
1496
|
+
* const testContent = generateTestFile('product', {
|
|
1497
|
+
* presets: ['softDelete'],
|
|
1498
|
+
* modulePath: './modules/catalog',
|
|
1499
|
+
* });
|
|
1500
|
+
* fs.writeFileSync('product.test.js', testContent);
|
|
1379
1501
|
*/
|
|
1380
|
-
function
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
},
|
|
1390
|
-
reset: () => {
|
|
1391
|
-
time = Date.now();
|
|
1392
|
-
}
|
|
1393
|
-
};
|
|
1394
|
-
}
|
|
1502
|
+
function generateTestFile(resourceName, options = {}) {
|
|
1503
|
+
const { presets = [], modulePath = "." } = options;
|
|
1504
|
+
const className = resourceName.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
1505
|
+
const varName = className.charAt(0).toLowerCase() + className.slice(1);
|
|
1506
|
+
return `/**
|
|
1507
|
+
* ${className} Resource Tests
|
|
1508
|
+
*
|
|
1509
|
+
* Auto-generated baseline tests. Customize as needed.
|
|
1510
|
+
*/
|
|
1395
1511
|
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1512
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
1513
|
+
import mongoose from 'mongoose';
|
|
1514
|
+
import { createTestHarness } from '@classytic/arc/testing';
|
|
1515
|
+
import ${varName}Resource from '${modulePath}/${resourceName}.resource.js';
|
|
1516
|
+
import ${className} from '${modulePath}/${resourceName}.model.js';
|
|
1517
|
+
|
|
1518
|
+
const MONGO_URI = process.env.MONGO_TEST_URI || 'mongodb://localhost:27017/${resourceName}-test';
|
|
1519
|
+
|
|
1520
|
+
// Test fixtures
|
|
1521
|
+
const fixtures = {
|
|
1522
|
+
valid: {
|
|
1523
|
+
name: 'Test ${className}',
|
|
1524
|
+
// Add required fields here
|
|
1525
|
+
},
|
|
1526
|
+
update: {
|
|
1527
|
+
name: 'Updated ${className}',
|
|
1528
|
+
},
|
|
1529
|
+
invalid: {
|
|
1530
|
+
// Empty or invalid data
|
|
1531
|
+
},
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
// Create test harness
|
|
1535
|
+
const harness = createTestHarness(${varName}Resource, {
|
|
1536
|
+
fixtures,
|
|
1537
|
+
mongoUri: MONGO_URI,
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
// Run all baseline tests
|
|
1541
|
+
harness.runAll();
|
|
1542
|
+
|
|
1543
|
+
// Custom tests
|
|
1544
|
+
describe('${className} Custom Tests', () => {
|
|
1545
|
+
let testId;
|
|
1546
|
+
|
|
1547
|
+
beforeAll(async () => {
|
|
1548
|
+
await mongoose.connect(MONGO_URI);
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
afterAll(async () => {
|
|
1552
|
+
if (testId) {
|
|
1553
|
+
await ${className}.findByIdAndDelete(testId);
|
|
1554
|
+
}
|
|
1555
|
+
await mongoose.disconnect();
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
// Add your custom tests here
|
|
1559
|
+
it('should pass custom validation', async () => {
|
|
1560
|
+
// Example: const doc = await ${className}.create(fixtures.valid);
|
|
1561
|
+
// testId = doc._id;
|
|
1562
|
+
// expect(doc.someField).toBe('expectedValue');
|
|
1563
|
+
expect(true).toBe(true);
|
|
1564
|
+
});
|
|
1565
|
+
});
|
|
1566
|
+
`;
|
|
1408
1567
|
}
|
|
1409
1568
|
/**
|
|
1410
|
-
*
|
|
1569
|
+
* Run config-level tests for a resource (no DB required)
|
|
1411
1570
|
*
|
|
1412
|
-
*
|
|
1413
|
-
*
|
|
1571
|
+
* Tests field permissions, pipeline configuration, and event definitions.
|
|
1572
|
+
* Works with any adapter — no Mongoose dependency.
|
|
1573
|
+
*
|
|
1574
|
+
* @param resource - The Arc resource definition to test
|
|
1575
|
+
*
|
|
1576
|
+
* @example
|
|
1577
|
+
* ```typescript
|
|
1578
|
+
* import { createConfigTestSuite } from '@classytic/arc/testing';
|
|
1579
|
+
* import productResource from './product.resource.js';
|
|
1580
|
+
*
|
|
1581
|
+
* // Generates field permission, pipeline, and event tests
|
|
1582
|
+
* createConfigTestSuite(productResource);
|
|
1583
|
+
* ```
|
|
1414
1584
|
*/
|
|
1415
|
-
function
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1585
|
+
function createConfigTestSuite(resource) {
|
|
1586
|
+
const fieldPerms = resource.fields;
|
|
1587
|
+
const pipe = resource.pipe;
|
|
1588
|
+
const events = resource.events;
|
|
1589
|
+
if (fieldPerms && Object.keys(fieldPerms).length > 0) runFieldPermissionTests(resource.displayName, fieldPerms);
|
|
1590
|
+
if (pipe) runPipelineTests(resource.displayName, pipe);
|
|
1591
|
+
if (events && Object.keys(events).length > 0) runEventTests(resource.name, resource.displayName, events);
|
|
1592
|
+
if (resource.permissions && Object.keys(resource.permissions).length > 0) describe(`${resource.displayName} Permission Config`, () => {
|
|
1593
|
+
for (const op of CRUD_OPERATIONS) {
|
|
1594
|
+
const check = resource.permissions[op];
|
|
1595
|
+
if (check) it(`${op} permission should be a function`, () => {
|
|
1596
|
+
expect(typeof check).toBe("function");
|
|
1423
1597
|
});
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
function runFieldPermissionTests(displayName, fieldPerms) {
|
|
1602
|
+
describe(`${displayName} Field Permissions`, () => {
|
|
1603
|
+
for (const [field, perm] of Object.entries(fieldPerms)) switch (perm._type) {
|
|
1604
|
+
case "hidden":
|
|
1605
|
+
it(`should always hide field '${field}'`, () => {
|
|
1606
|
+
expect(applyFieldReadPermissions({
|
|
1607
|
+
[field]: "secret",
|
|
1608
|
+
other: "visible"
|
|
1609
|
+
}, fieldPerms, [])[field]).toBeUndefined();
|
|
1610
|
+
});
|
|
1611
|
+
it(`should strip hidden field '${field}' from writes`, () => {
|
|
1612
|
+
expect(applyFieldWritePermissions({
|
|
1613
|
+
[field]: "attempt",
|
|
1614
|
+
name: "test"
|
|
1615
|
+
}, fieldPerms, [])[field]).toBeUndefined();
|
|
1616
|
+
});
|
|
1617
|
+
break;
|
|
1618
|
+
case "visibleTo":
|
|
1619
|
+
it(`should hide field '${field}' from non-privileged users`, () => {
|
|
1620
|
+
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, ["_no_role_"])[field]).toBeUndefined();
|
|
1621
|
+
});
|
|
1622
|
+
if (perm.roles && perm.roles.length > 0) {
|
|
1623
|
+
const allowedRole = perm.roles[0];
|
|
1624
|
+
it(`should show field '${field}' to roles: ${[...perm.roles].join(", ")}`, () => {
|
|
1625
|
+
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, [allowedRole])[field]).toBe("sensitive");
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
break;
|
|
1629
|
+
case "writableBy":
|
|
1630
|
+
it(`should strip field '${field}' from writes by non-privileged users`, () => {
|
|
1631
|
+
expect(applyFieldWritePermissions({
|
|
1632
|
+
[field]: "v",
|
|
1633
|
+
name: "test"
|
|
1634
|
+
}, fieldPerms, ["_no_role_"])[field]).toBeUndefined();
|
|
1635
|
+
});
|
|
1636
|
+
if (perm.roles && perm.roles.length > 0) {
|
|
1637
|
+
const writeRole = perm.roles[0];
|
|
1638
|
+
it(`should allow writing field '${field}' by roles: ${[...perm.roles].join(", ")}`, () => {
|
|
1639
|
+
expect(applyFieldWritePermissions({ [field]: "v" }, fieldPerms, [writeRole])[field]).toBe("v");
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
break;
|
|
1643
|
+
case "redactFor":
|
|
1644
|
+
if (perm.roles && perm.roles.length > 0) {
|
|
1645
|
+
const redactRole = perm.roles[0];
|
|
1646
|
+
it(`should redact field '${field}' for roles: ${[...perm.roles].join(", ")}`, () => {
|
|
1647
|
+
expect(applyFieldReadPermissions({ [field]: "real" }, fieldPerms, [redactRole])[field]).toBe(perm.redactValue ?? "***");
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
it(`should show real value of field '${field}' to non-redacted roles`, () => {
|
|
1651
|
+
expect(applyFieldReadPermissions({ [field]: "real" }, fieldPerms, ["_other_"])[field]).toBe("real");
|
|
1652
|
+
});
|
|
1653
|
+
break;
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
function runPipelineTests(displayName, pipe) {
|
|
1658
|
+
const steps = collectPipelineSteps(pipe);
|
|
1659
|
+
if (steps.length === 0) return;
|
|
1660
|
+
const validOps = new Set(CRUD_OPERATIONS);
|
|
1661
|
+
describe(`${displayName} Pipeline`, () => {
|
|
1662
|
+
it("should have at least one pipeline step", () => {
|
|
1663
|
+
expect(steps.length).toBeGreaterThan(0);
|
|
1664
|
+
});
|
|
1665
|
+
for (const step of steps) {
|
|
1666
|
+
it(`${step._type} '${step.name}' should have a valid type`, () => {
|
|
1667
|
+
expect([
|
|
1668
|
+
"guard",
|
|
1669
|
+
"transform",
|
|
1670
|
+
"interceptor"
|
|
1671
|
+
]).toContain(step._type);
|
|
1438
1672
|
});
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
return {
|
|
1442
|
-
statusCode: res.statusCode,
|
|
1443
|
-
token: token || "",
|
|
1444
|
-
user: body?.user || body,
|
|
1445
|
-
body
|
|
1446
|
-
};
|
|
1447
|
-
},
|
|
1448
|
-
async createOrg(app, token, data) {
|
|
1449
|
-
const res = await app.inject({
|
|
1450
|
-
method: "POST",
|
|
1451
|
-
url: `${basePath}/organization/create`,
|
|
1452
|
-
headers: { authorization: `Bearer ${token}` },
|
|
1453
|
-
payload: data
|
|
1673
|
+
it(`${step._type} '${step.name}' should have a handler function`, () => {
|
|
1674
|
+
expect(typeof step.handler).toBe("function");
|
|
1454
1675
|
});
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
statusCode: res.statusCode,
|
|
1458
|
-
orgId: body?.id,
|
|
1459
|
-
body
|
|
1460
|
-
};
|
|
1461
|
-
},
|
|
1462
|
-
async setActiveOrg(app, token, orgId) {
|
|
1463
|
-
const res = await app.inject({
|
|
1464
|
-
method: "POST",
|
|
1465
|
-
url: `${basePath}/organization/set-active`,
|
|
1466
|
-
headers: { authorization: `Bearer ${token}` },
|
|
1467
|
-
payload: { organizationId: orgId }
|
|
1676
|
+
if (step.operations?.length) it(`${step._type} '${step.name}' should target valid operations`, () => {
|
|
1677
|
+
for (const op of step.operations) expect(validOps.has(op)).toBe(true);
|
|
1468
1678
|
});
|
|
1469
|
-
return {
|
|
1470
|
-
statusCode: res.statusCode,
|
|
1471
|
-
body: safeParseBody(res.body)
|
|
1472
|
-
};
|
|
1473
|
-
},
|
|
1474
|
-
authHeaders(token, orgId) {
|
|
1475
|
-
const h = { authorization: `Bearer ${token}` };
|
|
1476
|
-
if (orgId) h["x-organization-id"] = orgId;
|
|
1477
|
-
return h;
|
|
1478
1679
|
}
|
|
1479
|
-
};
|
|
1680
|
+
});
|
|
1480
1681
|
}
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
* { key: 'member', email: 'user@test.com', password: 'pass', name: 'User', role: 'member' },
|
|
1495
|
-
* ],
|
|
1496
|
-
* addMember: async (data) => {
|
|
1497
|
-
* await auth.api.addMember({ body: data });
|
|
1498
|
-
* return { statusCode: 200 };
|
|
1499
|
-
* },
|
|
1500
|
-
* });
|
|
1501
|
-
*
|
|
1502
|
-
* // Use in tests:
|
|
1503
|
-
* const res = await ctx.app.inject({
|
|
1504
|
-
* method: 'GET',
|
|
1505
|
-
* url: '/api/products',
|
|
1506
|
-
* headers: auth.authHeaders(ctx.users.admin.token, ctx.orgId),
|
|
1507
|
-
* });
|
|
1508
|
-
*
|
|
1509
|
-
* // Cleanup:
|
|
1510
|
-
* await ctx.teardown();
|
|
1511
|
-
* ```
|
|
1512
|
-
*/
|
|
1513
|
-
async function setupBetterAuthOrg(options) {
|
|
1514
|
-
const { createApp, org, users: userConfigs, addMember, afterSetup, authHelpers: helpersOptions } = options;
|
|
1515
|
-
const helpers = createBetterAuthTestHelpers(helpersOptions);
|
|
1516
|
-
const creators = userConfigs.filter((u) => u.isCreator);
|
|
1517
|
-
if (creators.length !== 1) throw new Error(`setupBetterAuthOrg: Exactly one user must have isCreator: true (found ${creators.length})`);
|
|
1518
|
-
const app = await createApp();
|
|
1519
|
-
await app.ready();
|
|
1520
|
-
const signups = /* @__PURE__ */ new Map();
|
|
1521
|
-
for (const userConfig of userConfigs) {
|
|
1522
|
-
const signup = await helpers.signUp(app, {
|
|
1523
|
-
email: userConfig.email,
|
|
1524
|
-
password: userConfig.password,
|
|
1525
|
-
name: userConfig.name
|
|
1526
|
-
});
|
|
1527
|
-
if (signup.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to sign up ${userConfig.email} (status ${signup.statusCode})`);
|
|
1528
|
-
signups.set(userConfig.key, signup);
|
|
1529
|
-
}
|
|
1530
|
-
const creatorConfig = creators[0];
|
|
1531
|
-
const creatorSignup = signups.get(creatorConfig.key);
|
|
1532
|
-
const orgResult = await helpers.createOrg(app, creatorSignup.token, org);
|
|
1533
|
-
if (orgResult.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to create org (status ${orgResult.statusCode})`);
|
|
1534
|
-
const orgId = orgResult.orgId;
|
|
1535
|
-
for (const userConfig of userConfigs) {
|
|
1536
|
-
if (userConfig.isCreator) continue;
|
|
1537
|
-
const result = await addMember({
|
|
1538
|
-
organizationId: orgId,
|
|
1539
|
-
userId: signups.get(userConfig.key).user?.id,
|
|
1540
|
-
role: userConfig.role
|
|
1541
|
-
});
|
|
1542
|
-
if (result.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to add member ${userConfig.email} (status ${result.statusCode})`);
|
|
1543
|
-
}
|
|
1544
|
-
await helpers.setActiveOrg(app, creatorSignup.token, orgId);
|
|
1545
|
-
const users = {};
|
|
1546
|
-
for (const userConfig of userConfigs) if (userConfig.isCreator) {
|
|
1547
|
-
const signup = signups.get(userConfig.key);
|
|
1548
|
-
users[userConfig.key] = {
|
|
1549
|
-
token: signup.token,
|
|
1550
|
-
userId: signup.user?.id,
|
|
1551
|
-
email: userConfig.email
|
|
1552
|
-
};
|
|
1553
|
-
} else {
|
|
1554
|
-
const login = await helpers.signIn(app, {
|
|
1555
|
-
email: userConfig.email,
|
|
1556
|
-
password: userConfig.password
|
|
1557
|
-
});
|
|
1558
|
-
await helpers.setActiveOrg(app, login.token, orgId);
|
|
1559
|
-
users[userConfig.key] = {
|
|
1560
|
-
token: login.token,
|
|
1561
|
-
userId: signups.get(userConfig.key).user?.id,
|
|
1562
|
-
email: userConfig.email
|
|
1563
|
-
};
|
|
1564
|
-
}
|
|
1565
|
-
const ctx = {
|
|
1566
|
-
app,
|
|
1567
|
-
orgId,
|
|
1568
|
-
users,
|
|
1569
|
-
async teardown() {
|
|
1570
|
-
await app.close();
|
|
1682
|
+
function runEventTests(resourceName, displayName, events) {
|
|
1683
|
+
describe(`${displayName} Events`, () => {
|
|
1684
|
+
for (const [action, def] of Object.entries(events)) {
|
|
1685
|
+
it(`event '${resourceName}:${action}' should have a handler function`, () => {
|
|
1686
|
+
expect(typeof def.handler).toBe("function");
|
|
1687
|
+
});
|
|
1688
|
+
it(`event '${resourceName}:${action}' should have a name`, () => {
|
|
1689
|
+
expect(def.name).toBeTruthy();
|
|
1690
|
+
});
|
|
1691
|
+
if (def.schema) it(`event '${resourceName}:${action}' schema should be an object`, () => {
|
|
1692
|
+
expect(typeof def.schema).toBe("object");
|
|
1693
|
+
expect(def.schema).not.toBeNull();
|
|
1694
|
+
});
|
|
1571
1695
|
}
|
|
1572
|
-
};
|
|
1573
|
-
if (afterSetup) await afterSetup(ctx);
|
|
1574
|
-
return ctx;
|
|
1696
|
+
});
|
|
1575
1697
|
}
|
|
1576
|
-
|
|
1577
1698
|
//#endregion
|
|
1578
|
-
//#region src/testing/
|
|
1699
|
+
//#region src/testing/testFactory.ts
|
|
1579
1700
|
/**
|
|
1580
|
-
*
|
|
1701
|
+
* Testing Utilities - Test App Factory
|
|
1581
1702
|
*
|
|
1582
|
-
*
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
*
|
|
1703
|
+
* Create Fastify test instances with Arc configuration
|
|
1704
|
+
*/
|
|
1705
|
+
/**
|
|
1706
|
+
* Create a test application instance with optional in-memory MongoDB
|
|
1586
1707
|
*
|
|
1587
|
-
*
|
|
1588
|
-
* - **Eager**: Pass options directly when app is available at construction time
|
|
1589
|
-
* - **Deferred**: Pass a getter function when app comes from async setup (beforeAll)
|
|
1708
|
+
* **Performance Boost**: Uses in-memory MongoDB by default for 10x faster tests.
|
|
1590
1709
|
*
|
|
1591
|
-
* @example
|
|
1710
|
+
* @example Basic usage with in-memory DB
|
|
1592
1711
|
* ```typescript
|
|
1593
|
-
*
|
|
1594
|
-
* app,
|
|
1595
|
-
* fixtures: { valid: { title: 'Test' } },
|
|
1596
|
-
* auth: createJwtAuthProvider({ app, users, adminRole: 'admin' }),
|
|
1597
|
-
* });
|
|
1598
|
-
* harness.runAll();
|
|
1599
|
-
* ```
|
|
1712
|
+
* import { createTestApp } from '@classytic/arc/testing';
|
|
1600
1713
|
*
|
|
1601
|
-
*
|
|
1602
|
-
*
|
|
1603
|
-
* let ctx: TestContext;
|
|
1604
|
-
* beforeAll(async () => { ctx = await setupTestOrg(); });
|
|
1605
|
-
* afterAll(async () => { await teardownTestOrg(ctx); });
|
|
1714
|
+
* describe('API Tests', () => {
|
|
1715
|
+
* let testApp: TestAppResult;
|
|
1606
1716
|
*
|
|
1607
|
-
*
|
|
1608
|
-
*
|
|
1609
|
-
*
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
* }));
|
|
1613
|
-
* harness.runAll();
|
|
1614
|
-
* ```
|
|
1615
|
-
*/
|
|
1616
|
-
/**
|
|
1617
|
-
* Create an auth provider for JWT-based apps.
|
|
1717
|
+
* beforeAll(async () => {
|
|
1718
|
+
* testApp = await createTestApp({
|
|
1719
|
+
* auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
|
|
1720
|
+
* });
|
|
1721
|
+
* });
|
|
1618
1722
|
*
|
|
1619
|
-
*
|
|
1723
|
+
* afterAll(async () => {
|
|
1724
|
+
* await testApp.close(); // Cleans up DB and disconnects
|
|
1725
|
+
* });
|
|
1620
1726
|
*
|
|
1621
|
-
*
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1626
|
-
*
|
|
1627
|
-
*
|
|
1628
|
-
* },
|
|
1629
|
-
* adminRole: 'admin',
|
|
1727
|
+
* test('GET /health', async () => {
|
|
1728
|
+
* const response = await testApp.app.inject({
|
|
1729
|
+
* method: 'GET',
|
|
1730
|
+
* url: '/health',
|
|
1731
|
+
* });
|
|
1732
|
+
* expect(response.statusCode).toBe(200);
|
|
1733
|
+
* });
|
|
1630
1734
|
* });
|
|
1631
1735
|
* ```
|
|
1632
|
-
*/
|
|
1633
|
-
function createJwtAuthProvider(options) {
|
|
1634
|
-
const { app, users, adminRole } = options;
|
|
1635
|
-
return {
|
|
1636
|
-
getHeaders(role) {
|
|
1637
|
-
const user = users[role];
|
|
1638
|
-
if (!user) throw new Error(`createJwtAuthProvider: Unknown role '${role}'. Available: ${Object.keys(users).join(", ")}`);
|
|
1639
|
-
const headers = { authorization: `Bearer ${app.jwt?.sign?.(user.payload) || "mock-token"}` };
|
|
1640
|
-
if (user.organizationId) headers["x-organization-id"] = user.organizationId;
|
|
1641
|
-
return headers;
|
|
1642
|
-
},
|
|
1643
|
-
availableRoles: Object.keys(users),
|
|
1644
|
-
adminRole
|
|
1645
|
-
};
|
|
1646
|
-
}
|
|
1647
|
-
/**
|
|
1648
|
-
* Create an auth provider for Better Auth apps.
|
|
1649
1736
|
*
|
|
1650
|
-
*
|
|
1737
|
+
* @example Using external MongoDB
|
|
1738
|
+
* ```typescript
|
|
1739
|
+
* const testApp = await createTestApp({
|
|
1740
|
+
* auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
|
|
1741
|
+
* useInMemoryDb: false,
|
|
1742
|
+
* mongoUri: 'mongodb://localhost:27017/test-db',
|
|
1743
|
+
* });
|
|
1744
|
+
* ```
|
|
1651
1745
|
*
|
|
1652
|
-
* @example
|
|
1746
|
+
* @example Accessing MongoDB URI for model connections
|
|
1653
1747
|
* ```typescript
|
|
1654
|
-
* const
|
|
1655
|
-
*
|
|
1656
|
-
* admin: ctx.users.admin.token,
|
|
1657
|
-
* member: ctx.users.member.token,
|
|
1658
|
-
* },
|
|
1659
|
-
* orgId: ctx.orgId,
|
|
1660
|
-
* adminRole: 'admin',
|
|
1748
|
+
* const testApp = await createTestApp({
|
|
1749
|
+
* auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
|
|
1661
1750
|
* });
|
|
1751
|
+
* await mongoose.connect(testApp.mongoUri); // Connect your models
|
|
1662
1752
|
* ```
|
|
1663
1753
|
*/
|
|
1664
|
-
function
|
|
1665
|
-
const {
|
|
1754
|
+
async function createTestApp(options = {}) {
|
|
1755
|
+
const { createApp } = await import("../createApp-ByWNRsZj.mjs").then((n) => n.r);
|
|
1756
|
+
const { useInMemoryDb = true, mongoUri: providedMongoUri, ...appOptions } = options;
|
|
1757
|
+
const defaultAuth = {
|
|
1758
|
+
type: "jwt",
|
|
1759
|
+
jwt: { secret: "test-secret-32-chars-minimum-len" }
|
|
1760
|
+
};
|
|
1761
|
+
let inMemoryDb = null;
|
|
1762
|
+
let mongoUri = providedMongoUri;
|
|
1763
|
+
if (useInMemoryDb && !providedMongoUri) try {
|
|
1764
|
+
inMemoryDb = new InMemoryDatabase();
|
|
1765
|
+
mongoUri = await inMemoryDb.start();
|
|
1766
|
+
} catch (err) {
|
|
1767
|
+
console.warn("[createTestApp] Failed to start in-memory MongoDB:", err.message, "\nFalling back to external MongoDB or no DB connection.");
|
|
1768
|
+
}
|
|
1769
|
+
const app = await createApp({
|
|
1770
|
+
preset: "testing",
|
|
1771
|
+
logger: false,
|
|
1772
|
+
helmet: false,
|
|
1773
|
+
cors: false,
|
|
1774
|
+
rateLimit: false,
|
|
1775
|
+
underPressure: false,
|
|
1776
|
+
auth: defaultAuth,
|
|
1777
|
+
...appOptions
|
|
1778
|
+
});
|
|
1666
1779
|
return {
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
};
|
|
1674
|
-
},
|
|
1675
|
-
availableRoles: Object.keys(tokens),
|
|
1676
|
-
adminRole
|
|
1780
|
+
app,
|
|
1781
|
+
mongoUri,
|
|
1782
|
+
async close() {
|
|
1783
|
+
await app.close();
|
|
1784
|
+
if (inMemoryDb) await inMemoryDb.stop();
|
|
1785
|
+
}
|
|
1677
1786
|
};
|
|
1678
1787
|
}
|
|
1679
1788
|
/**
|
|
1680
|
-
*
|
|
1789
|
+
* Create a minimal Fastify instance for unit tests
|
|
1681
1790
|
*
|
|
1682
|
-
*
|
|
1683
|
-
* routes, auth, permissions, pipeline, and response envelope.
|
|
1791
|
+
* Use when you don't need Arc's full plugin stack
|
|
1684
1792
|
*
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
this
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
});
|
|
1778
|
-
expect(res.statusCode).toBe(200);
|
|
1779
|
-
const body = JSON.parse(res.body);
|
|
1780
|
-
expect(body.success).toBe(true);
|
|
1781
|
-
const list = body.data ?? body.docs;
|
|
1782
|
-
expect(list).toBeDefined();
|
|
1783
|
-
expect(Array.isArray(list)).toBe(true);
|
|
1784
|
-
});
|
|
1785
|
-
if (enabledRoutes.has("get")) {
|
|
1786
|
-
it("GET /:id should return the resource", async () => {
|
|
1787
|
-
if (!createdId) return;
|
|
1788
|
-
const { app, auth } = this.getOptions();
|
|
1789
|
-
const baseUrl = this.getBaseUrl();
|
|
1790
|
-
const res = await app.inject({
|
|
1791
|
-
method: "GET",
|
|
1792
|
-
url: `${baseUrl}/${createdId}`,
|
|
1793
|
-
headers: auth.getHeaders(auth.adminRole)
|
|
1794
|
-
});
|
|
1795
|
-
expect(res.statusCode).toBe(200);
|
|
1796
|
-
const body = JSON.parse(res.body);
|
|
1797
|
-
expect(body.success).toBe(true);
|
|
1798
|
-
expect(body.data).toBeDefined();
|
|
1799
|
-
expect(body.data._id).toBe(createdId);
|
|
1800
|
-
});
|
|
1801
|
-
it("GET /:id with non-existent ID should return 404", async () => {
|
|
1802
|
-
const { app, auth } = this.getOptions();
|
|
1803
|
-
const baseUrl = this.getBaseUrl();
|
|
1804
|
-
const res = await app.inject({
|
|
1805
|
-
method: "GET",
|
|
1806
|
-
url: `${baseUrl}/000000000000000000000000`,
|
|
1807
|
-
headers: auth.getHeaders(auth.adminRole)
|
|
1808
|
-
});
|
|
1809
|
-
expect(res.statusCode).toBe(404);
|
|
1810
|
-
expect(JSON.parse(res.body).success).toBe(false);
|
|
1811
|
-
});
|
|
1812
|
-
}
|
|
1813
|
-
if (enabledRoutes.has("update")) {
|
|
1814
|
-
it(`${updateMethod} /:id should update the resource`, async () => {
|
|
1815
|
-
if (!createdId) return;
|
|
1816
|
-
const { app, auth, fixtures } = this.getOptions();
|
|
1817
|
-
const baseUrl = this.getBaseUrl();
|
|
1818
|
-
const updatePayload = fixtures.update || fixtures.valid;
|
|
1819
|
-
const res = await app.inject({
|
|
1820
|
-
method: updateMethod,
|
|
1821
|
-
url: `${baseUrl}/${createdId}`,
|
|
1822
|
-
headers: auth.getHeaders(auth.adminRole),
|
|
1823
|
-
payload: updatePayload
|
|
1824
|
-
});
|
|
1825
|
-
expect(res.statusCode).toBe(200);
|
|
1826
|
-
const body = JSON.parse(res.body);
|
|
1827
|
-
expect(body.success).toBe(true);
|
|
1828
|
-
expect(body.data).toBeDefined();
|
|
1829
|
-
});
|
|
1830
|
-
it(`${updateMethod} /:id with non-existent ID should return 404`, async () => {
|
|
1831
|
-
const { app, auth, fixtures } = this.getOptions();
|
|
1832
|
-
const baseUrl = this.getBaseUrl();
|
|
1833
|
-
expect((await app.inject({
|
|
1834
|
-
method: updateMethod,
|
|
1835
|
-
url: `${baseUrl}/000000000000000000000000`,
|
|
1836
|
-
headers: auth.getHeaders(auth.adminRole),
|
|
1837
|
-
payload: fixtures.update || fixtures.valid
|
|
1838
|
-
})).statusCode).toBe(404);
|
|
1839
|
-
});
|
|
1840
|
-
}
|
|
1841
|
-
if (enabledRoutes.has("delete")) {
|
|
1842
|
-
it("DELETE /:id should delete the resource", async () => {
|
|
1843
|
-
const { app, auth, fixtures } = this.getOptions();
|
|
1844
|
-
const baseUrl = this.getBaseUrl();
|
|
1845
|
-
const adminHeaders = auth.getHeaders(auth.adminRole);
|
|
1846
|
-
let deleteId;
|
|
1847
|
-
if (enabledRoutes.has("create")) {
|
|
1848
|
-
const createRes = await app.inject({
|
|
1849
|
-
method: "POST",
|
|
1850
|
-
url: baseUrl,
|
|
1851
|
-
headers: adminHeaders,
|
|
1852
|
-
payload: fixtures.valid
|
|
1853
|
-
});
|
|
1854
|
-
deleteId = JSON.parse(createRes.body).data?._id;
|
|
1855
|
-
}
|
|
1856
|
-
if (!deleteId) return;
|
|
1857
|
-
expect((await app.inject({
|
|
1858
|
-
method: "DELETE",
|
|
1859
|
-
url: `${baseUrl}/${deleteId}`,
|
|
1860
|
-
headers: adminHeaders
|
|
1861
|
-
})).statusCode).toBe(200);
|
|
1862
|
-
if (enabledRoutes.has("get")) expect((await app.inject({
|
|
1863
|
-
method: "GET",
|
|
1864
|
-
url: `${baseUrl}/${deleteId}`,
|
|
1865
|
-
headers: adminHeaders
|
|
1866
|
-
})).statusCode).toBe(404);
|
|
1867
|
-
});
|
|
1868
|
-
it("DELETE /:id with non-existent ID should return 404", async () => {
|
|
1869
|
-
const { app, auth } = this.getOptions();
|
|
1870
|
-
const baseUrl = this.getBaseUrl();
|
|
1871
|
-
expect((await app.inject({
|
|
1872
|
-
method: "DELETE",
|
|
1873
|
-
url: `${baseUrl}/000000000000000000000000`,
|
|
1874
|
-
headers: auth.getHeaders(auth.adminRole)
|
|
1875
|
-
})).statusCode).toBe(404);
|
|
1876
|
-
});
|
|
1877
|
-
}
|
|
1793
|
+
* @example
|
|
1794
|
+
* const app = createMinimalTestApp();
|
|
1795
|
+
* app.get('/test', async () => ({ success: true }));
|
|
1796
|
+
*
|
|
1797
|
+
* const response = await app.inject({ method: 'GET', url: '/test' });
|
|
1798
|
+
* expect(response.json()).toEqual({ success: true });
|
|
1799
|
+
*/
|
|
1800
|
+
function createMinimalTestApp(options = {}) {
|
|
1801
|
+
return Fastify({
|
|
1802
|
+
logger: false,
|
|
1803
|
+
...options
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Test request builder for cleaner tests
|
|
1808
|
+
*
|
|
1809
|
+
* @example
|
|
1810
|
+
* const request = new TestRequestBuilder(app)
|
|
1811
|
+
* .get('/products')
|
|
1812
|
+
* .withAuth(mockUser)
|
|
1813
|
+
* .withQuery({ page: 1, limit: 10 });
|
|
1814
|
+
*
|
|
1815
|
+
* const response = await request.send();
|
|
1816
|
+
* expect(response.statusCode).toBe(200);
|
|
1817
|
+
*/
|
|
1818
|
+
var TestRequestBuilder = class {
|
|
1819
|
+
method = "GET";
|
|
1820
|
+
url = "/";
|
|
1821
|
+
body;
|
|
1822
|
+
query;
|
|
1823
|
+
headers = {};
|
|
1824
|
+
app;
|
|
1825
|
+
constructor(app) {
|
|
1826
|
+
this.app = app;
|
|
1827
|
+
}
|
|
1828
|
+
get(url) {
|
|
1829
|
+
this.method = "GET";
|
|
1830
|
+
this.url = url;
|
|
1831
|
+
return this;
|
|
1832
|
+
}
|
|
1833
|
+
post(url) {
|
|
1834
|
+
this.method = "POST";
|
|
1835
|
+
this.url = url;
|
|
1836
|
+
return this;
|
|
1837
|
+
}
|
|
1838
|
+
put(url) {
|
|
1839
|
+
this.method = "PUT";
|
|
1840
|
+
this.url = url;
|
|
1841
|
+
return this;
|
|
1842
|
+
}
|
|
1843
|
+
patch(url) {
|
|
1844
|
+
this.method = "PATCH";
|
|
1845
|
+
this.url = url;
|
|
1846
|
+
return this;
|
|
1847
|
+
}
|
|
1848
|
+
delete(url) {
|
|
1849
|
+
this.method = "DELETE";
|
|
1850
|
+
this.url = url;
|
|
1851
|
+
return this;
|
|
1852
|
+
}
|
|
1853
|
+
withBody(body) {
|
|
1854
|
+
this.body = body;
|
|
1855
|
+
return this;
|
|
1856
|
+
}
|
|
1857
|
+
withQuery(query) {
|
|
1858
|
+
this.query = query;
|
|
1859
|
+
return this;
|
|
1860
|
+
}
|
|
1861
|
+
withHeader(key, value) {
|
|
1862
|
+
this.headers[key] = value;
|
|
1863
|
+
return this;
|
|
1864
|
+
}
|
|
1865
|
+
withAuth(userOrHeaders) {
|
|
1866
|
+
if ("authorization" in userOrHeaders || "Authorization" in userOrHeaders) {
|
|
1867
|
+
for (const [key, value] of Object.entries(userOrHeaders)) if (typeof value === "string") this.headers[key] = value;
|
|
1868
|
+
} else {
|
|
1869
|
+
const token = this.app.jwt?.sign?.(userOrHeaders) || "mock-token";
|
|
1870
|
+
this.headers.Authorization = `Bearer ${token}`;
|
|
1871
|
+
}
|
|
1872
|
+
return this;
|
|
1873
|
+
}
|
|
1874
|
+
withContentType(type) {
|
|
1875
|
+
this.headers["Content-Type"] = type;
|
|
1876
|
+
return this;
|
|
1877
|
+
}
|
|
1878
|
+
async send() {
|
|
1879
|
+
return this.app.inject({
|
|
1880
|
+
method: this.method,
|
|
1881
|
+
url: this.url,
|
|
1882
|
+
payload: this.body,
|
|
1883
|
+
query: this.query,
|
|
1884
|
+
headers: this.headers
|
|
1878
1885
|
});
|
|
1879
1886
|
}
|
|
1887
|
+
};
|
|
1888
|
+
/**
|
|
1889
|
+
* Helper to create a test request builder
|
|
1890
|
+
*/
|
|
1891
|
+
function request(app) {
|
|
1892
|
+
return new TestRequestBuilder(app);
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
1895
|
+
* Test helper for authentication
|
|
1896
|
+
*/
|
|
1897
|
+
function createTestAuth(app) {
|
|
1898
|
+
return {
|
|
1899
|
+
generateToken(user) {
|
|
1900
|
+
if (!app.jwt) throw new Error("JWT plugin not registered");
|
|
1901
|
+
return app.jwt.sign(user);
|
|
1902
|
+
},
|
|
1903
|
+
decodeToken(token) {
|
|
1904
|
+
if (!app.jwt) throw new Error("JWT plugin not registered");
|
|
1905
|
+
return app.jwt.decode(token);
|
|
1906
|
+
},
|
|
1907
|
+
async verifyToken(token) {
|
|
1908
|
+
if (!app.jwt) throw new Error("JWT plugin not registered");
|
|
1909
|
+
return app.jwt.verify(token);
|
|
1910
|
+
}
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Snapshot testing helper for API responses
|
|
1915
|
+
*/
|
|
1916
|
+
function createSnapshotMatcher() {
|
|
1917
|
+
return { matchStructure(response, expected) {
|
|
1918
|
+
if (typeof response !== typeof expected) return false;
|
|
1919
|
+
if (Array.isArray(response) && Array.isArray(expected)) return response.length === expected.length;
|
|
1920
|
+
if (typeof response === "object" && response !== null && typeof expected === "object" && expected !== null) {
|
|
1921
|
+
const r = response;
|
|
1922
|
+
const e = expected;
|
|
1923
|
+
const responseKeys = Object.keys(r).sort();
|
|
1924
|
+
const expectedKeys = Object.keys(e).sort();
|
|
1925
|
+
if (JSON.stringify(responseKeys) !== JSON.stringify(expectedKeys)) return false;
|
|
1926
|
+
for (const key of responseKeys) if (!this.matchStructure(r[key], e[key])) return false;
|
|
1927
|
+
return true;
|
|
1928
|
+
}
|
|
1929
|
+
return true;
|
|
1930
|
+
} };
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Bulk test data loader
|
|
1934
|
+
*/
|
|
1935
|
+
var TestDataLoader = class {
|
|
1936
|
+
data = /* @__PURE__ */ new Map();
|
|
1880
1937
|
/**
|
|
1881
|
-
*
|
|
1882
|
-
*
|
|
1883
|
-
* Tests that:
|
|
1884
|
-
* - Unauthenticated requests return 401
|
|
1885
|
-
* - Admin role gets 2xx for all operations
|
|
1938
|
+
* Load test data into database
|
|
1886
1939
|
*/
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
if (enabledRoutes.has("list")) it("GET list without auth should return 401", async () => {
|
|
1891
|
-
const { app } = this.getOptions();
|
|
1892
|
-
const baseUrl = this.getBaseUrl();
|
|
1893
|
-
expect((await app.inject({
|
|
1894
|
-
method: "GET",
|
|
1895
|
-
url: baseUrl
|
|
1896
|
-
})).statusCode).toBe(401);
|
|
1897
|
-
});
|
|
1898
|
-
if (enabledRoutes.has("get")) it("GET get without auth should return 401", async () => {
|
|
1899
|
-
const { app } = this.getOptions();
|
|
1900
|
-
const baseUrl = this.getBaseUrl();
|
|
1901
|
-
expect((await app.inject({
|
|
1902
|
-
method: "GET",
|
|
1903
|
-
url: `${baseUrl}/000000000000000000000000`
|
|
1904
|
-
})).statusCode).toBe(401);
|
|
1905
|
-
});
|
|
1906
|
-
if (enabledRoutes.has("create")) it("POST create without auth should return 401", async () => {
|
|
1907
|
-
const { app, fixtures } = this.getOptions();
|
|
1908
|
-
const baseUrl = this.getBaseUrl();
|
|
1909
|
-
expect((await app.inject({
|
|
1910
|
-
method: "POST",
|
|
1911
|
-
url: baseUrl,
|
|
1912
|
-
payload: fixtures.valid
|
|
1913
|
-
})).statusCode).toBe(401);
|
|
1914
|
-
});
|
|
1915
|
-
if (enabledRoutes.has("update")) it(`${updateMethod} update without auth should return 401`, async () => {
|
|
1916
|
-
const { app, fixtures } = this.getOptions();
|
|
1917
|
-
const baseUrl = this.getBaseUrl();
|
|
1918
|
-
expect((await app.inject({
|
|
1919
|
-
method: updateMethod,
|
|
1920
|
-
url: `${baseUrl}/000000000000000000000000`,
|
|
1921
|
-
payload: fixtures.update || fixtures.valid
|
|
1922
|
-
})).statusCode).toBe(401);
|
|
1923
|
-
});
|
|
1924
|
-
if (enabledRoutes.has("delete")) it("DELETE delete without auth should return 401", async () => {
|
|
1925
|
-
const { app } = this.getOptions();
|
|
1926
|
-
const baseUrl = this.getBaseUrl();
|
|
1927
|
-
expect((await app.inject({
|
|
1928
|
-
method: "DELETE",
|
|
1929
|
-
url: `${baseUrl}/000000000000000000000000`
|
|
1930
|
-
})).statusCode).toBe(401);
|
|
1931
|
-
});
|
|
1932
|
-
if (enabledRoutes.has("list")) it("admin should access list endpoint", async () => {
|
|
1933
|
-
const { app, auth } = this.getOptions();
|
|
1934
|
-
const baseUrl = this.getBaseUrl();
|
|
1935
|
-
expect((await app.inject({
|
|
1936
|
-
method: "GET",
|
|
1937
|
-
url: baseUrl,
|
|
1938
|
-
headers: auth.getHeaders(auth.adminRole)
|
|
1939
|
-
})).statusCode).toBeLessThan(400);
|
|
1940
|
-
});
|
|
1941
|
-
if (enabledRoutes.has("create")) it("admin should access create endpoint", async () => {
|
|
1942
|
-
const { app, auth, fixtures } = this.getOptions();
|
|
1943
|
-
const baseUrl = this.getBaseUrl();
|
|
1944
|
-
const res = await app.inject({
|
|
1945
|
-
method: "POST",
|
|
1946
|
-
url: baseUrl,
|
|
1947
|
-
headers: auth.getHeaders(auth.adminRole),
|
|
1948
|
-
payload: fixtures.valid
|
|
1949
|
-
});
|
|
1950
|
-
expect(res.statusCode).toBeLessThan(400);
|
|
1951
|
-
const body = JSON.parse(res.body);
|
|
1952
|
-
if (body.data?._id && enabledRoutes.has("delete")) await app.inject({
|
|
1953
|
-
method: "DELETE",
|
|
1954
|
-
url: `${baseUrl}/${body.data._id}`,
|
|
1955
|
-
headers: auth.getHeaders(auth.adminRole)
|
|
1956
|
-
});
|
|
1957
|
-
});
|
|
1958
|
-
});
|
|
1940
|
+
async load(collection, items) {
|
|
1941
|
+
this.data.set(collection, items);
|
|
1942
|
+
return items;
|
|
1959
1943
|
}
|
|
1960
1944
|
/**
|
|
1961
|
-
*
|
|
1962
|
-
*
|
|
1963
|
-
* Tests that invalid payloads return 400.
|
|
1945
|
+
* Clear all loaded test data
|
|
1964
1946
|
*/
|
|
1965
|
-
|
|
1966
|
-
const
|
|
1967
|
-
|
|
1968
|
-
describe(`${resource.displayName} HTTP Validation`, () => {
|
|
1969
|
-
it("POST with invalid payload should not return 2xx", async () => {
|
|
1970
|
-
const { app, auth, fixtures } = this.getOptions();
|
|
1971
|
-
const baseUrl = this.getBaseUrl();
|
|
1972
|
-
if (!fixtures.invalid) return;
|
|
1973
|
-
const res = await app.inject({
|
|
1974
|
-
method: "POST",
|
|
1975
|
-
url: baseUrl,
|
|
1976
|
-
headers: auth.getHeaders(auth.adminRole),
|
|
1977
|
-
payload: fixtures.invalid
|
|
1978
|
-
});
|
|
1979
|
-
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
|
1980
|
-
expect(JSON.parse(res.body).success).toBe(false);
|
|
1981
|
-
});
|
|
1982
|
-
});
|
|
1947
|
+
async cleanup() {
|
|
1948
|
+
for (const [_collection, _items] of this.data.entries());
|
|
1949
|
+
this.data.clear();
|
|
1983
1950
|
}
|
|
1984
1951
|
};
|
|
1985
|
-
/**
|
|
1986
|
-
* Create an HTTP test harness for an Arc resource.
|
|
1987
|
-
*
|
|
1988
|
-
* Accepts options directly or as a getter function for deferred resolution.
|
|
1989
|
-
*
|
|
1990
|
-
* @example Deferred (recommended for async setup)
|
|
1991
|
-
* ```typescript
|
|
1992
|
-
* let ctx: TestContext;
|
|
1993
|
-
* beforeAll(async () => { ctx = await setupTestOrg(); });
|
|
1994
|
-
*
|
|
1995
|
-
* createHttpTestHarness(jobResource, () => ({
|
|
1996
|
-
* app: ctx.app,
|
|
1997
|
-
* apiPrefix: '',
|
|
1998
|
-
* fixtures: { valid: { title: 'Test' } },
|
|
1999
|
-
* auth: createBetterAuthProvider({ ... }),
|
|
2000
|
-
* })).runAll();
|
|
2001
|
-
* ```
|
|
2002
|
-
*/
|
|
2003
|
-
function createHttpTestHarness(resource, optionsOrGetter) {
|
|
2004
|
-
return new HttpTestHarness(resource, optionsOrGetter);
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
1952
|
//#endregion
|
|
2008
|
-
export { DatabaseSnapshot, TestFixtures as DbTestFixtures, HttpTestHarness, InMemoryDatabase, TestDataLoader, TestDatabase, TestHarness, TestRequestBuilder, TestSeeder, TestTransaction, createBetterAuthProvider, createBetterAuthTestHelpers, createConfigTestSuite, createDataFactory, createHttpTestHarness, createJwtAuthProvider, createMinimalTestApp, createMockController, createMockReply, createMockRepository, createMockRequest, createMockUser, createSnapshotMatcher, createSpy, createTestApp, createTestAuth, createTestHarness, createTestTimer, generateTestFile, request, safeParseBody, setupBetterAuthOrg, waitFor, withTestDb };
|
|
1953
|
+
export { DatabaseSnapshot, TestFixtures as DbTestFixtures, HttpTestHarness, InMemoryDatabase, TestDataLoader, TestDatabase, TestHarness, TestRequestBuilder, TestSeeder, TestTransaction, createBetterAuthProvider, createBetterAuthTestHelpers, createConfigTestSuite, createDataFactory, createHttpTestHarness, createJwtAuthProvider, createMinimalTestApp, createMockController, createMockReply, createMockRepository, createMockRequest, createMockUser, createSnapshotMatcher, createSpy, createTestApp, createTestAuth, createTestHarness, createTestTimer, generateTestFile, request, safeParseBody, setupBetterAuthOrg, waitFor, withTestDb };
|