@auto-engineer/server-generator-apollo-emmett 1.149.0 → 1.152.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.
Files changed (43) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +5 -5
  3. package/.turbo/turbo-type-check.log +1 -1
  4. package/CHANGELOG.md +96 -0
  5. package/dist/src/codegen/extract/type-helpers.d.ts.map +1 -1
  6. package/dist/src/codegen/extract/type-helpers.js +3 -2
  7. package/dist/src/codegen/extract/type-helpers.js.map +1 -1
  8. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  9. package/dist/src/codegen/scaffoldFromSchema.js +44 -6
  10. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  11. package/dist/src/codegen/templateHelpers.d.ts +1 -0
  12. package/dist/src/codegen/templateHelpers.d.ts.map +1 -1
  13. package/dist/src/codegen/templateHelpers.js +12 -0
  14. package/dist/src/codegen/templateHelpers.js.map +1 -1
  15. package/dist/src/codegen/templates/command/decide.specs.specs.ts +688 -11
  16. package/dist/src/codegen/templates/command/decide.specs.ts.ejs +90 -3
  17. package/dist/src/codegen/templates/query/projection.specs.specs.ts +6 -3
  18. package/dist/src/codegen/templates/query/projection.specs.ts.ejs +3 -8
  19. package/dist/src/codegen/templates/query/query.resolver.specs.ts +63 -0
  20. package/dist/src/codegen/templates/query/query.resolver.ts.ejs +8 -2
  21. package/dist/src/codegen/templates/react/react.specs.ts.ejs +19 -5
  22. package/dist/src/codegen/templates/react/react.ts.ejs +71 -19
  23. package/dist/src/codegen/templates/react/react.ts.specs.ts +139 -0
  24. package/dist/src/codegen/templates/react/register.ts.ejs +34 -9
  25. package/dist/tsconfig.tsbuildinfo +1 -1
  26. package/ketchup-plan.md +5 -5
  27. package/package.json +4 -4
  28. package/src/codegen/extract/type-helpers.specs.ts +21 -0
  29. package/src/codegen/extract/type-helpers.ts +3 -2
  30. package/src/codegen/scaffoldFromSchema.filter.specs.ts +110 -1
  31. package/src/codegen/scaffoldFromSchema.ts +47 -8
  32. package/src/codegen/templateHelpers.specs.ts +21 -1
  33. package/src/codegen/templateHelpers.ts +9 -0
  34. package/src/codegen/templates/command/decide.specs.specs.ts +688 -11
  35. package/src/codegen/templates/command/decide.specs.ts.ejs +90 -3
  36. package/src/codegen/templates/query/projection.specs.specs.ts +6 -3
  37. package/src/codegen/templates/query/projection.specs.ts.ejs +3 -8
  38. package/src/codegen/templates/query/query.resolver.specs.ts +63 -0
  39. package/src/codegen/templates/query/query.resolver.ts.ejs +8 -2
  40. package/src/codegen/templates/react/react.specs.ts.ejs +19 -5
  41. package/src/codegen/templates/react/react.ts.ejs +71 -19
  42. package/src/codegen/templates/react/react.ts.specs.ts +139 -0
  43. package/src/codegen/templates/react/register.ts.ejs +34 -9
@@ -248,7 +248,7 @@ describe('spec.ts.ejs', () => {
248
248
  const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
249
249
 
250
250
  expect(specFile?.contents).toMatchInlineSnapshot(`
251
- "import { describe, it } from 'vitest';
251
+ "import { describe, expect, it } from 'vitest';
252
252
  import { DeciderSpecification } from '@event-driven-io/emmett';
253
253
  import { decide } from './decide';
254
254
  import { evolve } from './evolve';
@@ -297,6 +297,41 @@ describe('spec.ts.ejs', () => {
297
297
  );
298
298
  });
299
299
  });
300
+
301
+ describe('field completeness', () => {
302
+ it('should include all required fields in ListingRemoved', () => {
303
+ const state = [
304
+ {
305
+ type: 'ListingCreated' as const,
306
+ data: {
307
+ propertyId: 'listing_123',
308
+ listedAt: new Date('2024-01-15T10:00:00Z'),
309
+ rating: 4.8,
310
+ metadata: { foo: 'bar' },
311
+ },
312
+ },
313
+ ].reduce((s, e) => evolve(s, e), initialState());
314
+ const result = decide(
315
+ {
316
+ type: 'RemoveListing',
317
+ data: {
318
+ propertyId: 'listing_123',
319
+ },
320
+ metadata: { now: new Date() },
321
+ },
322
+ state,
323
+ );
324
+ const evtArray = Array.isArray(result) ? result : [result];
325
+
326
+ expect(evtArray[0]).toMatchObject({
327
+ type: 'ListingRemoved',
328
+ data: expect.objectContaining({
329
+ propertyId: expect.any(String),
330
+ removedAt: expect.any(Date),
331
+ }),
332
+ });
333
+ });
334
+ });
300
335
  "
301
336
  `);
302
337
  });
