opencode-andev-rules 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -64,30 +64,3 @@ scripts/
|
|
|
64
64
|
3. Chạy `npm run validate` trước khi push.
|
|
65
65
|
4. **Không sửa version bằng tay** — version do workflow publish tự tăng (xem dưới).
|
|
66
66
|
|
|
67
|
-
## Release lên npm
|
|
68
|
-
|
|
69
|
-
> npm **từ chối publish đè version cũ** — version được **tự tăng mỗi lần publish**, không ai cần sửa `package.json`.
|
|
70
|
-
|
|
71
|
-
**Cách chính — bấm nút (khuyên dùng):**
|
|
72
|
-
|
|
73
|
-
1. Vào tab **Actions** → workflow **Publish to npm** → **Run workflow**
|
|
74
|
-
2. Chọn loại tăng version: `patch` (fix skill, sửa doc) / `minor` (thêm skill) / `major` (breaking)
|
|
75
|
-
3. Workflow tự: validate → bump version → sync `skills/registry.json` → kiểm tra version chưa có trên npm → publish → push commit + tag `vX.Y.Z` về main
|
|
76
|
-
|
|
77
|
-
**Cách phụ — chạy local** (yêu cầu đã `npm login`):
|
|
78
|
-
|
|
79
|
-
```bash
|
|
80
|
-
bash scripts/release.sh patch # hoặc: minor | major | 1.2.3
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
Kiểm tra: https://www.npmjs.com/package/opencode-andev-rules
|
|
84
|
-
|
|
85
|
-
### Setup npm auth 1 lần duy nhất (chọn 1 trong 2)
|
|
86
|
-
|
|
87
|
-
**Cách A — NPM_TOKEN (nhanh nhất):**
|
|
88
|
-
1. npmjs.com → Access Tokens → Generate New Token → **Automation**
|
|
89
|
-
2. GitHub repo → Settings → Secrets → Actions → New secret `NPM_TOKEN` = token vừa tạo
|
|
90
|
-
|
|
91
|
-
**Cách B — Trusted Publishing (không cần token):**
|
|
92
|
-
1. npmjs.com → package `opencode-andev-rules` → Settings → Trusted Publisher → GitHub Actions → nhập `thaiannguyen-05/rules-for-global-agents` + workflow `publish.yml`
|
|
93
|
-
2. Xóa secret `NPM_TOKEN` (workflow tự fallback sang OIDC, Node 22 đã đủ npm ≥ 11.5.1)
|
package/package.json
CHANGED
|
@@ -252,3 +252,4 @@ export async function correlationIdPlugin(app: FastifyInstance) {
|
|
|
252
252
|
## References
|
|
253
253
|
|
|
254
254
|
- Feature slice layout, types, guards, dto, service, controller rules: `references/nestjs-module.md`.
|
|
255
|
+
- Unit, integration, E2E testing strategies, TestContainers, mocking: `references/nestjs-testing.md`.
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# NestJS Testing Strategies
|
|
2
|
+
|
|
3
|
+
## Test Types
|
|
4
|
+
|
|
5
|
+
### Test Pyramid
|
|
6
|
+
```
|
|
7
|
+
╱╲
|
|
8
|
+
╱ E2E ╲
|
|
9
|
+
╱────────╲
|
|
10
|
+
╱ Integration ╲
|
|
11
|
+
╱────────────────╲
|
|
12
|
+
╱ Unit Tests ╲
|
|
13
|
+
╱──────────────────╲
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Type | Speed | Scope | Confidence |
|
|
17
|
+
|------|-------|-------|------------|
|
|
18
|
+
| Unit | Fast | Single class/module | Low |
|
|
19
|
+
| Integration | Medium | Module interactions | Medium |
|
|
20
|
+
| E2E | Slow | Full system | High |
|
|
21
|
+
|
|
22
|
+
## Unit Testing
|
|
23
|
+
|
|
24
|
+
### Service Test
|
|
25
|
+
```typescript
|
|
26
|
+
import { Test } from '@nestjs/testing';
|
|
27
|
+
import { UserService } from './user.service';
|
|
28
|
+
import { UserRepository } from './user.repository';
|
|
29
|
+
import { EmailService } from './email.service';
|
|
30
|
+
|
|
31
|
+
describe('UserService', () => {
|
|
32
|
+
let service: UserService;
|
|
33
|
+
let userRepo: jest.Mocked<UserRepository>;
|
|
34
|
+
let emailService: jest.Mocked<EmailService>;
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
const module = await Test.createTestingModule({
|
|
38
|
+
providers: [
|
|
39
|
+
UserService,
|
|
40
|
+
{
|
|
41
|
+
provide: UserRepository,
|
|
42
|
+
useValue: {
|
|
43
|
+
findById: jest.fn(),
|
|
44
|
+
save: jest.fn(),
|
|
45
|
+
delete: jest.fn(),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
provide: EmailService,
|
|
50
|
+
useValue: {
|
|
51
|
+
sendWelcomeEmail: jest.fn(),
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
}).compile();
|
|
56
|
+
|
|
57
|
+
service = module.get(UserService);
|
|
58
|
+
userRepo = module.get(UserRepository);
|
|
59
|
+
emailService = module.get(EmailService);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('createUser', () => {
|
|
63
|
+
it('should create a user and send welcome email', async () => {
|
|
64
|
+
const dto = { email: 'test@example.com', name: 'Test' };
|
|
65
|
+
const expectedUser = { id: '1', ...dto };
|
|
66
|
+
|
|
67
|
+
userRepo.save.mockResolvedValue(expectedUser);
|
|
68
|
+
|
|
69
|
+
const result = await service.createUser(dto);
|
|
70
|
+
|
|
71
|
+
expect(result).toEqual(expectedUser);
|
|
72
|
+
expect(userRepo.save).toHaveBeenCalledWith(expect.objectContaining(dto));
|
|
73
|
+
expect(emailService.sendWelcomeEmail).toHaveBeenCalledWith(expectedUser.id, dto.email);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should throw if email already exists', async () => {
|
|
77
|
+
userRepo.findByEmail.mockResolvedValue({ id: '1', email: 'exists@example.com' });
|
|
78
|
+
|
|
79
|
+
await expect(
|
|
80
|
+
service.createUser({ email: 'exists@example.com', name: 'Test' }),
|
|
81
|
+
).rejects.toThrow('Email already exists');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Controller Test
|
|
88
|
+
```typescript
|
|
89
|
+
import { Test } from '@nestjs/testing';
|
|
90
|
+
import { UserController } from './user.controller';
|
|
91
|
+
import { UserService } from './user.service';
|
|
92
|
+
|
|
93
|
+
describe('UserController', () => {
|
|
94
|
+
let controller: UserController;
|
|
95
|
+
let service: jest.Mocked<UserService>;
|
|
96
|
+
|
|
97
|
+
beforeEach(async () => {
|
|
98
|
+
const module = await Test.createTestingModule({
|
|
99
|
+
controllers: [UserController],
|
|
100
|
+
providers: [
|
|
101
|
+
{
|
|
102
|
+
provide: UserService,
|
|
103
|
+
useValue: {
|
|
104
|
+
createUser: jest.fn(),
|
|
105
|
+
findById: jest.fn(),
|
|
106
|
+
findAll: jest.fn(),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
}).compile();
|
|
111
|
+
|
|
112
|
+
controller = module.get(UserController);
|
|
113
|
+
service = module.get(UserService);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should return created user', async () => {
|
|
117
|
+
const dto = { email: 'test@example.com', name: 'Test' };
|
|
118
|
+
const expected = { id: '1', ...dto };
|
|
119
|
+
|
|
120
|
+
service.createUser.mockResolvedValue(expected);
|
|
121
|
+
|
|
122
|
+
const result = await controller.create(dto);
|
|
123
|
+
|
|
124
|
+
expect(result).toEqual(expected);
|
|
125
|
+
expect(result).not.toHaveProperty('password');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Integration Testing
|
|
131
|
+
|
|
132
|
+
### Database Integration
|
|
133
|
+
```typescript
|
|
134
|
+
import { Test } from '@nestjs/testing';
|
|
135
|
+
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
136
|
+
import { UserService } from './user.service';
|
|
137
|
+
import { User } from './user.entity';
|
|
138
|
+
import { UserModule } from './user.module';
|
|
139
|
+
import { createTestDatabase } from '../test/utils/database';
|
|
140
|
+
|
|
141
|
+
describe('UserService Integration', () => {
|
|
142
|
+
let service: UserService;
|
|
143
|
+
let module: TestingModule;
|
|
144
|
+
|
|
145
|
+
beforeAll(async () => {
|
|
146
|
+
const database = await createTestDatabase();
|
|
147
|
+
|
|
148
|
+
module = await Test.createTestingModule({
|
|
149
|
+
imports: [
|
|
150
|
+
TypeOrmModule.forRoot(database.config),
|
|
151
|
+
UserModule,
|
|
152
|
+
],
|
|
153
|
+
}).compile();
|
|
154
|
+
|
|
155
|
+
service = module.get(UserService);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
afterAll(async () => {
|
|
159
|
+
await module.close();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
beforeEach(async () => {
|
|
163
|
+
await cleanDatabase();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should persist user to database', async () => {
|
|
167
|
+
const user = await service.createUser({
|
|
168
|
+
email: 'test@example.com',
|
|
169
|
+
name: 'Test User',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const found = await service.findById(user.id);
|
|
173
|
+
expect(found).toBeDefined();
|
|
174
|
+
expect(found.email).toBe('test@example.com');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should enforce unique email constraint', async () => {
|
|
178
|
+
await service.createUser({
|
|
179
|
+
email: 'dupe@example.com',
|
|
180
|
+
name: 'First',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await expect(
|
|
184
|
+
service.createUser({
|
|
185
|
+
email: 'dupe@example.com',
|
|
186
|
+
name: 'Second',
|
|
187
|
+
}),
|
|
188
|
+
).rejects.toThrow();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### HTTP Integration
|
|
194
|
+
```typescript
|
|
195
|
+
import * as request from 'supertest';
|
|
196
|
+
import { Test } from '@nestjs/testing';
|
|
197
|
+
import { INestApplication } from '@nestjs/common';
|
|
198
|
+
import { AppModule } from '../src/app.module';
|
|
199
|
+
|
|
200
|
+
describe('User API (integration)', () => {
|
|
201
|
+
let app: INestApplication;
|
|
202
|
+
|
|
203
|
+
beforeAll(async () => {
|
|
204
|
+
const module = await Test.createTestingModule({
|
|
205
|
+
imports: [AppModule],
|
|
206
|
+
}).compile();
|
|
207
|
+
|
|
208
|
+
app = module.createNestApplication();
|
|
209
|
+
await app.init();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
afterAll(async () => {
|
|
213
|
+
await app.close();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('POST /users', () => {
|
|
217
|
+
it('should create user', async () => {
|
|
218
|
+
const response = await request(app.getHttpServer())
|
|
219
|
+
.post('/users')
|
|
220
|
+
.send({ email: 'test@example.com', name: 'Test' })
|
|
221
|
+
.expect(201);
|
|
222
|
+
|
|
223
|
+
expect(response.body).toHaveProperty('id');
|
|
224
|
+
expect(response.body.email).toBe('test@example.com');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should reject invalid email', async () => {
|
|
228
|
+
await request(app.getHttpServer())
|
|
229
|
+
.post('/users')
|
|
230
|
+
.send({ email: 'invalid', name: 'Test' })
|
|
231
|
+
.expect(400);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## E2E Testing
|
|
238
|
+
|
|
239
|
+
### Full E2E Test
|
|
240
|
+
```typescript
|
|
241
|
+
import * as request from 'supertest';
|
|
242
|
+
import { Test } from '@nestjs/testing';
|
|
243
|
+
import { INestApplication } from '@nestjs/common';
|
|
244
|
+
import { AppModule } from '../src/app.module';
|
|
245
|
+
|
|
246
|
+
describe('User E2E', () => {
|
|
247
|
+
let app: INestApplication;
|
|
248
|
+
let authToken: string;
|
|
249
|
+
|
|
250
|
+
beforeAll(async () => {
|
|
251
|
+
const module = await Test.createTestingModule({
|
|
252
|
+
imports: [AppModule],
|
|
253
|
+
}).compile();
|
|
254
|
+
|
|
255
|
+
app = module.createNestApplication();
|
|
256
|
+
await app.init();
|
|
257
|
+
|
|
258
|
+
// Auth
|
|
259
|
+
const loginRes = await request(app.getHttpServer())
|
|
260
|
+
.post('/auth/login')
|
|
261
|
+
.send({ email: 'admin@test.com', password: 'admin123' });
|
|
262
|
+
|
|
263
|
+
authToken = loginRes.body.accessToken;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
afterAll(async () => {
|
|
267
|
+
await app.close();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('full user lifecycle', async () => {
|
|
271
|
+
// Create
|
|
272
|
+
const createRes = await request(app.getHttpServer())
|
|
273
|
+
.post('/users')
|
|
274
|
+
.set('Authorization', `Bearer ${authToken}`)
|
|
275
|
+
.send({ email: 'new@test.com', name: 'New User' })
|
|
276
|
+
.expect(201);
|
|
277
|
+
|
|
278
|
+
const userId = createRes.body.id;
|
|
279
|
+
|
|
280
|
+
// Read
|
|
281
|
+
await request(app.getHttpServer())
|
|
282
|
+
.get(`/users/${userId}`)
|
|
283
|
+
.set('Authorization', `Bearer ${authToken}`)
|
|
284
|
+
.expect(200);
|
|
285
|
+
|
|
286
|
+
// List
|
|
287
|
+
const listRes = await request(app.getHttpServer())
|
|
288
|
+
.get('/users')
|
|
289
|
+
.set('Authorization', `Bearer ${authToken}`)
|
|
290
|
+
.expect(200);
|
|
291
|
+
|
|
292
|
+
expect(listRes.body.data.length).toBeGreaterThan(0);
|
|
293
|
+
|
|
294
|
+
// Delete
|
|
295
|
+
await request(app.getHttpServer())
|
|
296
|
+
.delete(`/users/${userId}`)
|
|
297
|
+
.set('Authorization', `Bearer ${authToken}`)
|
|
298
|
+
.expect(204);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## Test Database Utilities
|
|
304
|
+
|
|
305
|
+
### Test Database Setup
|
|
306
|
+
```typescript
|
|
307
|
+
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
|
308
|
+
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
|
|
309
|
+
|
|
310
|
+
export async function createTestDatabase(): Promise<{
|
|
311
|
+
container: StartedPostgreSqlContainer;
|
|
312
|
+
config: TypeOrmModuleOptions;
|
|
313
|
+
}> {
|
|
314
|
+
const container = await new PostgreSqlContainer()
|
|
315
|
+
.withDatabase('test')
|
|
316
|
+
.withUsername('test')
|
|
317
|
+
.withPassword('test')
|
|
318
|
+
.start();
|
|
319
|
+
|
|
320
|
+
const config: TypeOrmModuleOptions = {
|
|
321
|
+
type: 'postgres',
|
|
322
|
+
host: container.getHost(),
|
|
323
|
+
port: container.getMappedPort(5432),
|
|
324
|
+
username: container.getUsername(),
|
|
325
|
+
password: container.getPassword(),
|
|
326
|
+
database: container.getDatabase(),
|
|
327
|
+
entities: ['dist/**/*.entity{.ts,.js}'],
|
|
328
|
+
synchronize: true,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return { container, config };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function cleanDatabase(): Promise<void> {
|
|
335
|
+
const entities = getMetadataArgsStorage().tables.map((t) => t.name);
|
|
336
|
+
for (const entity of entities) {
|
|
337
|
+
await getConnection().manager.query(`TRUNCATE TABLE "${entity}" CASCADE`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Mocking Strategies
|
|
343
|
+
|
|
344
|
+
### Service Mocking
|
|
345
|
+
```typescript
|
|
346
|
+
// Mock factory
|
|
347
|
+
export const createMockUserService = (): jest.Mocked<UserService> => ({
|
|
348
|
+
createUser: jest.fn(),
|
|
349
|
+
findById: jest.fn(),
|
|
350
|
+
findAll: jest.fn(),
|
|
351
|
+
updateUser: jest.fn(),
|
|
352
|
+
deleteUser: jest.fn(),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Custom provider
|
|
356
|
+
const mockUserService = createMockUserService();
|
|
357
|
+
mockUserService.findById.mockResolvedValue(testUser);
|
|
358
|
+
|
|
359
|
+
const module = await Test.createTestingModule({
|
|
360
|
+
providers: [
|
|
361
|
+
{ provide: UserService, useValue: mockUserService },
|
|
362
|
+
],
|
|
363
|
+
}).compile();
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Guard Mocking
|
|
367
|
+
```typescript
|
|
368
|
+
import { ExecutionContext } from '@nestjs/common';
|
|
369
|
+
|
|
370
|
+
export const mockAuthGuard = {
|
|
371
|
+
canActivate: jest.fn((context: ExecutionContext) => {
|
|
372
|
+
const req = context.switchToHttp().getRequest();
|
|
373
|
+
req.user = { id: 'test-user-id', roles: ['admin'] };
|
|
374
|
+
return true;
|
|
375
|
+
}),
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// Override guard in test
|
|
379
|
+
const module = await Test.createTestingModule({
|
|
380
|
+
imports: [UserModule],
|
|
381
|
+
})
|
|
382
|
+
.overrideGuard(AuthGuard('jwt'))
|
|
383
|
+
.useValue(mockAuthGuard)
|
|
384
|
+
.compile();
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
## Key Points
|
|
388
|
+
- Unit tests mock all dependencies and test single classes in isolation
|
|
389
|
+
- Integration tests use real database (TestContainers) and verify module interactions
|
|
390
|
+
- E2E tests exercise the full HTTP stack including guards, pipes, interceptors
|
|
391
|
+
- TestContainers provides disposable databases for integration testing
|
|
392
|
+
- Clean database between tests to avoid state pollution
|
|
393
|
+
- Guard mocking enables testing authenticated endpoints without tokens
|
|
394
|
+
- Service mocking with jest.Mocked<T> provides type-safe mocks
|
|
395
|
+
- Override providers and guards for controlled test scenarios
|
package/skills/registry.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "andev-skills",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "AI agent skills by thaiannguyen-05",
|
|
5
5
|
"skills": [
|
|
6
6
|
{
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"path": "skills/backend/nestJS",
|
|
34
34
|
"files": [
|
|
35
35
|
"SKILL.md",
|
|
36
|
-
"references/nestjs-module.md"
|
|
36
|
+
"references/nestjs-module.md",
|
|
37
|
+
"references/nestjs-testing.md"
|
|
37
38
|
]
|
|
38
39
|
},
|
|
39
40
|
{
|