@mettlecast/domain-cdk-packer 0.2.60 → 0.2.62

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.
Files changed (30) hide show
  1. package/dist/DomainStack.d.ts +5 -0
  2. package/dist/DomainStack.js +145 -67
  3. package/dist/__tests__/action-construct.test.d.ts +1 -0
  4. package/dist/__tests__/action-construct.test.js +159 -0
  5. package/dist/__tests__/domain-stack.test.js +315 -15
  6. package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.d.ts +11 -0
  7. package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.js +47 -0
  8. package/dist/__tests__/grouped-lambda-factory.test.d.ts +1 -0
  9. package/dist/__tests__/grouped-lambda-factory.test.js +146 -0
  10. package/dist/__tests__/lambda-factory.test.js +7 -7
  11. package/dist/__tests__/registry.test.js +111 -4
  12. package/dist/__tests__/security-assertion-aspect.test.d.ts +1 -0
  13. package/dist/__tests__/security-assertion-aspect.test.js +222 -0
  14. package/dist/aspects/index.d.ts +4 -0
  15. package/dist/aspects/index.js +2 -0
  16. package/dist/aspects/security-assertion-aspect.d.ts +161 -0
  17. package/dist/aspects/security-assertion-aspect.js +226 -0
  18. package/dist/constructs/action-construct.d.ts +16 -6
  19. package/dist/constructs/action-construct.js +10 -11
  20. package/dist/constructs/api-construct.d.ts +10 -2
  21. package/dist/constructs/api-construct.js +25 -5
  22. package/dist/grouped-lambda-factory.d.ts +69 -1
  23. package/dist/grouped-lambda-factory.js +98 -10
  24. package/dist/iam/iam-policy-builder.js +0 -2
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +1 -1
  27. package/dist/pack-domain.d.ts +2 -0
  28. package/dist/pack-domain.js +9 -3
  29. package/dist/registry.d.ts +98 -47
  30. package/package.json +1 -1
@@ -5,31 +5,61 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { DomainStack } from '../DomainStack.js';
7
7
  const DOMAIN_ROOT = path.join(process.cwd(), '.test-domain-cdk-packer');
8
+ // Wave 7 Task 7.3 (#4619): removed the runtime-export probe that used to
9
+ // gate the action-exposure block via `describe.skipIf(...)`. The probe
10
+ // returned `false` whenever the workspace-resolved `@mettlecast/domain-runtime`
11
+ // package lagged behind the integration branch, which silently masked
12
+ // regressions in the route/wrapper wiring that this block exists to catch.
13
+ //
14
+ // Coverage for the adapter-selection logic itself moved to
15
+ // `grouped-lambda-factory.test.ts` (pure, no CDK / esbuild, no dependency
16
+ // on the published runtime state). This file still exercises the
17
+ // end-to-end CDK wiring — route creation, JWT authorizer attachment, and
18
+ // the "no route for internal actions" guard — so the integration-level
19
+ // regressions the probe used to hide are now caught by the narrow unit
20
+ // tests instead.
8
21
  const minimalRegistry = {
9
22
  schemaVersion: '1',
10
23
  domainRoot: DOMAIN_ROOT,
11
24
  domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
12
- apis: [
25
+ // Issue #4689: the canonical HTTP endpoint surface is actions[] with
26
+ // exposure.type === 'api'. The minimal registry now declares its
27
+ // single test endpoint as an action; apis is always empty.
28
+ webhooks: [],
29
+ subscribers: [],
30
+ schedules: [],
31
+ jobs: [],
32
+ actions: [
13
33
  {
14
34
  id: 'get-users',
15
- kind: 'api',
35
+ kind: 'action',
16
36
  handlerFile: 'src/handlers/get-users.ts',
17
- path: '/users',
18
- method: 'GET',
19
- authType: 'jwt',
37
+ backendAccess: 'domain',
38
+ exposure: {
39
+ type: 'api',
40
+ path: '/v1/tenants/{tenantId}/users',
41
+ method: 'GET',
42
+ auth: 'required',
43
+ tenancy: 'required',
44
+ },
45
+ idempotent: false,
20
46
  },
21
47
  ],
22
- webhooks: [],
23
- subscribers: [],
24
- schedules: [],
25
- jobs: [],
26
- actions: [],
27
48
  integrations: [],
28
49
  events: [],
29
50
  };
30
51
  const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
31
52
  fs.mkdirSync(handlerDir, { recursive: true });
32
- fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), 'export const getUsers = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n');
53
+ // Match the grouped-lambda-factory's export-name conversion: kebab-case
54
+ // ids map to camelCase export names (e.g. `get-users` → `getUsers`).
55
+ const toExportName = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
56
+ const handlerStub = (id, method, path, auth = 'required', securityException) => `export const ${toExportName(id)} = { id: ${JSON.stringify(id)}, backendAccess: "domain", exposure: { type: "api", path: ${JSON.stringify(path)}, method: ${JSON.stringify(method)}, auth: ${JSON.stringify(auth)}, tenancy: "required"${securityException ? `, securityException: { reason: ${JSON.stringify(securityException)} }` : ''} }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n`;
57
+ fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), handlerStub('get-users', 'GET', '/v1/tenants/{tenantId}/users'));
58
+ fs.writeFileSync(path.join(handlerDir, 'create-user.ts'), handlerStub('create-user', 'POST', '/v1/tenants/{tenantId}/users'));
59
+ fs.writeFileSync(path.join(handlerDir, 'replace-user.ts'), handlerStub('replace-user', 'PUT', '/v1/tenants/{tenantId}/users/{id}'));
60
+ fs.writeFileSync(path.join(handlerDir, 'patch-user.ts'), handlerStub('patch-user', 'PATCH', '/v1/tenants/{tenantId}/users/{id}'));
61
+ fs.writeFileSync(path.join(handlerDir, 'delete-user.ts'), handlerStub('delete-user', 'DELETE', '/v1/tenants/{tenantId}/users/{id}'));
62
+ fs.writeFileSync(path.join(handlerDir, 'list-things.ts'), handlerStub('list-things', 'GET', '/v1/tenants/{tenantId}/things', 'none', 'public read-only listing'));
33
63
  afterAll(() => {
34
64
  fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
35
65
  });