@@ -443,7 +478,7 @@ describe('spec.ts.ejs', () => {
443
478
  const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
444
479
 
445
480
  expect(specFile?.contents).toMatchInlineSnapshot(`
446
- "import { describe, it } from 'vitest';
481
+ "import { describe, expect, it } from 'vitest';
447
482
  import { DeciderSpecification } from '@event-driven-io/emmett';
448
483
  import { decide } from './decide';
449
484
  import { evolve } from './evolve';
@@ -521,6 +556,47 @@ describe('spec.ts.ejs', () => {
521
556
  );
522
557
  });
523
558
  });
559
+
560
+ describe('field completeness', () => {
561
+ it('should include all required fields in QuestionAnswered, QuestionnaireEditRejected', () => {
562
+ const state = initialState();
563
+ const result = decide(
564
+ {
565
+ type: 'AnswerQuestion',
566
+ data: {
567
+ questionnaireId: 'q-001',
568
+ participantId: 'participant-abc',
569
+ questionId: 'q1',
570
+ answer: 'Yes',
571
+ },
572
+ metadata: { now: new Date() },
573
+ },
574
+ state,
575
+ );
576
+ const evtArray = Array.isArray(result) ? result : [result];
577
+
578
+ expect(evtArray[0]).toMatchObject({
579
+ type: 'QuestionAnswered',
580
+ data: expect.objectContaining({
581
+ questionnaireId: expect.any(String),
582
+ participantId: expect.any(String),
583
+ questionId: expect.any(String),
584
+ answer: expect.any(Object),
585
+ savedAt: expect.any(Date),
586
+ }),
587
+ });
588
+
589
+ expect(evtArray[1]).toMatchObject({
590
+ type: 'QuestionnaireEditRejected',
591
+ data: expect.objectContaining({
592
+ questionnaireId: expect.any(String),
593
+ participantId: expect.any(String),
594
+ reason: expect.any(String),
595
+ attemptedAt: expect.any(Date),
596
+ }),
597
+ });
598
+ });
599
+ });
524
600
  "
525
601
  `);
526
602
  });
@@ -706,7 +782,7 @@ describe('spec.ts.ejs', () => {
706
782
  const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
707
783
 
708
784
  expect(specFile?.contents).toMatchInlineSnapshot(`
709
- "import { describe, it } from 'vitest';
785
+ "import { describe, expect, it } from 'vitest';
710
786
  import { DeciderSpecification } from '@event-driven-io/emmett';
711
787
  import { decide } from './decide';
712
788
  import { evolve } from './evolve';
@@ -754,6 +830,40 @@ describe('spec.ts.ejs', () => {
754
830
  );
755
831
  });
756
832
  });
833
+
834
+ describe('field completeness', () => {
835
+ it('should include all required fields in StatsUpdated, WorkoutLogged', () => {
836
+ const state = initialState();
837
+ const result = decide(
838
+ {
839
+ type: 'SubmitWorkout',
840
+ data: {
841
+ workoutId: 'w1',
842
+ exercise: 'squat',
843
+ },
844
+ metadata: { now: new Date() },
845
+ },
846
+ state,
847
+ );
848
+ const evtArray = Array.isArray(result) ? result : [result];
849
+
850
+ expect(evtArray[0]).toMatchObject({
851
+ type: 'StatsUpdated',
852
+ data: expect.objectContaining({
853
+ workoutId: expect.any(String),
854
+ count: expect.any(Number),
855
+ }),
856
+ });
857
+
858
+ expect(evtArray[1]).toMatchObject({
859
+ type: 'WorkoutLogged',
860
+ data: expect.objectContaining({
861
+ workoutId: expect.any(String),
862
+ loggedAt: expect.any(Date),
863
+ }),
864
+ });
865
+ });
866
+ });
757
867
  "
758
868
  `);
759
869
  });
