Repository navigation
Expand file tree
/
Copy pathtest.js
More file actions
640 lines (564 loc) · 26.7 KB
/
Copy pathtest.js
File metadata and controls
640 lines (564 loc) · 26.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { createApprovedToolCall } from "./scripts/lib/approved-tool-call.mjs";
const transport = new StdioClientTransport({
command: "node",
args: ["index.js"],
env: {
...process.env,
YNAB_ALLOW_WRITES: "1",
YNAB_RATE_LIMIT_PER_HOUR: "0",
},
});
const client = new Client({ name: "test", version: "1.0.0" });
await client.connect(transport);
const approvedToolCall = createApprovedToolCall(client);
// This suite exercises the live YNAB API; without credentials every call
// returns the discovery-mode auth payload and the assertions below would
// fail with confusing type errors. Bail out early with a clear message.
{
const authResult = await client.callTool({ name: "ynab_auth_status", arguments: {} });
const auth = JSON.parse(authResult.content[0].text);
if (!auth.authenticated) {
console.error("test.js requires a real YNAB_API_TOKEN (live integration suite) and none was found; exiting with failure. Run `npm run test:unit` for the offline tests.");
await client.close();
process.exit(1);
}
}
const bid = process.env.YNAB_TEST_BUDGET_ID || process.env.YNAB_BUDGET_ID || "last-used";
const runNonReversibleTests = process.env.YNAB_RUN_NONREVERSIBLE_TESTS === "1";
// Dynamic date helpers keep tests current regardless of when they run.
const now = new Date();
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const fallbackMonth = prevMonth.toISOString().slice(0, 7) + "-01"; // e.g. "2025-03-01"
let testMonth = fallbackMonth;
let testMonthLabel = testMonth.slice(0, 7);
const todayDate = now.toISOString().slice(0, 10);
async function call(name, args = {}) {
const result = await approvedToolCall(name, args);
const text = result.content[0].text;
if (result.isError) throw new Error(text);
return JSON.parse(text);
}
let passed = 0;
let failed = 0;
let skipped = 0;
async function test(label, fn) {
try {
await fn();
console.log(` PASS: ${label}`);
passed++;
} catch (e) {
console.log(` FAIL: ${label}: ${e.message}`);
failed++;
}
}
function skip(label, reason) {
console.log(` SKIP: ${label}: ${reason}`);
skipped++;
}
function chooseTestMonth(months) {
const currentMonth = now.toISOString().slice(0, 7) + "-01";
const availableMonths = months
.filter((m) => !m.deleted)
.map((m) => m.month)
.filter(Boolean)
.sort();
if (availableMonths.includes(fallbackMonth)) return fallbackMonth;
if (availableMonths.includes(currentMonth)) return currentMonth;
return availableMonths[availableMonths.length - 1] || fallbackMonth;
}
// --- Read operations ---
console.log("\n=== Read Operations ===");
await test("get_user", async () => {
const user = await call("get_user");
if (!user.id) throw new Error("no user id");
});
await test("list_budgets", async () => {
const result = await call("list_budgets");
if (result.budgets.length === 0) throw new Error("no budgets");
});
await test("get_budget", async () => {
const b = await call("get_budget", { budgetId: bid });
if (!b.name) throw new Error("no budget name");
});
await test("get_budget_settings", async () => {
const s = await call("get_budget_settings", { budgetId: bid });
if (!s.currency_format) throw new Error("no currency format");
});
await test("list_accounts", async () => {
const a = await call("list_accounts", { budgetId: bid });
if (a.length === 0) throw new Error("no accounts");
if (typeof a[0].balance !== "number") throw new Error("balance not converted to dollars");
});
let testAccountId;
await test("get_account", async () => {
const accounts = await call("list_accounts", { budgetId: bid });
testAccountId = accounts[0].id;
const a = await call("get_account", { budgetId: bid, accountId: testAccountId });
if (!a.name) throw new Error("no account name");
});
let categories;
await test("list_categories", async () => {
categories = await call("list_categories", { budgetId: bid });
if (categories.length === 0) throw new Error("no category groups");
});
// Build a category lookup by name (emoji name)
const catByName = {};
for (const g of categories) {
for (const c of g.categories) {
catByName[c.name] = c.id;
}
}
// Pick a visible, non-deleted user category dynamically. YNAB internal categories
// can be read but reject update requests.
let testCatId;
let testCatName;
let testCatOriginalNote;
await test("get_category", async () => {
const found = categories
.filter(g => !g.hidden && !g.deleted && g.name !== "Internal Master Category")
.flatMap(g => g.categories)
.find(c => !c.hidden && !c.deleted);
if (!found) throw new Error("no visible mutable category found in budget");
testCatId = found.id;
testCatName = found.name;
const c = await call("get_category", { budgetId: bid, categoryId: testCatId });
if (!c.name) throw new Error("no category name");
if (typeof c.budgeted !== "number") throw new Error("budgeted not converted to dollars");
testCatOriginalNote = c.note ?? null;
// Verify no raw milliunits leak. Goal fields should be dollars or null.
if (c.goal_target !== null && c.goal_target !== undefined && Math.abs(c.goal_target) > 100000) {
throw new Error("goal_target appears to be in milliunits, not dollars");
}
});
let budgetMonths = [];
await test("list_months", async () => {
budgetMonths = await call("list_months", { budgetId: bid });
if (budgetMonths.length === 0) throw new Error("no months");
testMonth = chooseTestMonth(budgetMonths);
testMonthLabel = testMonth.slice(0, 7);
console.log(` using ${testMonthLabel} for month-scoped checks`);
});
await test("get_month_category", async () => {
const c = await call("get_month_category", { budgetId: bid, month: testMonth, categoryId: testCatId });
if (typeof c.budgeted !== "number") throw new Error("budgeted not a number");
});
await test("update_category (update note, verify round-trip)", async () => {
const marker = `MCP test ${Date.now()}`;
try {
const updated = await call("update_category", { budgetId: bid, categoryId: testCatId, note: marker });
if (updated.note !== marker) throw new Error("note not updated");
} finally {
await call("update_category", { budgetId: bid, categoryId: testCatId, note: testCatOriginalNote });
}
});
await test("list_payees", async () => {
const p = await call("list_payees", { budgetId: bid });
if (p.length === 0) throw new Error("no payees");
});
let testPayeeId;
await test("get_payee", async () => {
const payees = await call("list_payees", { budgetId: bid });
testPayeeId = payees.find(p => !p.transfer_account_id && p.name !== "Starting Balance" && !p.deleted)?.id
|| payees.find(p => !p.deleted)?.id;
if (!testPayeeId) throw new Error("no usable payee found");
const p = await call("get_payee", { budgetId: bid, payeeId: testPayeeId });
if (!p.name) throw new Error("no payee name");
});
// --- Payee Locations ---
console.log("\n=== Payee Locations ===");
await test("list_payee_locations", async () => {
const locs = await call("list_payee_locations", { budgetId: bid });
if (!Array.isArray(locs)) throw new Error("not an array");
console.log(` ✓ ${locs.length} payee locations`);
});
await test("get_payee_locations_by_payee", async () => {
const locs = await call("get_payee_locations_by_payee", { budgetId: bid, payeeId: testPayeeId });
if (!Array.isArray(locs)) throw new Error("not an array");
});
await test("get_month", async () => {
const m = await call("get_month", { budgetId: bid, month: testMonth });
if (!m.categories) throw new Error("no categories in month");
});
// --- Category & Category Group CRUD ---
console.log("\n=== Category & Category Group CRUD ===");
if (runNonReversibleTests) {
let testGroupId;
await test("create_category_group", async () => {
const g = await call("create_category_group", { budgetId: bid, name: "MCP Test Group" });
if (!g.id) throw new Error("no id returned");
testGroupId = g.id;
console.log(` ✓ Created group: ${g.name} (${g.id})`);
});
await test("update_category_group", async () => {
if (!testGroupId) throw new Error("no test group");
const g = await call("update_category_group", { budgetId: bid, categoryGroupId: testGroupId, name: "MCP Test Group Renamed" });
if (g.name !== "MCP Test Group Renamed") throw new Error("name not updated");
console.log(` ✓ Renamed group to: ${g.name}`);
});
let createdCatId;
await test("create_category", async () => {
if (!testGroupId) throw new Error("no test group");
const c = await call("create_category", { budgetId: bid, categoryGroupId: testGroupId, name: "MCP Test Category", note: "Test note" });
if (!c.id) throw new Error("no id returned");
createdCatId = c.id;
console.log(` ✓ Created category: ${c.name}`);
});
await test("verify created category", async () => {
if (!createdCatId) throw new Error("no test category");
const c = await call("get_category", { budgetId: bid, categoryId: createdCatId });
if (c.name !== "MCP Test Category") throw new Error("wrong name");
if (c.note !== "Test note") throw new Error("wrong note");
console.log(` ✓ Verified category: ${c.name}, note: ${c.note}`);
});
} else {
skip("create_category_group", "set YNAB_RUN_NONREVERSIBLE_TESTS=1 to create category groups in a live budget");
skip("update_category_group", "depends on a category group created by the non-reversible test set");
skip("create_category", "set YNAB_RUN_NONREVERSIBLE_TESTS=1 to create categories in a live budget");
skip("verify created category", "depends on a category created by the non-reversible test set");
}
// --- Money Movements ---
console.log("\n=== Money Movements ===");
await test("list_money_movements", async () => {
const ms = await call("list_money_movements", { budgetId: bid });
if (!Array.isArray(ms)) throw new Error("not an array");
console.log(` ✓ ${ms.length} money movements`);
});
await test("get_money_movements_by_month", async () => {
const ms = await call("get_money_movements_by_month", { budgetId: bid, month: testMonth });
if (!Array.isArray(ms)) throw new Error("not an array");
console.log(` ✓ ${ms.length} money movements in ${testMonthLabel}`);
if (ms.length > 0 && typeof ms[0].amount !== "number") throw new Error("amount not converted to dollars");
});
await test("list_money_movement_groups", async () => {
const gs = await call("list_money_movement_groups", { budgetId: bid });
if (!Array.isArray(gs)) throw new Error("not an array");
console.log(` ✓ ${gs.length} money movement groups`);
});
await test("get_money_movement_groups_by_month", async () => {
const gs = await call("get_money_movement_groups_by_month", { budgetId: bid, month: testMonth });
if (!Array.isArray(gs)) throw new Error("not an array");
console.log(` ✓ ${gs.length} money movement groups in ${testMonthLabel}`);
});
// --- Transaction reads ---
console.log("\n=== Transaction Read Operations ===");
let unapproved;
await test("get_transactions (unapproved)", async () => {
unapproved = await call("get_transactions", { budgetId: bid, type: "unapproved" });
if (!Array.isArray(unapproved)) throw new Error("not an array");
console.log(` (${unapproved.length} unapproved transactions)`);
});
await test("get_transactions (by account)", async () => {
const txns = await call("get_transactions", { budgetId: bid, accountId: testAccountId, sinceDate: testMonth });
if (!Array.isArray(txns)) throw new Error("not an array");
});
await test("get_transactions (untilDate)", async () => {
const txns = await call("get_transactions", {
budgetId: bid,
accountId: testAccountId,
sinceDate: testMonth,
untilDate: todayDate,
});
if (!Array.isArray(txns)) throw new Error("not an array");
});
await test("get_transactions (by category)", async () => {
const txns = await call("get_transactions", { budgetId: bid, categoryId: testCatId, sinceDate: "2026-02-01" });
if (!Array.isArray(txns)) throw new Error("not an array");
});
await test("get_transactions (by category and month)", async () => {
const byMonth = await call("get_transactions", { budgetId: bid, categoryId: testCatId, month: testMonth });
if (!Array.isArray(byMonth)) throw new Error("not an array");
for (const t of byMonth) {
if (t.category_id !== testCatId) throw new Error(`unexpected category ${t.category_id}`);
if (!t.date.startsWith(testMonthLabel)) throw new Error(`unexpected date ${t.date}`);
}
const [year, month] = testMonthLabel.split("-").map(Number);
const untilDate = new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10);
const byDateRange = await call("get_transactions", {
budgetId: bid,
categoryId: testCatId,
sinceDate: testMonth,
untilDate,
});
const monthIds = byMonth.map((t) => t.id).sort();
const dateRangeIds = byDateRange.map((t) => t.id).sort();
if (JSON.stringify(monthIds) !== JSON.stringify(dateRangeIds)) {
throw new Error("category+month did not match category+date-range results");
}
});
await test("get_transactions (by account and month)", async () => {
const txns = await call("get_transactions", { budgetId: bid, accountId: testAccountId, month: testMonth });
if (!Array.isArray(txns)) throw new Error("not an array");
for (const t of txns) {
if (t.account_id !== testAccountId) throw new Error(`unexpected account ${t.account_id}`);
if (!t.date.startsWith(testMonthLabel)) throw new Error(`unexpected date ${t.date}`);
}
});
await test("get_transactions (by payee)", async () => {
const txns = await call("get_transactions", { budgetId: bid, payeeId: testPayeeId, sinceDate: "2026-01-01" });
if (!Array.isArray(txns)) throw new Error("not an array");
});
await test("get_transactions (by payee and month)", async () => {
const txns = await call("get_transactions", { budgetId: bid, payeeId: testPayeeId, month: testMonth });
if (!Array.isArray(txns)) throw new Error("not an array");
for (const t of txns) {
if (t.payee_id !== testPayeeId) throw new Error(`unexpected payee ${t.payee_id}`);
if (!t.date.startsWith(testMonthLabel)) throw new Error(`unexpected date ${t.date}`);
}
});
await test("get_transactions rejects month with explicit date range", async () => {
try {
await call("get_transactions", { budgetId: bid, categoryId: testCatId, month: testMonth, sinceDate: testMonth });
} catch (e) {
if (!e.message.includes("either month or sinceDate/untilDate")) throw e;
return;
}
throw new Error("expected validation error");
});
await test("get_transaction (single)", async () => {
const candidates = unapproved.length > 0
? unapproved
: await call("get_transactions", { budgetId: bid, accountId: testAccountId });
// Skip composite scheduled-transaction IDs (uuid_YYYY-MM-DD); their underlying
// matched transaction may have been deleted, leaving the entry unfetchable.
const target = candidates.find((t) => !/_\d{4}-\d{2}-\d{2}$/.test(t.id));
if (!target) throw new Error("no real transactions to test");
const t = await call("get_transaction", { budgetId: bid, transactionId: target.id });
if (!t.id) throw new Error("no id");
// Verify import_id field is present (even if null)
if (!("import_id" in t)) throw new Error("import_id field missing from response");
});
await test("get_transaction (composite ID)", async () => {
const candidates = unapproved.length > 0
? unapproved
: await call("get_transactions", { budgetId: bid, accountId: testAccountId });
const composite = candidates.find((t) => /_\d{4}-\d{2}-\d{2}$/.test(t.id));
if (!composite) {
console.log(" (no composite scheduled-transaction IDs present — both happy path and fallback path untested in this run)");
return;
}
const t = await call("get_transaction", { budgetId: bid, transactionId: composite.id });
if (t.resource_type === "scheduled_transaction") {
// Fallback path: underlying matched transaction has been deleted.
if (t.reason !== "composite_id_with_no_matched_transaction") throw new Error(`unexpected reason: ${t.reason}`);
if (t.requested_id !== composite.id) throw new Error("requested_id does not echo input");
if (!t.scheduled_transaction?.id) throw new Error("missing scheduled_transaction.id in fallback shape");
console.log(` ✓ composite ID with stale match falls back to scheduled_transaction (${t.scheduled_transaction.id})`);
} else {
// Happy path: matched transaction still exists, composite ID resolves to a real transaction.
if (!t.id) throw new Error("no id on transaction response");
if (!("import_id" in t)) throw new Error("import_id field missing from response");
console.log(` ✓ composite ID with live match resolves to a real transaction (${t.id})`);
}
});
// --- Transaction writes (idempotent: creates own test data, cleans up after) ---
console.log("\n=== Transaction Write Operations ===");
// Pick any active user category for testing.
const testCatForWrite = categories
.filter(g => !g.hidden && !g.deleted && g.name !== "Internal Master Category")
.flatMap(g => g.categories)
.find(c => !c.hidden && !c.deleted);
if (!testCatForWrite) throw new Error("no visible mutable category for write tests");
let createdTxnId;
await test("create_transaction", async () => {
const t = await call("create_transaction", {
budgetId: bid,
accountId: testAccountId,
date: new Date().toISOString().slice(0, 10),
amount: -12.34,
payeeName: "MCP Test Payee",
categoryId: testCatForWrite.id,
memo: "MCP integration test - safe to delete",
approved: false,
});
if (!t.id) throw new Error("no id returned");
if (t.amount !== -12.34) throw new Error(`wrong amount: ${t.amount}`);
createdTxnId = t.id;
console.log(` ✓ Created ${t.payee_name} $${t.amount}`);
});
await test("update_transaction (recategorize + approve)", async () => {
if (!createdTxnId) throw new Error("no test transaction to update");
const t = await call("update_transaction", {
budgetId: bid,
transactionId: createdTxnId,
memo: "MCP test - updated",
approved: true,
});
if (t.approved !== true) throw new Error("not approved");
if (t.memo !== "MCP test - updated") throw new Error("memo not changed");
console.log(` ✓ ${t.payee_name} -> approved, memo updated`);
});
await test("update_transactions (batch update)", async () => {
if (!createdTxnId) throw new Error("no test transaction for batch update");
const result = await call("update_transactions", {
budgetId: bid,
transactions: [{ id: createdTxnId, memo: "MCP test - batch updated" }],
});
if (!result.updated || result.updated.length === 0) throw new Error("no transactions updated");
console.log(` ✓ Batch updated ${result.updated.length} transaction(s)`);
});
await test("update_transactions (batch category + approve verifies persisted fields)", async () => {
let regressionTxnId;
try {
const created = await call("create_transaction", {
budgetId: bid,
accountId: testAccountId,
date: new Date().toISOString().slice(0, 10),
amount: -6.78,
payeeName: "MCP Batch Verify Test",
memo: "MCP batch category+approve regression - safe to delete",
approved: false,
});
regressionTxnId = created.id;
const result = await call("update_transactions", {
budgetId: bid,
transactions: [{
id: regressionTxnId,
categoryId: testCatForWrite.id,
approved: true,
}],
});
if (!result.verification) throw new Error("missing post-write verification metadata");
if (result.verification.checked !== 1) throw new Error(`expected 1 verified transaction, got ${result.verification.checked}`);
if (result.verification.failed?.length) throw new Error(`verification failures: ${JSON.stringify(result.verification.failed)}`);
const refetched = await call("get_transaction", { budgetId: bid, transactionId: regressionTxnId });
if (refetched.approved !== true) throw new Error("approval did not persist after batch update");
if (refetched.category_id !== testCatForWrite.id) {
throw new Error(`category did not persist after batch update: ${refetched.category_id}`);
}
console.log(" ✓ Batch category+approve verified by refetch");
} finally {
if (regressionTxnId) {
await call("delete_transaction", { budgetId: bid, transactionId: regressionTxnId, confirmed: true });
}
}
});
await test("delete_transaction", async () => {
if (!createdTxnId) throw new Error("no test transaction to delete");
const t = await call("delete_transaction", { budgetId: bid, transactionId: createdTxnId, confirmed: true });
if (!t.id) throw new Error("no id in delete response");
console.log(` ✓ Deleted ${t.payee_name} $${t.amount}`);
});
// Find a second category for split test
const secondCat = categories
.filter(g => !g.hidden && !g.deleted && g.name !== "Internal Master Category")
.flatMap(g => g.categories)
.find(c => !c.hidden && !c.deleted && c.id !== testCatForWrite.id);
let splitTxnId;
await test("create_transaction (split)", async () => {
if (!secondCat) throw new Error("need 2 categories for split test");
try {
const t = await call("create_transaction", {
budgetId: bid,
accountId: testAccountId,
date: new Date().toISOString().slice(0, 10),
amount: -25.00,
payeeName: "MCP Split Test",
memo: "MCP split test - safe to delete",
approved: false,
subtransactions: [
{ amount: -15.00, categoryId: testCatForWrite.id, memo: "part 1" },
{ amount: -10.00, categoryId: secondCat.id, memo: "part 2" },
],
});
if (!t.id) throw new Error("no id returned");
splitTxnId = t.id;
if (!t.subtransactions || t.subtransactions.length !== 2) throw new Error(`expected 2 subtransactions, got ${t.subtransactions?.length}`);
console.log(` ✓ Created split: ${t.subtransactions.map(s => `$${s.amount}`).join(" + ")}`);
} finally {
if (splitTxnId) await call("delete_transaction", { budgetId: bid, transactionId: splitTxnId, confirmed: true });
}
});
await test("create_transactions (bulk)", async () => {
const today = new Date().toISOString().slice(0, 10);
let created = [];
try {
const result = await call("create_transactions", {
budgetId: bid,
transactions: [
{ accountId: testAccountId, date: today, amount: -5.00, payeeName: "MCP Bulk Test 1", categoryId: testCatForWrite.id, memo: "bulk test - safe to delete", approved: false },
{ accountId: testAccountId, date: today, amount: -3.50, payeeName: "MCP Bulk Test 2", categoryId: testCatForWrite.id, memo: "bulk test - safe to delete", approved: false },
],
});
created = result.created || [];
if (created.length !== 2) throw new Error(`expected 2 created, got ${created.length}`);
console.log(` ✓ Bulk created ${created.length} transactions`);
} finally {
for (const t of created) {
await call("delete_transaction", { budgetId: bid, transactionId: t.id, confirmed: true });
}
}
});
skip("import_transactions", "YNAB does not expose pending bank-import IDs and values for an exact preview. Import in YNAB, then review the imported rows; ordinary create_transactions is tested above.");
// --- Convenience tools ---
console.log("\n=== Convenience Tools ===");
await test("search_categories", async () => {
// Search for the first few alpha chars of a known category. Guaranteed to return at least one result.
const query = (testCatName ?? "").replace(/[^a-zA-Z]/g, "").slice(0, 3).toLowerCase() || "the";
const results = await call("search_categories", { budgetId: bid, query });
if (!Array.isArray(results)) throw new Error("expected array");
if (results.length === 0) throw new Error(`no results for '${query}'`);
console.log(` ✓ Found ${results.length} categories matching '${query}'`);
});
await test("search_payees", async () => {
const results = await call("search_payees", { budgetId: bid, query: "transfer" });
if (!Array.isArray(results) && !results.message) throw new Error("unexpected response shape");
});
await test("review_unapproved", async () => {
const result = await call("review_unapproved", { budgetId: bid });
if (typeof result.total !== "number") throw new Error("missing total");
if (!result.ready_to_approve) throw new Error("missing ready_to_approve");
if (!result.needs_category_first) throw new Error("missing needs_category_first");
console.log(` ✓ ${result.total} unapproved (${result.ready_to_approve.count} ready, ${result.needs_category_first.count} need category)`);
});
await test("get_transactions (by month)", async () => {
const txns = await call("get_transactions", { budgetId: bid, month: testMonth });
if (!Array.isArray(txns)) throw new Error("not an array");
console.log(` ✓ ${txns.length} transactions in ${testMonthLabel}`);
});
// --- Scheduled transactions ---
console.log("\n=== Scheduled Transaction Operations ===");
let scheduledTransactions = [];
await test("list_scheduled_transactions", async () => {
scheduledTransactions = await call("list_scheduled_transactions", { budgetId: bid });
if (!Array.isArray(scheduledTransactions)) throw new Error("not an array");
});
let testScheduledTxn;
if (scheduledTransactions.length > 0) {
await test("get_scheduled_transaction", async () => {
testScheduledTxn = scheduledTransactions[0];
const s = await call("get_scheduled_transaction", { budgetId: bid, scheduledTransactionId: testScheduledTxn.id });
if (!s.id) throw new Error("no id");
// Verify subtransactions field is present
if (!("subtransactions" in s)) throw new Error("subtransactions field missing");
});
await test("update_scheduled_transaction (update memo, verify round-trip)", async () => {
const marker = `MCP test ${Date.now()}`;
const original = await call("get_scheduled_transaction", { budgetId: bid, scheduledTransactionId: testScheduledTxn.id });
try {
const updated = await call("update_scheduled_transaction", {
budgetId: bid,
scheduledTransactionId: testScheduledTxn.id,
memo: marker,
});
if (updated.memo !== marker) throw new Error("memo not updated");
// Verify other fields preserved (fetch-then-merge worked)
if (updated.amount !== original.amount) throw new Error(`amount changed: ${original.amount} -> ${updated.amount}`);
if (updated.frequency !== original.frequency) throw new Error("frequency changed");
} finally {
await call("update_scheduled_transaction", {
budgetId: bid,
scheduledTransactionId: testScheduledTxn.id,
memo: original.memo,
});
}
});
} else {
skip("get_scheduled_transaction", "no scheduled transactions in this budget");
skip("update_scheduled_transaction (update memo, verify round-trip)", "no scheduled transactions in this budget");
}
// --- Summary ---
console.log(`\n=== Results: ${passed} passed, ${failed} failed, ${skipped} skipped ===`);
await client.close();
process.exit(failed > 0 ? 1 : 0);