@martinffx/atelier 1.2.2 → 1.3.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/CHANGELOG.md +19 -0
- package/README.md +22 -24
- package/agents/architect.md +1 -1
- package/agents/oracle.md +1 -1
- package/dist/atelier.js +12 -14
- package/package.json +1 -1
- package/skills/code-commit/SKILL.md +5 -13
- package/skills/code-pull-request/SKILL.md +36 -0
- package/skills/code-pull-request/references/common.md +76 -0
- package/skills/code-pull-request/references/create-pr.md +122 -0
- package/skills/code-pull-request/references/default-template.md +25 -0
- package/skills/code-pull-request/references/leave-comment.md +53 -0
- package/skills/code-pull-request/references/merge-pr.md +94 -0
- package/skills/code-pull-request/references/read-comments.md +61 -0
- package/skills/code-pull-request/references/respond-to-comment.md +66 -0
- package/skills/code-review/SKILL.md +21 -12
- package/skills/code-review/references/reviewers.md +142 -41
- package/skills/code-review/references/rq.md +48 -32
- package/skills/oracle-debug/SKILL.md +218 -0
- package/skills/{code-debug → oracle-debug}/references/bug-patterns.md +3 -3
- package/skills/oracle-domain-modelling/SKILL.md +84 -0
- package/skills/oracle-grill-me/SKILL.md +20 -0
- package/skills/spec-brainstorm/SKILL.md +91 -71
- package/skills/spec-finish/SKILL.md +20 -14
- package/skills/spec-implement/SKILL.md +3 -2
- package/skills/spec-orchestrator/SKILL.md +4 -15
- package/skills/spec-plan/SKILL.md +1 -1
- package/agents/scout.md +0 -40
- package/skills/code-commit/references/pr-workflow.md +0 -145
- package/skills/code-debug/SKILL.md +0 -199
- package/skills/code-docs/SKILL.md +0 -171
- package/skills/code-docs/references/api-docs.md +0 -116
- package/skills/code-docs/references/changelog.md +0 -64
- package/skills/code-docs/references/readme-structure.md +0 -94
- package/skills/oracle-architect/SKILL.md +0 -327
- package/skills/oracle-architect/references/api-design.md +0 -345
- package/skills/oracle-architect/references/data-modeling.md +0 -318
- package/skills/oracle-architect/references/ddd-patterns.md +0 -348
- package/skills/oracle-architect/references/interface-design.md +0 -203
- package/skills/oracle-challenge/SKILL.md +0 -126
- package/skills/oracle-doubt/SKILL.md +0 -252
- package/skills/oracle-doubt/references/adversarial-prompt.md +0 -47
- package/skills/oracle-grillme/SKILL.md +0 -160
- package/skills/oracle-security/SKILL.md +0 -225
- package/skills/oracle-security/references/security-patterns.md +0 -236
- package/skills/oracle-security/references/threat-modeling-guide.md +0 -137
- package/skills/oracle-testing/SKILL.md +0 -237
- package/skills/oracle-testing/references/anti-patterns.md +0 -618
- package/skills/oracle-testing/references/boundaries.md +0 -579
- package/skills/oracle-testing/references/core-testing.md +0 -691
- package/skills/oracle-testing/references/edge-testing.md +0 -794
- package/skills/oracle-testing/references/stub-driven-tdd.md +0 -322
- package/skills/oracle-thinkdeep/SKILL.md +0 -164
- /package/skills/{code-debug → oracle-debug}/references/language-tools.md +0 -0
- /package/skills/{code-debug → oracle-debug}/references/techniques.md +0 -0
- /package/skills/{oracle-grillme → oracle-domain-modelling}/references/ADR-FORMAT.md +0 -0
- /package/skills/{oracle-grillme → oracle-domain-modelling}/references/CONTEXT-FORMAT.md +0 -0
|
@@ -1,691 +0,0 @@
|
|
|
1
|
-
# Core Testing Patterns
|
|
2
|
-
|
|
3
|
-
Comprehensive Entity and Service testing patterns for functional core testing.
|
|
4
|
-
|
|
5
|
-
## Entity Testing Strategy
|
|
6
|
-
|
|
7
|
-
Entities are pure functions with no dependencies. Test all business logic here.
|
|
8
|
-
|
|
9
|
-
### What to Test
|
|
10
|
-
|
|
11
|
-
- Validation rules (required fields, constraints, ranges)
|
|
12
|
-
- Business rules (state machines, invariants, constraints)
|
|
13
|
-
- Data transformations (fromRequest, toRecord, toResponse)
|
|
14
|
-
- Edge cases and error conditions
|
|
15
|
-
- Calculated properties
|
|
16
|
-
|
|
17
|
-
### What NOT to Test
|
|
18
|
-
|
|
19
|
-
- Simple getters/setters
|
|
20
|
-
- Property assignments
|
|
21
|
-
- Framework code
|
|
22
|
-
- Trivial mappings without logic
|
|
23
|
-
|
|
24
|
-
### Entity Test Structure
|
|
25
|
-
|
|
26
|
-
```typescript
|
|
27
|
-
describe('Entity', () => {
|
|
28
|
-
describe('validation', () => {
|
|
29
|
-
// Required fields, constraints, formats
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe('business rules', () => {
|
|
33
|
-
// State transitions, constraints, invariants
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe('transformations', () => {
|
|
37
|
-
// fromRequest, toRecord, toResponse, fromRecord
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe('calculated properties', () => {
|
|
41
|
-
// Properties derived from other data
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe('invariants', () => {
|
|
45
|
-
// Conditions that must always be true
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
## Comprehensive Entity Example
|
|
51
|
-
|
|
52
|
-
```typescript
|
|
53
|
-
import { describe, it, expect } from 'vitest';
|
|
54
|
-
import { Order, OrderStatus } from './order';
|
|
55
|
-
import { Ok, Err } from './result';
|
|
56
|
-
|
|
57
|
-
describe('Order Entity', () => {
|
|
58
|
-
describe('validation', () => {
|
|
59
|
-
it('rejects order with empty items', () => {
|
|
60
|
-
const order = new Order('1', 'C1', [], OrderStatus.PENDING, 0);
|
|
61
|
-
const result = order.validate();
|
|
62
|
-
|
|
63
|
-
expect(result.ok).toBe(false);
|
|
64
|
-
expect(result.error).toContain('at least one item');
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('rejects order with negative total', () => {
|
|
68
|
-
const order = new Order(
|
|
69
|
-
'1',
|
|
70
|
-
'C1',
|
|
71
|
-
[{ productId: 'P1', quantity: 1, price: 10 }],
|
|
72
|
-
OrderStatus.PENDING,
|
|
73
|
-
-50
|
|
74
|
-
);
|
|
75
|
-
const result = order.validate();
|
|
76
|
-
|
|
77
|
-
expect(result.ok).toBe(false);
|
|
78
|
-
expect(result.error).toContain('total must be positive');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('rejects order with invalid customer id', () => {
|
|
82
|
-
const order = new Order('1', '', [{ productId: 'P1', quantity: 1, price: 10 }], OrderStatus.PENDING, 10);
|
|
83
|
-
const result = order.validate();
|
|
84
|
-
|
|
85
|
-
expect(result.ok).toBe(false);
|
|
86
|
-
expect(result.error).toContain('customer id');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('accepts valid order', () => {
|
|
90
|
-
const order = new Order(
|
|
91
|
-
'1',
|
|
92
|
-
'C1',
|
|
93
|
-
[{ productId: 'P1', quantity: 1, price: 10 }],
|
|
94
|
-
OrderStatus.PENDING,
|
|
95
|
-
10
|
|
96
|
-
);
|
|
97
|
-
const result = order.validate();
|
|
98
|
-
|
|
99
|
-
expect(result.ok).toBe(true);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('validates all items have positive quantity', () => {
|
|
103
|
-
const order = new Order(
|
|
104
|
-
'1',
|
|
105
|
-
'C1',
|
|
106
|
-
[
|
|
107
|
-
{ productId: 'P1', quantity: 1, price: 10 },
|
|
108
|
-
{ productId: 'P2', quantity: 0, price: 5 }
|
|
109
|
-
],
|
|
110
|
-
OrderStatus.PENDING,
|
|
111
|
-
10
|
|
112
|
-
);
|
|
113
|
-
const result = order.validate();
|
|
114
|
-
|
|
115
|
-
expect(result.ok).toBe(false);
|
|
116
|
-
expect(result.error).toContain('quantity');
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe('business rules', () => {
|
|
121
|
-
describe('order cancellation', () => {
|
|
122
|
-
it('allows cancellation of pending order', () => {
|
|
123
|
-
const order = new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 10 }], OrderStatus.PENDING, 10);
|
|
124
|
-
expect(order.canCancel()).toBe(true);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('prevents cancellation of shipped order', () => {
|
|
128
|
-
const order = new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 10 }], OrderStatus.SHIPPED, 10);
|
|
129
|
-
expect(order.canCancel()).toBe(false);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('prevents cancellation of delivered order', () => {
|
|
133
|
-
const order = new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 10 }], OrderStatus.DELIVERED, 10);
|
|
134
|
-
expect(order.canCancel()).toBe(false);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('prevents cancellation of cancelled order', () => {
|
|
138
|
-
const order = new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 10 }], OrderStatus.CANCELLED, 10);
|
|
139
|
-
expect(order.canCancel()).toBe(false);
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe('status transitions', () => {
|
|
144
|
-
it('allows valid transition from pending to shipped', () => {
|
|
145
|
-
const order = new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 10 }], OrderStatus.PENDING, 10);
|
|
146
|
-
expect(() => order.updateStatus(OrderStatus.SHIPPED)).not.toThrow();
|
|
147
|
-
expect(order.status).toBe(OrderStatus.SHIPPED);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('prevents invalid transition from shipped back to pending', () => {
|
|
151
|
-
const order = new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 10 }], OrderStatus.SHIPPED, 10);
|
|
152
|
-
expect(() => order.updateStatus(OrderStatus.PENDING)).toThrow('Invalid status transition');
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('prevents invalid transition from delivered to pending', () => {
|
|
156
|
-
const order = new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 10 }], OrderStatus.DELIVERED, 10);
|
|
157
|
-
expect(() => order.updateStatus(OrderStatus.PENDING)).toThrow('Invalid status transition');
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
describe('discount application', () => {
|
|
162
|
-
it('applies percentage discount', () => {
|
|
163
|
-
const order = new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 100 }], OrderStatus.PENDING, 100);
|
|
164
|
-
const discounted = order.applyDiscount(0.1); // 10% off
|
|
165
|
-
|
|
166
|
-
expect(discounted.total).toBe(90);
|
|
167
|
-
expect(order.total).toBe(100); // Original unchanged
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('prevents discount over 50%', () => {
|
|
171
|
-
const order = new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 100 }], OrderStatus.PENDING, 100);
|
|
172
|
-
|
|
173
|
-
expect(() => order.applyDiscount(0.6)).toThrow('Discount exceeds maximum');
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('prevents negative discount', () => {
|
|
177
|
-
const order = new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 100 }], OrderStatus.PENDING, 100);
|
|
178
|
-
|
|
179
|
-
expect(() => order.applyDiscount(-0.1)).toThrow('Discount must be non-negative');
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
describe('transformations', () => {
|
|
185
|
-
describe('fromRequest', () => {
|
|
186
|
-
it('transforms request to entity with calculated total', () => {
|
|
187
|
-
const order = Order.fromRequest({
|
|
188
|
-
customerId: 'C1',
|
|
189
|
-
items: [
|
|
190
|
-
{ productId: 'P1', quantity: 2, price: 10 },
|
|
191
|
-
{ productId: 'P2', quantity: 1, price: 15 }
|
|
192
|
-
]
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
expect(order.customerId).toBe('C1');
|
|
196
|
-
expect(order.total).toBe(35);
|
|
197
|
-
expect(order.status).toBe(OrderStatus.PENDING);
|
|
198
|
-
expect(order.items).toHaveLength(2);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('assigns unique id on creation', () => {
|
|
202
|
-
const order1 = Order.fromRequest({
|
|
203
|
-
customerId: 'C1',
|
|
204
|
-
items: [{ productId: 'P1', quantity: 1, price: 10 }]
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
const order2 = Order.fromRequest({
|
|
208
|
-
customerId: 'C1',
|
|
209
|
-
items: [{ productId: 'P1', quantity: 1, price: 10 }]
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
expect(order1.id).not.toBe(order2.id);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('creates timestamp on creation', () => {
|
|
216
|
-
const before = Date.now();
|
|
217
|
-
const order = Order.fromRequest({
|
|
218
|
-
customerId: 'C1',
|
|
219
|
-
items: [{ productId: 'P1', quantity: 1, price: 10 }]
|
|
220
|
-
});
|
|
221
|
-
const after = Date.now();
|
|
222
|
-
|
|
223
|
-
expect(order.createdAt.getTime()).toBeGreaterThanOrEqual(before);
|
|
224
|
-
expect(order.createdAt.getTime()).toBeLessThanOrEqual(after);
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
describe('toRecord', () => {
|
|
229
|
-
it('converts entity to database record', () => {
|
|
230
|
-
const order = new Order(
|
|
231
|
-
'123',
|
|
232
|
-
'C1',
|
|
233
|
-
[{ productId: 'P1', quantity: 2, price: 10 }],
|
|
234
|
-
OrderStatus.PENDING,
|
|
235
|
-
20,
|
|
236
|
-
new Date('2025-01-01')
|
|
237
|
-
);
|
|
238
|
-
|
|
239
|
-
const record = order.toRecord();
|
|
240
|
-
|
|
241
|
-
expect(record.id).toBe('123');
|
|
242
|
-
expect(record.customer_id).toBe('C1');
|
|
243
|
-
expect(record.status).toBe('pending');
|
|
244
|
-
expect(record.total).toBe(20);
|
|
245
|
-
expect(record.items).toBe(JSON.stringify([{ productId: 'P1', quantity: 2, price: 10 }]));
|
|
246
|
-
expect(record.created_at).toBe('2025-01-01T00:00:00.000Z');
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('preserves nested item structure', () => {
|
|
250
|
-
const items = [
|
|
251
|
-
{ productId: 'P1', quantity: 2, price: 10 },
|
|
252
|
-
{ productId: 'P2', quantity: 1, price: 15 }
|
|
253
|
-
];
|
|
254
|
-
const order = new Order('123', 'C1', items, OrderStatus.PENDING, 35);
|
|
255
|
-
|
|
256
|
-
const record = order.toRecord();
|
|
257
|
-
const parsed = JSON.parse(record.items);
|
|
258
|
-
|
|
259
|
-
expect(parsed).toEqual(items);
|
|
260
|
-
});
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
describe('fromRecord', () => {
|
|
264
|
-
it('converts database record back to entity', () => {
|
|
265
|
-
const record = {
|
|
266
|
-
id: '123',
|
|
267
|
-
customer_id: 'C1',
|
|
268
|
-
items: '[{"productId":"P1","quantity":2,"price":10}]',
|
|
269
|
-
status: 'pending',
|
|
270
|
-
total: 20,
|
|
271
|
-
created_at: '2025-01-01T00:00:00.000Z'
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
const order = Order.fromRecord(record);
|
|
275
|
-
|
|
276
|
-
expect(order.id).toBe('123');
|
|
277
|
-
expect(order.customerId).toBe('C1');
|
|
278
|
-
expect(order.status).toBe(OrderStatus.PENDING);
|
|
279
|
-
expect(order.total).toBe(20);
|
|
280
|
-
expect(order.items).toHaveLength(1);
|
|
281
|
-
expect(order.items[0].productId).toBe('P1');
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it('parses nested JSON items correctly', () => {
|
|
285
|
-
const record = {
|
|
286
|
-
id: '123',
|
|
287
|
-
customer_id: 'C1',
|
|
288
|
-
items: '[{"productId":"P1","quantity":2,"price":10},{"productId":"P2","quantity":1,"price":15}]',
|
|
289
|
-
status: 'pending',
|
|
290
|
-
total: 35,
|
|
291
|
-
created_at: '2025-01-01T00:00:00.000Z'
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const order = Order.fromRecord(record);
|
|
295
|
-
|
|
296
|
-
expect(order.items).toHaveLength(2);
|
|
297
|
-
expect(order.items[1].productId).toBe('P2');
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it('preserves timestamps from record', () => {
|
|
301
|
-
const record = {
|
|
302
|
-
id: '123',
|
|
303
|
-
customer_id: 'C1',
|
|
304
|
-
items: '[]',
|
|
305
|
-
status: 'pending',
|
|
306
|
-
total: 0,
|
|
307
|
-
created_at: '2024-06-15T10:30:00.000Z'
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
const order = Order.fromRecord(record);
|
|
311
|
-
|
|
312
|
-
expect(order.createdAt).toEqual(new Date('2024-06-15T10:30:00.000Z'));
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
describe('toResponse', () => {
|
|
317
|
-
it('converts entity to API response', () => {
|
|
318
|
-
const order = new Order(
|
|
319
|
-
'123',
|
|
320
|
-
'C1',
|
|
321
|
-
[{ productId: 'P1', quantity: 2, price: 10 }],
|
|
322
|
-
OrderStatus.PENDING,
|
|
323
|
-
20
|
|
324
|
-
);
|
|
325
|
-
|
|
326
|
-
const response = order.toResponse();
|
|
327
|
-
|
|
328
|
-
expect(response.id).toBe('123');
|
|
329
|
-
expect(response.customerId).toBe('C1');
|
|
330
|
-
expect(response.status).toBe('pending');
|
|
331
|
-
expect(response.total).toBe(20);
|
|
332
|
-
expect(response.items).toHaveLength(1);
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
it('includes calculated fields in response', () => {
|
|
336
|
-
const order = new Order(
|
|
337
|
-
'123',
|
|
338
|
-
'C1',
|
|
339
|
-
[{ productId: 'P1', quantity: 2, price: 10 }],
|
|
340
|
-
OrderStatus.PENDING,
|
|
341
|
-
20
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
const response = order.toResponse();
|
|
345
|
-
|
|
346
|
-
expect(response).toHaveProperty('id');
|
|
347
|
-
expect(response).toHaveProperty('customerId');
|
|
348
|
-
expect(response).toHaveProperty('status');
|
|
349
|
-
expect(response).toHaveProperty('total');
|
|
350
|
-
expect(response).toHaveProperty('createdAt');
|
|
351
|
-
});
|
|
352
|
-
});
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
describe('calculated properties', () => {
|
|
356
|
-
it('calculates total from items correctly', () => {
|
|
357
|
-
const order = new Order(
|
|
358
|
-
'1',
|
|
359
|
-
'C1',
|
|
360
|
-
[
|
|
361
|
-
{ productId: 'P1', quantity: 2, price: 10 },
|
|
362
|
-
{ productId: 'P2', quantity: 1, price: 15 }
|
|
363
|
-
],
|
|
364
|
-
OrderStatus.PENDING,
|
|
365
|
-
35
|
|
366
|
-
);
|
|
367
|
-
|
|
368
|
-
// If total is recalculated
|
|
369
|
-
const calculated = order.items.reduce((sum, item) => sum + item.quantity * item.price, 0);
|
|
370
|
-
expect(calculated).toBe(35);
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
it('updates total when discount applied', () => {
|
|
374
|
-
const order = new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 100 }], OrderStatus.PENDING, 100);
|
|
375
|
-
const discounted = order.applyDiscount(0.2);
|
|
376
|
-
|
|
377
|
-
expect(discounted.total).toBe(80);
|
|
378
|
-
});
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
describe('invariants', () => {
|
|
382
|
-
it('maintains total consistency with items', () => {
|
|
383
|
-
const items = [
|
|
384
|
-
{ productId: 'P1', quantity: 2, price: 10 },
|
|
385
|
-
{ productId: 'P2', quantity: 3, price: 5 }
|
|
386
|
-
];
|
|
387
|
-
const expected = 2 * 10 + 3 * 5; // 35
|
|
388
|
-
|
|
389
|
-
const order = new Order('1', 'C1', items, OrderStatus.PENDING, expected);
|
|
390
|
-
|
|
391
|
-
const calculated = order.items.reduce((sum, item) => sum + item.quantity * item.price, 0);
|
|
392
|
-
expect(order.total).toBe(calculated);
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
it('never allows negative total', () => {
|
|
396
|
-
expect(() => {
|
|
397
|
-
new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 10 }], OrderStatus.PENDING, -10);
|
|
398
|
-
}).toThrow('Total cannot be negative');
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
it('ensures status is always valid', () => {
|
|
402
|
-
const order = new Order('1', 'C1', [{ productId: 'P1', quantity: 1, price: 10 }], OrderStatus.PENDING, 10);
|
|
403
|
-
|
|
404
|
-
// All valid statuses should work
|
|
405
|
-
expect(() => order.updateStatus(OrderStatus.SHIPPED)).not.toThrow();
|
|
406
|
-
expect(() => order.updateStatus(OrderStatus.DELIVERED)).not.toThrow();
|
|
407
|
-
});
|
|
408
|
-
});
|
|
409
|
-
});
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
## Service Testing Strategy
|
|
413
|
-
|
|
414
|
-
Services orchestrate entities and repositories. Test the business workflow.
|
|
415
|
-
|
|
416
|
-
### What to Test
|
|
417
|
-
|
|
418
|
-
- Business workflow execution
|
|
419
|
-
- Orchestration of multiple entities
|
|
420
|
-
- Error propagation and handling
|
|
421
|
-
- Repository interaction patterns
|
|
422
|
-
- Validation and state transitions
|
|
423
|
-
|
|
424
|
-
### What NOT to Test
|
|
425
|
-
|
|
426
|
-
- Repository implementation details
|
|
427
|
-
- External API behavior
|
|
428
|
-
- Database queries
|
|
429
|
-
- Internal helper methods
|
|
430
|
-
|
|
431
|
-
### Service Test Structure
|
|
432
|
-
|
|
433
|
-
```typescript
|
|
434
|
-
describe('Service', () => {
|
|
435
|
-
describe('business_workflow', () => {
|
|
436
|
-
it('executes workflow with valid data', async () => {
|
|
437
|
-
// Setup stubs
|
|
438
|
-
// Execute workflow
|
|
439
|
-
// Assert business outcome
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
it('handles error conditions', async () => {
|
|
443
|
-
// Setup error state
|
|
444
|
-
// Execute workflow
|
|
445
|
-
// Assert error propagation
|
|
446
|
-
});
|
|
447
|
-
});
|
|
448
|
-
});
|
|
449
|
-
```
|
|
450
|
-
|
|
451
|
-
## Comprehensive Service Example
|
|
452
|
-
|
|
453
|
-
```typescript
|
|
454
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
455
|
-
import { OrderService } from './order-service';
|
|
456
|
-
import { OrderRepository } from './order-repository';
|
|
457
|
-
import { InventoryRepository } from './inventory-repository';
|
|
458
|
-
import { Order, OrderStatus } from './order';
|
|
459
|
-
import { Ok, Err } from './result';
|
|
460
|
-
|
|
461
|
-
describe('OrderService', () => {
|
|
462
|
-
let service: OrderService;
|
|
463
|
-
let mockOrderRepo: OrderRepository;
|
|
464
|
-
let mockInventoryRepo: InventoryRepository;
|
|
465
|
-
|
|
466
|
-
beforeEach(() => {
|
|
467
|
-
mockOrderRepo = {
|
|
468
|
-
save: vi.fn(),
|
|
469
|
-
findById: vi.fn(),
|
|
470
|
-
delete: vi.fn(),
|
|
471
|
-
findByCustomerId: vi.fn()
|
|
472
|
-
};
|
|
473
|
-
|
|
474
|
-
mockInventoryRepo = {
|
|
475
|
-
checkAvailability: vi.fn(),
|
|
476
|
-
reserve: vi.fn(),
|
|
477
|
-
release: vi.fn(),
|
|
478
|
-
getStock: vi.fn()
|
|
479
|
-
};
|
|
480
|
-
|
|
481
|
-
service = new OrderService(mockOrderRepo, mockInventoryRepo);
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
describe('createOrder', () => {
|
|
485
|
-
it('creates order with valid data', async () => {
|
|
486
|
-
mockInventoryRepo.checkAvailability.mockResolvedValue({ available: true });
|
|
487
|
-
mockOrderRepo.save.mockResolvedValue({
|
|
488
|
-
id: '123',
|
|
489
|
-
customer_id: 'C1',
|
|
490
|
-
items: '[]',
|
|
491
|
-
status: 'pending',
|
|
492
|
-
total: 35
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
const result = await service.createOrder({
|
|
496
|
-
customerId: 'C1',
|
|
497
|
-
items: [
|
|
498
|
-
{ productId: 'P1', quantity: 2, price: 10 },
|
|
499
|
-
{ productId: 'P2', quantity: 1, price: 15 }
|
|
500
|
-
]
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
expect(result.ok).toBe(true);
|
|
504
|
-
if (result.ok) {
|
|
505
|
-
expect(result.value.total).toBe(35);
|
|
506
|
-
}
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
it('validates inventory before saving', async () => {
|
|
510
|
-
mockInventoryRepo.checkAvailability.mockResolvedValue({ available: true });
|
|
511
|
-
mockOrderRepo.save.mockResolvedValue({ id: '123', customer_id: 'C1', items: '[]', status: 'pending', total: 20 });
|
|
512
|
-
|
|
513
|
-
await service.createOrder({
|
|
514
|
-
customerId: 'C1',
|
|
515
|
-
items: [
|
|
516
|
-
{ productId: 'P1', quantity: 2 },
|
|
517
|
-
{ productId: 'P2', quantity: 3 }
|
|
518
|
-
]
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
expect(mockInventoryRepo.checkAvailability).toHaveBeenCalledWith(
|
|
522
|
-
expect.arrayContaining([
|
|
523
|
-
expect.objectContaining({ productId: 'P1', quantity: 2 }),
|
|
524
|
-
expect.objectContaining({ productId: 'P2', quantity: 3 })
|
|
525
|
-
])
|
|
526
|
-
);
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
it('does not save when inventory unavailable', async () => {
|
|
530
|
-
mockInventoryRepo.checkAvailability.mockResolvedValue({ available: false, reason: 'P1 out of stock' });
|
|
531
|
-
|
|
532
|
-
const result = await service.createOrder({
|
|
533
|
-
customerId: 'C1',
|
|
534
|
-
items: [{ productId: 'P1', quantity: 100 }]
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
expect(result.ok).toBe(false);
|
|
538
|
-
expect(mockOrderRepo.save).not.toHaveBeenCalled();
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
it('reserves inventory when order is created', async () => {
|
|
542
|
-
mockInventoryRepo.checkAvailability.mockResolvedValue({ available: true });
|
|
543
|
-
mockOrderRepo.save.mockResolvedValue({ id: '123', customer_id: 'C1', items: '[]', status: 'pending', total: 20 });
|
|
544
|
-
|
|
545
|
-
await service.createOrder({
|
|
546
|
-
customerId: 'C1',
|
|
547
|
-
items: [{ productId: 'P1', quantity: 2 }]
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
expect(mockInventoryRepo.reserve).toHaveBeenCalledWith(
|
|
551
|
-
expect.arrayContaining([
|
|
552
|
-
expect.objectContaining({ productId: 'P1', quantity: 2 })
|
|
553
|
-
])
|
|
554
|
-
);
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
it('releases inventory if save fails', async () => {
|
|
558
|
-
mockInventoryRepo.checkAvailability.mockResolvedValue({ available: true });
|
|
559
|
-
mockOrderRepo.save.mockRejectedValue(new Error('Database error'));
|
|
560
|
-
|
|
561
|
-
const result = await service.createOrder({
|
|
562
|
-
customerId: 'C1',
|
|
563
|
-
items: [{ productId: 'P1', quantity: 2 }]
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
expect(result.ok).toBe(false);
|
|
567
|
-
expect(mockInventoryRepo.release).toHaveBeenCalled();
|
|
568
|
-
});
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
describe('cancelOrder', () => {
|
|
572
|
-
it('cancels pending order successfully', async () => {
|
|
573
|
-
const orderRecord = {
|
|
574
|
-
id: '123',
|
|
575
|
-
customer_id: 'C1',
|
|
576
|
-
items: '[{"productId":"P1","quantity":2,"price":10}]',
|
|
577
|
-
status: 'pending',
|
|
578
|
-
total: 20
|
|
579
|
-
};
|
|
580
|
-
|
|
581
|
-
mockOrderRepo.findById.mockResolvedValue(orderRecord);
|
|
582
|
-
mockOrderRepo.save.mockResolvedValue({ ...orderRecord, status: 'cancelled' });
|
|
583
|
-
|
|
584
|
-
const result = await service.cancelOrder('123');
|
|
585
|
-
|
|
586
|
-
expect(result.ok).toBe(true);
|
|
587
|
-
expect(mockOrderRepo.save).toHaveBeenCalledWith(
|
|
588
|
-
expect.objectContaining({ status: 'cancelled' })
|
|
589
|
-
);
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
it('releases inventory when cancelling', async () => {
|
|
593
|
-
const orderRecord = {
|
|
594
|
-
id: '123',
|
|
595
|
-
customer_id: 'C1',
|
|
596
|
-
items: '[{"productId":"P1","quantity":2,"price":10}]',
|
|
597
|
-
status: 'pending',
|
|
598
|
-
total: 20
|
|
599
|
-
};
|
|
600
|
-
|
|
601
|
-
mockOrderRepo.findById.mockResolvedValue(orderRecord);
|
|
602
|
-
mockOrderRepo.save.mockResolvedValue({ ...orderRecord, status: 'cancelled' });
|
|
603
|
-
|
|
604
|
-
await service.cancelOrder('123');
|
|
605
|
-
|
|
606
|
-
expect(mockInventoryRepo.release).toHaveBeenCalledWith(
|
|
607
|
-
expect.arrayContaining([
|
|
608
|
-
expect.objectContaining({ productId: 'P1', quantity: 2 })
|
|
609
|
-
])
|
|
610
|
-
);
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
it('rejects cancelling shipped order', async () => {
|
|
614
|
-
mockOrderRepo.findById.mockResolvedValue({
|
|
615
|
-
id: '123',
|
|
616
|
-
customer_id: 'C1',
|
|
617
|
-
items: '[]',
|
|
618
|
-
status: 'shipped',
|
|
619
|
-
total: 0
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
const result = await service.cancelOrder('123');
|
|
623
|
-
|
|
624
|
-
expect(result.ok).toBe(false);
|
|
625
|
-
expect(mockOrderRepo.save).not.toHaveBeenCalled();
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
it('returns error for non-existent order', async () => {
|
|
629
|
-
mockOrderRepo.findById.mockResolvedValue(null);
|
|
630
|
-
|
|
631
|
-
const result = await service.cancelOrder('999');
|
|
632
|
-
|
|
633
|
-
expect(result.ok).toBe(false);
|
|
634
|
-
});
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
describe('getOrder', () => {
|
|
638
|
-
it('retrieves order by id', async () => {
|
|
639
|
-
mockOrderRepo.findById.mockResolvedValue({
|
|
640
|
-
id: '123',
|
|
641
|
-
customer_id: 'C1',
|
|
642
|
-
items: '[]',
|
|
643
|
-
status: 'pending',
|
|
644
|
-
total: 0
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
const result = await service.getOrder('123');
|
|
648
|
-
|
|
649
|
-
expect(result.ok).toBe(true);
|
|
650
|
-
if (result.ok) {
|
|
651
|
-
expect(result.value.id).toBe('123');
|
|
652
|
-
}
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
it('returns error for non-existent order', async () => {
|
|
656
|
-
mockOrderRepo.findById.mockResolvedValue(null);
|
|
657
|
-
|
|
658
|
-
const result = await service.getOrder('999');
|
|
659
|
-
|
|
660
|
-
expect(result.ok).toBe(false);
|
|
661
|
-
});
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
describe('getCustomerOrders', () => {
|
|
665
|
-
it('retrieves all orders for customer', async () => {
|
|
666
|
-
mockOrderRepo.findByCustomerId.mockResolvedValue([
|
|
667
|
-
{ id: '123', customer_id: 'C1', items: '[]', status: 'pending', total: 0 },
|
|
668
|
-
{ id: '124', customer_id: 'C1', items: '[]', status: 'delivered', total: 50 }
|
|
669
|
-
]);
|
|
670
|
-
|
|
671
|
-
const result = await service.getCustomerOrders('C1');
|
|
672
|
-
|
|
673
|
-
expect(result.ok).toBe(true);
|
|
674
|
-
if (result.ok) {
|
|
675
|
-
expect(result.value).toHaveLength(2);
|
|
676
|
-
}
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
it('returns empty list for customer with no orders', async () => {
|
|
680
|
-
mockOrderRepo.findByCustomerId.mockResolvedValue([]);
|
|
681
|
-
|
|
682
|
-
const result = await service.getCustomerOrders('C999');
|
|
683
|
-
|
|
684
|
-
expect(result.ok).toBe(true);
|
|
685
|
-
if (result.ok) {
|
|
686
|
-
expect(result.value).toHaveLength(0);
|
|
687
|
-
}
|
|
688
|
-
});
|
|
689
|
-
});
|
|
690
|
-
});
|
|
691
|
-
```
|