@@ -1215,14 +1325,19 @@ describe('spec.ts.ejs', () => {
1215
1325
  const { plans } = await generateScaffoldFilePlans(spec.scenes, spec.messages, undefined, 'src/domain/narratives');
1216
1326
  const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
1217
1327
 
1218
- expect(specFile?.contents).not.toContain('workoutId:');
1219
- expect(specFile?.contents).toContain("memberId: 'mem_001'");
1220
- expect(specFile?.contents).toContain("date: '2024-01-15'");
1221
- expect(specFile?.contents).toContain("exercises: ['bench press']");
1222
- expect(specFile?.contents).toContain('const expectEvents');
1223
- expect(specFile?.contents).toContain('events as Events[]');
1224
- expect(specFile?.contents).toContain('.then(\n');
1225
- expect(specFile?.contents).toContain('expectEvents(');
1328
+ const contents = specFile?.contents ?? '';
1329
+ // workoutId should NOT appear in the DeciderSpecification expectEvents block
1330
+ const deciderSection = contents.split("describe('field completeness'")[0];
1331
+ expect(deciderSection).not.toContain('workoutId:');
1332
+ expect(contents).toContain("memberId: 'mem_001'");
1333
+ expect(contents).toContain("date: '2024-01-15'");
1334
+ expect(contents).toContain("exercises: ['bench press']");
1335
+ expect(contents).toContain('const expectEvents');
1336
+ expect(contents).toContain('events as Events[]');
1337
+ expect(contents).toContain('.then(\n');
1338
+ expect(contents).toContain('expectEvents(');
1339
+ // workoutId is not traceable to any Given event, so uses expect.any()
1340
+ expect(contents).toContain('workoutId: expect.any(String)');
1226
1341
  });
1227
1342
 
