@dizzlkheinz/ynab-mcpb 0.18.3 → 0.18.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/CHANGELOG.md +17 -0
- package/dist/bundle/index.cjs +40 -40
- package/dist/tools/reconcileAdapter.js +3 -0
- package/dist/tools/reconciliation/analyzer.js +72 -7
- package/dist/tools/reconciliation/reportFormatter.js +26 -2
- package/dist/tools/reconciliation/types.d.ts +3 -0
- package/dist/tools/transactionSchemas.d.ts +309 -0
- package/dist/tools/transactionSchemas.js +215 -0
- package/dist/tools/transactionTools.d.ts +3 -281
- package/dist/tools/transactionTools.js +4 -559
- package/dist/tools/transactionUtils.d.ts +31 -0
- package/dist/tools/transactionUtils.js +349 -0
- package/docs/plans/2025-12-25-transaction-tools-refactor-design.md +211 -0
- package/docs/plans/2025-12-25-transaction-tools-refactor.md +905 -0
- package/package.json +4 -2
- package/scripts/run-all-tests.js +196 -0
- package/src/tools/__tests__/transactionSchemas.test.ts +1188 -0
- package/src/tools/__tests__/transactionUtils.test.ts +989 -0
- package/src/tools/reconcileAdapter.ts +6 -0
- package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +22 -8
- package/src/tools/reconciliation/__tests__/adapter.test.ts +3 -0
- package/src/tools/reconciliation/__tests__/analyzer.test.ts +65 -0
- package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +3 -0
- package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +4 -1
- package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +3 -0
- package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +5 -1
- package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +22 -8
- package/src/tools/reconciliation/analyzer.ts +127 -11
- package/src/tools/reconciliation/reportFormatter.ts +39 -2
- package/src/tools/reconciliation/types.ts +6 -0
- package/src/tools/transactionSchemas.ts +453 -0
- package/src/tools/transactionTools.ts +102 -823
- package/src/tools/transactionUtils.ts +536 -0
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
ListTransactionsSchema,
|
|
4
|
+
GetTransactionSchema,
|
|
5
|
+
CreateTransactionSchema,
|
|
6
|
+
CreateTransactionsSchema,
|
|
7
|
+
CreateReceiptSplitTransactionSchema,
|
|
8
|
+
UpdateTransactionSchema,
|
|
9
|
+
UpdateTransactionsSchema,
|
|
10
|
+
DeleteTransactionSchema,
|
|
11
|
+
} from '../transactionSchemas.js';
|
|
12
|
+
|
|
13
|
+
describe('Transaction Schemas', () => {
|
|
14
|
+
describe('ListTransactionsSchema', () => {
|
|
15
|
+
it('should validate with only budget_id', () => {
|
|
16
|
+
const result = ListTransactionsSchema.parse({ budget_id: 'budget-1' });
|
|
17
|
+
expect(result.budget_id).toBe('budget-1');
|
|
18
|
+
expect(result.account_id).toBeUndefined();
|
|
19
|
+
expect(result.category_id).toBeUndefined();
|
|
20
|
+
expect(result.since_date).toBeUndefined();
|
|
21
|
+
expect(result.type).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should validate with all optional fields', () => {
|
|
25
|
+
const result = ListTransactionsSchema.parse({
|
|
26
|
+
budget_id: 'budget-1',
|
|
27
|
+
account_id: 'account-1',
|
|
28
|
+
category_id: 'category-1',
|
|
29
|
+
since_date: '2024-01-01',
|
|
30
|
+
type: 'uncategorized',
|
|
31
|
+
});
|
|
32
|
+
expect(result.budget_id).toBe('budget-1');
|
|
33
|
+
expect(result.account_id).toBe('account-1');
|
|
34
|
+
expect(result.category_id).toBe('category-1');
|
|
35
|
+
expect(result.since_date).toBe('2024-01-01');
|
|
36
|
+
expect(result.type).toBe('uncategorized');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should validate type as unapproved', () => {
|
|
40
|
+
const result = ListTransactionsSchema.parse({
|
|
41
|
+
budget_id: 'budget-1',
|
|
42
|
+
type: 'unapproved',
|
|
43
|
+
});
|
|
44
|
+
expect(result.type).toBe('unapproved');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should reject empty budget_id', () => {
|
|
48
|
+
expect(() => ListTransactionsSchema.parse({ budget_id: '' })).toThrow(
|
|
49
|
+
'Budget ID is required',
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should reject missing budget_id', () => {
|
|
54
|
+
expect(() => ListTransactionsSchema.parse({})).toThrow();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should reject invalid date format (missing dashes)', () => {
|
|
58
|
+
expect(() =>
|
|
59
|
+
ListTransactionsSchema.parse({
|
|
60
|
+
budget_id: 'budget-1',
|
|
61
|
+
since_date: '20240101',
|
|
62
|
+
}),
|
|
63
|
+
).toThrow('Date must be in ISO format (YYYY-MM-DD)');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should reject invalid date format (wrong separator)', () => {
|
|
67
|
+
expect(() =>
|
|
68
|
+
ListTransactionsSchema.parse({
|
|
69
|
+
budget_id: 'budget-1',
|
|
70
|
+
since_date: '2024/01/01',
|
|
71
|
+
}),
|
|
72
|
+
).toThrow('Date must be in ISO format (YYYY-MM-DD)');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should reject invalid type value', () => {
|
|
76
|
+
expect(() =>
|
|
77
|
+
ListTransactionsSchema.parse({
|
|
78
|
+
budget_id: 'budget-1',
|
|
79
|
+
type: 'invalid',
|
|
80
|
+
}),
|
|
81
|
+
).toThrow();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should reject extra fields (strict mode)', () => {
|
|
85
|
+
expect(() =>
|
|
86
|
+
ListTransactionsSchema.parse({
|
|
87
|
+
budget_id: 'budget-1',
|
|
88
|
+
extra_field: 'value',
|
|
89
|
+
}),
|
|
90
|
+
).toThrow();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('GetTransactionSchema', () => {
|
|
95
|
+
it('should validate with required fields', () => {
|
|
96
|
+
const result = GetTransactionSchema.parse({
|
|
97
|
+
budget_id: 'budget-1',
|
|
98
|
+
transaction_id: 'transaction-1',
|
|
99
|
+
});
|
|
100
|
+
expect(result.budget_id).toBe('budget-1');
|
|
101
|
+
expect(result.transaction_id).toBe('transaction-1');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should reject empty budget_id', () => {
|
|
105
|
+
expect(() =>
|
|
106
|
+
GetTransactionSchema.parse({
|
|
107
|
+
budget_id: '',
|
|
108
|
+
transaction_id: 'transaction-1',
|
|
109
|
+
}),
|
|
110
|
+
).toThrow('Budget ID is required');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should reject empty transaction_id', () => {
|
|
114
|
+
expect(() =>
|
|
115
|
+
GetTransactionSchema.parse({
|
|
116
|
+
budget_id: 'budget-1',
|
|
117
|
+
transaction_id: '',
|
|
118
|
+
}),
|
|
119
|
+
).toThrow('Transaction ID is required');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should reject missing transaction_id', () => {
|
|
123
|
+
expect(() => GetTransactionSchema.parse({ budget_id: 'budget-1' })).toThrow();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should reject extra fields (strict mode)', () => {
|
|
127
|
+
expect(() =>
|
|
128
|
+
GetTransactionSchema.parse({
|
|
129
|
+
budget_id: 'budget-1',
|
|
130
|
+
transaction_id: 'transaction-1',
|
|
131
|
+
extra_field: 'value',
|
|
132
|
+
}),
|
|
133
|
+
).toThrow();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('CreateTransactionSchema', () => {
|
|
138
|
+
const validBase = {
|
|
139
|
+
budget_id: 'budget-1',
|
|
140
|
+
account_id: 'account-1',
|
|
141
|
+
amount: 25500,
|
|
142
|
+
date: '2024-01-15',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
it('should validate with required fields only', () => {
|
|
146
|
+
const result = CreateTransactionSchema.parse(validBase);
|
|
147
|
+
expect(result.budget_id).toBe('budget-1');
|
|
148
|
+
expect(result.account_id).toBe('account-1');
|
|
149
|
+
expect(result.amount).toBe(25500);
|
|
150
|
+
expect(result.date).toBe('2024-01-15');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should validate with all optional fields', () => {
|
|
154
|
+
const result = CreateTransactionSchema.parse({
|
|
155
|
+
...validBase,
|
|
156
|
+
payee_name: 'Grocery Store',
|
|
157
|
+
payee_id: 'payee-1',
|
|
158
|
+
category_id: 'category-1',
|
|
159
|
+
memo: 'Weekly groceries',
|
|
160
|
+
cleared: 'cleared',
|
|
161
|
+
approved: true,
|
|
162
|
+
flag_color: 'red',
|
|
163
|
+
import_id: 'YNAB:12345:2024-01-15:1',
|
|
164
|
+
dry_run: true,
|
|
165
|
+
});
|
|
166
|
+
expect(result.payee_name).toBe('Grocery Store');
|
|
167
|
+
expect(result.payee_id).toBe('payee-1');
|
|
168
|
+
expect(result.category_id).toBe('category-1');
|
|
169
|
+
expect(result.memo).toBe('Weekly groceries');
|
|
170
|
+
expect(result.cleared).toBe('cleared');
|
|
171
|
+
expect(result.approved).toBe(true);
|
|
172
|
+
expect(result.flag_color).toBe('red');
|
|
173
|
+
expect(result.import_id).toBe('YNAB:12345:2024-01-15:1');
|
|
174
|
+
expect(result.dry_run).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should validate all cleared status values', () => {
|
|
178
|
+
['cleared', 'uncleared', 'reconciled'].forEach((status) => {
|
|
179
|
+
const result = CreateTransactionSchema.parse({
|
|
180
|
+
...validBase,
|
|
181
|
+
cleared: status,
|
|
182
|
+
});
|
|
183
|
+
expect(result.cleared).toBe(status);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should validate all flag colors', () => {
|
|
188
|
+
['red', 'orange', 'yellow', 'green', 'blue', 'purple'].forEach((color) => {
|
|
189
|
+
const result = CreateTransactionSchema.parse({
|
|
190
|
+
...validBase,
|
|
191
|
+
flag_color: color,
|
|
192
|
+
});
|
|
193
|
+
expect(result.flag_color).toBe(color);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should validate negative amounts', () => {
|
|
198
|
+
const result = CreateTransactionSchema.parse({
|
|
199
|
+
...validBase,
|
|
200
|
+
amount: -25500,
|
|
201
|
+
});
|
|
202
|
+
expect(result.amount).toBe(-25500);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should validate zero amount', () => {
|
|
206
|
+
const result = CreateTransactionSchema.parse({
|
|
207
|
+
...validBase,
|
|
208
|
+
amount: 0,
|
|
209
|
+
});
|
|
210
|
+
expect(result.amount).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should reject non-integer amount', () => {
|
|
214
|
+
expect(() =>
|
|
215
|
+
CreateTransactionSchema.parse({
|
|
216
|
+
...validBase,
|
|
217
|
+
amount: 25.5,
|
|
218
|
+
}),
|
|
219
|
+
).toThrow('Amount must be an integer in milliunits');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should reject invalid date format', () => {
|
|
223
|
+
expect(() =>
|
|
224
|
+
CreateTransactionSchema.parse({
|
|
225
|
+
...validBase,
|
|
226
|
+
date: '2024/01/15',
|
|
227
|
+
}),
|
|
228
|
+
).toThrow('Date must be in ISO format (YYYY-MM-DD)');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should reject invalid cleared status', () => {
|
|
232
|
+
expect(() =>
|
|
233
|
+
CreateTransactionSchema.parse({
|
|
234
|
+
...validBase,
|
|
235
|
+
cleared: 'invalid',
|
|
236
|
+
}),
|
|
237
|
+
).toThrow();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should reject invalid flag color', () => {
|
|
241
|
+
expect(() =>
|
|
242
|
+
CreateTransactionSchema.parse({
|
|
243
|
+
...validBase,
|
|
244
|
+
flag_color: 'pink',
|
|
245
|
+
}),
|
|
246
|
+
).toThrow();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should reject empty import_id', () => {
|
|
250
|
+
expect(() =>
|
|
251
|
+
CreateTransactionSchema.parse({
|
|
252
|
+
...validBase,
|
|
253
|
+
import_id: '',
|
|
254
|
+
}),
|
|
255
|
+
).toThrow('Import ID cannot be empty');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should validate subtransactions with matching total', () => {
|
|
259
|
+
const result = CreateTransactionSchema.parse({
|
|
260
|
+
...validBase,
|
|
261
|
+
amount: 30000,
|
|
262
|
+
subtransactions: [
|
|
263
|
+
{ amount: 15000, category_id: 'cat-1' },
|
|
264
|
+
{ amount: 10000, category_id: 'cat-2' },
|
|
265
|
+
{ amount: 5000, category_id: 'cat-3' },
|
|
266
|
+
],
|
|
267
|
+
});
|
|
268
|
+
expect(result.subtransactions).toHaveLength(3);
|
|
269
|
+
expect(result.subtransactions![0].amount).toBe(15000);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should validate subtransactions with all optional fields', () => {
|
|
273
|
+
const result = CreateTransactionSchema.parse({
|
|
274
|
+
...validBase,
|
|
275
|
+
amount: 10000,
|
|
276
|
+
subtransactions: [
|
|
277
|
+
{
|
|
278
|
+
amount: 10000,
|
|
279
|
+
payee_name: 'Sub Payee',
|
|
280
|
+
payee_id: 'payee-sub',
|
|
281
|
+
category_id: 'cat-1',
|
|
282
|
+
memo: 'Subtransaction memo',
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
});
|
|
286
|
+
expect(result.subtransactions![0].payee_name).toBe('Sub Payee');
|
|
287
|
+
expect(result.subtransactions![0].payee_id).toBe('payee-sub');
|
|
288
|
+
expect(result.subtransactions![0].memo).toBe('Subtransaction memo');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should reject subtransactions with non-matching total', () => {
|
|
292
|
+
expect(() =>
|
|
293
|
+
CreateTransactionSchema.parse({
|
|
294
|
+
...validBase,
|
|
295
|
+
amount: 30000,
|
|
296
|
+
subtransactions: [
|
|
297
|
+
{ amount: 15000, category_id: 'cat-1' },
|
|
298
|
+
{ amount: 10000, category_id: 'cat-2' },
|
|
299
|
+
],
|
|
300
|
+
}),
|
|
301
|
+
).toThrow('Amount must equal the sum of subtransaction amounts');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should reject subtransactions with total exceeding parent', () => {
|
|
305
|
+
expect(() =>
|
|
306
|
+
CreateTransactionSchema.parse({
|
|
307
|
+
...validBase,
|
|
308
|
+
amount: 20000,
|
|
309
|
+
subtransactions: [
|
|
310
|
+
{ amount: 15000, category_id: 'cat-1' },
|
|
311
|
+
{ amount: 10000, category_id: 'cat-2' },
|
|
312
|
+
],
|
|
313
|
+
}),
|
|
314
|
+
).toThrow('Amount must equal the sum of subtransaction amounts');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should reject empty subtransactions array', () => {
|
|
318
|
+
expect(() =>
|
|
319
|
+
CreateTransactionSchema.parse({
|
|
320
|
+
...validBase,
|
|
321
|
+
subtransactions: [],
|
|
322
|
+
}),
|
|
323
|
+
).toThrow('At least one subtransaction is required when provided');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should reject subtransaction with non-integer amount', () => {
|
|
327
|
+
expect(() =>
|
|
328
|
+
CreateTransactionSchema.parse({
|
|
329
|
+
...validBase,
|
|
330
|
+
amount: 10000,
|
|
331
|
+
subtransactions: [{ amount: 10000.5, category_id: 'cat-1' }],
|
|
332
|
+
}),
|
|
333
|
+
).toThrow('Subtransaction amount must be an integer in milliunits');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should reject extra fields in subtransaction', () => {
|
|
337
|
+
expect(() =>
|
|
338
|
+
CreateTransactionSchema.parse({
|
|
339
|
+
...validBase,
|
|
340
|
+
amount: 10000,
|
|
341
|
+
subtransactions: [
|
|
342
|
+
{
|
|
343
|
+
amount: 10000,
|
|
344
|
+
category_id: 'cat-1',
|
|
345
|
+
extra_field: 'value',
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
}),
|
|
349
|
+
).toThrow();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe('CreateTransactionsSchema', () => {
|
|
354
|
+
const validTransaction = {
|
|
355
|
+
account_id: 'account-1',
|
|
356
|
+
amount: 10000,
|
|
357
|
+
date: '2024-01-15',
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
it('should validate with single transaction', () => {
|
|
361
|
+
const result = CreateTransactionsSchema.parse({
|
|
362
|
+
budget_id: 'budget-1',
|
|
363
|
+
transactions: [validTransaction],
|
|
364
|
+
});
|
|
365
|
+
expect(result.budget_id).toBe('budget-1');
|
|
366
|
+
expect(result.transactions).toHaveLength(1);
|
|
367
|
+
expect(result.transactions[0].account_id).toBe('account-1');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should validate with multiple transactions', () => {
|
|
371
|
+
const result = CreateTransactionsSchema.parse({
|
|
372
|
+
budget_id: 'budget-1',
|
|
373
|
+
transactions: [
|
|
374
|
+
validTransaction,
|
|
375
|
+
{ ...validTransaction, account_id: 'account-2' },
|
|
376
|
+
{ ...validTransaction, account_id: 'account-3' },
|
|
377
|
+
],
|
|
378
|
+
});
|
|
379
|
+
expect(result.transactions).toHaveLength(3);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should validate with dry_run flag', () => {
|
|
383
|
+
const result = CreateTransactionsSchema.parse({
|
|
384
|
+
budget_id: 'budget-1',
|
|
385
|
+
transactions: [validTransaction],
|
|
386
|
+
dry_run: true,
|
|
387
|
+
});
|
|
388
|
+
expect(result.dry_run).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('should validate transaction with all optional fields', () => {
|
|
392
|
+
const result = CreateTransactionsSchema.parse({
|
|
393
|
+
budget_id: 'budget-1',
|
|
394
|
+
transactions: [
|
|
395
|
+
{
|
|
396
|
+
...validTransaction,
|
|
397
|
+
payee_name: 'Store',
|
|
398
|
+
payee_id: 'payee-1',
|
|
399
|
+
category_id: 'cat-1',
|
|
400
|
+
memo: 'Test memo',
|
|
401
|
+
cleared: 'cleared',
|
|
402
|
+
approved: true,
|
|
403
|
+
flag_color: 'blue',
|
|
404
|
+
import_id: 'YNAB:123:2024-01-15:1',
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
});
|
|
408
|
+
expect(result.transactions[0].payee_name).toBe('Store');
|
|
409
|
+
expect(result.transactions[0].memo).toBe('Test memo');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('should reject empty transactions array', () => {
|
|
413
|
+
expect(() =>
|
|
414
|
+
CreateTransactionsSchema.parse({
|
|
415
|
+
budget_id: 'budget-1',
|
|
416
|
+
transactions: [],
|
|
417
|
+
}),
|
|
418
|
+
).toThrow('At least one transaction is required');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('should reject more than 100 transactions', () => {
|
|
422
|
+
const transactions = Array(101)
|
|
423
|
+
.fill(null)
|
|
424
|
+
.map((_, i) => ({
|
|
425
|
+
...validTransaction,
|
|
426
|
+
account_id: `account-${i}`,
|
|
427
|
+
}));
|
|
428
|
+
|
|
429
|
+
expect(() =>
|
|
430
|
+
CreateTransactionsSchema.parse({
|
|
431
|
+
budget_id: 'budget-1',
|
|
432
|
+
transactions,
|
|
433
|
+
}),
|
|
434
|
+
).toThrow('A maximum of 100 transactions may be created at once');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('should accept exactly 100 transactions', () => {
|
|
438
|
+
const transactions = Array(100)
|
|
439
|
+
.fill(null)
|
|
440
|
+
.map((_, i) => ({
|
|
441
|
+
...validTransaction,
|
|
442
|
+
account_id: `account-${i}`,
|
|
443
|
+
}));
|
|
444
|
+
|
|
445
|
+
const result = CreateTransactionsSchema.parse({
|
|
446
|
+
budget_id: 'budget-1',
|
|
447
|
+
transactions,
|
|
448
|
+
});
|
|
449
|
+
expect(result.transactions).toHaveLength(100);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('should reject transaction with subtransactions field (not supported in bulk)', () => {
|
|
453
|
+
expect(() =>
|
|
454
|
+
CreateTransactionsSchema.parse({
|
|
455
|
+
budget_id: 'budget-1',
|
|
456
|
+
transactions: [
|
|
457
|
+
{
|
|
458
|
+
...validTransaction,
|
|
459
|
+
subtransactions: [{ amount: 10000, category_id: 'cat-1' }],
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
}),
|
|
463
|
+
).toThrow();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should reject empty budget_id', () => {
|
|
467
|
+
expect(() =>
|
|
468
|
+
CreateTransactionsSchema.parse({
|
|
469
|
+
budget_id: '',
|
|
470
|
+
transactions: [validTransaction],
|
|
471
|
+
}),
|
|
472
|
+
).toThrow('Budget ID is required');
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe('CreateReceiptSplitTransactionSchema', () => {
|
|
477
|
+
const validReceipt = {
|
|
478
|
+
budget_id: 'budget-1',
|
|
479
|
+
account_id: 'account-1',
|
|
480
|
+
payee_name: 'Grocery Store',
|
|
481
|
+
receipt_tax: 2.5,
|
|
482
|
+
receipt_total: 27.5,
|
|
483
|
+
categories: [
|
|
484
|
+
{
|
|
485
|
+
category_id: 'cat-1',
|
|
486
|
+
items: [
|
|
487
|
+
{ name: 'Milk', amount: 5.0 },
|
|
488
|
+
{ name: 'Bread', amount: 3.0 },
|
|
489
|
+
],
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
category_id: 'cat-2',
|
|
493
|
+
items: [{ name: 'Apples', amount: 7.0 }],
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
category_id: 'cat-3',
|
|
497
|
+
items: [{ name: 'Chicken', amount: 10.0 }],
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
it('should validate with required fields only', () => {
|
|
503
|
+
const result = CreateReceiptSplitTransactionSchema.parse(validReceipt);
|
|
504
|
+
expect(result.budget_id).toBe('budget-1');
|
|
505
|
+
expect(result.account_id).toBe('account-1');
|
|
506
|
+
expect(result.payee_name).toBe('Grocery Store');
|
|
507
|
+
expect(result.receipt_tax).toBe(2.5);
|
|
508
|
+
expect(result.receipt_total).toBe(27.5);
|
|
509
|
+
expect(result.categories).toHaveLength(3);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('should validate with all optional fields', () => {
|
|
513
|
+
const result = CreateReceiptSplitTransactionSchema.parse({
|
|
514
|
+
...validReceipt,
|
|
515
|
+
date: '2024-01-15',
|
|
516
|
+
memo: 'Weekly shopping',
|
|
517
|
+
receipt_subtotal: 25.0,
|
|
518
|
+
cleared: 'cleared',
|
|
519
|
+
approved: true,
|
|
520
|
+
flag_color: 'green',
|
|
521
|
+
dry_run: true,
|
|
522
|
+
});
|
|
523
|
+
expect(result.date).toBe('2024-01-15');
|
|
524
|
+
expect(result.memo).toBe('Weekly shopping');
|
|
525
|
+
expect(result.receipt_subtotal).toBe(25.0);
|
|
526
|
+
expect(result.cleared).toBe('cleared');
|
|
527
|
+
expect(result.approved).toBe(true);
|
|
528
|
+
expect(result.flag_color).toBe('green');
|
|
529
|
+
expect(result.dry_run).toBe(true);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('should validate items with quantity', () => {
|
|
533
|
+
const result = CreateReceiptSplitTransactionSchema.parse({
|
|
534
|
+
...validReceipt,
|
|
535
|
+
categories: [
|
|
536
|
+
{
|
|
537
|
+
category_id: 'cat-1',
|
|
538
|
+
items: [
|
|
539
|
+
{
|
|
540
|
+
name: 'Apples',
|
|
541
|
+
amount: 10.0,
|
|
542
|
+
quantity: 2.5,
|
|
543
|
+
memo: '2.5 lbs @ $4/lb',
|
|
544
|
+
},
|
|
545
|
+
],
|
|
546
|
+
},
|
|
547
|
+
],
|
|
548
|
+
receipt_total: 12.5,
|
|
549
|
+
});
|
|
550
|
+
expect(result.categories[0].items[0].quantity).toBe(2.5);
|
|
551
|
+
expect(result.categories[0].items[0].memo).toBe('2.5 lbs @ $4/lb');
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('should validate items with category_name', () => {
|
|
555
|
+
const result = CreateReceiptSplitTransactionSchema.parse({
|
|
556
|
+
...validReceipt,
|
|
557
|
+
categories: [
|
|
558
|
+
{
|
|
559
|
+
category_id: 'cat-1',
|
|
560
|
+
category_name: 'Groceries',
|
|
561
|
+
items: [{ name: 'Item', amount: 25.0 }],
|
|
562
|
+
},
|
|
563
|
+
],
|
|
564
|
+
});
|
|
565
|
+
expect(result.categories[0].category_name).toBe('Groceries');
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('should validate when subtotal + tax = total (exact match)', () => {
|
|
569
|
+
const result = CreateReceiptSplitTransactionSchema.parse({
|
|
570
|
+
...validReceipt,
|
|
571
|
+
receipt_subtotal: 25.0,
|
|
572
|
+
receipt_tax: 2.5,
|
|
573
|
+
receipt_total: 27.5,
|
|
574
|
+
});
|
|
575
|
+
expect(result.receipt_subtotal).toBe(25.0);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('should validate when items total matches receipt_subtotal', () => {
|
|
579
|
+
const result = CreateReceiptSplitTransactionSchema.parse({
|
|
580
|
+
budget_id: 'budget-1',
|
|
581
|
+
account_id: 'account-1',
|
|
582
|
+
payee_name: 'Store',
|
|
583
|
+
receipt_subtotal: 50.0,
|
|
584
|
+
receipt_tax: 5.0,
|
|
585
|
+
receipt_total: 55.0,
|
|
586
|
+
categories: [
|
|
587
|
+
{
|
|
588
|
+
category_id: 'cat-1',
|
|
589
|
+
items: [
|
|
590
|
+
{ name: 'Item 1', amount: 30.0 },
|
|
591
|
+
{ name: 'Item 2', amount: 20.0 },
|
|
592
|
+
],
|
|
593
|
+
},
|
|
594
|
+
],
|
|
595
|
+
});
|
|
596
|
+
expect(result.categories[0].items).toHaveLength(2);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('should reject when items total does not match receipt_subtotal', () => {
|
|
600
|
+
expect(() =>
|
|
601
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
602
|
+
...validReceipt,
|
|
603
|
+
receipt_subtotal: 30.0, // Items sum to 25.0
|
|
604
|
+
}),
|
|
605
|
+
).toThrow('Receipt subtotal (30.00) does not match categorized items total (25.00)');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('should reject when subtotal + tax does not match total', () => {
|
|
609
|
+
expect(() =>
|
|
610
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
611
|
+
...validReceipt,
|
|
612
|
+
receipt_total: 30.0, // Should be 27.5
|
|
613
|
+
}),
|
|
614
|
+
).toThrow('Receipt total (30.00) does not match subtotal plus tax (27.50)');
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('should allow small rounding differences (within tolerance)', () => {
|
|
618
|
+
// This should NOT throw because difference is less than 0.01 (0.001)
|
|
619
|
+
const result = CreateReceiptSplitTransactionSchema.parse({
|
|
620
|
+
budget_id: 'budget-1',
|
|
621
|
+
account_id: 'account-1',
|
|
622
|
+
payee_name: 'Store',
|
|
623
|
+
receipt_subtotal: 25.001,
|
|
624
|
+
receipt_tax: 2.5,
|
|
625
|
+
receipt_total: 27.5,
|
|
626
|
+
categories: [
|
|
627
|
+
{
|
|
628
|
+
category_id: 'cat-1',
|
|
629
|
+
items: [{ name: 'Item', amount: 25.0 }],
|
|
630
|
+
},
|
|
631
|
+
],
|
|
632
|
+
});
|
|
633
|
+
expect(result.receipt_subtotal).toBe(25.001);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('should reject rounding differences exceeding 0.01', () => {
|
|
637
|
+
expect(() =>
|
|
638
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
639
|
+
budget_id: 'budget-1',
|
|
640
|
+
account_id: 'account-1',
|
|
641
|
+
payee_name: 'Store',
|
|
642
|
+
receipt_subtotal: 25.02,
|
|
643
|
+
receipt_tax: 2.5,
|
|
644
|
+
receipt_total: 27.5,
|
|
645
|
+
categories: [
|
|
646
|
+
{
|
|
647
|
+
category_id: 'cat-1',
|
|
648
|
+
items: [{ name: 'Item', amount: 25.0 }],
|
|
649
|
+
},
|
|
650
|
+
],
|
|
651
|
+
}),
|
|
652
|
+
).toThrow('Receipt subtotal (25.02) does not match categorized items total (25.00)');
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('should reject negative receipt_subtotal', () => {
|
|
656
|
+
expect(() =>
|
|
657
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
658
|
+
...validReceipt,
|
|
659
|
+
receipt_subtotal: -10.0,
|
|
660
|
+
}),
|
|
661
|
+
).toThrow('Receipt subtotal must be zero or greater');
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('should accept zero receipt_subtotal with positive total', () => {
|
|
665
|
+
// receipt_total must be > 0, even if subtotal is 0 (e.g., all tax)
|
|
666
|
+
const result = CreateReceiptSplitTransactionSchema.parse({
|
|
667
|
+
budget_id: 'budget-1',
|
|
668
|
+
account_id: 'account-1',
|
|
669
|
+
payee_name: 'Store',
|
|
670
|
+
receipt_subtotal: 0.0,
|
|
671
|
+
receipt_tax: 1.0,
|
|
672
|
+
receipt_total: 1.0,
|
|
673
|
+
categories: [
|
|
674
|
+
{
|
|
675
|
+
category_id: 'cat-1',
|
|
676
|
+
items: [{ name: 'Free item', amount: 0.0 }],
|
|
677
|
+
},
|
|
678
|
+
],
|
|
679
|
+
});
|
|
680
|
+
expect(result.receipt_subtotal).toBe(0.0);
|
|
681
|
+
expect(result.receipt_total).toBe(1.0);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('should reject zero or negative receipt_total', () => {
|
|
685
|
+
expect(() =>
|
|
686
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
687
|
+
...validReceipt,
|
|
688
|
+
receipt_total: 0,
|
|
689
|
+
}),
|
|
690
|
+
).toThrow('Receipt total must be greater than zero');
|
|
691
|
+
|
|
692
|
+
expect(() =>
|
|
693
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
694
|
+
...validReceipt,
|
|
695
|
+
receipt_total: -10.0,
|
|
696
|
+
}),
|
|
697
|
+
).toThrow('Receipt total must be greater than zero');
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('should reject Infinity values', () => {
|
|
701
|
+
expect(() =>
|
|
702
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
703
|
+
...validReceipt,
|
|
704
|
+
receipt_tax: Infinity,
|
|
705
|
+
}),
|
|
706
|
+
).toThrow();
|
|
707
|
+
|
|
708
|
+
expect(() =>
|
|
709
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
710
|
+
...validReceipt,
|
|
711
|
+
receipt_total: Infinity,
|
|
712
|
+
}),
|
|
713
|
+
).toThrow();
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('should reject NaN values', () => {
|
|
717
|
+
expect(() =>
|
|
718
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
719
|
+
...validReceipt,
|
|
720
|
+
receipt_tax: NaN,
|
|
721
|
+
}),
|
|
722
|
+
).toThrow();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('should reject empty categories array', () => {
|
|
726
|
+
expect(() =>
|
|
727
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
728
|
+
...validReceipt,
|
|
729
|
+
categories: [],
|
|
730
|
+
}),
|
|
731
|
+
).toThrow('At least one categorized group is required to create a split transaction');
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it('should reject category with empty items array', () => {
|
|
735
|
+
expect(() =>
|
|
736
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
737
|
+
...validReceipt,
|
|
738
|
+
categories: [
|
|
739
|
+
{
|
|
740
|
+
category_id: 'cat-1',
|
|
741
|
+
items: [],
|
|
742
|
+
},
|
|
743
|
+
],
|
|
744
|
+
}),
|
|
745
|
+
).toThrow('Each category must include at least one item');
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('should reject item with empty name', () => {
|
|
749
|
+
expect(() =>
|
|
750
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
751
|
+
...validReceipt,
|
|
752
|
+
categories: [
|
|
753
|
+
{
|
|
754
|
+
category_id: 'cat-1',
|
|
755
|
+
items: [{ name: '', amount: 10.0 }],
|
|
756
|
+
},
|
|
757
|
+
],
|
|
758
|
+
}),
|
|
759
|
+
).toThrow('Item name is required');
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it('should reject item with zero or negative quantity', () => {
|
|
763
|
+
expect(() =>
|
|
764
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
765
|
+
...validReceipt,
|
|
766
|
+
categories: [
|
|
767
|
+
{
|
|
768
|
+
category_id: 'cat-1',
|
|
769
|
+
items: [{ name: 'Item', amount: 10.0, quantity: 0 }],
|
|
770
|
+
},
|
|
771
|
+
],
|
|
772
|
+
}),
|
|
773
|
+
).toThrow('Quantity must be greater than zero');
|
|
774
|
+
|
|
775
|
+
expect(() =>
|
|
776
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
777
|
+
...validReceipt,
|
|
778
|
+
categories: [
|
|
779
|
+
{
|
|
780
|
+
category_id: 'cat-1',
|
|
781
|
+
items: [{ name: 'Item', amount: 10.0, quantity: -1 }],
|
|
782
|
+
},
|
|
783
|
+
],
|
|
784
|
+
}),
|
|
785
|
+
).toThrow('Quantity must be greater than zero');
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('should reject empty payee_name', () => {
|
|
789
|
+
expect(() =>
|
|
790
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
791
|
+
...validReceipt,
|
|
792
|
+
payee_name: '',
|
|
793
|
+
}),
|
|
794
|
+
).toThrow('Payee name is required');
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('should reject empty category_id', () => {
|
|
798
|
+
expect(() =>
|
|
799
|
+
CreateReceiptSplitTransactionSchema.parse({
|
|
800
|
+
...validReceipt,
|
|
801
|
+
categories: [
|
|
802
|
+
{
|
|
803
|
+
category_id: '',
|
|
804
|
+
items: [{ name: 'Item', amount: 25.0 }],
|
|
805
|
+
},
|
|
806
|
+
],
|
|
807
|
+
}),
|
|
808
|
+
).toThrow('Category ID is required');
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
describe('UpdateTransactionSchema', () => {
|
|
813
|
+
const validBase = {
|
|
814
|
+
budget_id: 'budget-1',
|
|
815
|
+
transaction_id: 'trans-1',
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
it('should validate with only required fields', () => {
|
|
819
|
+
const result = UpdateTransactionSchema.parse(validBase);
|
|
820
|
+
expect(result.budget_id).toBe('budget-1');
|
|
821
|
+
expect(result.transaction_id).toBe('trans-1');
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it('should validate with all optional fields', () => {
|
|
825
|
+
const result = UpdateTransactionSchema.parse({
|
|
826
|
+
...validBase,
|
|
827
|
+
account_id: 'account-1',
|
|
828
|
+
amount: 15000,
|
|
829
|
+
date: '2024-01-20',
|
|
830
|
+
payee_name: 'New Payee',
|
|
831
|
+
payee_id: 'payee-2',
|
|
832
|
+
category_id: 'cat-2',
|
|
833
|
+
memo: 'Updated memo',
|
|
834
|
+
cleared: 'reconciled',
|
|
835
|
+
approved: false,
|
|
836
|
+
flag_color: 'purple',
|
|
837
|
+
dry_run: true,
|
|
838
|
+
});
|
|
839
|
+
expect(result.account_id).toBe('account-1');
|
|
840
|
+
expect(result.amount).toBe(15000);
|
|
841
|
+
expect(result.date).toBe('2024-01-20');
|
|
842
|
+
expect(result.memo).toBe('Updated memo');
|
|
843
|
+
expect(result.cleared).toBe('reconciled');
|
|
844
|
+
expect(result.approved).toBe(false);
|
|
845
|
+
expect(result.flag_color).toBe('purple');
|
|
846
|
+
expect(result.dry_run).toBe(true);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it('should reject non-integer amount', () => {
|
|
850
|
+
expect(() =>
|
|
851
|
+
UpdateTransactionSchema.parse({
|
|
852
|
+
...validBase,
|
|
853
|
+
amount: 15.5,
|
|
854
|
+
}),
|
|
855
|
+
).toThrow('Amount must be an integer in milliunits');
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('should reject invalid date format', () => {
|
|
859
|
+
expect(() =>
|
|
860
|
+
UpdateTransactionSchema.parse({
|
|
861
|
+
...validBase,
|
|
862
|
+
date: '01/20/2024',
|
|
863
|
+
}),
|
|
864
|
+
).toThrow('Date must be in ISO format (YYYY-MM-DD)');
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it('should reject empty transaction_id', () => {
|
|
868
|
+
expect(() =>
|
|
869
|
+
UpdateTransactionSchema.parse({
|
|
870
|
+
budget_id: 'budget-1',
|
|
871
|
+
transaction_id: '',
|
|
872
|
+
}),
|
|
873
|
+
).toThrow('Transaction ID is required');
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it('should reject extra fields', () => {
|
|
877
|
+
expect(() =>
|
|
878
|
+
UpdateTransactionSchema.parse({
|
|
879
|
+
...validBase,
|
|
880
|
+
extra_field: 'value',
|
|
881
|
+
}),
|
|
882
|
+
).toThrow();
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
describe('UpdateTransactionsSchema', () => {
|
|
887
|
+
const validUpdate = {
|
|
888
|
+
id: 'trans-1',
|
|
889
|
+
amount: 10000,
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
it('should validate with single update', () => {
|
|
893
|
+
const result = UpdateTransactionsSchema.parse({
|
|
894
|
+
budget_id: 'budget-1',
|
|
895
|
+
transactions: [validUpdate],
|
|
896
|
+
});
|
|
897
|
+
expect(result.transactions).toHaveLength(1);
|
|
898
|
+
expect(result.transactions[0].id).toBe('trans-1');
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it('should validate with multiple updates', () => {
|
|
902
|
+
const result = UpdateTransactionsSchema.parse({
|
|
903
|
+
budget_id: 'budget-1',
|
|
904
|
+
transactions: [
|
|
905
|
+
validUpdate,
|
|
906
|
+
{ id: 'trans-2', memo: 'Updated' },
|
|
907
|
+
{ id: 'trans-3', cleared: 'cleared' },
|
|
908
|
+
],
|
|
909
|
+
});
|
|
910
|
+
expect(result.transactions).toHaveLength(3);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
it('should validate with all updatable fields', () => {
|
|
914
|
+
const result = UpdateTransactionsSchema.parse({
|
|
915
|
+
budget_id: 'budget-1',
|
|
916
|
+
transactions: [
|
|
917
|
+
{
|
|
918
|
+
id: 'trans-1',
|
|
919
|
+
amount: 20000,
|
|
920
|
+
date: '2024-02-01',
|
|
921
|
+
payee_name: 'Updated Payee',
|
|
922
|
+
payee_id: 'payee-3',
|
|
923
|
+
category_id: 'cat-3',
|
|
924
|
+
memo: 'Updated',
|
|
925
|
+
cleared: 'uncleared',
|
|
926
|
+
approved: true,
|
|
927
|
+
flag_color: 'yellow',
|
|
928
|
+
original_account_id: 'account-1',
|
|
929
|
+
original_date: '2024-01-01',
|
|
930
|
+
},
|
|
931
|
+
],
|
|
932
|
+
});
|
|
933
|
+
expect(result.transactions[0].amount).toBe(20000);
|
|
934
|
+
expect(result.transactions[0].original_account_id).toBe('account-1');
|
|
935
|
+
expect(result.transactions[0].original_date).toBe('2024-01-01');
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it('should reject empty transactions array', () => {
|
|
939
|
+
expect(() =>
|
|
940
|
+
UpdateTransactionsSchema.parse({
|
|
941
|
+
budget_id: 'budget-1',
|
|
942
|
+
transactions: [],
|
|
943
|
+
}),
|
|
944
|
+
).toThrow('At least one transaction is required');
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
it('should reject more than 100 transactions', () => {
|
|
948
|
+
const updates = Array(101)
|
|
949
|
+
.fill(null)
|
|
950
|
+
.map((_, i) => ({
|
|
951
|
+
id: `trans-${i}`,
|
|
952
|
+
amount: 10000,
|
|
953
|
+
}));
|
|
954
|
+
|
|
955
|
+
expect(() =>
|
|
956
|
+
UpdateTransactionsSchema.parse({
|
|
957
|
+
budget_id: 'budget-1',
|
|
958
|
+
transactions: updates,
|
|
959
|
+
}),
|
|
960
|
+
).toThrow('A maximum of 100 transactions may be updated at once');
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it('should accept exactly 100 transactions', () => {
|
|
964
|
+
const updates = Array(100)
|
|
965
|
+
.fill(null)
|
|
966
|
+
.map((_, i) => ({
|
|
967
|
+
id: `trans-${i}`,
|
|
968
|
+
amount: 10000,
|
|
969
|
+
}));
|
|
970
|
+
|
|
971
|
+
const result = UpdateTransactionsSchema.parse({
|
|
972
|
+
budget_id: 'budget-1',
|
|
973
|
+
transactions: updates,
|
|
974
|
+
});
|
|
975
|
+
expect(result.transactions).toHaveLength(100);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it('should reject update with missing id', () => {
|
|
979
|
+
expect(() =>
|
|
980
|
+
UpdateTransactionsSchema.parse({
|
|
981
|
+
budget_id: 'budget-1',
|
|
982
|
+
transactions: [{ amount: 10000 }],
|
|
983
|
+
}),
|
|
984
|
+
).toThrow();
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it('should reject update with empty id', () => {
|
|
988
|
+
expect(() =>
|
|
989
|
+
UpdateTransactionsSchema.parse({
|
|
990
|
+
budget_id: 'budget-1',
|
|
991
|
+
transactions: [{ id: '', amount: 10000 }],
|
|
992
|
+
}),
|
|
993
|
+
).toThrow('Transaction ID is required');
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
it('should reject invalid original_date format', () => {
|
|
997
|
+
expect(() =>
|
|
998
|
+
UpdateTransactionsSchema.parse({
|
|
999
|
+
budget_id: 'budget-1',
|
|
1000
|
+
transactions: [
|
|
1001
|
+
{
|
|
1002
|
+
id: 'trans-1',
|
|
1003
|
+
amount: 10000,
|
|
1004
|
+
original_date: '2024/01/01',
|
|
1005
|
+
},
|
|
1006
|
+
],
|
|
1007
|
+
}),
|
|
1008
|
+
).toThrow('Date must be in ISO format (YYYY-MM-DD)');
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it('should reject extra fields in update', () => {
|
|
1012
|
+
expect(() =>
|
|
1013
|
+
UpdateTransactionsSchema.parse({
|
|
1014
|
+
budget_id: 'budget-1',
|
|
1015
|
+
transactions: [
|
|
1016
|
+
{
|
|
1017
|
+
id: 'trans-1',
|
|
1018
|
+
extra_field: 'value',
|
|
1019
|
+
},
|
|
1020
|
+
],
|
|
1021
|
+
}),
|
|
1022
|
+
).toThrow();
|
|
1023
|
+
});
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
describe('DeleteTransactionSchema', () => {
|
|
1027
|
+
it('should validate with required fields', () => {
|
|
1028
|
+
const result = DeleteTransactionSchema.parse({
|
|
1029
|
+
budget_id: 'budget-1',
|
|
1030
|
+
transaction_id: 'trans-1',
|
|
1031
|
+
});
|
|
1032
|
+
expect(result.budget_id).toBe('budget-1');
|
|
1033
|
+
expect(result.transaction_id).toBe('trans-1');
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it('should validate with dry_run flag', () => {
|
|
1037
|
+
const result = DeleteTransactionSchema.parse({
|
|
1038
|
+
budget_id: 'budget-1',
|
|
1039
|
+
transaction_id: 'trans-1',
|
|
1040
|
+
dry_run: true,
|
|
1041
|
+
});
|
|
1042
|
+
expect(result.dry_run).toBe(true);
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it('should reject empty budget_id', () => {
|
|
1046
|
+
expect(() =>
|
|
1047
|
+
DeleteTransactionSchema.parse({
|
|
1048
|
+
budget_id: '',
|
|
1049
|
+
transaction_id: 'trans-1',
|
|
1050
|
+
}),
|
|
1051
|
+
).toThrow('Budget ID is required');
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it('should reject empty transaction_id', () => {
|
|
1055
|
+
expect(() =>
|
|
1056
|
+
DeleteTransactionSchema.parse({
|
|
1057
|
+
budget_id: 'budget-1',
|
|
1058
|
+
transaction_id: '',
|
|
1059
|
+
}),
|
|
1060
|
+
).toThrow('Transaction ID is required');
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it('should reject missing fields', () => {
|
|
1064
|
+
expect(() => DeleteTransactionSchema.parse({ budget_id: 'budget-1' })).toThrow();
|
|
1065
|
+
expect(() => DeleteTransactionSchema.parse({ transaction_id: 'trans-1' })).toThrow();
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
it('should reject extra fields', () => {
|
|
1069
|
+
expect(() =>
|
|
1070
|
+
DeleteTransactionSchema.parse({
|
|
1071
|
+
budget_id: 'budget-1',
|
|
1072
|
+
transaction_id: 'trans-1',
|
|
1073
|
+
extra_field: 'value',
|
|
1074
|
+
}),
|
|
1075
|
+
).toThrow();
|
|
1076
|
+
});
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
describe('Edge Cases', () => {
|
|
1080
|
+
it('should handle very large integer amounts', () => {
|
|
1081
|
+
const result = CreateTransactionSchema.parse({
|
|
1082
|
+
budget_id: 'budget-1',
|
|
1083
|
+
account_id: 'account-1',
|
|
1084
|
+
amount: 999999999999,
|
|
1085
|
+
date: '2024-01-15',
|
|
1086
|
+
});
|
|
1087
|
+
expect(result.amount).toBe(999999999999);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it('should handle very large negative amounts', () => {
|
|
1091
|
+
const result = CreateTransactionSchema.parse({
|
|
1092
|
+
budget_id: 'budget-1',
|
|
1093
|
+
account_id: 'account-1',
|
|
1094
|
+
amount: -999999999999,
|
|
1095
|
+
date: '2024-01-15',
|
|
1096
|
+
});
|
|
1097
|
+
expect(result.amount).toBe(-999999999999);
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it('should handle boundary date values', () => {
|
|
1101
|
+
const dates = ['2024-01-01', '2024-12-31', '2000-01-01', '2099-12-31'];
|
|
1102
|
+
|
|
1103
|
+
dates.forEach((date) => {
|
|
1104
|
+
const result = CreateTransactionSchema.parse({
|
|
1105
|
+
budget_id: 'budget-1',
|
|
1106
|
+
account_id: 'account-1',
|
|
1107
|
+
amount: 1000,
|
|
1108
|
+
date,
|
|
1109
|
+
});
|
|
1110
|
+
expect(result.date).toBe(date);
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
it('should reject null values for required fields', () => {
|
|
1115
|
+
expect(() =>
|
|
1116
|
+
CreateTransactionSchema.parse({
|
|
1117
|
+
budget_id: 'budget-1',
|
|
1118
|
+
account_id: 'account-1',
|
|
1119
|
+
amount: null,
|
|
1120
|
+
date: '2024-01-15',
|
|
1121
|
+
}),
|
|
1122
|
+
).toThrow();
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it('should reject undefined for required fields', () => {
|
|
1126
|
+
expect(() =>
|
|
1127
|
+
CreateTransactionSchema.parse({
|
|
1128
|
+
budget_id: 'budget-1',
|
|
1129
|
+
account_id: 'account-1',
|
|
1130
|
+
amount: undefined,
|
|
1131
|
+
date: '2024-01-15',
|
|
1132
|
+
}),
|
|
1133
|
+
).toThrow();
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
it('should handle very long string values', () => {
|
|
1137
|
+
const longString = 'a'.repeat(1000);
|
|
1138
|
+
const result = CreateTransactionSchema.parse({
|
|
1139
|
+
budget_id: 'budget-1',
|
|
1140
|
+
account_id: 'account-1',
|
|
1141
|
+
amount: 1000,
|
|
1142
|
+
date: '2024-01-15',
|
|
1143
|
+
memo: longString,
|
|
1144
|
+
});
|
|
1145
|
+
expect(result.memo).toBe(longString);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it('should handle special characters in string fields', () => {
|
|
1149
|
+
const specialChars = '!@#$%^&*()_+-={}[]|\\:";\'<>?,./';
|
|
1150
|
+
const result = CreateTransactionSchema.parse({
|
|
1151
|
+
budget_id: 'budget-1',
|
|
1152
|
+
account_id: 'account-1',
|
|
1153
|
+
amount: 1000,
|
|
1154
|
+
date: '2024-01-15',
|
|
1155
|
+
payee_name: specialChars,
|
|
1156
|
+
memo: specialChars,
|
|
1157
|
+
});
|
|
1158
|
+
expect(result.payee_name).toBe(specialChars);
|
|
1159
|
+
expect(result.memo).toBe(specialChars);
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
it('should handle unicode characters', () => {
|
|
1163
|
+
const unicode = '🏪 Café München 日本語';
|
|
1164
|
+
const result = CreateTransactionSchema.parse({
|
|
1165
|
+
budget_id: 'budget-1',
|
|
1166
|
+
account_id: 'account-1',
|
|
1167
|
+
amount: 1000,
|
|
1168
|
+
date: '2024-01-15',
|
|
1169
|
+
payee_name: unicode,
|
|
1170
|
+
});
|
|
1171
|
+
expect(result.payee_name).toBe(unicode);
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it('should handle empty strings for optional fields', () => {
|
|
1175
|
+
// Empty strings are allowed for optional string fields (they're just strings)
|
|
1176
|
+
const result = CreateTransactionSchema.parse({
|
|
1177
|
+
budget_id: 'budget-1',
|
|
1178
|
+
account_id: 'account-1',
|
|
1179
|
+
amount: 1000,
|
|
1180
|
+
date: '2024-01-15',
|
|
1181
|
+
memo: '',
|
|
1182
|
+
payee_name: '',
|
|
1183
|
+
});
|
|
1184
|
+
expect(result.memo).toBe('');
|
|
1185
|
+
expect(result.payee_name).toBe('');
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
1188
|
+
});
|