langaro-api 1.2.2 → 1.2.4
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/bin/langaro-api.js +12 -2
- package/lib/cli/documentation-templates/01-architecture-overview.md +240 -0
- package/lib/cli/documentation-templates/02-crud-layer.md +504 -0
- package/lib/cli/documentation-templates/03-models.md +362 -0
- package/lib/cli/documentation-templates/04-services.md +355 -0
- package/lib/cli/documentation-templates/05-controllers.md +395 -0
- package/lib/cli/documentation-templates/06-routes.md +268 -0
- package/lib/cli/documentation-templates/07-jobs.md +361 -0
- package/lib/cli/documentation-templates/08-tasks.md +265 -0
- package/lib/cli/documentation-templates/09-middlewares.md +238 -0
- package/lib/cli/documentation-templates/10-integrations.md +332 -0
- package/lib/cli/documentation-templates/11-config-and-bootstrap.md +352 -0
- package/lib/cli/documentation-templates/12-queues.md +205 -0
- package/lib/cli/documentation-templates/13-utils.md +281 -0
- package/lib/cli/documentation-templates/14-testing.md +315 -0
- package/lib/cli/documentation-templates/15-cli-and-scaffolding.md +344 -0
- package/lib/cli/documentation-templates/SUMMARY.md +116 -0
- package/lib/cli/init.js +30 -2
- package/package.json +2 -2
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# Utils
|
|
2
|
+
|
|
3
|
+
## Role
|
|
4
|
+
|
|
5
|
+
Utils are **pure utility functions** — small, reusable helpers that don't contain business logic. They handle validation, formatting, date operations, and common transformations.
|
|
6
|
+
|
|
7
|
+
**What utils do:**
|
|
8
|
+
- Validate input (required fields, field formats)
|
|
9
|
+
- Format data (dates, currencies, strings)
|
|
10
|
+
- Calculate values (taxes, discounts)
|
|
11
|
+
- Provide shared helpers across controllers/services
|
|
12
|
+
|
|
13
|
+
**What utils do NOT do:**
|
|
14
|
+
- Access the database
|
|
15
|
+
- Call external APIs
|
|
16
|
+
- Contain business logic workflows
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Location & Naming
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
src/utils/
|
|
24
|
+
├── index.js # Re-exports common packages + local utils
|
|
25
|
+
├── sleep.js # sleep(ms) utility
|
|
26
|
+
├── validate-*.js # Validation utilities
|
|
27
|
+
├── format-*.js # Formatting utilities
|
|
28
|
+
├── calculate-*.js # Calculation utilities
|
|
29
|
+
├── determine-*.js # Decision logic
|
|
30
|
+
├── get-*.js # Data retrieval helpers
|
|
31
|
+
├── parse-*.js # Parsing utilities
|
|
32
|
+
└── factory.js # Factory patterns
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Naming convention:** Kebab-case with verb prefix describing the function category.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## The Utils Index
|
|
40
|
+
|
|
41
|
+
`src/utils/index.js` re-exports commonly used packages:
|
|
42
|
+
|
|
43
|
+
```javascript
|
|
44
|
+
export * from 'validate-required-fields';
|
|
45
|
+
export * from 'trigger-api-error';
|
|
46
|
+
export * from 'http-status-codes';
|
|
47
|
+
export * from 'forbid-fields-sent';
|
|
48
|
+
export * from 'validate-fields-format';
|
|
49
|
+
export * from './sleep';
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This allows single-line imports across the codebase:
|
|
53
|
+
|
|
54
|
+
```javascript
|
|
55
|
+
const {
|
|
56
|
+
validateRequiredFields,
|
|
57
|
+
ApiError,
|
|
58
|
+
StatusCodes,
|
|
59
|
+
validateFieldsFormat,
|
|
60
|
+
forbidFieldsSent,
|
|
61
|
+
} = require('@/utils');
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Core Validation Utilities
|
|
67
|
+
|
|
68
|
+
### validateRequiredFields
|
|
69
|
+
|
|
70
|
+
Validates that required fields are present in the data object. Throws if missing.
|
|
71
|
+
|
|
72
|
+
```javascript
|
|
73
|
+
const { validateRequiredFields } = require('@/utils');
|
|
74
|
+
|
|
75
|
+
// Throws if name or email is missing/empty
|
|
76
|
+
validateRequiredFields(data, ['name', 'email', 'company_id']);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
From package: `validate-required-fields`
|
|
80
|
+
|
|
81
|
+
### validateFieldsFormat
|
|
82
|
+
|
|
83
|
+
Validates field formats (email, phone, array, etc.). Throws if invalid.
|
|
84
|
+
|
|
85
|
+
```javascript
|
|
86
|
+
const { validateFieldsFormat } = require('@/utils');
|
|
87
|
+
|
|
88
|
+
validateFieldsFormat([
|
|
89
|
+
['email', data.email, 'email field'],
|
|
90
|
+
['phone', data.phone, 'phone field'],
|
|
91
|
+
['array', data.items, 'items field'],
|
|
92
|
+
['date', data.start_date, 'start date field'],
|
|
93
|
+
['number', data.amount, 'amount field'],
|
|
94
|
+
]);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
From package: `validate-fields-format`
|
|
98
|
+
|
|
99
|
+
### ApiError
|
|
100
|
+
|
|
101
|
+
Creates a "safe" error that returns a specific HTTP status and message to the client:
|
|
102
|
+
|
|
103
|
+
```javascript
|
|
104
|
+
const { ApiError, StatusCodes } = require('@/utils');
|
|
105
|
+
|
|
106
|
+
// 404 Not Found
|
|
107
|
+
ApiError(StatusCodes.NOT_FOUND, 'User not found');
|
|
108
|
+
|
|
109
|
+
// 400 Bad Request
|
|
110
|
+
ApiError(StatusCodes.BAD_REQUEST, 'Invalid email format');
|
|
111
|
+
|
|
112
|
+
// 409 Conflict
|
|
113
|
+
ApiError(StatusCodes.CONFLICT, 'Email already registered');
|
|
114
|
+
|
|
115
|
+
// 422 Unprocessable Entity
|
|
116
|
+
ApiError(StatusCodes.UNPROCESSABLE_ENTITY, 'Cannot cancel a completed invoice');
|
|
117
|
+
|
|
118
|
+
// With error code
|
|
119
|
+
ApiError(StatusCodes.FORBIDDEN, 'Insufficient permissions', 'PERM_DENIED');
|
|
120
|
+
|
|
121
|
+
// Passing to next() in middleware
|
|
122
|
+
ApiError(StatusCodes.UNAUTHORIZED, 'Token expired', undefined, next);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
From package: `trigger-api-error`
|
|
126
|
+
|
|
127
|
+
### forbidFieldsSent
|
|
128
|
+
|
|
129
|
+
Validates that forbidden fields are not present in the request:
|
|
130
|
+
|
|
131
|
+
```javascript
|
|
132
|
+
const { forbidFieldsSent } = require('@/utils');
|
|
133
|
+
|
|
134
|
+
// Throws if any of these fields are in data
|
|
135
|
+
forbidFieldsSent(data, ['role', 'api_key', 'password_hash']);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
From package: `forbid-fields-sent`
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Common Utility Patterns
|
|
143
|
+
|
|
144
|
+
### Filter Parsing
|
|
145
|
+
|
|
146
|
+
Utility for parsing query string filters into andWhere conditions:
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
// src/utils/parse-and-validate-filters.js
|
|
150
|
+
function parseAndValidateFilters(query, filterOptions) {
|
|
151
|
+
const andWhere = [];
|
|
152
|
+
|
|
153
|
+
if (!filterOptions) return andWhere;
|
|
154
|
+
|
|
155
|
+
filterOptions.forEach(filter => {
|
|
156
|
+
const value = query[filter.name];
|
|
157
|
+
if (value === undefined) return;
|
|
158
|
+
|
|
159
|
+
const column = filter.column || filter.name;
|
|
160
|
+
|
|
161
|
+
switch (filter.type) {
|
|
162
|
+
case 'select_multiple':
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
andWhere.push([column, 'in', value]);
|
|
165
|
+
} else {
|
|
166
|
+
andWhere.push([column, value]);
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
case 'date':
|
|
170
|
+
if (query[`${filter.name}_start`] && query[`${filter.name}_end`]) {
|
|
171
|
+
andWhere.push([column, 'between', [
|
|
172
|
+
query[`${filter.name}_start`],
|
|
173
|
+
query[`${filter.name}_end`],
|
|
174
|
+
]]);
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
case 'integer':
|
|
178
|
+
if (query[`${filter.name}_min`]) {
|
|
179
|
+
andWhere.push([column, '>=', query[`${filter.name}_min`]]);
|
|
180
|
+
}
|
|
181
|
+
if (query[`${filter.name}_max`]) {
|
|
182
|
+
andWhere.push([column, '<=', query[`${filter.name}_max`]]);
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
case 'is_not_null':
|
|
186
|
+
if (value === 'true') andWhere.push([column, '<>', null]);
|
|
187
|
+
if (value === 'false') andWhere.push([column, null]);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return andWhere;
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Security Details Extraction
|
|
197
|
+
|
|
198
|
+
```javascript
|
|
199
|
+
// src/utils/get-request-security-details.js
|
|
200
|
+
function getRequestSecurityDetails(req) {
|
|
201
|
+
return {
|
|
202
|
+
ip_address: req.clientIp,
|
|
203
|
+
device: req.headers['user-agent'],
|
|
204
|
+
os: parseOS(req.headers['user-agent']),
|
|
205
|
+
browser: parseBrowser(req.headers['user-agent']),
|
|
206
|
+
// Additional geolocation from IP if available
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Date Utilities
|
|
212
|
+
|
|
213
|
+
```javascript
|
|
214
|
+
// src/utils/date-fns.js
|
|
215
|
+
const { format, addMinutes, differenceInMilliseconds, parseISO } = require('date-fns');
|
|
216
|
+
const { toZonedTime } = require('date-fns-tz');
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
format,
|
|
220
|
+
addMinutes,
|
|
221
|
+
differenceInMilliseconds,
|
|
222
|
+
parseISO,
|
|
223
|
+
toZonedTime,
|
|
224
|
+
// Re-export for single import point
|
|
225
|
+
};
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Sleep Utility
|
|
229
|
+
|
|
230
|
+
```javascript
|
|
231
|
+
// src/utils/sleep.js
|
|
232
|
+
exports.sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Creating from Scratch
|
|
238
|
+
|
|
239
|
+
1. **Create the file:** `src/utils/{verb-noun}.js` (e.g., `validate-document.js`, `format-currency.js`)
|
|
240
|
+
2. **Export the function:**
|
|
241
|
+
```javascript
|
|
242
|
+
function validateDocument(document, type) {
|
|
243
|
+
// Validation logic
|
|
244
|
+
if (!isValid) {
|
|
245
|
+
const { ApiError, StatusCodes } = require('@/utils');
|
|
246
|
+
ApiError(StatusCodes.BAD_REQUEST, `Invalid ${type}`);
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { validateDocument };
|
|
252
|
+
```
|
|
253
|
+
3. **Import where needed:**
|
|
254
|
+
```javascript
|
|
255
|
+
const { validateDocument } = require('@/utils/validate-document');
|
|
256
|
+
```
|
|
257
|
+
4. **Optionally re-export from `utils/index.js`** if the utility is used across many files
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Anti-patterns
|
|
262
|
+
|
|
263
|
+
- **Do NOT put database access in utils.** Utils are pure functions.
|
|
264
|
+
- **Do NOT create utils that depend on `req` or `res`.** Extract data first, then pass to the util.
|
|
265
|
+
- **Do NOT create God utils** (one file with 50+ unrelated functions). Split by category.
|
|
266
|
+
- **Do NOT duplicate validation** that the CRUD layer already does. Use model schemas for field validation.
|
|
267
|
+
- **Do NOT create utils for one-off operations.** Inline the logic if it's only used once.
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Checklist
|
|
272
|
+
|
|
273
|
+
When creating a utility:
|
|
274
|
+
|
|
275
|
+
- [ ] File is in `src/utils/`
|
|
276
|
+
- [ ] Filename uses kebab-case with verb prefix
|
|
277
|
+
- [ ] Function is pure (no side effects, no database access)
|
|
278
|
+
- [ ] Throws `ApiError` for validation failures (not generic Error)
|
|
279
|
+
- [ ] Exports named functions (not default export)
|
|
280
|
+
- [ ] Added to `utils/index.js` if widely reused
|
|
281
|
+
- [ ] No HTTP-specific logic (`req`, `res`)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
## Role
|
|
4
|
+
|
|
5
|
+
Tests validate that services, controllers, and business logic work correctly. The framework provides Jest configuration, test database isolation, and queue mocking out of the box.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Configuration
|
|
10
|
+
|
|
11
|
+
### Jest Config
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
Location: jest.config.js
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```javascript
|
|
18
|
+
module.exports = {
|
|
19
|
+
clearMocks: true,
|
|
20
|
+
coverageDirectory: 'coverage',
|
|
21
|
+
coverageProvider: 'v8',
|
|
22
|
+
forceExit: true,
|
|
23
|
+
globalSetup: '<rootDir>/src/config/tests/global-setup-tests.js',
|
|
24
|
+
maxWorkers: 4,
|
|
25
|
+
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
|
|
26
|
+
setupFilesAfterEnv: ['<rootDir>/src/config/tests/setup-tests.js'],
|
|
27
|
+
slowTestThreshold: 30,
|
|
28
|
+
testEnvironment: 'node',
|
|
29
|
+
testMatch: ['**/src/**/*.spec.js'],
|
|
30
|
+
transform: { '^.+\\.js$': 'babel-jest' },
|
|
31
|
+
verbose: true,
|
|
32
|
+
};
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Key settings:**
|
|
36
|
+
- `moduleNameMapper`: Supports `@/` path alias in tests
|
|
37
|
+
- `testMatch`: Test files must be `*.spec.js` inside `src/`
|
|
38
|
+
- `globalSetup`: Runs once before all test suites
|
|
39
|
+
- `setupFilesAfterEnv`: Runs before each test file
|
|
40
|
+
- `maxWorkers: 4`: Limits parallel test runners
|
|
41
|
+
|
|
42
|
+
### Global Setup
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
Location: src/config/tests/global-setup-tests.js
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Loads `.env.test` for the test environment:
|
|
49
|
+
```javascript
|
|
50
|
+
require('dotenv').config({ path: '.env.test' });
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Test Setup
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
Location: src/config/tests/setup-tests.js
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Adds extended matchers:
|
|
60
|
+
```javascript
|
|
61
|
+
const matchers = require('jest-extended');
|
|
62
|
+
expect.extend(matchers);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Test Database
|
|
68
|
+
|
|
69
|
+
### Configuration
|
|
70
|
+
|
|
71
|
+
Create `.env.test` with a separate test database:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
NODE_ENV="test"
|
|
75
|
+
DB_HOST="127.0.0.1"
|
|
76
|
+
DB_USER="root"
|
|
77
|
+
DB_PASSWORD=""
|
|
78
|
+
DB_NAME="my_project_tests" # MUST end with '_tests'
|
|
79
|
+
DB_PORT="3306"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Critical:** The database name MUST contain `_tests`. The connection factory throws if the test environment tries to use a non-test database.
|
|
83
|
+
|
|
84
|
+
### Setup
|
|
85
|
+
|
|
86
|
+
1. Create the test database in MySQL
|
|
87
|
+
2. Run migrations against the test database
|
|
88
|
+
3. Tests use the same schema as production
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Queue Mocking
|
|
93
|
+
|
|
94
|
+
In the test environment (`NODE_ENV=test`), the queue system automatically returns a mock:
|
|
95
|
+
|
|
96
|
+
```javascript
|
|
97
|
+
// From queues.js initializeQueues:
|
|
98
|
+
if (isTestEnv) {
|
|
99
|
+
return { add: jest.fn() };
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
You can assert jobs were queued without actually processing them:
|
|
104
|
+
|
|
105
|
+
```javascript
|
|
106
|
+
expect(Queue.add).toHaveBeenCalledWith('send-mail', expect.objectContaining({
|
|
107
|
+
templateName: 'welcome',
|
|
108
|
+
}));
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Test File Naming
|
|
114
|
+
|
|
115
|
+
Test files live next to their source:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
src/database/services/
|
|
119
|
+
├── users.services.js
|
|
120
|
+
├── users.services.spec.js # Tests for users service
|
|
121
|
+
src/controllers/users/
|
|
122
|
+
├── users.controller.js
|
|
123
|
+
├── users.controller.spec.js # Tests for users controller
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Convention:** `{filename}.spec.js`
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Testing Services
|
|
131
|
+
|
|
132
|
+
```javascript
|
|
133
|
+
// src/database/services/users.services.spec.js
|
|
134
|
+
const { getAppInstance, getQueue } = require('@/config/app');
|
|
135
|
+
const knex = require('@/database/connection')('test');
|
|
136
|
+
|
|
137
|
+
let services;
|
|
138
|
+
let Queue;
|
|
139
|
+
|
|
140
|
+
beforeAll(async () => {
|
|
141
|
+
const app = getAppInstance(knex);
|
|
142
|
+
await app.readyPromise;
|
|
143
|
+
Queue = await getQueue(knex);
|
|
144
|
+
|
|
145
|
+
// Access services from the app
|
|
146
|
+
// (exact access pattern depends on project setup)
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterAll(async () => {
|
|
150
|
+
await knex.destroy();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('UsersServices', () => {
|
|
154
|
+
it('should create a user with permissions', async () => {
|
|
155
|
+
const result = await services.UsersServices.newUserRegistered({
|
|
156
|
+
name: 'Test User',
|
|
157
|
+
email: 'test@example.com',
|
|
158
|
+
password: 'password123',
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(result.success).toBe(true);
|
|
162
|
+
expect(result.data.id).toBeDefined();
|
|
163
|
+
|
|
164
|
+
// Verify permissions were created
|
|
165
|
+
const { data: permissions } = await services.UsersPermissionsServices.getWhere(
|
|
166
|
+
'user_id', result.data.id
|
|
167
|
+
);
|
|
168
|
+
expect(permissions.length).toBeGreaterThan(0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Testing Controllers / Routes
|
|
176
|
+
|
|
177
|
+
Using supertest:
|
|
178
|
+
|
|
179
|
+
```javascript
|
|
180
|
+
const request = require('supertest');
|
|
181
|
+
const { getAppInstance } = require('@/config/app');
|
|
182
|
+
const knex = require('@/database/connection')('test');
|
|
183
|
+
|
|
184
|
+
let app;
|
|
185
|
+
let token;
|
|
186
|
+
|
|
187
|
+
beforeAll(async () => {
|
|
188
|
+
const appInstance = getAppInstance(knex);
|
|
189
|
+
await appInstance.readyPromise;
|
|
190
|
+
app = appInstance.express;
|
|
191
|
+
|
|
192
|
+
// Get auth token
|
|
193
|
+
const loginResponse = await request(app)
|
|
194
|
+
.post('/auth/login')
|
|
195
|
+
.send({ email: 'test@example.com', password: 'password123' });
|
|
196
|
+
token = loginResponse.body.data.token;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
afterAll(async () => {
|
|
200
|
+
await knex.destroy();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('GET /users', () => {
|
|
204
|
+
it('should return user profile', async () => {
|
|
205
|
+
const response = await request(app)
|
|
206
|
+
.get('/users')
|
|
207
|
+
.set('Authorization', `Bearer ${token}`)
|
|
208
|
+
.expect(200);
|
|
209
|
+
|
|
210
|
+
expect(response.body.data.email).toBe('test@example.com');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should reject unauthenticated requests', async () => {
|
|
214
|
+
await request(app)
|
|
215
|
+
.get('/users')
|
|
216
|
+
.expect(403);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Test Data Management
|
|
224
|
+
|
|
225
|
+
### Transaction Rollback Pattern
|
|
226
|
+
|
|
227
|
+
Wrap each test in a transaction that rolls back:
|
|
228
|
+
|
|
229
|
+
```javascript
|
|
230
|
+
let trx;
|
|
231
|
+
|
|
232
|
+
beforeEach(async () => {
|
|
233
|
+
trx = await knex.transaction();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
afterEach(async () => {
|
|
237
|
+
await trx.rollback();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should create something', async () => {
|
|
241
|
+
// Use trx for all operations — rolled back after test
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Truncation Pattern
|
|
246
|
+
|
|
247
|
+
Clean specific tables between tests:
|
|
248
|
+
|
|
249
|
+
```javascript
|
|
250
|
+
afterEach(async () => {
|
|
251
|
+
await knex('users').truncate();
|
|
252
|
+
await knex('companies').truncate();
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Scripts
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
# Run all tests
|
|
262
|
+
npm test
|
|
263
|
+
|
|
264
|
+
# Run specific test file
|
|
265
|
+
npx jest src/database/services/users.services.spec.js
|
|
266
|
+
|
|
267
|
+
# Watch mode (re-run on file changes)
|
|
268
|
+
npm run test:watch
|
|
269
|
+
|
|
270
|
+
# With coverage report
|
|
271
|
+
npm run test:coverage
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## Creating from Scratch
|
|
277
|
+
|
|
278
|
+
1. **Create the test file:** `{component}.spec.js` next to the component
|
|
279
|
+
2. **Use the template:**
|
|
280
|
+
```javascript
|
|
281
|
+
describe('ComponentName', () => {
|
|
282
|
+
it('should do something', async () => {
|
|
283
|
+
// Arrange
|
|
284
|
+
// Act
|
|
285
|
+
// Assert
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
```
|
|
289
|
+
3. **Set up test fixtures** (database connection, auth tokens)
|
|
290
|
+
4. **Clean up** in `afterAll` / `afterEach`
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Anti-patterns
|
|
295
|
+
|
|
296
|
+
- **Do NOT test against the production database.** Always use `.env.test` with a `_tests` database.
|
|
297
|
+
- **Do NOT forget `forceExit: true`** — without it, Jest may hang due to open connections.
|
|
298
|
+
- **Do NOT import services directly.** Use the app bootstrap to get properly initialized instances.
|
|
299
|
+
- **Do NOT test CRUD methods directly** (they're tested by knex-extended-crud). Test your custom service methods and controller logic.
|
|
300
|
+
- **Do NOT skip cleanup.** Leftover test data causes flaky tests.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Checklist
|
|
305
|
+
|
|
306
|
+
When writing tests:
|
|
307
|
+
|
|
308
|
+
- [ ] Test file is named `{component}.spec.js`
|
|
309
|
+
- [ ] Test file is next to the source file
|
|
310
|
+
- [ ] Uses `.env.test` database (name contains `_tests`)
|
|
311
|
+
- [ ] Database connections destroyed in `afterAll`
|
|
312
|
+
- [ ] Test data cleaned up between tests
|
|
313
|
+
- [ ] Auth tokens obtained via login (not hardcoded)
|
|
314
|
+
- [ ] Queue interactions verified via `jest.fn()` assertions
|
|
315
|
+
- [ ] Tests are independent (don't depend on execution order)
|