1228
1343
  it('should keep non-command fields whose key+value match a Given event', async () => {
@@ -1388,6 +1503,166 @@ describe('spec.ts.ejs', () => {
1388
1503
  expect(decideFile?.contents).not.toContain('`NotFoundError`');
1389
1504
  });
1390
1505
 
1506
+ it('should exclude state-only-traceable fields from main test then block', async () => {
1507
+ const spec: SpecsSchema = {
1508
+ variant: 'specs',
1509
+ scenes: [
1510
+ {
1511
+ name: 'Exercise scene',
1512
+ moments: [
1513
+ {
1514
+ type: 'command',
1515
+ name: 'Log exercise',
1516
+ stream: 'exercise',
1517
+ client: { specs: [] },
1518
+ server: {
1519
+ description: '',
1520
+ specs: [
1521
+ {
1522
+ type: 'gherkin',
1523
+ feature: 'Log exercise',
1524
+ rules: [
1525
+ {
1526
+ name: 'Should log exercise',
1527
+ examples: [
1528
+ {
1529
+ name: 'Exercise logged from draft',
1530
+ steps: [
1531
+ {
1532
+ keyword: 'Given',
1533
+ text: 'ExerciseDraft',
1534
+ docString: { id: 'e1', userId: 'u1' },
1535
+ },
1536
+ {
1537
+ keyword: 'When',
1538
+ text: 'LogExercise',
1539
+ docString: { sets: 3, reps: 10 },
1540
+ },
1541
+ {
1542
+ keyword: 'Then',
1543
+ text: 'ExerciseLogged',
1544
+ docString: { id: 'e1', userId: 'u1', sets: 3, reps: 10 },
1545
+ },
1546
+ ],
1547
+ },
1548
+ ],
1549
+ },
1550
+ ],
1551
+ },
1552
+ ],
1553
+ },
1554
+ },
1555
+ ],
1556
+ },
1557
+ ],
1558
+ messages: [
1559
+ {
1560
+ type: 'state',
1561
+ name: 'ExerciseDraft',
1562
+ fields: [
1563
+ { name: 'id', type: 'string', required: true },
1564
+ { name: 'userId', type: 'string', required: true },
1565
+ ],
1566
+ },
1567
+ {
1568
+ type: 'command',
1569
+ name: 'LogExercise',
1570
+ fields: [
1571
+ { name: 'sets', type: 'number', required: true },
1572
+ { name: 'reps', type: 'number', required: true },
1573
+ ],
1574
+ },
1575
+ {
1576
+ type: 'event',
1577
+ name: 'ExerciseLogged',
1578
+ source: 'internal',
1579
+ fields: [
1580
+ { name: 'id', type: 'string', required: true },
1581
+ { name: 'userId', type: 'string', required: true },
1582
+ { name: 'sets', type: 'number', required: true },
1583
+ { name: 'reps', type: 'number', required: true },
1584
+ ],
1585
+ },
1586
+ ],
1587
+ };
1588
+
1589
+ const { plans } = await generateScaffoldFilePlans(spec.scenes, spec.messages, undefined, 'src/domain/narratives');
1590
+ const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
1591
+
1592
+ expect(specFile?.contents).toMatchInlineSnapshot(`
1593
+ "import { describe, expect, it } from 'vitest';
1594
+ import { DeciderSpecification } from '@event-driven-io/emmett';
1595
+ import { decide } from './decide';
1596
+ import { evolve } from './evolve';
1597
+ import { initialState, State } from './state';
1598
+ import type { ExerciseLogged } from './events';
1599
+ import type { LogExercise } from './commands';
1600
+
1601
+ describe('Should log exercise', () => {
1602
+ type Events = ExerciseLogged;
1603
+
1604
+ const given = DeciderSpecification.for<LogExercise, Events, State>({
1605
+ decide,
1606
+ evolve,
1607
+ initialState,
1608
+ });
1609
+
1610
+ const expectEvents = (...events: Array<{ type: string; data: unknown }>) => events as Events[];
1611
+
1612
+ it('Exercise logged from draft', () => {
1613
+ given([])
1614
+ .when({
1615
+ type: 'LogExercise',
1616
+ data: {
1617
+ sets: 3,
1618
+ reps: 10,
1619
+ },
1620
+ metadata: { now: new Date() },
1621
+ })
1622
+
1623
+ .then(
1624
+ expectEvents({
1625
+ type: 'ExerciseLogged',
1626
+ data: {
1627
+ sets: 3,
1628
+ reps: 10,
1629
+ },
1630
+ }),
1631
+ );
1632
+ });
1633
+ });
1634
+
1635
+ describe('field completeness', () => {
1636
+ it('should include all required fields in ExerciseLogged', () => {
1637
+ const state = initialState();
1638
+ const result = decide(
1639
+ {
1640
+ type: 'LogExercise',
1641
+ data: {
1642
+ sets: 3,
1643
+ reps: 10,
1644
+ },
1645
+ metadata: { now: new Date() },
1646
+ },
1647
+ state,
1648
+ );
1649
+ const evtArray = Array.isArray(result) ? result : [result];
1650
+
1651
+ expect(evtArray[0]).toMatchObject({
1652
+ type: 'ExerciseLogged',
1653
+ data: expect.objectContaining({
1654
+ id: expect.any(String),
1655
+ userId: expect.any(String),
1656
+ sets: expect.any(Number),
1657
+ reps: expect.any(Number),
1658
+ }),
1659
+ });
1660
+ });
1661
+ });
1662
+ "
1663
+ `);
1664
+ });
1665
+
1391
1666
  it('should omit state-based Given refs from given() array', async () => {
1392
1667
  const spec: SpecsSchema = {
1393
1668
  variant: 'specs',
@@ -2116,4 +2391,406 @@ describe('spec.ts.ejs', () => {
2116
2391
  expect(specFile?.contents).toContain('should emit OrderPlaced for PlaceOrder');
2117
2392
  expect(specFile?.contents).not.toContain('for valid');
2118
2393
  });
2394
+
2395
+ it('should generate field completeness assertion for nonCommandFields', async () => {
2396
+ const spec: SpecsSchema = {
2397
+ variant: 'specs',
2398
+ scenes: [
2399
+ {
2400
+ name: 'Booking scene',
2401
+ moments: [
2402
+ {
2403
+ type: 'command',
2404
+ name: 'Request booking',
2405
+ client: { specs: [] },
2406
+ server: {
2407
+ description: '',
2408
+ specs: [
2409
+ {
2410
+ type: 'gherkin',
2411
+ feature: 'Request booking spec',
2412
+ rules: [
2413
+ {
2414
+ name: 'Booking can be requested',
2415
+ examples: [
2416
+ {
2417
+ name: 'Guest requests a booking',
2418
+ steps: [
2419
+ {
2420
+ keyword: 'When',
2421
+ text: 'RequestBooking',
2422
+ docString: { listingId: 'l1', guestId: 'g1', nights: 3 },
2423
+ },
2424
+ {
2425
+ keyword: 'Then',
2426
+ text: 'BookingRequested',
2427
+ docString: {
2428
+ listingId: 'l1',
2429
+ guestId: 'g1',
2430
+ nights: 3,
2431
+ bookingId: 'b1',
2432
+ totalPrice: 750,
2433
+ status: 'pending',
2434
+ },
2435
+ },
2436
+ ],
2437
+ },
2438
+ ],
2439
+ },
2440
+ ],
2441
+ },
2442
+ ],
2443
+ },
2444
+ },
2445
+ ],
2446
+ },
2447
+ ],
2448
+ messages: [
2449
+ {
2450
+ type: 'command',
2451
+ name: 'RequestBooking',
2452
+ fields: [
2453
+ { name: 'listingId', type: 'string', required: true },
2454
+ { name: 'guestId', type: 'string', required: true },
2455
+ { name: 'nights', type: 'number', required: true },
2456
+ ],
2457
+ },
2458
+ {
2459
+ type: 'event',
2460
+ name: 'BookingRequested',
2461
+ source: 'internal',
2462
+ fields: [
2463
+ { name: 'listingId', type: 'string', required: true },
2464
+ { name: 'guestId', type: 'string', required: true },
2465
+ { name: 'nights', type: 'number', required: true },
2466
+ { name: 'bookingId', type: 'string', required: true },
2467
+ { name: 'totalPrice', type: 'number', required: true },
2468
+ { name: 'status', type: 'string', required: true },
2469
+ ],
2470
+ },
2471
+ ],
2472
+ };
2473
+
2474
+ const { plans } = await generateScaffoldFilePlans(spec.scenes, spec.messages, undefined, 'src/domain/narratives');
2475
+ const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
2476
+ const contents = specFile?.contents ?? '';
2477
+
2478
+ // Should include expect in vitest import
2479
+ expect(contents).toContain("import { describe, expect, it } from 'vitest'");
2480
+
2481
+ // Should generate field completeness block
2482
+ expect(contents).toContain('expect.objectContaining');
2483
+
2484
+ // Command fields use expect.any(Constructor)
2485
+ expect(contents).toContain('listingId: expect.any(String)');
2486
+ expect(contents).toContain('guestId: expect.any(String)');
2487
+ expect(contents).toContain('nights: expect.any(Number)');
2488
+
2489
+ // NonCommandFields without Given-event traceability use expect.any()
2490
+ expect(contents).toContain('bookingId: expect.any(String)');
2491
+ expect(contents).toContain('totalPrice: expect.any(Number)');
2492
+ expect(contents).toContain('status: expect.any(String)');
2493
+ });
2494
+
2495
+ it('should not generate field completeness block when all event fields are command fields', async () => {
2496
+ const spec: SpecsSchema = {
2497
+ variant: 'specs',
2498
+ scenes: [
2499
+ {
2500
+ name: 'Simple scene',
2501
+ moments: [
2502
+ {
2503
+ type: 'command',
2504
+ name: 'Rename item',
2505
+ client: { specs: [] },
2506
+ server: {
2507
+ description: '',
2508
+ specs: [
2509
+ {
2510
+ type: 'gherkin',
2511
+ feature: 'Rename item spec',
2512
+ rules: [
2513
+ {
2514
+ name: 'Item can be renamed',
2515
+ examples: [
2516
+ {
2517
+ name: 'Rename succeeds',
2518
+ steps: [
2519
+ {
2520
+ keyword: 'When',
2521
+ text: 'RenameItem',
2522
+ docString: { itemId: 'i1', newName: 'Widget' },
2523
+ },
2524
+ {
2525
+ keyword: 'Then',
2526
+ text: 'ItemRenamed',
2527
+ docString: { itemId: 'i1', newName: 'Widget' },
2528
+ },
2529
+ ],
2530
+ },
2531
+ ],
2532
+ },
2533
+ ],
2534
+ },
2535
+ ],
2536
+ },
2537
+ },
2538
+ ],
2539
+ },
2540
+ ],
2541
+ messages: [
2542
+ {
2543
+ type: 'command',
2544
+ name: 'RenameItem',
2545
+ fields: [
2546
+ { name: 'itemId', type: 'string', required: true },
2547
+ { name: 'newName', type: 'string', required: true },
2548
+ ],
2549
+ },
2550
+ {
2551
+ type: 'event',
2552
+ name: 'ItemRenamed',
2553
+ source: 'internal',
2554
+ fields: [
2555
+ { name: 'itemId', type: 'string', required: true },
2556
+ { name: 'newName', type: 'string', required: true },
2557
+ ],
2558
+ },
2559
+ ],
2560
+ };
2561
+
2562
+ const { plans } = await generateScaffoldFilePlans(spec.scenes, spec.messages, undefined, 'src/domain/narratives');
2563
+ const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
2564
+ const contents = specFile?.contents ?? '';
2565
+
2566
+ // Should NOT generate field completeness block
2567
+ expect(contents).not.toContain('expect.objectContaining');
2568
+ expect(contents).not.toContain('expect.any');
2569
+ // Should still use plain import without expect
2570
+ expect(contents).toContain("import { describe, it } from 'vitest'");
2571
+ });
2572
+
2573
+ it('should not duplicate field completeness assertions when multiple examples produce the same event type', async () => {
2574
+ const spec: SpecsSchema = {
2575
+ variant: 'specs',
2576
+ scenes: [
2577
+ {
2578
+ name: 'Booking scene',
2579
+ moments: [
2580
+ {
2581
+ type: 'command',
2582
+ name: 'Book appointment',
2583
+ client: { specs: [] },
2584
+ server: {
2585
+ description: '',
2586
+ specs: [
2587
+ {
2588
+ type: 'gherkin',
2589
+ feature: 'Book appointment spec',
2590
+ rules: [
2591
+ {
2592
+ name: 'Appointment can be booked',
2593
+ examples: [
2594
+ {
2595
+ name: 'Successful booking',
2596
+ steps: [
2597
+ {
2598
+ keyword: 'Given',
2599
+ text: 'AppointmentDrafted',
2600
+ docString: { appointmentId: 'apt1', status: 'draft' },
2601
+ },
2602
+ {
2603
+ keyword: 'When',
2604
+ text: 'BookAppointment',
2605
+ docString: { appointmentId: 'apt1', barberId: 'barb1' },
2606
+ },
2607
+ {
2608
+ keyword: 'Then',
2609
+ text: 'AppointmentBooked',
2610
+ docString: { appointmentId: 'apt1', barberId: 'barb1', status: 'booked' },
2611
+ },
2612
+ ],
2613
+ },
2614
+ {
2615
+ name: 'Draft already submitted',
2616
+ steps: [
2617
+ {
2618
+ keyword: 'Given',
2619
+ text: 'AppointmentDrafted',
2620
+ docString: { appointmentId: 'apt1', status: 'submitted' },
2621
+ },
2622
+ {
2623
+ keyword: 'When',
2624
+ text: 'BookAppointment',
2625
+ docString: { appointmentId: 'apt1', barberId: 'barb1' },
2626
+ },
2627
+ {
2628
+ keyword: 'Then',
2629
+ text: 'AppointmentBooked',
2630
+ docString: { appointmentId: 'apt1', barberId: 'barb1', status: 'booked' },
2631
+ },
2632
+ ],
2633
+ },
2634
+ ],
2635
+ },
2636
+ ],
2637
+ },
2638
+ ],
2639
+ },
2640
+ },
2641
+ ],
2642
+ },
2643
+ ],
2644
+ messages: [
2645
+ {
2646
+ type: 'command',
2647
+ name: 'BookAppointment',
2648
+ fields: [
2649
+ { name: 'appointmentId', type: 'string', required: true },
2650
+ { name: 'barberId', type: 'string', required: true },
2651
+ ],
2652
+ },
2653
+ {
2654
+ type: 'event',
2655
+ name: 'AppointmentBooked',
2656
+ source: 'internal',
2657
+ fields: [
2658
+ { name: 'appointmentId', type: 'string', required: true },
2659
+ { name: 'barberId', type: 'string', required: true },
2660
+ { name: 'status', type: 'string', required: true },
2661
+ ],
2662
+ },
2663
+ {
2664
+ type: 'event',
2665
+ name: 'AppointmentDrafted',
2666
+ source: 'internal',
2667
+ fields: [
2668
+ { name: 'appointmentId', type: 'string', required: true },
2669
+ { name: 'status', type: 'string', required: true },
2670
+ ],
2671
+ },
2672
+ ],
2673
+ };
2674
+
2675
+ const { plans } = await generateScaffoldFilePlans(spec.scenes, spec.messages, undefined, 'src/domain/narratives');
2676
+ const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
2677
+
2678
+ expect(specFile?.contents).toMatchInlineSnapshot(`
2679
+ "import { describe, expect, it } from 'vitest';
2680
+ import { DeciderSpecification } from '@event-driven-io/emmett';
2681
+ import { decide } from './decide';
2682
+ import { evolve } from './evolve';
2683
+ import { initialState, State } from './state';
2684
+ import type { AppointmentBooked, AppointmentDrafted } from './events';
2685
+ import type { BookAppointment } from './commands';
2686
+
2687
+ describe('Appointment can be booked', () => {
2688
+ type Events = AppointmentBooked | AppointmentDrafted;
2689
+
2690
+ const given = DeciderSpecification.for<BookAppointment, Events, State>({
2691
+ decide,
2692
+ evolve,
2693
+ initialState,
2694
+ });
2695
+
2696
+ const expectEvents = (...events: Array<{ type: string; data: unknown }>) => events as Events[];
2697
+
2698
+ it('Successful booking', () => {
2699
+ given([
2700
+ {
2701
+ type: 'AppointmentDrafted',
2702
+ data: {
2703
+ appointmentId: 'apt1',
2704
+ status: 'draft',
2705
+ },
2706
+ },
2707
+ ])
2708
+ .when({
2709
+ type: 'BookAppointment',
2710
+ data: {
2711
+ appointmentId: 'apt1',
2712
+ barberId: 'barb1',
2713
+ },
2714
+ metadata: { now: new Date() },
2715
+ })
2716
+
2717
+ .then(
2718
+ expectEvents({
2719
+ type: 'AppointmentBooked',
2720
+ data: {
2721
+ appointmentId: 'apt1',
2722
+ barberId: 'barb1',
2723
+ },
2724
+ }),
2725
+ );
2726
+ });
2727
+
2728
+ it('Draft already submitted', () => {
2729
+ given([
2730
+ {
2731
+ type: 'AppointmentDrafted',
2732
+ data: {
2733
+ appointmentId: 'apt1',
2734
+ status: 'submitted',
2735
+ },
2736
+ },
2737
+ ])
2738
+ .when({
2739
+ type: 'BookAppointment',
2740
+ data: {
2741
+ appointmentId: 'apt1',
2742
+ barberId: 'barb1',
2743
+ },
2744
+ metadata: { now: new Date() },
2745
+ })
2746
+
2747
+ .then(
2748
+ expectEvents({
2749
+ type: 'AppointmentBooked',
2750
+ data: {
2751
+ appointmentId: 'apt1',
2752
+ barberId: 'barb1',
2753
+ },
2754
+ }),
2755
+ );
2756
+ });
2757
+ });
2758
+
2759
+ describe('field completeness', () => {
2760
+ it('should include all required fields in AppointmentBooked', () => {
2761
+ const state = [
2762
+ {
2763
+ type: 'AppointmentDrafted' as const,
2764
+ data: {
2765
+ appointmentId: 'apt1',
2766
+ status: 'draft',
2767
+ },
2768
+ },
2769
+ ].reduce((s, e) => evolve(s, e), initialState());
2770
+ const result = decide(
2771
+ {
2772
+ type: 'BookAppointment',
2773
+ data: {
2774
+ appointmentId: 'apt1',
2775
+ barberId: 'barb1',
2776
+ },
2777
+ metadata: { now: new Date() },
2778
+ },
2779
+ state,
2780
+ );
2781
+ const evtArray = Array.isArray(result) ? result : [result];
2782
+
2783
+ expect(evtArray[0]).toMatchObject({
2784
+ type: 'AppointmentBooked',
2785
+ data: expect.objectContaining({
2786
+ appointmentId: expect.any(String),
2787
+ barberId: expect.any(String),
2788
+ status: expect.any(String),
2789
+ }),
2790
+ });
2791
+ });
2792
+ });
2793
+ "
2794
+ `);
2795
+ });
2119
2796
  });