@lenne.tech/cli 1.0.0 → 1.0.1

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 (25) hide show
  1. package/build/commands/claude/install-commands.js +332 -0
  2. package/build/commands/claude/install-skills.js +5 -1
  3. package/build/commands/server/add-property.js +22 -41
  4. package/build/extensions/server.js +142 -46
  5. package/build/templates/claude-commands/code-cleanup.md +82 -0
  6. package/build/templates/claude-commands/mr-description-clipboard.md +48 -0
  7. package/build/templates/claude-commands/mr-description.md +33 -0
  8. package/build/templates/claude-commands/sec-review.md +62 -0
  9. package/build/templates/claude-commands/skill-optimize.md +140 -0
  10. package/build/templates/claude-commands/test-generate.md +45 -0
  11. package/build/templates/claude-skills/nest-server-generator/SKILL.md +372 -1314
  12. package/build/templates/claude-skills/nest-server-generator/configuration.md +279 -0
  13. package/build/templates/claude-skills/nest-server-generator/declare-keyword-warning.md +124 -0
  14. package/build/templates/claude-skills/nest-server-generator/description-management.md +217 -0
  15. package/build/templates/claude-skills/nest-server-generator/examples.md +131 -5
  16. package/build/templates/claude-skills/nest-server-generator/quality-review.md +855 -0
  17. package/build/templates/claude-skills/nest-server-generator/reference.md +67 -13
  18. package/build/templates/claude-skills/nest-server-generator/security-rules.md +358 -0
  19. package/build/templates/claude-skills/story-tdd/SKILL.md +1173 -0
  20. package/build/templates/claude-skills/story-tdd/code-quality.md +266 -0
  21. package/build/templates/claude-skills/story-tdd/database-indexes.md +173 -0
  22. package/build/templates/claude-skills/story-tdd/examples.md +1332 -0
  23. package/build/templates/claude-skills/story-tdd/reference.md +1180 -0
  24. package/build/templates/claude-skills/story-tdd/security-review.md +299 -0
  25. package/package.json +1 -1
