@classytic/arc 2.41.0 → 2.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{EventTransport-CbUKDzZZ.d.mts → EventTransport-CiicKz2Z.d.mts} +1 -1
- package/dist/{EventTransport-n7qO5Cz3.mjs → EventTransport-tzA7CAB4.mjs} +22 -17
- package/dist/{HookSystem-CxvZBo77.mjs → HookSystem-B6UaKRbF.mjs} +2 -2
- package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
- package/dist/{ResourceRegistry-Cu7uTJp9.mjs → ResourceRegistry-CuCARu2B.mjs} +44 -13
- package/dist/actionPermissions-BotX1YOa.mjs +776 -0
- package/dist/audit/index.d.mts +43 -5
- package/dist/audit/index.mjs +48 -26
- package/dist/auth/audit.mjs +1 -1
- package/dist/auth/index.d.mts +43 -13
- package/dist/auth/index.mjs +130 -155
- package/dist/auth/redis-session.mjs +1 -1
- package/dist/{authorizationDecision-2YY_Kaj1.mjs → authorizationDecision-DQYrkl5f.mjs} +7 -102
- package/dist/{base-CxrV1LmG.d.mts → base-CZyixEQR.d.mts} +27 -5
- package/dist/boundedMap-BULzfXYU.mjs +57 -0
- package/dist/{buildHandler-CUhjGHGZ.mjs → buildHandler-CRQ8EcX5.mjs} +4 -4
- package/dist/cache/index.d.mts +22 -34
- package/dist/cache/index.mjs +4 -4
- package/dist/{caching-DsWDjIxx.mjs → caching-Cc2M1DwO.mjs} +3 -3
- package/dist/claimed-B10RTfkM.mjs +8 -0
- package/dist/cleanup/index.d.mts +6 -10
- package/dist/cleanup/index.mjs +27 -73
- package/dist/cli/commands/describe.d.mts +4 -2
- package/dist/cli/commands/describe.mjs +7 -4
- package/dist/cli/commands/docs.mjs +3 -3
- package/dist/cli/commands/doctor.mjs +1 -1
- package/dist/cli/commands/generate.mjs +2 -2
- package/dist/cli/commands/init.mjs +2 -2
- package/dist/cli/commands/introspect.mjs +2 -2
- package/dist/cli/index.mjs +3 -3
- package/dist/{constants-BxLgNFF7.mjs → constants-Cu7Mx7_U.mjs} +4 -2
- package/dist/context/index.d.mts +2 -5
- package/dist/context/index.mjs +18 -38
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +8 -8
- package/dist/{core-iNGqYerO.mjs → core-Cb0LjccA.mjs} +5 -4
- package/dist/{createActionRouter-BBlbPs3k.mjs → createActionRouter-xjqonMRO.mjs} +87 -39
- package/dist/{createAggregationRouter-Dv5SuK2x.mjs → createAggregationRouter-zRYWw_5D.mjs} +11 -8
- package/dist/{preloadResources-Bbp4QJ8V.mjs → createApp-D-953ZAq.mjs} +299 -390
- package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-C9cxS8zt.d.mts} +1 -1
- package/dist/{defineAggregation-UyeurGkp.mjs → defineAggregation-Bm8RjMbq.mjs} +1 -1
- package/dist/{defineResource-DzGT-2LP.mjs → defineResource-DIC7JZGU.mjs} +118 -1024
- package/dist/discovery/index.mjs +1 -1
- package/dist/docs/index.d.mts +10 -4
- package/dist/docs/index.mjs +4 -4
- package/dist/{elevation-BXCfVcNG.d.mts → elevation-BW2J3tNj.d.mts} +1 -2
- package/dist/{elevation-BF-vhGxY.mjs → elevation-DG8SrLN-.mjs} +41 -12
- package/dist/encryption/index.mjs +3 -3
- package/dist/{errorHandler-CIMkCOWm.mjs → errorHandler-BnfDviL1.mjs} +26 -5
- package/dist/{errors-C_YhELhe.mjs → errors-CtrywMHP.mjs} +58 -23
- package/dist/{errors-BGdVwP25.d.mts → errors-JYc9vwqE.d.mts} +9 -0
- package/dist/{eventPlugin-B52abJ8W.d.mts → eventPlugin-BtmA4-3P.d.mts} +65 -9
- package/dist/{eventPlugin-CmXaPWLv.mjs → eventPlugin-Q6BhzHzs.mjs} +82 -21
- package/dist/{subscribe-helpers-AVYPgqTl.mjs → eventTypes-Cdhm6ZlV.mjs} +151 -17
- package/dist/events/index.d.mts +33 -8
- package/dist/events/index.mjs +49 -53
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +139 -157
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +2 -2
- package/dist/factory/index.d.mts +32 -17
- package/dist/factory/index.mjs +213 -32
- package/dist/{fastify-augmentation-Cq2nHVaW.d.mts → fastify-augmentation-DhUJ-mvo.d.mts} +11 -25
- package/dist/{fields-I_EeRWND.d.mts → fields-BBSpf8pS.d.mts} +1 -1
- package/dist/{fields-BTyPLHK0.mjs → fields-ToPYpMqS.mjs} +1 -1
- package/dist/filter-merge-IpGywvym.mjs +99 -0
- package/dist/{generate-D_YmiCNf.mjs → generate-DRTkLlIl.mjs} +2 -2
- package/dist/{headers-CNECDE6q.mjs → headers-BbkIF6vA.mjs} +2 -2
- package/dist/health-D-Ft6JM6.mjs +22 -0
- package/dist/{health-CxQzbJmr.mjs → health-DqbsG-_w.mjs} +115 -55
- package/dist/hooks/index.d.mts +2 -2
- package/dist/hooks/index.mjs +2 -2
- package/dist/idempotency/index.d.mts +31 -7
- package/dist/idempotency/index.mjs +155 -98
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +48 -11
- package/dist/{index-BHdZD8zK.d.mts → index-Bxriq3so.d.mts} +84 -21
- package/dist/{preloadResources-CjS2OfpU.d.mts → index-C8zVGYsk.d.mts} +37 -38
- package/dist/{index-D4u76eQW.d.mts → index-CRoPtISf.d.mts} +23 -73
- package/dist/{index-CbJiYrfv.d.mts → index-Chgz3d0i.d.mts} +1 -1
- package/dist/index-JdbZAIsm.d.mts +510 -0
- package/dist/{index-DHbyPo5j.d.mts → index-mqaqZNpe.d.mts} +253 -254
- package/dist/index.d.mts +8 -8
- package/dist/index.mjs +11 -11
- package/dist/{init-Y2MlSN9n.mjs → init-B_oUYDXI.mjs} +1 -1
- package/dist/integrations/event-gateway.d.mts +8 -3
- package/dist/integrations/event-gateway.mjs +4 -4
- package/dist/integrations/index.d.mts +3 -3
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs/index.d.mts +1 -1
- package/dist/integrations/jobs/index.mjs +18 -5
- package/dist/integrations/mcp/index.d.mts +72 -59
- package/dist/integrations/mcp/index.mjs +19 -4
- package/dist/integrations/mcp/testing.d.mts +2 -2
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.d.mts +78 -136
- package/dist/integrations/streamline.mjs +116 -54
- package/dist/integrations/webhooks.d.mts +60 -120
- package/dist/integrations/webhooks.mjs +36 -80
- package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
- package/dist/integrations/websocket-pushref-redis.mjs +1 -1
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket-redis.mjs +1 -1
- package/dist/integrations/websocket.d.mts +3 -3
- package/dist/integrations/websocket.mjs +2 -2
- package/dist/{interface-DEWNOMza.d.mts → interface-C0395yUN.d.mts} +13 -1
- package/dist/{jsonBody-Cq9KYl2Z.mjs → jsonBody-B4YVGcql.mjs} +2 -2
- package/dist/{keys-Cmv4cBRa.mjs → keys-B2KtgMLJ.mjs} +7 -18
- package/dist/{loadResources-CblF7xt2.mjs → loadResources-Dn4lalw2.mjs} +3 -3
- package/dist/{loadResourcesFromEntry-99fIMC3p.mjs → loadResourcesFromEntry-3sLYbuse.mjs} +2 -2
- package/dist/logger/index.d.mts +1 -8
- package/dist/logger/index.mjs +3 -153
- package/dist/logger-DXjfIqjV.mjs +160 -0
- package/dist/{mcpPlugin-xF3Gw-HW.mjs → mcpPlugin-C08HalN5.mjs} +433 -170
- package/dist/{memory-CpDZ9NPw.mjs → memory-9Cp7611C.mjs} +36 -34
- package/dist/{metrics-BaKnq8ag.mjs → metrics-CWI11eRu.mjs} +39 -72
- package/dist/middleware/index.d.mts +2 -2
- package/dist/middleware/index.mjs +3 -3
- package/dist/migrations/index.mjs +2 -2
- package/dist/{module-BkQEesNQ.mjs → module-BSvOQC7E.mjs} +8 -8
- package/dist/multiTenant-cY0FkDiA.d.mts +104 -0
- package/dist/{multipartBody-C92aJ8am.mjs → multipartBody-CXM2a-Dy.mjs} +46 -7
- package/dist/{openapi-C3LFRzPq.mjs → openapi-DT_G4VhY.mjs} +48 -10
- package/dist/outbox-admin/index.d.mts +3 -3
- package/dist/outbox-admin/index.mjs +6 -18
- package/dist/{outbox-module-dsCyLxXH.mjs → outbox-module-Bjgzl6tg.mjs} +203 -33
- package/dist/{outbox-module-BHdUnuuK.d.mts → outbox-module-gZCHoE4H.d.mts} +79 -6
- package/dist/permissions/index.d.mts +5 -4
- package/dist/permissions/index.mjs +8 -6
- package/dist/{permissions-DiHXy8Ck.mjs → permissions-Cm0uYHvd.mjs} +149 -33
- package/dist/{pipe-CBVmUJ-j.mjs → pipe-BqeGT6UO.mjs} +2 -2
- package/dist/pipeline/index.d.mts +2 -2
- package/dist/pipeline/index.mjs +2 -2
- package/dist/plan-FVR6LP59.mjs +565 -0
- package/dist/{BaseController-DYJRZ0EJ.mjs → plugin-BccET1tL.mjs} +1256 -344
- package/dist/plugins/index.d.mts +14 -11
- package/dist/plugins/index.mjs +44 -22
- package/dist/plugins/response-cache.mjs +8 -14
- package/dist/plugins/tracing-entry.mjs +2 -2
- package/dist/{pluralize-2_U6QZQg.mjs → pluralize-CVIOcgMF.mjs} +1 -1
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +5 -5
- package/dist/presets/index.d.mts +26 -31
- package/dist/presets/index.mjs +4 -4
- package/dist/presets/multiTenant.d.mts +2 -107
- package/dist/presets/multiTenant.mjs +22 -162
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +2 -2
- package/dist/{presets-BdlugYTc.mjs → presets-Cpvj6fGO.mjs} +99 -107
- package/dist/{projection-Uvgp-EEj.mjs → projection-Ce4aQhQk.mjs} +2 -2
- package/dist/{pushref-registry-Dov9l4qE.d.mts → pushref-registry-UxCkoA4C.d.mts} +1 -1
- package/dist/{queryCachePlugin-BzqxconG.d.mts → queryCachePlugin-C2lZjTxk.d.mts} +1 -1
- package/dist/{queryCachePlugin-B5F09bYr.mjs → queryCachePlugin-CFWNH8aT.mjs} +12 -14
- package/dist/{rateLimitKey-CK-EU2L8.mjs → rateLimitKey-C4O-kPm0.mjs} +1 -1
- package/dist/{realtime-FnnD0n3Z.mjs → realtime-DmPE9S54.mjs} +25 -21
- package/dist/redis-stream-BMk-bpQS.d.mts +219 -0
- package/dist/{redis-Bzi1y3F9.d.mts → redis-vLvtcGy_.d.mts} +13 -24
- package/dist/redisShapes-CQksY67q.d.mts +55 -0
- package/dist/registry/index.d.mts +9 -4
- package/dist/registry/index.mjs +4 -4
- package/dist/{registry-D3y8VEJ1.mjs → registry-Bcz9G1bh.mjs} +10 -8
- package/dist/{renewingLease-B3VYh5aU.mjs → renewingLease-qODa7KJQ.mjs} +1 -1
- package/dist/{replyHelpers-DPHsdw-5.mjs → replyHelpers-CG_VtVgS.mjs} +2 -2
- package/dist/{requestContext-cSVKnYPk.mjs → requestContext-Dsu9P1bV.mjs} +1 -1
- package/dist/requirement-CbiLdQ1n.d.mts +22 -0
- package/dist/requirement-KDdoRcOP.mjs +35 -0
- package/dist/resourceRateLimit-Wf6d6FL9.d.mts +30 -0
- package/dist/resourceRateLimit-dSjVr1pk.mjs +180 -0
- package/dist/{rolldown-runtime-DjDuyglE.mjs → rolldown-runtime-D1w9Y1VJ.mjs} +1 -1
- package/dist/routeValidator-D36SW8qt.mjs +70 -0
- package/dist/{runtimeCapabilities-DSA_dH1_.mjs → runtimeCapabilities-0tccjRlj.mjs} +1 -1
- package/dist/{schedules-BktJb3Y3.mjs → schedules-C-OpmHBq.mjs} +46 -37
- package/dist/{schemaConverter-BLCSmNbr.mjs → schemaConverter-BORek3O-.mjs} +2 -2
- package/dist/{schemaIR-K16Bb5BX.mjs → schemaIR-C7Vbp71I.mjs} +2 -2
- package/dist/schemas/index.d.mts +10 -0
- package/dist/schemas/index.mjs +8 -3
- package/dist/scim/index.mjs +2 -2
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +6 -6
- package/dist/{scope-jSxuBTFM.mjs → scope-DQthe88b.mjs} +4 -4
- package/dist/shutdown-MqA81z_P.mjs +59 -0
- package/dist/{simpleEqualityMatcher-BiOnyflP.mjs → simpleEqualityMatcher-auW8QjXN.mjs} +1 -1
- package/dist/{sse-CH7Slxc1.mjs → sse-CYzQBVFe.mjs} +11 -15
- package/dist/{sseStream-CN-Lh1Tu.mjs → sseStream-B5_Afiqn.mjs} +2 -2
- package/dist/{store-helpers-9DX9-1j_.mjs → store-helpers-BOzk-CuX.mjs} +1 -1
- package/dist/{streaming-dvppnxsr.mjs → streaming-DjTQhZrN.mjs} +1 -1
- package/dist/subscriberIdentity-CW39uA8G.mjs +28 -0
- package/dist/tenancy--ed9eH_z.mjs +43 -0
- package/dist/tenantPreparation-BKon6Yox.mjs +67 -0
- package/dist/testing/cleanupStoreContract.mjs +1 -1
- package/dist/testing/index.d.mts +193 -175
- package/dist/testing/index.mjs +286 -212
- package/dist/testing/outboxStoreContract.mjs +137 -2
- package/dist/testing/requiredPorts.d.mts +17 -0
- package/dist/testing/requiredPorts.mjs +65 -0
- package/dist/testing/storageContract.mjs +1 -1
- package/dist/timing-qczZ0YC-.mjs +46 -0
- package/dist/{transactionContext-DCWMI6hV.mjs → transactionContext-DjFkrNG6.mjs} +1 -1
- package/dist/{typeGuards-D7tHFdH6.mjs → typeGuards-CySF12G-.mjs} +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/index.mjs +1 -1
- package/dist/types/storage.mjs +1 -1
- package/dist/{types-CWQn9-IC.d.mts → types-B4Aq4Sdj.d.mts} +22 -9
- package/dist/{types-DfVDvkdP.d.mts → types-BJ-2XqVF.d.mts} +189 -204
- package/dist/{types-CDy3-zw1.mjs → types-Bk9NOgOC.mjs} +4 -4
- package/dist/usage/index.d.mts +2 -3
- package/dist/usage/index.mjs +5 -5
- package/dist/{userHelpers-DDMOZ_Pm.mjs → userHelpers-aklawRaK.mjs} +1 -1
- package/dist/utils/index.d.mts +84 -223
- package/dist/utils/index.mjs +13 -12
- package/dist/{utils-B-zj_b__.mjs → utils-ec1oTo1n.mjs} +315 -404
- package/dist/{validate-R0G_JrnK.mjs → validate-9yzHC1e2.mjs} +6 -14
- package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
- package/dist/{versioning-DuVWcu-F.mjs → versioning-fhhWHV74.mjs} +4 -4
- package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
- package/dist/{workScope-BhsWde4k.mjs → workScope-zpPyge4t.mjs} +2 -2
- package/package.json +578 -574
- package/skills/arc/SKILL.md +13 -4
- package/skills/arc/references/auth.md +8 -5
- package/skills/arc/references/integrations.md +1 -1
- package/skills/arc/references/mcp.md +8 -6
- package/skills/arc/references/multi-tenancy.md +19 -14
- package/skills/arc/references/production.md +27 -10
- package/dist/actionPermissions-DPwPWPOV.mjs +0 -350
- package/dist/index-CFfLKMH4.d.mts +0 -290
- package/dist/interface-BIpgzbZl.mjs +0 -17
- package/dist/redis-stream-BPKscsTB.d.mts +0 -260
- package/dist/registerRoute-DRsSHPSM.mjs +0 -364
- package/dist/sync/changelog.d.mts +0 -3
- package/dist/sync/changelog.mjs +0 -3
package/dist/testing/index.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import { t as CRUD_OPERATIONS } from "../constants-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { t as defineAggregation } from "../defineAggregation-
|
|
6
|
-
import { t as defineResource } from "../defineResource-
|
|
7
|
-
import { E as requireOrgRole } from "../permissions-
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
1
|
+
/*! @classytic/arc v2.43.0 | MIT | https://classytic.com */
|
|
2
|
+
import { t as CRUD_OPERATIONS } from "../constants-Cu7Mx7_U.mjs";
|
|
3
|
+
import { c as BaseController } from "../plugin-BccET1tL.mjs";
|
|
4
|
+
import { g as getUserId } from "../types-Bk9NOgOC.mjs";
|
|
5
|
+
import { t as defineAggregation } from "../defineAggregation-Bm8RjMbq.mjs";
|
|
6
|
+
import { t as defineResource } from "../defineResource-DIC7JZGU.mjs";
|
|
7
|
+
import { E as requireOrgRole } from "../permissions-Cm0uYHvd.mjs";
|
|
8
|
+
import { h as asModulePorts, r as createApp } from "../createApp-D-953ZAq.mjs";
|
|
9
|
+
import { d as resolveModule, l as isLazyBridge, o as resolveModuleClosure } from "../plan-FVR6LP59.mjs";
|
|
10
|
+
import { n as getModuleExports } from "../module-BSvOQC7E.mjs";
|
|
10
11
|
import { runStorageContract } from "./storageContract.mjs";
|
|
11
12
|
import Fastify from "fastify";
|
|
12
13
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
@@ -134,6 +135,243 @@ function expectArc(response) {
|
|
|
134
135
|
return assertion;
|
|
135
136
|
}
|
|
136
137
|
//#endregion
|
|
138
|
+
//#region src/testing/testActor.ts
|
|
139
|
+
/**
|
|
140
|
+
* Test-only actor installation — how a package's HTTP tests say "run this
|
|
141
|
+
* request as a manager in org X".
|
|
142
|
+
*
|
|
143
|
+
* Identity in arc comes from `request.scope` and only from `request.scope`;
|
|
144
|
+
* headers like `x-role` are not identity. That rule left tests with no seam —
|
|
145
|
+
* `bootModuleApp` boots with `auth: false`, so every injected request arrived as
|
|
146
|
+
* `PUBLIC_SCOPE` and any role-gated route answered 401 with no supported way to
|
|
147
|
+
* be somebody.
|
|
148
|
+
*
|
|
149
|
+
* The `x-test-actor` header is a TRANSPORT for the test's intent, not a trusted
|
|
150
|
+
* claim. The authenticator that reads it is installed by `bootModuleApp` alone,
|
|
151
|
+
* behind the `@classytic/arc/testing` entrypoint; `createApp` never registers
|
|
152
|
+
* it, so no production build turns a forged header into identity. It is
|
|
153
|
+
* per-request rather than boot-only because the interesting authorization tests
|
|
154
|
+
* are the ones where a cashier is refused what a manager is allowed.
|
|
155
|
+
*
|
|
156
|
+
* Gate the routes under test with arc's own primitives — `requireRoles`,
|
|
157
|
+
* `requireOrgMembership`, `allOf(...)`. A test-specific gate would be a second
|
|
158
|
+
* implementation of authorization whose semantics could drift from the one
|
|
159
|
+
* actually shipping.
|
|
160
|
+
*/
|
|
161
|
+
/** The header the testing harness uses to carry a request's actor. */
|
|
162
|
+
const TEST_ACTOR_HEADER = "x-test-actor";
|
|
163
|
+
/**
|
|
164
|
+
* Build the headers that make an injected request act as `actor`.
|
|
165
|
+
*
|
|
166
|
+
* A string is shorthand for a single role. `null` means "stay public", so a test
|
|
167
|
+
* can prove an endpoint is reachable anonymously.
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* ```ts
|
|
171
|
+
* t.app.inject({ method: 'GET', url: '/pos/shifts/current',
|
|
172
|
+
* headers: testActorHeaders('manager', ORG) });
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
function testActorHeaders(actor, organizationId) {
|
|
176
|
+
if (actor === null) return {};
|
|
177
|
+
const org = organizationId !== void 0 ? { organizationId } : {};
|
|
178
|
+
const spec = typeof actor === "string" ? {
|
|
179
|
+
roles: [actor],
|
|
180
|
+
...org
|
|
181
|
+
} : {
|
|
182
|
+
...actor,
|
|
183
|
+
...org
|
|
184
|
+
};
|
|
185
|
+
return { [TEST_ACTOR_HEADER]: JSON.stringify(spec) };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Turn a spec into a `RequestScope`.
|
|
189
|
+
*
|
|
190
|
+
* `orgRoles` defaults to `roles`: a test that says "manager" means it at both
|
|
191
|
+
* levels, and making the caller repeat itself only produces tests that pass a
|
|
192
|
+
* global gate and mysteriously fail an org one.
|
|
193
|
+
*/
|
|
194
|
+
function scopeFromTestActor(spec) {
|
|
195
|
+
const roles = spec.roles ?? [];
|
|
196
|
+
const userId = spec.userId ?? `test-${roles.join("-") || "anon"}`;
|
|
197
|
+
if (spec.elevated !== void 0 && spec.elevated !== false) return {
|
|
198
|
+
kind: "elevated",
|
|
199
|
+
userId,
|
|
200
|
+
elevatedBy: spec.elevated === true ? "test" : spec.elevated,
|
|
201
|
+
...spec.organizationId !== void 0 ? { organizationId: spec.organizationId } : {},
|
|
202
|
+
...spec.context !== void 0 ? { context: Object.freeze({ ...spec.context }) } : {}
|
|
203
|
+
};
|
|
204
|
+
if (spec.organizationId !== void 0) return {
|
|
205
|
+
kind: "member",
|
|
206
|
+
userId,
|
|
207
|
+
userRoles: roles,
|
|
208
|
+
organizationId: spec.organizationId,
|
|
209
|
+
orgRoles: spec.orgRoles ?? roles,
|
|
210
|
+
...spec.teamId !== void 0 ? { teamId: spec.teamId } : {},
|
|
211
|
+
...spec.context !== void 0 ? { context: Object.freeze({ ...spec.context }) } : {}
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
kind: "authenticated",
|
|
215
|
+
userId,
|
|
216
|
+
userRoles: roles
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Build the `auth` option that makes the test actor arc's authenticator.
|
|
221
|
+
*
|
|
222
|
+
* It must be an authenticator, not a hook. Fastify encapsulates plugins and
|
|
223
|
+
* modules register DURING `createApp`, so a hook added to the root instance
|
|
224
|
+
* afterwards never runs for their routes — every request still resolves as
|
|
225
|
+
* public and every role gate still answers 401, which reads exactly like a
|
|
226
|
+
* broken authorization rule in the code under test. Registering as
|
|
227
|
+
* `auth: { type: 'authenticator' }` puts the scope where arc's own auth would,
|
|
228
|
+
* at the phase arc expects, so tenant filtering and permission checks behave
|
|
229
|
+
* identically to production.
|
|
230
|
+
*
|
|
231
|
+
* `defaultActor` applies to requests carrying no header. A malformed header
|
|
232
|
+
* REJECTS rather than falling back to public — a test that meant to be an admin
|
|
233
|
+
* and got quietly demoted is the most expensive way to fail.
|
|
234
|
+
*/
|
|
235
|
+
function testActorAuth(defaultActor) {
|
|
236
|
+
const authenticate = async (request) => {
|
|
237
|
+
const req = request;
|
|
238
|
+
const raw = req.headers[TEST_ACTOR_HEADER];
|
|
239
|
+
let spec = defaultActor;
|
|
240
|
+
if (typeof raw === "string") try {
|
|
241
|
+
spec = JSON.parse(raw);
|
|
242
|
+
} catch {
|
|
243
|
+
throw new Error(`[arc-testing] malformed ${TEST_ACTOR_HEADER} header: ${raw}. Build it with testActorHeaders() rather than by hand.`);
|
|
244
|
+
}
|
|
245
|
+
if (spec === void 0) return;
|
|
246
|
+
req.scope = scopeFromTestActor(spec);
|
|
247
|
+
req.user = {
|
|
248
|
+
id: getUserId(req.scope) ?? "test-anon",
|
|
249
|
+
...spec.roles !== void 0 ? { role: spec.roles } : {},
|
|
250
|
+
...spec.organizationId !== void 0 ? { organizationId: spec.organizationId } : {}
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
return {
|
|
254
|
+
type: "authenticator",
|
|
255
|
+
authenticate
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/testing/bootModuleApp.ts
|
|
260
|
+
/**
|
|
261
|
+
* The built-in DB factory: in-memory MongoDB via lazy devDep imports.
|
|
262
|
+
* Everything Mongo-specific about the harness lives HERE — the harness
|
|
263
|
+
* itself is driver-blind and any kit can replace this via `database`.
|
|
264
|
+
*/
|
|
265
|
+
async function mongoMemoryDatabase(opts) {
|
|
266
|
+
let mms;
|
|
267
|
+
try {
|
|
268
|
+
mms = await import("mongodb-memory-server");
|
|
269
|
+
} catch {
|
|
270
|
+
throw new Error("[arc-testing] bootModuleApp's default database is in-memory MongoDB — install 'mongodb-memory-server' + 'mongoose' as devDependencies, or inject your own `database` factory for a different store");
|
|
271
|
+
}
|
|
272
|
+
const server = opts.replset ? await mms.MongoMemoryReplSet.create({ replSet: { count: 1 } }) : await mms.MongoMemoryServer.create();
|
|
273
|
+
const uri = server.getUri();
|
|
274
|
+
const mongoose = (await import("mongoose")).default;
|
|
275
|
+
if (mongoose.connection.readyState !== 0) await mongoose.disconnect();
|
|
276
|
+
await mongoose.connect(uri);
|
|
277
|
+
mongoose.connection.deleteModel(/.*/);
|
|
278
|
+
return {
|
|
279
|
+
uri,
|
|
280
|
+
connection: mongoose.connection,
|
|
281
|
+
teardown: async () => {
|
|
282
|
+
if (mongoose.connection.readyState !== 0) await mongoose.disconnect().catch(() => void 0);
|
|
283
|
+
await server.stop().catch(() => void 0);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
async function bootModuleApp(modules, options = {}) {
|
|
288
|
+
const db = await (options.database ?? mongoMemoryDatabase)({ replset: options.replset ?? false });
|
|
289
|
+
const ctx = {
|
|
290
|
+
uri: db.uri,
|
|
291
|
+
connection: db.connection
|
|
292
|
+
};
|
|
293
|
+
const resolved = typeof modules === "function" ? await modules(ctx) : modules;
|
|
294
|
+
const list = Array.isArray(resolved) ? resolved : [resolved];
|
|
295
|
+
let app;
|
|
296
|
+
try {
|
|
297
|
+
app = await createApp({
|
|
298
|
+
preset: "testing",
|
|
299
|
+
/**
|
|
300
|
+
* The test actor is arc's AUTHENTICATOR rather than a bolted-on hook, so the scope
|
|
301
|
+
* lands at the phase the rest of the pipeline expects — tenant filtering and
|
|
302
|
+
* permission checks then behave exactly as they do in production.
|
|
303
|
+
*
|
|
304
|
+
* This replaces the old `auth: false`. The authenticator is inert for a request
|
|
305
|
+
* carrying no `x-test-actor` header and no configured default, so a suite that
|
|
306
|
+
* never mentions an actor behaves as it did before. `options.app` still wins, so a
|
|
307
|
+
* suite exercising real auth can override it.
|
|
308
|
+
*/
|
|
309
|
+
auth: testActorAuth(options.actor),
|
|
310
|
+
sensible: false,
|
|
311
|
+
...options.app,
|
|
312
|
+
...options.ports ? { ports: options.ports } : {},
|
|
313
|
+
modules: list
|
|
314
|
+
});
|
|
315
|
+
await app.ready();
|
|
316
|
+
} catch (err) {
|
|
317
|
+
await db.teardown().catch(() => void 0);
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
let closed = false;
|
|
321
|
+
return {
|
|
322
|
+
app,
|
|
323
|
+
uri: db.uri,
|
|
324
|
+
connection: db.connection,
|
|
325
|
+
exports: (name) => getModuleExports(app, name),
|
|
326
|
+
close: async () => {
|
|
327
|
+
if (closed) return;
|
|
328
|
+
closed = true;
|
|
329
|
+
await app.close().catch(() => void 0);
|
|
330
|
+
await db.teardown().catch(() => void 0);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
//#endregion
|
|
335
|
+
//#region src/testing/assertModuleBootsAlone.ts
|
|
336
|
+
/**
|
|
337
|
+
* Boot a module inside its closure — `resolveModuleClosure` (its `dependsOn` and
|
|
338
|
+
* `requires.modules`, transitively, plus every platform module) and every bridge in the list,
|
|
339
|
+
* which arc activates only when both ends are present — and fail naming the module when the boot
|
|
340
|
+
* or the `probe` throws. A hidden edge fails here, by name.
|
|
341
|
+
*
|
|
342
|
+
* ```ts
|
|
343
|
+
* it("order boots alone", () => assertModuleBootsAlone(allModules, "order", { probe: (t) => drivePorts(t) }));
|
|
344
|
+
* ```
|
|
345
|
+
*/
|
|
346
|
+
async function assertModuleBootsAlone(modules, name, options = {}) {
|
|
347
|
+
const roots = typeof name === "string" ? [name] : name;
|
|
348
|
+
const label = roots.map((r) => `"${r}"`).join(" + ");
|
|
349
|
+
let closure = [];
|
|
350
|
+
try {
|
|
351
|
+
const { probe, ...bootOptions } = options;
|
|
352
|
+
const t = await bootModuleApp(async (ctx) => {
|
|
353
|
+
const resolved = typeof modules === "function" ? await modules(ctx) : modules;
|
|
354
|
+
const inputs = isInputList(resolved) ? resolved : [resolved];
|
|
355
|
+
const nodes = await Promise.all(inputs.map((input) => isLazyBridge(input) ? input : resolveModule(input)));
|
|
356
|
+
const included = new Set(resolveModuleClosure(nodes, roots).map((m) => m.name));
|
|
357
|
+
closure = nodes.filter((m) => included.has(m.name)).map((m) => m.name);
|
|
358
|
+
return nodes.filter((m) => included.has(m.name) || m.kind === "bridge");
|
|
359
|
+
}, bootOptions);
|
|
360
|
+
try {
|
|
361
|
+
await probe?.(t);
|
|
362
|
+
} finally {
|
|
363
|
+
await t.close();
|
|
364
|
+
}
|
|
365
|
+
} catch (err) {
|
|
366
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
367
|
+
throw new Error(`[arc/testing] module ${label} does not boot alone in its closure [${closure.join(", ") || "unresolved"}]: ${msg}`, { cause: err });
|
|
368
|
+
}
|
|
369
|
+
return { closure };
|
|
370
|
+
}
|
|
371
|
+
function isInputList(value) {
|
|
372
|
+
return Array.isArray(value);
|
|
373
|
+
}
|
|
374
|
+
//#endregion
|
|
137
375
|
//#region src/testing/mocks.ts
|
|
138
376
|
/**
|
|
139
377
|
* Create a mock repository for testing
|
|
@@ -833,202 +1071,6 @@ async function setupBetterAuthTestApp(input) {
|
|
|
833
1071
|
};
|
|
834
1072
|
}
|
|
835
1073
|
//#endregion
|
|
836
|
-
//#region src/testing/testActor.ts
|
|
837
|
-
/**
|
|
838
|
-
* Test-only actor installation — how a package's HTTP tests say "run this
|
|
839
|
-
* request as a manager in org X".
|
|
840
|
-
*
|
|
841
|
-
* Identity in arc comes from `request.scope` and only from `request.scope`;
|
|
842
|
-
* headers like `x-role` are not identity. That rule left tests with no seam —
|
|
843
|
-
* `bootModuleApp` boots with `auth: false`, so every injected request arrived as
|
|
844
|
-
* `PUBLIC_SCOPE` and any role-gated route answered 401 with no supported way to
|
|
845
|
-
* be somebody.
|
|
846
|
-
*
|
|
847
|
-
* The `x-test-actor` header is a TRANSPORT for the test's intent, not a trusted
|
|
848
|
-
* claim. The authenticator that reads it is installed by `bootModuleApp` alone,
|
|
849
|
-
* behind the `@classytic/arc/testing` entrypoint; `createApp` never registers
|
|
850
|
-
* it, so no production build turns a forged header into identity. It is
|
|
851
|
-
* per-request rather than boot-only because the interesting authorization tests
|
|
852
|
-
* are the ones where a cashier is refused what a manager is allowed.
|
|
853
|
-
*
|
|
854
|
-
* Gate the routes under test with arc's own primitives — `requireRoles`,
|
|
855
|
-
* `requireOrgMembership`, `allOf(...)`. A test-specific gate would be a second
|
|
856
|
-
* implementation of authorization whose semantics could drift from the one
|
|
857
|
-
* actually shipping.
|
|
858
|
-
*/
|
|
859
|
-
/** The header the testing harness uses to carry a request's actor. */
|
|
860
|
-
const TEST_ACTOR_HEADER = "x-test-actor";
|
|
861
|
-
/**
|
|
862
|
-
* Build the headers that make an injected request act as `actor`.
|
|
863
|
-
*
|
|
864
|
-
* A string is shorthand for a single role. `null` means "stay public", so a test
|
|
865
|
-
* can prove an endpoint is reachable anonymously.
|
|
866
|
-
*
|
|
867
|
-
* @example
|
|
868
|
-
* ```ts
|
|
869
|
-
* t.app.inject({ method: 'GET', url: '/pos/shifts/current',
|
|
870
|
-
* headers: testActorHeaders('manager', ORG) });
|
|
871
|
-
* ```
|
|
872
|
-
*/
|
|
873
|
-
function testActorHeaders(actor, organizationId) {
|
|
874
|
-
if (actor === null) return {};
|
|
875
|
-
const org = organizationId !== void 0 ? { organizationId } : {};
|
|
876
|
-
const spec = typeof actor === "string" ? {
|
|
877
|
-
roles: [actor],
|
|
878
|
-
...org
|
|
879
|
-
} : {
|
|
880
|
-
...actor,
|
|
881
|
-
...org
|
|
882
|
-
};
|
|
883
|
-
return { [TEST_ACTOR_HEADER]: JSON.stringify(spec) };
|
|
884
|
-
}
|
|
885
|
-
/**
|
|
886
|
-
* Turn a spec into a `RequestScope`.
|
|
887
|
-
*
|
|
888
|
-
* `orgRoles` defaults to `roles`: a test that says "manager" means it at both
|
|
889
|
-
* levels, and making the caller repeat itself only produces tests that pass a
|
|
890
|
-
* global gate and mysteriously fail an org one.
|
|
891
|
-
*/
|
|
892
|
-
function scopeFromTestActor(spec) {
|
|
893
|
-
const roles = spec.roles ?? [];
|
|
894
|
-
const userId = spec.userId ?? `test-${roles.join("-") || "anon"}`;
|
|
895
|
-
if (spec.elevated !== void 0 && spec.elevated !== false) return {
|
|
896
|
-
kind: "elevated",
|
|
897
|
-
userId,
|
|
898
|
-
elevatedBy: spec.elevated === true ? "test" : spec.elevated,
|
|
899
|
-
...spec.organizationId !== void 0 ? { organizationId: spec.organizationId } : {},
|
|
900
|
-
...spec.context !== void 0 ? { context: Object.freeze({ ...spec.context }) } : {}
|
|
901
|
-
};
|
|
902
|
-
if (spec.organizationId !== void 0) return {
|
|
903
|
-
kind: "member",
|
|
904
|
-
userId,
|
|
905
|
-
userRoles: roles,
|
|
906
|
-
organizationId: spec.organizationId,
|
|
907
|
-
orgRoles: spec.orgRoles ?? roles,
|
|
908
|
-
...spec.teamId !== void 0 ? { teamId: spec.teamId } : {},
|
|
909
|
-
...spec.context !== void 0 ? { context: Object.freeze({ ...spec.context }) } : {}
|
|
910
|
-
};
|
|
911
|
-
return {
|
|
912
|
-
kind: "authenticated",
|
|
913
|
-
userId,
|
|
914
|
-
userRoles: roles
|
|
915
|
-
};
|
|
916
|
-
}
|
|
917
|
-
/**
|
|
918
|
-
* Build the `auth` option that makes the test actor arc's authenticator.
|
|
919
|
-
*
|
|
920
|
-
* It must be an authenticator, not a hook. Fastify encapsulates plugins and
|
|
921
|
-
* modules register DURING `createApp`, so a hook added to the root instance
|
|
922
|
-
* afterwards never runs for their routes — every request still resolves as
|
|
923
|
-
* public and every role gate still answers 401, which reads exactly like a
|
|
924
|
-
* broken authorization rule in the code under test. Registering as
|
|
925
|
-
* `auth: { type: 'authenticator' }` puts the scope where arc's own auth would,
|
|
926
|
-
* at the phase arc expects, so tenant filtering and permission checks behave
|
|
927
|
-
* identically to production.
|
|
928
|
-
*
|
|
929
|
-
* `defaultActor` applies to requests carrying no header. A malformed header
|
|
930
|
-
* REJECTS rather than falling back to public — a test that meant to be an admin
|
|
931
|
-
* and got quietly demoted is the most expensive way to fail.
|
|
932
|
-
*/
|
|
933
|
-
function testActorAuth(defaultActor) {
|
|
934
|
-
const authenticate = async (request) => {
|
|
935
|
-
const req = request;
|
|
936
|
-
const raw = req.headers[TEST_ACTOR_HEADER];
|
|
937
|
-
let spec = defaultActor;
|
|
938
|
-
if (typeof raw === "string") try {
|
|
939
|
-
spec = JSON.parse(raw);
|
|
940
|
-
} catch {
|
|
941
|
-
throw new Error(`[arc-testing] malformed ${TEST_ACTOR_HEADER} header: ${raw}. Build it with testActorHeaders() rather than by hand.`);
|
|
942
|
-
}
|
|
943
|
-
if (spec === void 0) return;
|
|
944
|
-
req.scope = scopeFromTestActor(spec);
|
|
945
|
-
req.user = {
|
|
946
|
-
id: getUserId(req.scope) ?? "test-anon",
|
|
947
|
-
...spec.roles !== void 0 ? { role: spec.roles } : {},
|
|
948
|
-
...spec.organizationId !== void 0 ? { organizationId: spec.organizationId } : {}
|
|
949
|
-
};
|
|
950
|
-
};
|
|
951
|
-
return {
|
|
952
|
-
type: "authenticator",
|
|
953
|
-
authenticate
|
|
954
|
-
};
|
|
955
|
-
}
|
|
956
|
-
//#endregion
|
|
957
|
-
//#region src/testing/bootModuleApp.ts
|
|
958
|
-
/**
|
|
959
|
-
* The built-in DB factory: in-memory MongoDB via lazy devDep imports.
|
|
960
|
-
* Everything Mongo-specific about the harness lives HERE — the harness
|
|
961
|
-
* itself is driver-blind and any kit can replace this via `database`.
|
|
962
|
-
*/
|
|
963
|
-
async function mongoMemoryDatabase(opts) {
|
|
964
|
-
let mms;
|
|
965
|
-
try {
|
|
966
|
-
mms = await import("mongodb-memory-server");
|
|
967
|
-
} catch {
|
|
968
|
-
throw new Error("[arc-testing] bootModuleApp's default database is in-memory MongoDB — install 'mongodb-memory-server' + 'mongoose' as devDependencies, or inject your own `database` factory for a different store");
|
|
969
|
-
}
|
|
970
|
-
const server = opts.replset ? await mms.MongoMemoryReplSet.create({ replSet: { count: 1 } }) : await mms.MongoMemoryServer.create();
|
|
971
|
-
const uri = server.getUri();
|
|
972
|
-
const mongoose = (await import("mongoose")).default;
|
|
973
|
-
if (mongoose.connection.readyState !== 0) await mongoose.disconnect();
|
|
974
|
-
await mongoose.connect(uri);
|
|
975
|
-
mongoose.connection.deleteModel(/.*/);
|
|
976
|
-
return {
|
|
977
|
-
uri,
|
|
978
|
-
connection: mongoose.connection,
|
|
979
|
-
teardown: async () => {
|
|
980
|
-
if (mongoose.connection.readyState !== 0) await mongoose.disconnect().catch(() => void 0);
|
|
981
|
-
await server.stop().catch(() => void 0);
|
|
982
|
-
}
|
|
983
|
-
};
|
|
984
|
-
}
|
|
985
|
-
async function bootModuleApp(modules, options = {}) {
|
|
986
|
-
const db = await (options.database ?? mongoMemoryDatabase)({ replset: options.replset ?? false });
|
|
987
|
-
const ctx = {
|
|
988
|
-
uri: db.uri,
|
|
989
|
-
connection: db.connection
|
|
990
|
-
};
|
|
991
|
-
const resolved = typeof modules === "function" ? await modules(ctx) : modules;
|
|
992
|
-
const list = Array.isArray(resolved) ? resolved : [resolved];
|
|
993
|
-
let app;
|
|
994
|
-
try {
|
|
995
|
-
app = await createApp({
|
|
996
|
-
preset: "testing",
|
|
997
|
-
/**
|
|
998
|
-
* The test actor is arc's AUTHENTICATOR rather than a bolted-on hook, so the scope
|
|
999
|
-
* lands at the phase the rest of the pipeline expects — tenant filtering and
|
|
1000
|
-
* permission checks then behave exactly as they do in production.
|
|
1001
|
-
*
|
|
1002
|
-
* This replaces the old `auth: false`. The authenticator is inert for a request
|
|
1003
|
-
* carrying no `x-test-actor` header and no configured default, so a suite that
|
|
1004
|
-
* never mentions an actor behaves as it did before. `options.app` still wins, so a
|
|
1005
|
-
* suite exercising real auth can override it.
|
|
1006
|
-
*/
|
|
1007
|
-
auth: testActorAuth(options.actor),
|
|
1008
|
-
sensible: false,
|
|
1009
|
-
...options.app,
|
|
1010
|
-
modules: list
|
|
1011
|
-
});
|
|
1012
|
-
await app.ready();
|
|
1013
|
-
} catch (err) {
|
|
1014
|
-
await db.teardown().catch(() => void 0);
|
|
1015
|
-
throw err;
|
|
1016
|
-
}
|
|
1017
|
-
let closed = false;
|
|
1018
|
-
return {
|
|
1019
|
-
app,
|
|
1020
|
-
uri: db.uri,
|
|
1021
|
-
connection: db.connection,
|
|
1022
|
-
exports: (name) => getModuleExports(app, name),
|
|
1023
|
-
close: async () => {
|
|
1024
|
-
if (closed) return;
|
|
1025
|
-
closed = true;
|
|
1026
|
-
await app.close().catch(() => void 0);
|
|
1027
|
-
await db.teardown().catch(() => void 0);
|
|
1028
|
-
}
|
|
1029
|
-
};
|
|
1030
|
-
}
|
|
1031
|
-
//#endregion
|
|
1032
1074
|
//#region src/testing/fixtures.ts
|
|
1033
1075
|
function createTestFixtures() {
|
|
1034
1076
|
const registry = /* @__PURE__ */ new Map();
|
|
@@ -1356,13 +1398,45 @@ function createHttpTestHarness(resource, optionsOrGetter) {
|
|
|
1356
1398
|
}
|
|
1357
1399
|
//#endregion
|
|
1358
1400
|
//#region src/testing/moduleSetup.ts
|
|
1359
|
-
|
|
1401
|
+
/**
|
|
1402
|
+
* A real `ModuleSetupContext` for tests that invoke a module's setup phases
|
|
1403
|
+
* DIRECTLY — `mod.bootstrap(app, ctx)`, `mod.plugins(app, ctx)` — instead of
|
|
1404
|
+
* booting a whole app through `createApp` / `bootModuleApp`.
|
|
1405
|
+
*
|
|
1406
|
+
* ## Why this is not `{ defer: () => {} }`
|
|
1407
|
+
*
|
|
1408
|
+
* That literal satisfies the type and throws away every disposer the module
|
|
1409
|
+
* registers. A test written against it passes whether the module releases what
|
|
1410
|
+
* it acquired or leaks it, so the one thing `defer` exists to make testable
|
|
1411
|
+
* becomes the one thing that cannot be tested. It also gives a false negative
|
|
1412
|
+
* in the other direction: a module that starts deferring later keeps its green
|
|
1413
|
+
* test, which is precisely the "compiles today, wrong tomorrow" shape the
|
|
1414
|
+
* disposer contract was introduced to remove.
|
|
1415
|
+
*
|
|
1416
|
+
* This collects them instead, and `dispose()` unwinds LIFO — the same order
|
|
1417
|
+
* arc's own teardown uses, so an ordering bug surfaces here rather than in
|
|
1418
|
+
* production shutdown.
|
|
1419
|
+
*
|
|
1420
|
+
* @example
|
|
1421
|
+
* ```ts
|
|
1422
|
+
* const setup = createTestModuleSetup();
|
|
1423
|
+
* const engine = await mod.bootstrap?.(app, setup.context);
|
|
1424
|
+
*
|
|
1425
|
+
* expect(setup.deferred).toHaveLength(1); // it registered its teardown
|
|
1426
|
+
* await setup.dispose(); // and it actually releases
|
|
1427
|
+
* expect(engine.closed).toBe(true);
|
|
1428
|
+
* ```
|
|
1429
|
+
*/
|
|
1430
|
+
function createTestModuleSetup(options = {}) {
|
|
1360
1431
|
const deferred = [];
|
|
1361
1432
|
return {
|
|
1362
|
-
context: {
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1433
|
+
context: {
|
|
1434
|
+
ports: asModulePorts(Object.freeze({ ...options.ports })),
|
|
1435
|
+
defer(disposer) {
|
|
1436
|
+
if (typeof disposer !== "function") throw new TypeError(`[arc/testing] defer() expected a function, received ${typeof disposer}.`);
|
|
1437
|
+
deferred.push(disposer);
|
|
1438
|
+
}
|
|
1439
|
+
},
|
|
1366
1440
|
deferred,
|
|
1367
1441
|
async dispose() {
|
|
1368
1442
|
const errors = [];
|
|
@@ -1504,7 +1578,7 @@ function pickDefaultAuth(authMode, callerAuth) {
|
|
|
1504
1578
|
};
|
|
1505
1579
|
}
|
|
1506
1580
|
async function createTestApp(options = {}) {
|
|
1507
|
-
const { createApp } = await import("../
|
|
1581
|
+
const { createApp } = await import("../createApp-D-953ZAq.mjs").then((n) => n.i);
|
|
1508
1582
|
const { resources = [], db = "in-memory", connectMongoose = false, authMode = "jwt", defaultOrgId, plugins, auth: callerAuth, ...appOptions } = options;
|
|
1509
1583
|
let dbHandle;
|
|
1510
1584
|
let dbUri;
|
|
@@ -1574,4 +1648,4 @@ function createMinimalTestApp(options = {}) {
|
|
|
1574
1648
|
});
|
|
1575
1649
|
}
|
|
1576
1650
|
//#endregion
|
|
1577
|
-
export { HttpTestHarness, TEST_ACTOR_HEADER, bootModuleApp, createBetterAuthProvider, createBetterAuthTestHelpers, createCustomAuthProvider, createDataFactory, createHttpTestHarness, createJwtAuthProvider, createMinimalTestApp, createMockController, createMockReply, createMockRepository, createMockRequest, createMockUser, createSpy, createTestApp, createTestFixtures, createTestModuleSetup, createTestTimer, expectArc, mongoMemoryDatabase,
|
|
1651
|
+
export { HttpTestHarness, TEST_ACTOR_HEADER, assertModuleBootsAlone, bootModuleApp, createBetterAuthProvider, createBetterAuthTestHelpers, createCustomAuthProvider, createDataFactory, createHttpTestHarness, createJwtAuthProvider, createMinimalTestApp, createMockController, createMockReply, createMockRepository, createMockRequest, createMockUser, createSpy, createTestApp, createTestFixtures, createTestModuleSetup, createTestTimer, expectArc, mongoMemoryDatabase, runAuthorizationConformance, runStorageContract, safeParseBody, scopeFromTestActor, scopeRoleGate, setupBetterAuthTestApp, testActorAuth, testActorHeaders, waitFor };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import {
|
|
1
|
+
/*! @classytic/arc v2.43.0 | MIT | https://classytic.com */
|
|
2
|
+
import { t as claimedAttempts } from "../claimed-B10RTfkM.mjs";
|
|
3
|
+
import { OutboxConflictError, OutboxOwnershipError } from "@classytic/primitives/outbox";
|
|
3
4
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
4
5
|
//#region src/testing/outboxStoreContract.ts
|
|
5
6
|
let seq = 0;
|
|
@@ -81,6 +82,26 @@ function runOutboxStoreContract(name, setup) {
|
|
|
81
82
|
expect((await store.getPending(10)).length).toBe(0);
|
|
82
83
|
});
|
|
83
84
|
});
|
|
85
|
+
describe("save — one row per event id", () => {
|
|
86
|
+
it("a second save of the same id and payload is a no-op", async () => {
|
|
87
|
+
const e = event();
|
|
88
|
+
await store.save(e);
|
|
89
|
+
await store.save({
|
|
90
|
+
...e,
|
|
91
|
+
meta: { ...e.meta }
|
|
92
|
+
});
|
|
93
|
+
expect((await store.getPending(10)).map(idOf)).toEqual([idOf(e)]);
|
|
94
|
+
});
|
|
95
|
+
it("THROWS OutboxConflictError when the same id arrives with a different payload", async () => {
|
|
96
|
+
const e = event();
|
|
97
|
+
await store.save(e);
|
|
98
|
+
await expect(store.save({
|
|
99
|
+
...e,
|
|
100
|
+
payload: { n: -1 }
|
|
101
|
+
})).rejects.toBeInstanceOf(OutboxConflictError);
|
|
102
|
+
expect((await store.getPending(10)).length).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
84
105
|
describe("getPending", () => {
|
|
85
106
|
it("returns events FIFO (oldest first)", async () => {
|
|
86
107
|
const saved = await seed(3);
|
|
@@ -164,6 +185,120 @@ function runOutboxStoreContract(name, setup) {
|
|
|
164
185
|
expect(claimed.map(idOf)).toEqual(saved.slice(0, 2).map(idOf));
|
|
165
186
|
});
|
|
166
187
|
});
|
|
188
|
+
/**
|
|
189
|
+
* The fenced claim had NO coverage here, though it is the path the relay
|
|
190
|
+
* prefers and the one fencing exists for. Two guarantees, both silent when
|
|
191
|
+
* broken:
|
|
192
|
+
* - a re-claim after lease expiry mints a STRICTLY GREATER token, so the
|
|
193
|
+
* crashed-and-resumed ex-holder cannot acknowledge an event it no longer
|
|
194
|
+
* owns;
|
|
195
|
+
* - `attempts`, when a store reports it, is the DURABLE count — it must
|
|
196
|
+
* rise across claims by different consumers, because a count that each
|
|
197
|
+
* relay keeps for itself is the thing it exists to replace.
|
|
198
|
+
*/
|
|
199
|
+
describe("claimPendingFenced — fencing tokens + durable attempts", () => {
|
|
200
|
+
it("mints a STRICTLY GREATER token when a lease is taken over", async ({ skip }) => {
|
|
201
|
+
if (!store.claimPendingFenced) return skip();
|
|
202
|
+
await seed(1);
|
|
203
|
+
const [first] = await store.claimPendingFenced({
|
|
204
|
+
consumerId: "dead",
|
|
205
|
+
limit: 1,
|
|
206
|
+
leaseMs: 0
|
|
207
|
+
});
|
|
208
|
+
const [second] = await store.claimPendingFenced({
|
|
209
|
+
consumerId: "c2",
|
|
210
|
+
limit: 1
|
|
211
|
+
});
|
|
212
|
+
if (!first || !second) return skip();
|
|
213
|
+
expect(second.fencingToken).toBeGreaterThan(first.fencingToken);
|
|
214
|
+
});
|
|
215
|
+
it("REJECTS an acknowledge carrying the superseded token", async ({ skip }) => {
|
|
216
|
+
if (!store.claimPendingFenced) return skip();
|
|
217
|
+
await seed(1);
|
|
218
|
+
const [stale] = await store.claimPendingFenced({
|
|
219
|
+
consumerId: "dead",
|
|
220
|
+
limit: 1,
|
|
221
|
+
leaseMs: 0
|
|
222
|
+
});
|
|
223
|
+
const [current] = await store.claimPendingFenced({
|
|
224
|
+
consumerId: "c2",
|
|
225
|
+
limit: 1
|
|
226
|
+
});
|
|
227
|
+
if (!stale || !current) return skip();
|
|
228
|
+
await expect(store.acknowledge(idOf(stale.event), { fencingToken: stale.fencingToken })).rejects.toBeInstanceOf(OutboxOwnershipError);
|
|
229
|
+
});
|
|
230
|
+
it("reports attempts = 1 on the first claim, when it reports attempts at all", async ({ skip }) => {
|
|
231
|
+
if (!store.claimPendingFenced) return skip();
|
|
232
|
+
await seed(1);
|
|
233
|
+
const [claimed] = await store.claimPendingFenced({
|
|
234
|
+
consumerId: "c1",
|
|
235
|
+
limit: 1
|
|
236
|
+
});
|
|
237
|
+
const attempts = claimed ? claimedAttempts(claimed) : void 0;
|
|
238
|
+
if (attempts === void 0) return skip();
|
|
239
|
+
expect(attempts).toBe(1);
|
|
240
|
+
});
|
|
241
|
+
it("attempts is DURABLE — it rises across claims by DIFFERENT consumers", async ({ skip }) => {
|
|
242
|
+
if (!store.claimPendingFenced || !store.fail) return skip();
|
|
243
|
+
await seed(1);
|
|
244
|
+
const seen = [];
|
|
245
|
+
for (const consumerId of [
|
|
246
|
+
"relay-a",
|
|
247
|
+
"relay-b",
|
|
248
|
+
"relay-c"
|
|
249
|
+
]) {
|
|
250
|
+
const [claimed] = await store.claimPendingFenced({
|
|
251
|
+
consumerId,
|
|
252
|
+
limit: 1
|
|
253
|
+
});
|
|
254
|
+
if (!claimed) return skip();
|
|
255
|
+
const attempts = claimedAttempts(claimed);
|
|
256
|
+
if (attempts === void 0) return skip();
|
|
257
|
+
seen.push(attempts);
|
|
258
|
+
await store.fail(idOf(claimed.event), { message: "boom" }, {
|
|
259
|
+
consumerId,
|
|
260
|
+
fencingToken: claimed.fencingToken
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
expect(seen).toEqual([
|
|
264
|
+
1,
|
|
265
|
+
2,
|
|
266
|
+
3
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
it("requeue RESETS attempts — a re-driven event starts over", async ({ skip }) => {
|
|
270
|
+
if (!store.claimPendingFenced || !store.fail || !store.requeue) return skip();
|
|
271
|
+
await seed(1);
|
|
272
|
+
const [claimed] = await store.claimPendingFenced({
|
|
273
|
+
consumerId: "c1",
|
|
274
|
+
limit: 1
|
|
275
|
+
});
|
|
276
|
+
if (!claimed || claimedAttempts(claimed) === void 0) return skip();
|
|
277
|
+
await store.fail(idOf(claimed.event), { message: "fatal" }, {
|
|
278
|
+
consumerId: "c1",
|
|
279
|
+
fencingToken: claimed.fencingToken,
|
|
280
|
+
deadLetter: true
|
|
281
|
+
});
|
|
282
|
+
expect(await store.requeue(idOf(claimed.event))).toBe(true);
|
|
283
|
+
const [again] = await store.claimPendingFenced({
|
|
284
|
+
consumerId: "c2",
|
|
285
|
+
limit: 1
|
|
286
|
+
});
|
|
287
|
+
expect(again ? claimedAttempts(again) : void 0).toBe(1);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
describe("acknowledgeMany — one write per flush", () => {
|
|
291
|
+
it("marks every listed event delivered and ignores unknown ids", async ({ skip }) => {
|
|
292
|
+
if (!store.acknowledgeMany) return skip();
|
|
293
|
+
const events = await seed(3);
|
|
294
|
+
await store.acknowledgeMany([
|
|
295
|
+
idOf(events[0]),
|
|
296
|
+
idOf(events[2]),
|
|
297
|
+
"nope"
|
|
298
|
+
]);
|
|
299
|
+
expect((await store.getPending(10)).map(idOf)).toEqual([idOf(events[1])]);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
167
302
|
describe("acknowledge", () => {
|
|
168
303
|
it("removes the event from pending", async () => {
|
|
169
304
|
const [e] = await seed(1);
|