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