@@ -0,0 +1,1332 @@
1
+ ---
2
+ name: story-tdd-examples
3
+ version: 1.0.0
4
+ description: Complete examples for Test-Driven Development workflow with NestJS story tests
5
+ ---
6
+
7
+ # Story-Based TDD Examples
8
+
9
+ This document provides complete examples of the TDD workflow for different types of user stories.
10
+
11
+ ## Example 1: Simple CRUD Feature - Product Reviews
12
+
13
+ ### Story Requirement
14
+
15
+ ```
16
+ As a user, I want to add reviews to products so that I can share my experience with other customers.
17
+
18
+ Acceptance Criteria:
19
+ - Users can create a review with rating (1-5) and comment
20
+ - Rating is required, comment is optional
21
+ - Only authenticated users can create reviews
22
+ - Users can view all reviews for a product
23
+ - Each review shows author name and creation date
24
+ ```
25
+
26
+ ### Step 1: Story Analysis
27
+
28
+ **Analysis notes:**
29
+ - New feature, likely needs new Review module
30
+ - Needs relationship between Review and Product
31
+ - Security: Only authenticated users (S_USER role minimum)
32
+ - No mention of update/delete, so only CREATE and READ operations
33
+
34
+ **Questions to clarify:**
35
+ - Can users edit their reviews? (Assuming NO for this example)
36
+ - Can users review a product multiple times? (Assuming NO)
37
+ - What validation for rating? (Assuming 1-5 integer)
38
+
39
+ ### Step 2: Create Story Test
40
+
41
+ **File:** `test/stories/product-review.story.test.ts`
42
+
43
+ ```typescript
44
+ import {
45
+ ConfigService,
46
+ HttpExceptionLogFilter,
47
+ TestGraphQLType,
48
+ TestHelper,
49
+ } from '@lenne.tech/nest-server';
50
+ import { Test, TestingModule } from '@nestjs/testing';
51
+ import { PubSub } from 'graphql-subscriptions';
52
+ import { MongoClient, ObjectId } from 'mongodb';
53
+
54
+ import envConfig from '../../src/config.env';
55
+ import { RoleEnum } from '../../src/server/common/enums/role.enum';
56
+ import { ProductService } from '../../src/server/modules/product/product.service';
57
+ import { ReviewService } from '../../src/server/modules/review/review.service';
58
+ import { UserService } from '../../src/server/modules/user/user.service';
59
+ import { imports, ServerModule } from '../../src/server/server.module';
60
+
61
+ describe('Product Review Story', () => {
62
+ // Test environment properties
63
+ let app;
64
+ let testHelper: TestHelper;
65
+
66
+ // Database
67
+ let connection;
68
+ let db;
69
+
70
+ // Services
71
+ let userService: UserService;
72
+ let productService: ProductService;
73
+ let reviewService: ReviewService;
74
+
75
+ // Global test data
76
+ let gAdminToken: string;
77
+ let gAdminId: string;
78
+ let gUserToken: string;
79
+ let gUserId: string;
80
+ let gProductId: string;
81
+
82
+ // Track created entities for cleanup
83
+ let createdReviewIds: string[] = [];
84
+
85
+ beforeAll(async () => {
86
+ // Start server for testing
87
+ const moduleFixture: TestingModule = await Test.createTestingModule({
88
+ imports: [...imports, ServerModule],
89
+ providers: [
90
+ UserService,
91
+ ProductService,
92
+ ReviewService,
93
+ {
94
+ provide: 'PUB_SUB',
95
+ useValue: new PubSub(),
96
+ },
97
+ ],
98
+ }).compile();
99
+
100
+ app = moduleFixture.createNestApplication();
101
+ app.useGlobalFilters(new HttpExceptionLogFilter());
102
+ app.setBaseViewsDir(envConfig.templates.path);
103
+ app.setViewEngine(envConfig.templates.engine);
104
+ await app.init();
105
+
106
+ testHelper = new TestHelper(app);
107
+ userService = moduleFixture.get(UserService);
108
+ productService = moduleFixture.get(ProductService);
109
+ reviewService = moduleFixture.get(ReviewService);
110
+
111
+ // Connection to database
112
+ connection = await MongoClient.connect(envConfig.mongoose.uri);
113
+ db = await connection.db();
114
+
115
+ // Create admin user
116
+ const adminPassword = Math.random().toString(36).substring(7);
117
+ const adminEmail = `admin-${adminPassword}@test.com`;
118
+ const adminSignUp = await testHelper.graphQl({
119
+ arguments: {
120
+ input: {
121
+ email: adminEmail,
122
+ firstName: 'Admin',
123
+ password: adminPassword,
124
+ },
125
+ },
126
+ fields: ['token', { user: ['id', 'email', 'roles'] }],
127
+ name: 'signUp',
128
+ type: TestGraphQLType.MUTATION,
129
+ });
130
+ gAdminId = adminSignUp.user.id;
131
+ gAdminToken = adminSignUp.token;
132
+
133
+ // Set admin role
134
+ await userService.update(gAdminId, { roles: [RoleEnum.ADMIN] }, gAdminId);
135
+
136
+ // Create normal user
137
+ const userPassword = Math.random().toString(36).substring(7);
138
+ const userEmail = `user-${userPassword}@test.com`;
139
+ const userSignUp = await testHelper.graphQl({
140
+ arguments: {
141
+ input: {
142
+ email: userEmail,
143
+ firstName: 'Test',
144
+ password: userPassword,
145
+ },
146
+ },
147
+ fields: ['token', { user: ['id', 'email'] }],
148
+ name: 'signUp',
149
+ type: TestGraphQLType.MUTATION,
150
+ });
151
+ gUserId = userSignUp.user.id;
152
+ gUserToken = userSignUp.token;
153
+
154
+ // Create test product
155
+ const product = await testHelper.rest('/api/products', {
156
+ method: 'POST',
157
+ payload: {
158
+ name: 'Test Product',
159
+ price: 99.99,
160
+ },
161
+ token: gAdminToken,
162
+ });
163
+ gProductId = product.id;
164
+ });
165
+
166
+ afterAll(async () => {
167
+ // 🧹 CLEANUP: Delete all test data created during tests
168
+ try {
169
+ // Delete all created reviews
170
+ if (createdReviewIds.length > 0) {
171
+ await db.collection('reviews').deleteMany({
172
+ _id: { $in: createdReviewIds.map(id => new ObjectId(id)) }
173
+ });
174
+ }
175
+
176
+ // Delete test product
177
+ if (gProductId) {
178
+ await db.collection('products').deleteOne({ _id: new ObjectId(gProductId) });
179
+ }
180
+
181
+ // Delete test users
182
+ if (gUserId) {
183
+ await db.collection('users').deleteOne({ _id: new ObjectId(gUserId) });
184
+ }
185
+ if (gAdminId) {
186
+ await db.collection('users').deleteOne({ _id: new ObjectId(gAdminId) });
187
+ }
188
+ } catch (error) {
189
+ console.error('Cleanup failed:', error);
190
+ }
191
+
192
+ await connection.close();
193
+ await app.close();
194
+ });
195
+
196
+ describe('Creating Reviews', () => {
197
+ it('should allow authenticated user to create review with rating and comment', async () => {
198
+ const review = await testHelper.rest('/api/reviews', {
199
+ method: 'POST',
200
+ payload: {
201
+ productId: gProductId,
202
+ rating: 5,
203
+ comment: 'Excellent product!',
204
+ },
205
+ token: gUserToken,
206
+ });
207
+
208
+ expect(review).toMatchObject({
209
+ rating: 5,
210
+ comment: 'Excellent product!',
211
+ authorId: gUserId,
212
+ });
213
+ expect(review.id).toBeDefined();
214
+ expect(review.createdAt).toBeDefined();
215
+
216
+ // Track for cleanup
217
+ createdReviewIds.push(review.id);
218
+ });
219
+
220
+ it('should allow review with rating only (no comment)', async () => {
221
+ const review = await testHelper.rest('/api/reviews', {
222
+ method: 'POST',
223
+ payload: {
224
+ productId: gProductId,
225
+ rating: 4,
226
+ },
227
+ token: gUserToken,
228
+ });
229
+
230
+ expect(review.rating).toBe(4);
231
+ expect(review.comment).toBeUndefined();
232
+
233
+ // Track for cleanup
234
+ createdReviewIds.push(review.id);
235
+ });
236
+
237
+ it('should reject review without rating', async () => {
238
+ await testHelper.rest('/api/reviews', {
239
+ method: 'POST',
240
+ payload: {
241
+ productId: gProductId,
242
+ comment: 'Missing rating',
243
+ },
244
+ statusCode: 400,
245
+ token: gUserToken,
246
+ });
247
+ });
248
+
249
+ it('should reject review with invalid rating', async () => {
250
+ await testHelper.rest('/api/reviews', {
251
+ method: 'POST',
252
+ payload: {
253
+ productId: gProductId,
254
+ rating: 6, // Invalid: must be 1-5
255
+ },
256
+ statusCode: 400,
257
+ token: gUserToken,
258
+ });
259
+ });
260
+
261
+ it('should reject unauthenticated review creation', async () => {
262
+ await testHelper.rest('/api/reviews', {
263
+ method: 'POST',
264
+ payload: {
265
+ productId: gProductId,
266
+ rating: 5,
267
+ comment: 'Trying without auth',
268
+ },
269
+ statusCode: 401,
270
+ });
271
+ });
272
+ });
273
+
274
+ describe('Viewing Reviews', () => {
275
+ let createdReviewId: string;
276
+
277
+ beforeAll(async () => {
278
+ // Create a review for testing
279
+ const review = await testHelper.rest('/api/reviews', {
280
+ method: 'POST',
281
+ payload: {
282
+ productId: gProductId,
283
+ rating: 5,
284
+ comment: 'Great product',
285
+ },
286
+ token: gUserToken,
287
+ });
288
+ createdReviewId = review.id;
289
+
290
+ // Track for cleanup
291
+ createdReviewIds.push(review.id);
292
+ });
293
+
294
+ it('should allow anyone to view product reviews', async () => {
295
+ const reviews = await testHelper.rest(`/api/products/${gProductId}/reviews`);
296
+
297
+ expect(reviews).toBeInstanceOf(Array);
298
+ expect(reviews.length).toBeGreaterThan(0);
299
+
300
+ const review = reviews.find(r => r.id === createdReviewId);
301
+ expect(review).toMatchObject({
302
+ rating: 5,
303
+ comment: 'Great product',
304
+ });
305
+ expect(review.author).toBeDefined();
306
+ expect(review.createdAt).toBeDefined();
307
+ });
308
+
309
+ it('should return empty array for product with no reviews', async () => {
310
+ // Create product without reviews
311
+ const newProduct = await testHelper.rest('/api/products', {
312
+ method: 'POST',
313
+ payload: {
314
+ name: 'New Product',
315
+ price: 49.99,
316
+ },
317
+ token: gAdminToken,
318
+ });
319
+
320
+ const reviews = await testHelper.rest(`/api/products/${newProduct.id}/reviews`);
321
+ expect(reviews).toEqual([]);
322
+ });
323
+ });
324
+ });
325
+ ```
326
+
327
+ ### Step 3-5: Implementation Iteration
328
+
329
+ **First run - Expected failures:**
330
+ ```
331
+ ❌ POST /api/reviews → 404 (endpoint doesn't exist)
332
+ ❌ GET /api/products/:id/reviews → 404 (endpoint doesn't exist)
333
+ ```
334
+
335
+ **Implementation (using nest-server-generator):**
336
+ ```bash
337
+ # Create Review module
338
+ lt server module Review --no-interactive
339
+
340
+ # Add properties
341
+ lt server addProp Review productId:string --no-interactive
342
+ lt server addProp Review authorId:string --no-interactive
343
+ lt server addProp Review rating:number --no-interactive
344
+ lt server addProp Review comment:string? --no-interactive
345
+ ```
346
+
347
+ **Manual adjustments needed:**
348
+ - Add validation for rating (1-5 range)
349
+ - Add @Restricted decorator with appropriate roles
350
+ - Add GET endpoint to ProductController for reviews
351
+ - Add relationship between Product and Review
352
+
353
+ **Final run - All tests pass:**
354
+ ```
355
+ ✅ All tests passing (8 scenarios)
356
+ ```
357
+
358
+ ---
359
+
360
+ ## Example 2: Complex Business Logic - Order Processing
361
+
362
+ ### Story Requirement
363
+
364
+ ```
365
+ As a customer, I want to place an order with multiple products so that I can purchase items together.
366
+
367
+ Acceptance Criteria:
368
+ - Order contains multiple products with quantities
369
+ - Order calculates total price automatically
370
+ - Order cannot be created with empty product list
371
+ - Order requires delivery address
372
+ - Order status is initially "pending"
373
+ - Products are checked for availability
374
+ - Insufficient stock prevents order creation
375
+ ```
376
+
377
+ ### Step 1: Story Analysis
378
+
379
+ **Analysis notes:**
380
+ - Needs Order module with relationship to Product
381
+ - Needs OrderItem subobject for quantity tracking
382
+ - Business logic: stock validation
383
+ - Calculated field: total price
384
+ - Complex validation rules
385
+
386
+ **Architecture decisions:**
387
+ - Use SubObject for OrderItem (embedded in Order)
388
+ - Total price should be calculated in service layer
389
+ - Stock check happens in service before saving
390
+
391
+ ### Step 2: Create Story Test
392
+
393
+ **File:** `test/stories/order-processing.story.test.ts`
394
+
395
+ ```typescript
396
+ import {
397
+ ConfigService,
398
+ HttpExceptionLogFilter,
399
+ TestGraphQLType,
400
+ TestHelper,
401
+ } from '@lenne.tech/nest-server';
402
+ import { Test, TestingModule } from '@nestjs/testing';
403
+ import { PubSub } from 'graphql-subscriptions';
404
+ import { MongoClient, ObjectId } from 'mongodb';
405
+
406
+ import envConfig from '../../src/config.env';
407
+ import { RoleEnum } from '../../src/server/common/enums/role.enum';
408
+ import { OrderService } from '../../src/server/modules/order/order.service';
409
+ import { ProductService } from '../../src/server/modules/product/product.service';
410
+ import { UserService } from '../../src/server/modules/user/user.service';
411
+ import { imports, ServerModule } from '../../src/server/server.module';
412
+
413
+ describe('Order Processing Story', () => {
414
+ // Test environment properties
415
+ let app;
416
+ let testHelper: TestHelper;
417
+
418
+ // Database
419
+ let connection;
420
+ let db;
421
+
422
+ // Services
423
+ let userService: UserService;
424
+ let productService: ProductService;
425
+ let orderService: OrderService;
426
+
427
+ // Global test data
428
+ let gAdminToken: string;
429
+ let gAdminId: string;
430
+ let gCustomerToken: string;
431
+ let gCustomerId: string;
432
+ let gProduct1Id: string;
433
+ let gProduct1Stock: number;
434
+ let gProduct2Id: string;
435
+
436
+ // Track created entities for cleanup
437
+ let createdOrderIds: string[] = [];
438
+ let createdProductIds: string[] = [];
439
+
440
+ beforeAll(async () => {
441
+ // Start server for testing
442
+ const moduleFixture: TestingModule = await Test.createTestingModule({
443
+ imports: [...imports, ServerModule],
444
+ providers: [
445
+ UserService,
446
+ ProductService,
447
+ OrderService,
448
+ {
449
+ provide: 'PUB_SUB',
450
+ useValue: new PubSub(),
451
+ },
452
+ ],
453
+ }).compile();
454
+
455
+ app = moduleFixture.createNestApplication();
456
+ app.useGlobalFilters(new HttpExceptionLogFilter());
457
+ app.setBaseViewsDir(envConfig.templates.path);
458
+ app.setViewEngine(envConfig.templates.engine);
459
+ await app.init();
460
+
461
+ testHelper = new TestHelper(app);
462
+ userService = moduleFixture.get(UserService);
463
+ productService = moduleFixture.get(ProductService);
464
+ orderService = moduleFixture.get(OrderService);
465
+
466
+ // Connection to database
467
+ connection = await MongoClient.connect(envConfig.mongoose.uri);
468
+ db = await connection.db();
469
+
470
+ // Create admin user
471
+ const adminPassword = Math.random().toString(36).substring(7);
472
+ const adminEmail = `admin-${adminPassword}@test.com`;
473
+ const adminSignUp = await testHelper.graphQl({
474
+ arguments: {
475
+ input: {
476
+ email: adminEmail,
477
+ firstName: 'Admin',
478
+ password: adminPassword,
479
+ },
480
+ },
481
+ fields: ['token', { user: ['id', 'email'] }],
482
+ name: 'signUp',
483
+ type: TestGraphQLType.MUTATION,
484
+ });
485
+ gAdminId = adminSignUp.user.id;
486
+ gAdminToken = adminSignUp.token;
487
+
488
+ // Set admin role
489
+ await userService.update(gAdminId, { roles: [RoleEnum.ADMIN] }, gAdminId);
490
+
491
+ // Create customer user
492
+ const customerPassword = Math.random().toString(36).substring(7);
493
+ const customerEmail = `customer-${customerPassword}@test.com`;
494
+ const customerSignUp = await testHelper.graphQl({
495
+ arguments: {
496
+ input: {
497
+ email: customerEmail,
498
+ firstName: 'Customer',
499
+ password: customerPassword,
500
+ },
501
+ },
502
+ fields: ['token', { user: ['id'] }],
503
+ name: 'signUp',
504
+ type: TestGraphQLType.MUTATION,
505
+ });
506
+ gCustomerId = customerSignUp.user.id;
507
+ gCustomerToken = customerSignUp.token;
508
+
509
+ // Create test products with stock
510
+ const product1 = await testHelper.rest('/api/products', {
511
+ method: 'POST',
512
+ payload: {
513
+ name: 'Product A',
514
+ price: 10.00,
515
+ stock: 100,
516
+ },
517
+ token: gAdminToken,
518
+ });
519
+ gProduct1Id = product1.id;
520
+ gProduct1Stock = product1.stock;
521
+
522
+ const product2 = await testHelper.rest('/api/products', {
523
+ method: 'POST',
524
+ payload: {
525
+ name: 'Product B',
526
+ price: 25.50,
527
+ stock: 50,
528
+ },
529
+ token: gAdminToken,
530
+ });
531
+ gProduct2Id = product2.id;
532
+
533
+ // Track products for cleanup
534
+ createdProductIds.push(gProduct1Id, gProduct2Id);
535
+ });
536
+
537
+ afterAll(async () => {
538
+ // 🧹 CLEANUP: Delete all test data created during tests
539
+ try {
540
+ // Delete all created orders first (child entities)
541
+ if (createdOrderIds.length > 0) {
542
+ await db.collection('orders').deleteMany({
543
+ _id: { $in: createdOrderIds.map(id => new ObjectId(id)) }
544
+ });
545
+ }
546
+
547
+ // Delete all created products
548
+ if (createdProductIds.length > 0) {
549
+ await db.collection('products').deleteMany({
550
+ _id: { $in: createdProductIds.map(id => new ObjectId(id)) }
551
+ });
552
+ }
553
+
554
+ // Delete test users
555
+ if (gCustomerId) {
556
+ await db.collection('users').deleteOne({ _id: new ObjectId(gCustomerId) });
557
+ }
558
+ if (gAdminId) {
559
+ await db.collection('users').deleteOne({ _id: new ObjectId(gAdminId) });
560
+ }
561
+ } catch (error) {
562
+ console.error('Cleanup failed:', error);
563
+ }
564
+
565
+ await connection.close();
566
+ await app.close();
567
+ });
568
+
569
+ describe('Order Creation - Happy Path', () => {
570
+ it('should create order with multiple products and calculate total', async () => {
571
+ const orderData = {
572
+ items: [
573
+ { productId: gProduct1Id, quantity: 2 },
574
+ { productId: gProduct2Id, quantity: 1 },
575
+ ],
576
+ deliveryAddress: {
577
+ street: '123 Main St',
578
+ city: 'Test City',
579
+ zipCode: '12345',
580
+ country: 'Germany',
581
+ },
582
+ };
583
+
584
+ const order = await testHelper.rest('/api/orders', {
585
+ method: 'POST',
586
+ payload: orderData,
587
+ token: gCustomerToken,
588
+ });
589
+
590
+ expect(order).toMatchObject({
591
+ status: 'pending',
592
+ customerId: gCustomerId,
593
+ totalPrice: 45.50, // (10.00 * 2) + (25.50 * 1)
594
+ deliveryAddress: orderData.deliveryAddress,
595
+ });
596
+
597
+ expect(order.items).toHaveLength(2);
598
+ expect(order.items[0]).toMatchObject({
599
+ productId: gProduct1Id,
600
+ quantity: 2,
601
+ priceAtOrder: 10.00,
602
+ });
603
+
604
+ // Track for cleanup
605
+ createdOrderIds.push(order.id);
606
+ });
607
+
608
+ it('should create order with single product', async () => {
609
+ const orderData = {
610
+ items: [
611
+ { productId: gProduct1Id, quantity: 1 },
612
+ ],
613
+ deliveryAddress: {
614
+ street: '456 Oak Ave',
615
+ city: 'Sample Town',
616
+ zipCode: '54321',
617
+ country: 'Germany',
618
+ },
619
+ };
620
+
621
+ const order = await testHelper.rest('/api/orders', {
622
+ method: 'POST',
623
+ payload: orderData,
624
+ token: gCustomerToken,
625
+ });
626
+
627
+ expect(order.totalPrice).toBe(10.00);
628
+
629
+ // Track for cleanup
630
+ createdOrderIds.push(order.id);
631
+ });
632
+ });
633
+
634
+ describe('Order Validation', () => {
635
+ it('should reject order with empty product list', async () => {
636
+ const orderData = {
637
+ items: [],
638
+ deliveryAddress: {
639
+ street: '123 Main St',
640
+ city: 'Test City',
641
+ zipCode: '12345',
642
+ country: 'Germany',
643
+ },
644
+ };
645
+
646
+ await testHelper.rest('/api/orders', {
647
+ method: 'POST',
648
+ payload: orderData,
649
+ statusCode: 400,
650
+ token: gCustomerToken,
651
+ });
652
+ });
653
+
654
+ it('should reject order without delivery address', async () => {
655
+ const orderData = {
656
+ items: [
657
+ { productId: gProduct1Id, quantity: 1 },
658
+ ],
659
+ };
660
+
661
+ await testHelper.rest('/api/orders', {
662
+ method: 'POST',
663
+ payload: orderData,
664
+ statusCode: 400,
665
+ token: gCustomerToken,
666
+ });
667
+ });
668
+
669
+ it('should reject order with invalid product ID', async () => {
670
+ const orderData = {
671
+ items: [
672
+ { productId: 'invalid-id', quantity: 1 },
673
+ ],
674
+ deliveryAddress: {
675
+ street: '123 Main St',
676
+ city: 'Test City',
677
+ zipCode: '12345',
678
+ country: 'Germany',
679
+ },
680
+ };
681
+
682
+ await testHelper.rest('/api/orders', {
683
+ method: 'POST',
684
+ payload: orderData,
685
+ statusCode: 404,
686
+ token: gCustomerToken,
687
+ });
688
+ });
689
+ });
690
+
691
+ describe('Stock Management', () => {
692
+ it('should reject order when product stock is insufficient', async () => {
693
+ // Create product with limited stock
694
+ const limitedProduct = await testHelper.rest('/api/products', {
695
+ method: 'POST',
696
+ payload: {
697
+ name: 'Limited Product',
698
+ price: 100.00,
699
+ stock: 5,
700
+ },
701
+ token: gAdminToken,
702
+ });
703
+
704
+ // Track for cleanup
705
+ createdProductIds.push(limitedProduct.id);
706
+
707
+ const orderData = {
708
+ items: [
709
+ { productId: limitedProduct.id, quantity: 10 }, // More than available
710
+ ],
711
+ deliveryAddress: {
712
+ street: '123 Main St',
713
+ city: 'Test City',
714
+ zipCode: '12345',
715
+ country: 'Germany',
716
+ },
717
+ };
718
+
719
+ const response = await testHelper.rest('/api/orders', {
720
+ method: 'POST',
721
+ payload: orderData,
722
+ statusCode: 400,
723
+ token: gCustomerToken,
724
+ });
725
+
726
+ expect(response.message).toContain('insufficient stock');
727
+ });
728
+
729
+ it('should reduce product stock after successful order', async () => {
730
+ const initialStock = gProduct1Stock;
731
+
732
+ const orderData = {
733
+ items: [
734
+ { productId: gProduct1Id, quantity: 3 },
735
+ ],
736
+ deliveryAddress: {
737
+ street: '123 Main St',
738
+ city: 'Test City',
739
+ zipCode: '12345',
740
+ country: 'Germany',
741
+ },
742
+ };
743
+
744
+ const order = await testHelper.rest('/api/orders', {
745
+ method: 'POST',
746
+ payload: orderData,
747
+ token: gCustomerToken,
748
+ });
749
+
750
+ // Track for cleanup
751
+ createdOrderIds.push(order.id);
752
+
753
+ // Check product stock was reduced
754
+ const updatedProduct = await testHelper.rest(`/api/products/${gProduct1Id}`, {
755
+ token: gAdminToken,
756
+ });
757
+
758
+ expect(updatedProduct.stock).toBe(initialStock - 3);
759
+
760
+ // Update global stock for subsequent tests
761
+ gProduct1Stock = updatedProduct.stock;
762
+ });
763
+ });
764
+
765
+ describe('Authorization', () => {
766
+ it('should reject unauthenticated order creation', async () => {
767
+ const orderData = {
768
+ items: [
769
+ { productId: gProduct1Id, quantity: 1 },
770
+ ],
771
+ deliveryAddress: {
772
+ street: '123 Main St',
773
+ city: 'Test City',
774
+ zipCode: '12345',
775
+ country: 'Germany',
776
+ },
777
+ };
778
+
779
+ await testHelper.rest('/api/orders', {
780
+ method: 'POST',
781
+ payload: orderData,
782
+ statusCode: 401,
783
+ });
784
+ });
785
+ });
786
+ });
787
+ ```
788
+
789
+ ### Implementation Steps
790
+
791
+ **SubObject creation:**
792
+ ```typescript
793
+ // Create OrderItem SubObject manually
794
+ // File: src/server/modules/order/order-item.subobject.ts
795
+
796
+ @SubObjectType()
797
+ export class OrderItem {
798
+ @UnifiedField({
799
+ description: 'Reference to product',
800
+ mongoose: { index: true, type: String } // ✅ Index for queries by product
801
+ })
802
+ productId: string;
803
+
804
+ @UnifiedField({
805
+ description: 'Quantity ordered',
806
+ mongoose: { type: Number }
807
+ })
808
+ quantity: number;
809
+
810
+ @UnifiedField({
811
+ description: 'Price when order was placed',
812
+ mongoose: { type: Number }
813
+ })
814
+ priceAtOrder: number;
815
+ }
816
+ ```
817
+
818
+ **Model with indexes:**
819
+ ```typescript
820
+ // File: src/server/modules/order/order.model.ts
821
+
822
+ @Schema()
823
+ export class Order {
824
+ @UnifiedField({
825
+ description: 'Customer who placed the order',
826
+ mongoose: { index: true, type: String } // ✅ Frequent queries by customer
827
+ })
828
+ customerId: string;
829
+
830
+ @UnifiedField({
831
+ description: 'Order status',
832
+ mongoose: { index: true, type: String } // ✅ Filtering by status
833
+ })
834
+ status: string;
835
+
836
+ @UnifiedField({
837
+ description: 'Order items',
838
+ mongoose: { type: [OrderItem] }
839
+ })
840
+ items: OrderItem[];
841
+
842
+ @UnifiedField({
843
+ description: 'Total price calculated from items',
844
+ mongoose: { type: Number }
845
+ })
846
+ totalPrice: number;
847
+
848
+ @UnifiedField({
849
+ description: 'Delivery address',
850
+ mongoose: { type: Object }
851
+ })
852
+ deliveryAddress: Address;
853
+ }
854
+ ```
855
+
856
+ **Why these indexes?**
857
+ - `customerId`: Service queries orders by customer → needs index
858
+ - `status`: Service filters by status (pending, completed) → needs index
859
+ - Both indexed individually for flexible querying
860
+
861
+ **Service logic for total calculation and stock validation:**
862
+ ```typescript
863
+ // In OrderService (extends CrudService)
864
+
865
+ async create(input: CreateOrderInput, userId: string): Promise<Order> {
866
+ // Validate items exist
867
+ if (!input.items || input.items.length === 0) {
868
+ throw new BadRequestException('Order must contain at least one item');
869
+ }
870
+
871
+ // Check stock and calculate total
872
+ let totalPrice = 0;
873
+ const orderItems = [];
874
+
875
+ for (const item of input.items) {
876
+ const product = await this.productService.findById(item.productId);
877
+ if (!product) {
878
+ throw new NotFoundException(`Product ${item.productId} not found`);
879
+ }
880
+
881
+ if (product.stock < item.quantity) {
882
+ throw new BadRequestException(
883
+ `Insufficient stock for product ${product.name}`
884
+ );
885
+ }
886
+
887
+ orderItems.push({
888
+ productId: product.id,
889
+ quantity: item.quantity,
890
+ priceAtOrder: product.price,
891
+ });
892
+
893
+ totalPrice += product.price * item.quantity;
894
+ }
895
+
896
+ // Create order
897
+ const order = await super.create({
898
+ ...input,
899
+ items: orderItems,
900
+ totalPrice,
901
+ customerId: userId,
902
+ status: 'pending',
903
+ });
904
+
905
+ // Reduce stock
906
+ for (const item of input.items) {
907
+ await this.productService.reduceStock(item.productId, item.quantity);
908
+ }
909
+
910
+ return order;
911
+ }
912
+ ```
913
+
914
+ ---
915
+
916
+ ## Example 3: GraphQL Mutation - User Profile Update
917
+
918
+ ### Story Requirement
919
+
920
+ ```
921
+ As a user, I want to update my profile information so that my account reflects current details.
922
+
923
+ Acceptance Criteria:
924
+ - Users can update their firstName, lastName, phone
925
+ - Users cannot change their email through this endpoint
926
+ - Users can only update their own profile
927
+ - Admin users can update any profile
928
+ - Phone number must be validated (German format)
929
+ ```
930
+
931
+ ### Step 2: Create Story Test (GraphQL)
932
+
933
+ **File:** `test/stories/profile-update.story.test.ts`
934
+
935
+ ```typescript
936
+ import {
937
+ ConfigService,
938
+ HttpExceptionLogFilter,
939
+ TestGraphQLType,
940
+ TestHelper,
941
+ } from '@lenne.tech/nest-server';
942
+ import { Test, TestingModule } from '@nestjs/testing';
943
+ import { PubSub } from 'graphql-subscriptions';
944
+ import { MongoClient } from 'mongodb';
945
+
946
+ import envConfig from '../../src/config.env';
947
+ import { RoleEnum } from '../../src/server/common/enums/role.enum';
948
+ import { UserService } from '../../src/server/modules/user/user.service';
949
+ import { imports, ServerModule } from '../../src/server/server.module';
950
+
951
+ describe('Profile Update Story (GraphQL)', () => {
952
+ // Test environment properties
953
+ let app;
954
+ let testHelper: TestHelper;
955
+
956
+ // Database
957
+ let connection;
958
+ let db;
959
+
960
+ // Services
961
+ let userService: UserService;
962
+
963
+ // Global test data
964
+ let gNormalUserId: string;
965
+ let gNormalUserToken: string;
966
+ let gNormalUserEmail: string;
967
+ let gOtherUserId: string;
968
+ let gOtherUserToken: string;
969
+ let gAdminUserId: string;
970
+ let gAdminUserToken: string;
971
+
972
+ // Track created entities for cleanup
973
+ let createdUserIds: string[] = [];
974
+
975
+ beforeAll(async () => {
976
+ // Start server for testing
977
+ const moduleFixture: TestingModule = await Test.createTestingModule({
978
+ imports: [...imports, ServerModule],
979
+ providers: [
980
+ UserService,
981
+ {
982
+ provide: 'PUB_SUB',
983
+ useValue: new PubSub(),
984
+ },
985
+ ],
986
+ }).compile();
987
+
988
+ app = moduleFixture.createNestApplication();
989
+ app.useGlobalFilters(new HttpExceptionLogFilter());
990
+ app.setBaseViewsDir(envConfig.templates.path);
991
+ app.setViewEngine(envConfig.templates.engine);
992
+ await app.init();
993
+
994
+ testHelper = new TestHelper(app);
995
+ userService = moduleFixture.get(UserService);
996
+
997
+ // Connection to database
998
+ connection = await MongoClient.connect(envConfig.mongoose.uri);
999
+ db = await connection.db();
1000
+
1001
+ // Create normal user
1002
+ const normalPassword = Math.random().toString(36).substring(7);
1003
+ gNormalUserEmail = `user-${normalPassword}@test.com`;
1004
+ const normalSignUp = await testHelper.graphQl({
1005
+ arguments: {
1006
+ input: {
1007
+ email: gNormalUserEmail,
1008
+ firstName: 'John',
1009
+ lastName: 'Doe',
1010
+ password: normalPassword,
1011
+ },
1012
+ },
1013
+ fields: ['token', { user: ['id', 'email'] }],
1014
+ name: 'signUp',
1015
+ type: TestGraphQLType.MUTATION,
1016
+ });
1017
+ gNormalUserId = normalSignUp.user.id;
1018
+ gNormalUserToken = normalSignUp.token;
1019
+
1020
+ // Track for cleanup
1021
+ createdUserIds.push(gNormalUserId);
1022
+
1023
+ // Create other user
1024
+ const otherPassword = Math.random().toString(36).substring(7);
1025
+ const otherEmail = `other-${otherPassword}@test.com`;
1026
+ const otherSignUp = await testHelper.graphQl({
1027
+ arguments: {
1028
+ input: {
1029
+ email: otherEmail,
1030
+ firstName: 'Other',
1031
+ password: otherPassword,
1032
+ },
1033
+ },
1034
+ fields: ['token', { user: ['id'] }],
1035
+ name: 'signUp',
1036
+ type: TestGraphQLType.MUTATION,
1037
+ });
1038
+ gOtherUserId = otherSignUp.user.id;
1039
+ gOtherUserToken = otherSignUp.token;
1040
+
1041
+ // Track for cleanup
1042
+ createdUserIds.push(gOtherUserId);
1043
+
1044
+ // Create admin user
1045
+ const adminPassword = Math.random().toString(36).substring(7);
1046
+ const adminEmail = `admin-${adminPassword}@test.com`;
1047
+ const adminSignUp = await testHelper.graphQl({
1048
+ arguments: {
1049
+ input: {
1050
+ email: adminEmail,
1051
+ firstName: 'Admin',
1052
+ password: adminPassword,
1053
+ },
1054
+ },
1055
+ fields: ['token', { user: ['id'] }],
1056
+ name: 'signUp',
1057
+ type: TestGraphQLType.MUTATION,
1058
+ });
1059
+ gAdminUserId = adminSignUp.user.id;
1060
+ gAdminUserToken = adminSignUp.token;
1061
+
1062
+ // Track for cleanup
1063
+ createdUserIds.push(gAdminUserId);
1064
+
1065
+ // Set admin role
1066
+ await userService.update(gAdminUserId, { roles: [RoleEnum.ADMIN] }, gAdminUserId);
1067
+ });
1068
+
1069
+ afterAll(async () => {
1070
+ // 🧹 CLEANUP: Delete all test data created during tests
1071
+ try {
1072
+ // Delete all created users
1073
+ if (createdUserIds.length > 0) {
1074
+ await db.collection('users').deleteMany({
1075
+ _id: { $in: createdUserIds.map(id => new ObjectId(id)) }
1076
+ });
1077
+ }
1078
+ } catch (error) {
1079
+ console.error('Cleanup failed:', error);
1080
+ }
1081
+
1082
+ await connection.close();
1083
+ await app.close();
1084
+ });
1085
+
1086
+ describe('Own Profile Update', () => {
1087
+ it('should allow user to update own profile', async () => {
1088
+ const result = await testHelper.graphQl({
1089
+ arguments: {
1090
+ id: gNormalUserId,
1091
+ input: {
1092
+ firstName: 'Jane',
1093
+ lastName: 'Smith',
1094
+ phone: '+49 123 456789',
1095
+ },
1096
+ },
1097
+ fields: ['id', 'firstName', 'lastName', 'phone', 'email'],
1098
+ name: 'updateUser',
1099
+ type: TestGraphQLType.MUTATION,
1100
+ }, { token: gNormalUserToken });
1101
+
1102
+ expect(result).toMatchObject({
1103
+ id: gNormalUserId,
1104
+ firstName: 'Jane',
1105
+ lastName: 'Smith',
1106
+ phone: '+49 123 456789',
1107
+ email: gNormalUserEmail, // Email unchanged
1108
+ });
1109
+ });
1110
+
1111
+ it('should prevent user from changing email', async () => {
1112
+ const result = await testHelper.graphQl({
1113
+ arguments: {
1114
+ id: gNormalUserId,
1115
+ input: {
1116
+ firstName: 'John',
1117
+ email: 'newemail@test.com', // Attempt to change email
1118
+ },
1119
+ },
1120
+ fields: ['email'],
1121
+ name: 'updateUser',
1122
+ type: TestGraphQLType.MUTATION,
1123
+ }, { token: gNormalUserToken });
1124
+
1125
+ // Email should remain unchanged
1126
+ expect(result.email).toBe(gNormalUserEmail);
1127
+ });
1128
+ });
1129
+
1130
+ describe('Authorization', () => {
1131
+ it('should prevent user from updating other user profile', async () => {
1132
+ const result = await testHelper.graphQl({
1133
+ arguments: {
1134
+ id: gOtherUserId,
1135
+ input: {
1136
+ firstName: 'Hacker',
1137
+ },
1138
+ },
1139
+ fields: ['id', 'firstName'],
1140
+ name: 'updateUser',
1141
+ type: TestGraphQLType.MUTATION,
1142
+ }, { token: gNormalUserToken, statusCode: 200 });
1143
+
1144
+ expect(result.errors).toBeDefined();
1145
+ expect(result.errors[0].message).toContain('Forbidden');
1146
+ });
1147
+
1148
+ it('should allow admin to update any profile', async () => {
1149
+ const result = await testHelper.graphQl({
1150
+ arguments: {
1151
+ id: gNormalUserId,
1152
+ input: {
1153
+ firstName: 'AdminUpdated',
1154
+ },
1155
+ },
1156
+ fields: ['firstName'],
1157
+ name: 'updateUser',
1158
+ type: TestGraphQLType.MUTATION,
1159
+ }, { token: gAdminUserToken });
1160
+
1161
+ expect(result.firstName).toBe('AdminUpdated');
1162
+ });
1163
+ });
1164
+
1165
+ describe('Validation', () => {
1166
+ it('should reject invalid phone number format', async () => {
1167
+ const result = await testHelper.graphQl({
1168
+ arguments: {
1169
+ id: gNormalUserId,
1170
+ input: {
1171
+ phone: '123', // Invalid format
1172
+ },
1173
+ },
1174
+ fields: ['phone'],
1175
+ name: 'updateUser',
1176
+ type: TestGraphQLType.MUTATION,
1177
+ }, { token: gNormalUserToken, statusCode: 200 });
1178
+
1179
+ expect(result.errors).toBeDefined();
1180
+ expect(result.errors[0].message).toContain('phone');
1181
+ });
1182
+
1183
+ it('should accept valid German phone formats', async () => {
1184
+ const validPhones = [
1185
+ '+49 123 456789',
1186
+ '+49 (0)123 456789',
1187
+ '0123 456789',
1188
+ ];
1189
+
1190
+ for (const phone of validPhones) {
1191
+ const result = await testHelper.graphQl({
1192
+ arguments: {
1193
+ id: gNormalUserId,
1194
+ input: { phone },
1195
+ },
1196
+ fields: ['phone'],
1197
+ name: 'updateUser',
1198
+ type: TestGraphQLType.MUTATION,
1199
+ }, { token: gNormalUserToken });
1200
+
1201
+ expect(result.phone).toBe(phone);
1202
+ }
1203
+ });
1204
+ });
1205
+ });
1206
+ ```
1207
+
1208
+ ---
1209
+
1210
+ ## Debugging Test Failures
1211
+
1212
+ When your tests fail and error messages are unclear, enable debugging:
1213
+
1214
+ ### TestHelper Debugging Options
1215
+
1216
+ ```typescript
1217
+ // Add to any failing test for detailed output
1218
+ const result = await testHelper.graphQl({
1219
+ arguments: { id: userId },
1220
+ fields: ['id', 'email'],
1221
+ name: 'getUser',
1222
+ type: TestGraphQLType.MUTATION,
1223
+ }, {
1224
+ token: userToken,
1225
+ log: true, // Logs request details to console
1226
+ logError: true, // Logs detailed error information
1227
+ });
1228
+
1229
+ // Or for REST calls
1230
+ const result = await testHelper.rest('/api/endpoint', {
1231
+ method: 'POST',
1232
+ payload: data,
1233
+ token: userToken,
1234
+ log: true,
1235
+ logError: true,
1236
+ });
1237
+ ```
1238
+
1239
+ ### Server-Side Debugging
1240
+
1241
+ **Enable exception logging** in `src/config.env.ts`:
1242
+ ```typescript
1243
+ export default {
1244
+ logExceptions: true, // Shows stack traces for all exceptions
1245
+ // ... other config
1246
+ };
1247
+ ```
1248
+
1249
+ **Enable validation debugging** via environment variable:
1250
+ ```bash
1251
+ # Run tests with validation debugging
1252
+ DEBUG_VALIDATION=true npm test
1253
+ ```
1254
+
1255
+ Or set in your test file:
1256
+ ```typescript
1257
+ beforeAll(async () => {
1258
+ // Enable validation debug logging
1259
+ process.env.DEBUG_VALIDATION = 'true';
1260
+
1261
+ // ... rest of setup
1262
+ });
1263
+ ```
1264
+
1265
+ This enables detailed console.debug output from MapAndValidatePipe (`node_modules/@lenne.tech/nest-server/src/core/common/pipes/map-and-validate.pipe.ts`).
1266
+
1267
+ ### Full Debugging Setup Example
1268
+
1269
+ ```typescript
1270
+ describe('My Story Test', () => {
1271
+ beforeAll(async () => {
1272
+ // Enable validation debugging
1273
+ process.env.DEBUG_VALIDATION = 'true';
1274
+
1275
+ // ... normal setup
1276
+ });
1277
+
1278
+ it('should debug this failing test', async () => {
1279
+ const result = await testHelper.graphQl({
1280
+ // ... your test config
1281
+ }, {
1282
+ log: true, // Enable request/response logging
1283
+ logError: true, // Enable error logging
1284
+ });
1285
+ });
1286
+ });
1287
+ ```
1288
+
1289
+ **Remember to disable debugging logs before committing** to keep test output clean in CI/CD.
1290
+
1291
+ ---
1292
+
1293
+ ## Key Takeaways from Examples
1294
+
1295
+ ### 1. Test Structure
1296
+ - Always setup test data in `beforeAll`
1297
+ - Clean up in `afterAll`
1298
+ - Group related tests in `describe` blocks
1299
+ - Test happy path, validation, authorization separately
1300
+
1301
+ ### 2. Security Testing
1302
+ - Create users with different roles
1303
+ - Test both authorized and unauthorized access
1304
+ - Never weaken security to make tests pass
1305
+ - Test permission boundaries explicitly
1306
+
1307
+ ### 3. Business Logic
1308
+ - Test calculated fields (like totalPrice)
1309
+ - Test side effects (like stock reduction)
1310
+ - Test validation rules thoroughly
1311
+ - Test edge cases and error conditions
1312
+
1313
+ ### 4. Implementation Strategy
1314
+ - Use nest-server-generator for scaffolding
1315
+ - Implement business logic in services
1316
+ - Add custom validation where needed
1317
+ - Follow existing patterns in codebase
1318
+
1319
+ ### 5. Debugging
1320
+ - Use `log: true` and `logError: true` in TestHelper for detailed output
1321
+ - Enable `logExceptions` in config.env.ts for server-side errors
1322
+ - Use `DEBUG_VALIDATION=true` for validation debugging
1323
+ - Disable the debug logs again once all tests have been completed without errors
1324
+
1325
+ ### 6. Iteration
1326
+ - First run will always fail (expected)
1327
+ - Fix failures systematically
1328
+ - Enable debugging when error messages are unclear
1329
+ - Re-run tests after each change
1330
+ - Continue until all tests pass
1331
+
1332
+ Remember: **Tests define the contract, code fulfills the contract.**