@@ -66,14 +96,22 @@ describe('DomainStack', () => {
66
96
  AuthorizerType: 'JWT',
67
97
  });
68
98
  });
69
- it('no authorizer attached when authType is none', () => {
99
+ it('no authorizer attached when no JWT-protected actions are declared', () => {
70
100
  const app = new cdk.App();
71
101
  const registryNoAuth = {
72
102
  ...minimalRegistry,
73
- apis: [
103
+ // Replace the JWT action with an auth=none + securityException
104
+ // action so the authorizer is not required.
105
+ actions: [
74
106
  {
75
- ...minimalRegistry.apis[0],
76
- authType: 'none',
107
+ id: 'list-things', kind: 'action', handlerFile: 'src/handlers/list-things.ts',
108
+ backendAccess: 'domain',
109
+ exposure: {
110
+ type: 'api', path: '/v1/tenants/{tenantId}/things', method: 'GET',
111
+ auth: 'none', tenancy: 'required',
112
+ securityException: { reason: 'public read-only listing' },
113
+ },
114
+ idempotent: false,
77
115
  },
78
116
  ],
79
117
  };
@@ -169,4 +207,266 @@ describe('DomainStack', () => {
169
207
  template.hasOutput('DomainTenantScopedRoleArn', {});
170
208
  });
171
209
  });
210
+ /**
211
+ * Wave 4 Task 4.1 — fixes the route key bug where every non-GET method was
212
+ * silently coerced to "POST" and the JWT authorizer was never attached
213
+ * because the HttpJwtAuthorizer was never bound to a route.
214
+ */
215
+ describe('route keys and JWT authorizer wiring (#4619)', () => {
216
+ const userPoolArn = 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz';
217
+ const userPoolClientId = 'test-client-id';
218
+ /**
219
+ * Build a registry whose API entries cover all HTTP method variants the
220
+ * route key bug previously broke. Each entry uses `authType: 'jwt'` so
221
+ * every method exercises the new JWT-wiring path through `addRouteToApi`.
222
+ */
223
+ const multiMethodRegistry = {
224
+ ...minimalRegistry,
225
+ actions: [
226
+ {
227
+ id: 'get-users', kind: 'action', handlerFile: 'src/handlers/get-users.ts',
228
+ backendAccess: 'domain',
229
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
230
+ idempotent: false,
231
+ },
232
+ {
233
+ id: 'create-user', kind: 'action', handlerFile: 'src/handlers/create-user.ts',
234
+ backendAccess: 'domain',
235
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'POST', auth: 'required', tenancy: 'required' },
236
+ idempotent: false,
237
+ },
238
+ {
239
+ id: 'replace-user', kind: 'action', handlerFile: 'src/handlers/replace-user.ts',
240
+ backendAccess: 'domain',
241
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users/{id}', method: 'PUT', auth: 'required', tenancy: 'required' },
242
+ idempotent: false,
243
+ },
244
+ {
245
+ id: 'patch-user', kind: 'action', handlerFile: 'src/handlers/patch-user.ts',
246
+ backendAccess: 'domain',
247
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users/{id}', method: 'PATCH', auth: 'required', tenancy: 'required' },
248
+ idempotent: false,
249
+ },
250
+ {
251
+ id: 'delete-user', kind: 'action', handlerFile: 'src/handlers/delete-user.ts',
252
+ backendAccess: 'domain',
253
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users/{id}', method: 'DELETE', auth: 'required', tenancy: 'required' },
254
+ idempotent: false,
255
+ },
256
+ {
257
+ id: 'list-things', kind: 'action', handlerFile: 'src/handlers/list-things.ts',
258
+ backendAccess: 'domain',
259
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/things', method: 'GET', auth: 'none', tenancy: 'required', securityException: { reason: 'public read-only listing' } },
260
+ idempotent: false,
261
+ },
262
+ ],
263
+ };
264
+ it('uses the actual HTTP method for each route key (GET/POST/PUT/PATCH/DELETE)', () => {
265
+ const app = new cdk.App();
266
+ const stack = new DomainStack(app, 'TestDomainStackMethods', {
267
+ registry: multiMethodRegistry,
268
+ eventBusArn,
269
+ userPoolArn,
270
+ userPoolClientId,
271
+ });
272
+ const template = Template.fromStack(stack);
273
+ const routes = template.findResources('AWS::ApiGatewayV2::Route');
274
+ const routeKeys = Object.values(routes).map(r => r.Properties.RouteKey);
275
+ // Every method must round-trip — the previous implementation coerced all
276
+ // non-GET methods to POST, so PUT/PATCH/DELETE were broken.
277
+ // Issue #4689: routes now use the action's exposure.path (which
278
+ // already includes `/v1/tenants/{tenantId}/`) and the canonical
279
+ // tenant placeholder.
280
+ expect(routeKeys).toContain('GET /test-domain/v1/tenants/{tenantId}/users');
281
+ expect(routeKeys).toContain('POST /test-domain/v1/tenants/{tenantId}/users');
282
+ expect(routeKeys).toContain('PUT /test-domain/v1/tenants/{tenantId}/users/{id}');
283
+ expect(routeKeys).toContain('PATCH /test-domain/v1/tenants/{tenantId}/users/{id}');
284
+ expect(routeKeys).toContain('DELETE /test-domain/v1/tenants/{tenantId}/users/{id}');
285
+ });
286
+ it('sets AuthorizationType=JWT and AuthorizerId on jwt-auth routes', () => {
287
+ const app = new cdk.App();
288
+ const stack = new DomainStack(app, 'TestDomainStackRouteAuth', {
289
+ registry: multiMethodRegistry,
290
+ eventBusArn,
291
+ userPoolArn,
292
+ userPoolClientId,
293
+ });
294
+ const template = Template.fromStack(stack);
295
+ const routes = template.findResources('AWS::ApiGatewayV2::Route');
296
+ const jwtRoutes = Object.values(routes).filter(r => {
297
+ const key = r.Properties.RouteKey;
298
+ // Issue #4689: paths now include the tenant placeholder.
299
+ return key.includes('/users') && !key.includes('Health') && !key.includes('Ready');
300
+ });
301
+ // Every jwt-auth route must carry AuthorizationType and AuthorizerId.
302
+ expect(jwtRoutes.length).toBeGreaterThan(0);
303
+ for (const r of jwtRoutes) {
304
+ expect(r.Properties.AuthorizationType).toBe('JWT');
305
+ const authorizerId = r.Properties.AuthorizerId;
306
+ expect(authorizerId).toBeDefined();
307
+ }
308
+ });
309
+ it('does not attach authorizer to non-jwt routes (auth=none)', () => {
310
+ const app = new cdk.App();
311
+ const stack = new DomainStack(app, 'TestDomainStackUnauthRoute', {
312
+ registry: multiMethodRegistry,
313
+ eventBusArn,
314
+ userPoolArn,
315
+ userPoolClientId,
316
+ });
317
+ const template = Template.fromStack(stack);
318
+ const routes = template.findResources('AWS::ApiGatewayV2::Route');
319
+ const unauthRoute = Object.values(routes).find(r => r.Properties.RouteKey === 'GET /test-domain/v1/tenants/{tenantId}/things');
320
+ expect(unauthRoute).toBeDefined();
321
+ expect(unauthRoute.Properties.AuthorizationType).toBeUndefined();
322
+ expect(unauthRoute.Properties.AuthorizerId).toBeUndefined();
323
+ });
324
+ it('emits a synth-time error annotation when JWT route is declared without Cognito config', () => {
325
+ const app = new cdk.App();
326
+ const stack = new DomainStack(app, 'TestDomainStackMissingPool', {
327
+ registry: multiMethodRegistry,
328
+ eventBusArn,
329
+ // No userPoolArn / userPoolId / userPoolClientId provided
330
+ });
331
+ // `Annotations.of(stack).errors` is not exposed; read the construct's
332
+ // metadata directly and filter for error-level entries.
333
+ const metadata = stack.node.metadata;
334
+ const errorMessages = metadata
335
+ .filter(m => m.type === 'aws:cdk:error')
336
+ .map(m => (typeof m.data === 'string' ? m.data : JSON.stringify(m.data)));
337
+ expect(errorMessages.some(a => a.includes('JWT-protected routes') && a.includes('Cognito'))).toBe(true);
338
+ });
339
+ it('does NOT emit the missing-pool error when no JWT-protected actions are present', () => {
340
+ const app = new cdk.App();
341
+ const noJwtRegistry = {
342
+ ...minimalRegistry,
343
+ // Replace the JWT-protected action with an auth=none + securityException
344
+ // action so the JWT-needs-pool check does not fire.
345
+ actions: [
346
+ {
347
+ id: 'list-things', kind: 'action', handlerFile: 'src/handlers/list-things.ts',
348
+ backendAccess: 'domain',
349
+ exposure: {
350
+ type: 'api', path: '/v1/tenants/{tenantId}/things', method: 'GET',
351
+ auth: 'none', tenancy: 'required',
352
+ securityException: { reason: 'public read-only listing' },
353
+ },
354
+ idempotent: false,
355
+ },
356
+ ],
357
+ };
358
+ const stack = new DomainStack(app, 'TestDomainStackNoJwtNeeded', {
359
+ registry: noJwtRegistry,
360
+ eventBusArn,
361
+ });
362
+ const metadata = stack.node.metadata;
363
+ const errorMessages = metadata
364
+ .filter(m => m.type === 'aws:cdk:error')
365
+ .map(m => (typeof m.data === 'string' ? m.data : JSON.stringify(m.data)));
366
+ expect(errorMessages.some(a => a.includes('JWT-protected routes'))).toBe(false);
367
+ });
368
+ });
369
+ /**
370
+ * Wave 4 Task 4.1 — generate API Gateway routes for actions whose
371
+ * `exposure.type === 'api'` and skip `exposure.type === 'internal'`.
372
+ *
373
+ * Wave 7 Task 7.3 (#4619) removed the runtime-export probe and the
374
+ * `describe.skipIf(...)` wrapper. The route-level assertions still need
375
+ * a full CDK synth so the L1 `CfnRoute` resources can be inspected, but
376
+ * adapter-selection coverage (the part most likely to regress when the
377
+ * runtime barrel changes) lives in `grouped-lambda-factory.test.ts`
378
+ * where it runs as a pure unit test in milliseconds. If the runtime
379
+ * barrel ever drops an export we expect to see this block fail with a
380
+ * bundling error rather than be silently skipped.
381
+ */
382
+ describe('action exposure routing (#4619)', () => {
383
+ const actionHandlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
384
+ fs.mkdirSync(actionHandlerDir, { recursive: true });
385
+ fs.writeFileSync(path.join(actionHandlerDir, 'list-invoices.ts'), 'export const listInvoices = { id: "list-invoices", backendAccess: "domain", exposure: { type: "api", path: "/v1/tenants/{tenantId}/invoices", method: "GET", auth: "required", tenancy: "required" }, input: { parse: (x: unknown) => x }, output: { parse: (x: unknown) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n');
386
+ fs.writeFileSync(path.join(actionHandlerDir, 'charge-card.ts'), 'export const chargeCard = { id: "charge-card", backendAccess: "domain", exposure: { type: "api", path: "/v1/tenants/{tenantId}/charge", method: "POST", auth: "required", tenancy: "required" }, input: { parse: (x: unknown) => x }, output: { parse: (x: unknown) => x }, idempotent: true, handler: async () => ({ ok: true }) };\n');
387
+ fs.writeFileSync(path.join(actionHandlerDir, 'internal-helper.ts'), 'export const internalHelper = { id: "internal-helper", backendAccess: "private", exposure: { type: "internal" }, input: { parse: (x: unknown) => x }, output: { parse: (x: unknown) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n');
388
+ const registryWithActions = {
389
+ ...minimalRegistry,
390
+ actions: [
391
+ {
392
+ id: 'list-invoices',
393
+ kind: 'action',
394
+ handlerFile: 'src/handlers/list-invoices.ts',
395
+ backendAccess: 'domain',
396
+ exposure: {
397
+ type: 'api',
398
+ path: '/v1/tenants/{tenantId}/invoices',
399
+ method: 'GET',
400
+ auth: 'required',
401
+ tenancy: 'required',
402
+ },
403
+ idempotent: false,
404
+ },
405
+ {
406
+ id: 'charge-card',
407
+ kind: 'action',
408
+ handlerFile: 'src/handlers/charge-card.ts',
409
+ backendAccess: 'domain',
410
+ exposure: {
411
+ type: 'api',
412
+ path: '/v1/tenants/{tenantId}/charge',
413
+ method: 'POST',
414
+ auth: 'required',
415
+ tenancy: 'required',
416
+ },
417
+ idempotent: true,
418
+ },
419
+ {
420
+ id: 'internal-helper',
421
+ kind: 'action',
422
+ handlerFile: 'src/handlers/internal-helper.ts',
423
+ backendAccess: 'private',
424
+ exposure: { type: 'internal' },
425
+ idempotent: false,
426
+ },
427
+ ],
428
+ };
429
+ it('synthesises routes for API-exposed actions', () => {
430
+ const app = new cdk.App();
431
+ const stack = new DomainStack(app, 'TestDomainStackActionRoutes', {
432
+ registry: registryWithActions,
433
+ eventBusArn,
434
+ userPoolArn: 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz',
435
+ userPoolClientId: 'test-client-id',
436
+ });
437
+ const template = Template.fromStack(stack);
438
+ const routes = template.findResources('AWS::ApiGatewayV2::Route');
439
+ const routeKeys = Object.values(routes).map(r => r.Properties.RouteKey);
440
+ expect(routeKeys).toContain('GET /test-domain/v1/tenants/{tenantId}/invoices');
441
+ expect(routeKeys).toContain('POST /test-domain/v1/tenants/{tenantId}/charge');
442
+ });
443
+ it('does NOT create a route for internal exposure actions', () => {
444
+ const app = new cdk.App();
445
+ const stack = new DomainStack(app, 'TestDomainStackInternalOnly', {
446
+ registry: registryWithActions,
447
+ eventBusArn,
448
+ userPoolArn: 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz',
449
+ userPoolClientId: 'test-client-id',
450
+ });
451
+ const template = Template.fromStack(stack);
452
+ const routes = template.findResources('AWS::ApiGatewayV2::Route');
453
+ const routeKeys = Object.values(routes).map(r => r.Properties.RouteKey);
454
+ expect(routeKeys.some(k => k.includes('internal-helper'))).toBe(false);
455
+ });
456
+ it('attaches JWT authorizer to API-exposed action routes when auth=required', () => {
457
+ const app = new cdk.App();
458
+ const stack = new DomainStack(app, 'TestDomainStackActionJwt', {
459
+ registry: registryWithActions,
460
+ eventBusArn,
461
+ userPoolArn: 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz',
462
+ userPoolClientId: 'test-client-id',
463
+ });
464
+ const template = Template.fromStack(stack);
465
+ const routes = template.findResources('AWS::ApiGatewayV2::Route');
466
+ const actionRoute = Object.values(routes).find(r => r.Properties.RouteKey === 'POST /test-domain/v1/tenants/{tenantId}/charge');
467
+ expect(actionRoute).toBeDefined();
468
+ expect(actionRoute.Properties.AuthorizationType).toBe('JWT');
469
+ expect(actionRoute.Properties.AuthorizerId).toBeDefined();
470
+ });
471
+ });
172
472
  });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Wave 7 Task 7.1 (#4619) — generated dedicated Lambda wrapper tests.
3
+ *
4
+ * The dedicated-mode bundler writes an entry module that wires a domain action
5
+ * export into a runtime adapter. For action adapters the wiring is non-trivial:
6
+ * `createActionLambdaHandler` takes `(registry, options)` and
7
+ * `createExposedActionApiHandler` takes `(action, options)`. These assertions
8
+ * use the pure entry-content helper so they do not race with other tests that
9
+ * also write `.tib-domain-entries` artifacts.
10
+ */
11
+ export {};
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Wave 7 Task 7.1 (#4619) — generated dedicated Lambda wrapper tests.
3
+ *
4
+ * The dedicated-mode bundler writes an entry module that wires a domain action
5
+ * export into a runtime adapter. For action adapters the wiring is non-trivial:
6
+ * `createActionLambdaHandler` takes `(registry, options)` and
7
+ * `createExposedActionApiHandler` takes `(action, options)`. These assertions
8
+ * use the pure entry-content helper so they do not race with other tests that
9
+ * also write `.tib-domain-entries` artifacts.
10
+ */
11
+ import { describe, it, expect } from 'vitest';
12
+ import { buildDedicatedEntryContent } from '../grouped-lambda-factory.js';
13
+ const DOMAIN_ID = 'billing';
14
+ function render(entry, primitiveType = 'action') {
15
+ return buildDedicatedEntryContent('./handler.js', entry, primitiveType, DOMAIN_ID);
16
+ }
17
+ describe('grouped-lambda-factory — generated action wrapper (Wave 7 Task 7.1)', () => {
18
+ it('emits a createActionLambdaHandler(registry, options) wrapper for internal actions', () => {
19
+ const content = render({ id: 'internal-helper', handlerFile: 'src/actions/internal-helper.ts' });
20
+ expect(content).toMatch(/import\s*\{\s*createActionLambdaHandler\s*\}\s*from\s*['"]@mettlecast\/domain-runtime['"]/);
21
+ expect(content).toMatch(/const\s+registry\s*=\s*\{\s*"billing"\s*:\s*\{\s*"internal-helper"\s*:\s*internalHelper\s*\}\s*\}/);
22
+ expect(content).toMatch(/export\s+const\s+handler\s*=\s*createActionLambdaHandler\(\s*registry\s*,\s*\{/);
23
+ expect(content).toMatch(/domainId:\s*"billing"/);
24
+ expect(content).toMatch(/databaseUrl:\s*process\.env\.DATABASE_URL/);
25
+ expect(content).toMatch(/eventBusName:\s*process\.env\.EVENT_BUS_NAME/);
26
+ expect(content).toMatch(/idempotencyTableName:\s*process\.env\.IDEMPOTENCY_TABLE/);
27
+ });
28
+ it('emits a createExposedActionApiHandler(action, options) wrapper for API-exposed actions', () => {
29
+ const content = render({
30
+ id: 'public-charge',
31
+ handlerFile: 'src/actions/public-charge.ts',
32
+ adapter: 'createExposedActionApiHandler',
33
+ });
34
+ expect(content).toMatch(/import\s*\{\s*createExposedActionApiHandler\s*\}\s*from\s*['"]@mettlecast\/domain-runtime['"]/);
35
+ expect(content).toMatch(/const\s+actionRegistry\s*=\s*\{\s*"billing"\s*:\s*\{\s*"public-charge"\s*:\s*publicCharge\s*\}\s*\}/);
36
+ expect(content).toMatch(/export\s+const\s+handler\s*=\s*createExposedActionApiHandler\(\s*publicCharge\s*,\s*\{/);
37
+ expect(content).toMatch(/definingDomain:\s*"billing"/);
38
+ expect(content).toMatch(/domainId:\s*"billing"/);
39
+ expect(content).toMatch(/actionRegistry,/);
40
+ expect(content).toMatch(/callerDomainId:\s*"billing"/);
41
+ });
42
+ it('does NOT emit a bare `${adapter}(${exportName})` shape for any action adapter', () => {
43
+ const content = render({ id: 'another-internal', handlerFile: 'src/actions/another-internal.ts' });
44
+ expect(content).not.toMatch(/createActionLambdaHandler\(\s*anotherInternal\s*\)/);
45
+ expect(content).not.toMatch(/createExposedActionApiHandler\(\s*anotherInternal\s*\)/);
46
+ });
47
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildDedicatedEntryContent, ADAPTER_BY_PRIMITIVE, EXPOSED_ACTION_ADAPTER, } from '../grouped-lambda-factory.js';
3
+ /**
4
+ * Wave 7 Task 7.3 (#4619) — narrow, pure tests for the dedicated-mode
5
+ * Lambda entry-content generator.
6
+ *
7
+ * These tests pin the adapter-selection logic added in Wave 4 Task 4.1
8
+ * (API-exposed actions wrap with `createExposedActionApiHandler` while
9
+ * internal actions keep `createActionLambdaHandler`) WITHOUT spinning up
10
+ * CDK or esbuild. The previous version of `domain-stack.test.ts` skipped
11
+ * these assertions via a runtime-export probe because the installed
12
+ * `@mettlecast/domain-runtime` package sometimes lags behind the
13
+ * integration branch. Testing the source code we generate — not the
14
+ * downstream package state — removes that skip and the regression-masking
15
+ * risk that came with it.
16
+ *
17
+ * End-to-end route-creation / authorizer-attachment behaviour is still
18
+ * covered by `domain-stack.test.ts` (the action-exposure block at the
19
+ * bottom of that file), but the heavy assertions about WHICH adapter is
20
+ * wired into WHICH entry now live here where they run in milliseconds.
21
+ *
22
+ * Compatibility note (Task 7.1 overlap, #4619): the test surface here
23
+ * deliberately targets only the parts of `buildDedicatedEntryContent`
24
+ * that are stable across the Wave 7.1 wrapper fixes (which change the
25
+ * action-adapter call shape, not the adapter selection logic). When
26
+ * Task 7.1 lands, this file can grow assertions for the new wrappers
27
+ * without rewriting the existing ones.
28
+ */
29
+ describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
30
+ const DOMAIN_ID = 'billing';
31
+ const entry = (id, adapter) => ({
32
+ id,
33
+ handlerFile: `src/handlers/${id}.ts`,
34
+ ...(adapter !== undefined ? { adapter } : {}),
35
+ });
36
+ describe('adapter selection by primitive type (defaults)', () => {
37
+ const cases = [
38
+ { primitive: 'subscriber', expectedAdapter: 'createSubscriberLambdaHandler' },
39
+ { primitive: 'job', expectedAdapter: 'createJobLambdaHandler' },
40
+ { primitive: 'webhook', expectedAdapter: 'createWebhookLambdaHandler' },
41
+ { primitive: 'action', expectedAdapter: 'createActionLambdaHandler' },
42
+ ];
43
+ for (const { primitive, expectedAdapter } of cases) {
44
+ it(`imports the default adapter for primitive type "${primitive}"`, () => {
45
+ const content = buildDedicatedEntryContent('./handler.js', entry('list-things'), primitive, DOMAIN_ID);
46
+ expect(content).toContain(`import { ${expectedAdapter} } from '@mettlecast/domain-runtime';`);
47
+ if (primitive === 'action') {
48
+ expect(content).toContain(`const registry = { "${DOMAIN_ID}": { "list-things": listThings } };`);
49
+ expect(content).toContain('export const handler = createActionLambdaHandler(registry, {');
50
+ expect(content).toContain(`domainId: "${DOMAIN_ID}"`);
51
+ }
52
+ else {
53
+ expect(content).toContain(`export const handler = ${expectedAdapter}(listThings);`);
54
+ }
55
+ // Adapter map should agree with what is generated
56
+ expect(ADAPTER_BY_PRIMITIVE[primitive]).toBe(expectedAdapter);
57
+ });
58
+ }
59
+ it('schedule has no entry in ADAPTER_BY_PRIMITIVE (uses hydrateCtx envelope instead)', () => {
60
+ // Schedules intentionally fall through to the inline `hydrateCtx`
61
+ // wrapper path (see buildDedicatedEntryContent's schedule branch).
62
+ // Pinning that here guards against future refactors silently
63
+ // promoting schedules into the adapter-map path.
64
+ const keys = Object.keys(ADAPTER_BY_PRIMITIVE);
65
+ expect(keys).not.toContain('schedule');
66
+ });
67
+ });
68
+ describe('action adapter override (Wave 4 Task 4.1, #4619)', () => {
69
+ it('uses createExposedActionApiHandler when entry.adapter is the exposed-action adapter', () => {
70
+ const apiExposed = entry('list-invoices', EXPOSED_ACTION_ADAPTER);
71
+ const content = buildDedicatedEntryContent('./handler.js', apiExposed, 'action', DOMAIN_ID);
72
+ expect(content).toContain(`import { ${EXPOSED_ACTION_ADAPTER} } from '@mettlecast/domain-runtime';`);
73
+ expect(content).toContain(`const actionRegistry = { "${DOMAIN_ID}": { "list-invoices": listInvoices } };`);
74
+ expect(content).toContain(`export const handler = ${EXPOSED_ACTION_ADAPTER}(listInvoices, {`);
75
+ expect(content).toContain(`definingDomain: "${DOMAIN_ID}"`);
76
+ expect(content).toContain('actionRegistry,');
77
+ // Must NOT also import the envelope-based adapter — that would be
78
+ // dead code and risks accidental misuse if the adapter selection ever
79
+ // regresses.
80
+ expect(content).not.toContain('createActionLambdaHandler');
81
+ });
82
+ it('uses createActionLambdaHandler when no adapter override is supplied (internal action)', () => {
83
+ const internal = entry('internal-helper');
84
+ const content = buildDedicatedEntryContent('./handler.js', internal, 'action', DOMAIN_ID);
85
+ expect(content).toContain(`import { createActionLambdaHandler } from '@mettlecast/domain-runtime';`);
86
+ expect(content).toContain(`const registry = { "${DOMAIN_ID}": { "internal-helper": internalHelper } };`);
87
+ expect(content).toContain('export const handler = createActionLambdaHandler(registry, {');
88
+ expect(content).toContain(`domainId: "${DOMAIN_ID}"`);
89
+ // Must NOT use the exposed adapter for internal actions.
90
+ expect(content).not.toContain(EXPOSED_ACTION_ADAPTER);
91
+ });
92
+ it('per-entry adapter override takes precedence over the primitive-type default', () => {
93
+ // Even when an explicit adapter is supplied, the override wins.
94
+ const overridden = entry('charge-card', EXPOSED_ACTION_ADAPTER);
95
+ const content = buildDedicatedEntryContent('./handler.js', overridden, 'webhook', DOMAIN_ID);
96
+ expect(content).toContain(`import { ${EXPOSED_ACTION_ADAPTER} } from '@mettlecast/domain-runtime';`);
97
+ expect(content).not.toContain('createWebhookLambdaHandler');
98
+ });
99
+ });
100
+ describe('schedule envelope', () => {
101
+ it('uses hydrateCtx (no per-primitive adapter) for schedules', () => {
102
+ const schedule = entry('nightly-rollup');
103
+ const content = buildDedicatedEntryContent('./handler.js', schedule, 'schedule', DOMAIN_ID);
104
+ expect(content).toContain(`import { hydrateCtx } from '@mettlecast/domain-runtime';`);
105
+ expect(content).toContain('hydrateCtx(event, {');
106
+ expect(content).toContain('databaseUrl: process.env.DATABASE_URL');
107
+ expect(content).toContain('eventBusName: process.env.EVENT_BUS_NAME');
108
+ expect(content).toContain('try { await ctx.db.release(); } catch {}');
109
+ // Schedules should NOT use any of the Lambda-handler adapter factories.
110
+ expect(content).not.toMatch(/import \{ \w+LambdaHandler \}/);
111
+ });
112
+ it('does NOT honour entry.adapter for schedules — hydrateCtx is the only path', () => {
113
+ const schedule = entry('nightly-rollup', EXPOSED_ACTION_ADAPTER);
114
+ const content = buildDedicatedEntryContent('./handler.js', schedule, 'schedule', DOMAIN_ID);
115
+ expect(content).toContain(`import { hydrateCtx } from '@mettlecast/domain-runtime';`);
116
+ expect(content).not.toContain(EXPOSED_ACTION_ADAPTER);
117
+ });
118
+ });
119
+ describe('handler import path wiring', () => {
120
+ it('uses the caller-supplied relative import path verbatim', () => {
121
+ const e = entry('list-things');
122
+ const content = buildDedicatedEntryContent('../../../../src/handlers/list-things.ts', e, 'webhook', DOMAIN_ID);
123
+ expect(content).toContain(`import { listThings } from '../../../../src/handlers/list-things.ts';`);
124
+ });
125
+ it('converts kebab-case handler IDs to camelCase export names', () => {
126
+ const e = entry('list-invoices-for-tenant');
127
+ const content = buildDedicatedEntryContent('./handler.js', e, 'action', DOMAIN_ID);
128
+ expect(content).toMatch(/import \{ listInvoicesForTenant \} from/);
129
+ expect(content).toContain(`"list-invoices-for-tenant": listInvoicesForTenant`);
130
+ expect(content).toContain('createActionLambdaHandler(registry, {');
131
+ });
132
+ it('emits a single export const handler per entry', () => {
133
+ const e = entry('list-things');
134
+ const content = buildDedicatedEntryContent('./handler.js', e, 'webhook', DOMAIN_ID);
135
+ const matches = content.match(/^export const handler\b/gm) ?? [];
136
+ expect(matches.length).toBe(1);
137
+ });
138
+ });
139
+ describe('EXPOSED_ACTION_ADAPTER constant', () => {
140
+ it('matches the Wave 3 / Wave 4 exported name', () => {
141
+ // Pinning the literal value guards against silent rename during future
142
+ // refactors of the runtime barrel.
143
+ expect(EXPOSED_ACTION_ADAPTER).toBe('createExposedActionApiHandler');
144
+ });
145
+ });
146
+ });
@@ -5,12 +5,12 @@ import * as lambdaNode from 'aws-cdk-lib/aws-lambda-nodejs';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { LambdaFactory } from '../lambda-factory.js';
8
- const DOMAIN_ROOT = '/tmp/test-domain-cdk-packer';
8
+ const DOMAIN_ROOT = path.join(process.cwd(), '.tmp', 'test-domain-cdk-packer');
9
9
  const minimalRegistry = {
10
10
  schemaVersion: '1',
11
11
  domainRoot: DOMAIN_ROOT,
12
12
  domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
13
- apis: [], webhooks: [], subscribers: [], schedules: [], jobs: [], actions: [], integrations: [], events: [],
13
+ webhooks: [], subscribers: [], schedules: [], jobs: [], actions: [], integrations: [], events: [],
14
14
  };
15
15
  beforeAll(() => {
16
16
  const handlerDir = path.join(DOMAIN_ROOT, 'src');
@@ -26,7 +26,7 @@ describe('LambdaFactory', () => {
26
26
  const stack = new cdk.Stack(app, 'TestStack');
27
27
  const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
28
28
  expect(() => {
29
- factory.createFunction({ id: 'test-api', kind: 'api', handlerFile: 'src/api.ts' });
29
+ factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
30
30
  }).not.toThrow();
31
31
  });
32
32
  describe('bundling configuration', () => {
@@ -34,7 +34,7 @@ describe('LambdaFactory', () => {
34
34
  const app = new cdk.App();
35
35
  const stack = new cdk.Stack(app, 'TestStackArch');
36
36
  const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
37
- factory.createFunction({ id: 'test-api', kind: 'api', handlerFile: 'src/api.ts' });
37
+ factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
38
38
  const template = Template.fromStack(stack);
39
39
  template.hasResourceProperties('AWS::Lambda::Function', {
40
40
  Architectures: ['arm64'],
@@ -44,7 +44,7 @@ describe('LambdaFactory', () => {
44
44
  const app = new cdk.App();
45
45
  const stack = new cdk.Stack(app, 'TestStackRuntime');
46
46
  const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
47
- factory.createFunction({ id: 'test-api', kind: 'api', handlerFile: 'src/api.ts' });
47
+ factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
48
48
  const template = Template.fromStack(stack);
49
49
  template.hasResourceProperties('AWS::Lambda::Function', {
50
50
  Runtime: 'nodejs22.x',
@@ -54,7 +54,7 @@ describe('LambdaFactory', () => {
54
54
  const app = new cdk.App();
55
55
  const stack = new cdk.Stack(app, 'TestStackSourceMaps');
56
56
  const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
57
- factory.createFunction({ id: 'test-api', kind: 'api', handlerFile: 'src/api.ts' });
57
+ factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
58
58
  const template = Template.fromStack(stack);
59
59
  template.hasResourceProperties('AWS::Lambda::Function', {
60
60
  Environment: {
@@ -68,7 +68,7 @@ describe('LambdaFactory', () => {
68
68
  const app = new cdk.App();
69
69
  const stack = new cdk.Stack(app, 'TestStackDefaults');
70
70
  const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
71
- const fn = factory.createFunction({ id: 'test-api', kind: 'api', handlerFile: 'src/api.ts' });
71
+ const fn = factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
72
72
  expect(fn).toBeInstanceOf(lambdaNode.NodejsFunction);
73
73
  expect(fn.runtime?.name).toContain('nodejs22');
74
74
  });