@bitbonsai/mcpvault 0.9.0 → 0.11.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.
@@ -1,946 +0,0 @@
1
- import { test, expect, beforeEach, afterEach } from "vitest";
2
- import { FileSystemService } from "./filesystem.js";
3
- import { PathFilter } from "./pathfilter.js";
4
- import { writeFile, readFile, mkdir, mkdtemp, rm } from "fs/promises";
5
- import { join } from "path";
6
- import { tmpdir } from "os";
7
- let testVaultPath;
8
- let fileSystem;
9
- beforeEach(async () => {
10
- testVaultPath = await mkdtemp(join(tmpdir(), "mcpvault-test-"));
11
- fileSystem = new FileSystemService(testVaultPath);
12
- });
13
- afterEach(async () => {
14
- try {
15
- await rm(testVaultPath, { recursive: true });
16
- }
17
- catch {
18
- // Ignore cleanup errors
19
- }
20
- });
21
- // ============================================================================
22
- // PATCH TESTS
23
- // ============================================================================
24
- test("patch note with single occurrence", async () => {
25
- const testPath = "test-note.md";
26
- const content = "# Test Note\n\nThis is the old content.\n\nMore text here.";
27
- await writeFile(join(testVaultPath, testPath), content);
28
- const result = await fileSystem.patchNote({
29
- path: testPath,
30
- oldString: "old content",
31
- newString: "new content",
32
- replaceAll: false
33
- });
34
- expect(result.success).toBe(true);
35
- expect(result.matchCount).toBe(1);
36
- expect(result.message).toContain("Successfully replaced 1 occurrence");
37
- const updatedNote = await fileSystem.readNote(testPath);
38
- expect(updatedNote.content).toContain("new content");
39
- expect(updatedNote.content).not.toContain("old content");
40
- });
41
- test("patch note with multiple occurrences requires replaceAll", async () => {
42
- const testPath = "test-note.md";
43
- const content = "# Test\n\nrepeat word repeat word repeat";
44
- await writeFile(join(testVaultPath, testPath), content);
45
- const result = await fileSystem.patchNote({
46
- path: testPath,
47
- oldString: "repeat",
48
- newString: "unique",
49
- replaceAll: false
50
- });
51
- expect(result.success).toBe(false);
52
- expect(result.matchCount).toBe(3);
53
- expect(result.message).toContain("Found 3 occurrences");
54
- expect(result.message).toContain("Use replaceAll=true");
55
- });
56
- test("patch note with replaceAll replaces all occurrences", async () => {
57
- const testPath = "test-note.md";
58
- const content = "# Test\n\nrepeat word repeat word repeat";
59
- await writeFile(join(testVaultPath, testPath), content);
60
- const result = await fileSystem.patchNote({
61
- path: testPath,
62
- oldString: "repeat",
63
- newString: "unique",
64
- replaceAll: true
65
- });
66
- expect(result.success).toBe(true);
67
- expect(result.matchCount).toBe(3);
68
- expect(result.message).toContain("Successfully replaced 3 occurrences");
69
- const updatedNote = await fileSystem.readNote(testPath);
70
- expect(updatedNote.content).not.toContain("repeat");
71
- expect(updatedNote.content.match(/unique/g)?.length).toBe(3);
72
- });
73
- test("patch note fails when string not found", async () => {
74
- const testPath = "test-note.md";
75
- const content = "# Test Note\n\nSome content here.";
76
- await writeFile(join(testVaultPath, testPath), content);
77
- const result = await fileSystem.patchNote({
78
- path: testPath,
79
- oldString: "non-existent string",
80
- newString: "replacement",
81
- replaceAll: false
82
- });
83
- expect(result.success).toBe(false);
84
- expect(result.matchCount).toBe(0);
85
- expect(result.message).toContain("String not found");
86
- });
87
- test("patch note with multiline replacement", async () => {
88
- const testPath = "test-note.md";
89
- const content = "# Test\n\n## Section A\nOld content\nOld lines\n\n## Section B\nOther content";
90
- await writeFile(join(testVaultPath, testPath), content);
91
- const result = await fileSystem.patchNote({
92
- path: testPath,
93
- oldString: "## Section A\nOld content\nOld lines",
94
- newString: "## Section A\nNew content\nNew improved lines",
95
- replaceAll: false
96
- });
97
- expect(result.success).toBe(true);
98
- expect(result.matchCount).toBe(1);
99
- const updatedNote = await fileSystem.readNote(testPath);
100
- expect(updatedNote.content).toContain("New content");
101
- expect(updatedNote.content).toContain("New improved lines");
102
- expect(updatedNote.content).not.toContain("Old content");
103
- });
104
- test("patch note with frontmatter preserved", async () => {
105
- const testPath = "test-note.md";
106
- const content = `---
107
- title: My Note
108
- tags: [test]
109
- ---
110
-
111
- # Content
112
-
113
- Old text here.`;
114
- await writeFile(join(testVaultPath, testPath), content);
115
- const result = await fileSystem.patchNote({
116
- path: testPath,
117
- oldString: "Old text here.",
118
- newString: "New text here.",
119
- replaceAll: false
120
- });
121
- expect(result.success).toBe(true);
122
- const updatedNote = await fileSystem.readNote(testPath);
123
- expect(updatedNote.frontmatter.title).toBe("My Note");
124
- expect(updatedNote.frontmatter.tags).toEqual(["test"]);
125
- expect(updatedNote.content).toContain("New text here.");
126
- });
127
- test("patch note fails when oldString equals newString", async () => {
128
- const testPath = "test-note.md";
129
- const content = "# Test\n\nSome content";
130
- await writeFile(join(testVaultPath, testPath), content);
131
- const result = await fileSystem.patchNote({
132
- path: testPath,
133
- oldString: "same",
134
- newString: "same",
135
- replaceAll: false
136
- });
137
- expect(result.success).toBe(false);
138
- expect(result.message).toContain("must be different");
139
- });
140
- test("patch note fails for filtered paths", async () => {
141
- const testPath = ".obsidian/config.json";
142
- const result = await fileSystem.patchNote({
143
- path: testPath,
144
- oldString: "old",
145
- newString: "new",
146
- replaceAll: false
147
- });
148
- expect(result.success).toBe(false);
149
- expect(result.message).toContain("Access denied");
150
- });
151
- test("patch note fails when file doesn't exist", async () => {
152
- const testPath = "non-existent-note.md";
153
- const result = await fileSystem.patchNote({
154
- path: testPath,
155
- oldString: "old",
156
- newString: "new",
157
- replaceAll: false
158
- });
159
- expect(result.success).toBe(false);
160
- expect(result.message).toContain("File not found");
161
- });
162
- test("patch note fails with empty oldString", async () => {
163
- const testPath = "test-note.md";
164
- const content = "# Test Note\n\nSome content.";
165
- await writeFile(join(testVaultPath, testPath), content);
166
- const result = await fileSystem.patchNote({
167
- path: testPath,
168
- oldString: "",
169
- newString: "new",
170
- replaceAll: false
171
- });
172
- expect(result.success).toBe(false);
173
- expect(result.message).toMatch(/empty|filled|required/i);
174
- });
175
- test("patch note fails with empty newString", async () => {
176
- const testPath = "test-note.md";
177
- const content = "# Test Note\n\nSome content.";
178
- await writeFile(join(testVaultPath, testPath), content);
179
- const result = await fileSystem.patchNote({
180
- path: testPath,
181
- oldString: "content",
182
- newString: "",
183
- replaceAll: false
184
- });
185
- expect(result.success).toBe(false);
186
- expect(result.message).toMatch(/empty|filled|required/i);
187
- });
188
- test("patch note fails with undefined newString", async () => {
189
- const testPath = "test-note.md";
190
- const content = "# Test Note\n\nSome content.";
191
- await writeFile(join(testVaultPath, testPath), content);
192
- const result = await fileSystem.patchNote({
193
- path: testPath,
194
- oldString: "content",
195
- newString: undefined,
196
- replaceAll: false
197
- });
198
- expect(result.success).toBe(false);
199
- expect(result.message).toMatch(/empty|filled|required/i);
200
- // Verify the note was NOT corrupted
201
- const note = await fileSystem.readNote(testPath);
202
- expect(note.content).not.toContain("undefined");
203
- expect(note.content).toContain("Some content.");
204
- });
205
- test("patch note fails with null newString", async () => {
206
- const testPath = "test-note.md";
207
- const content = "# Test Note\n\nSome content.";
208
- await writeFile(join(testVaultPath, testPath), content);
209
- const result = await fileSystem.patchNote({
210
- path: testPath,
211
- oldString: "content",
212
- newString: null,
213
- replaceAll: false
214
- });
215
- expect(result.success).toBe(false);
216
- expect(result.message).toMatch(/empty|filled|required/i);
217
- // Verify the note was NOT corrupted
218
- const note = await fileSystem.readNote(testPath);
219
- expect(note.content).not.toContain("null");
220
- expect(note.content).toContain("Some content.");
221
- });
222
- test("writeNote rejects undefined content", async () => {
223
- const testPath = "test-note.md";
224
- await expect(fileSystem.writeNote({
225
- path: testPath,
226
- content: undefined
227
- })).rejects.toThrow(/Content is required/);
228
- });
229
- test("writeNote rejects null content", async () => {
230
- const testPath = "test-note.md";
231
- await expect(fileSystem.writeNote({
232
- path: testPath,
233
- content: null
234
- })).rejects.toThrow(/Content is required/);
235
- });
236
- test("writeNote append with undefined content does not corrupt note", async () => {
237
- const testPath = "test-note.md";
238
- const content = "# Test Note\n\nOriginal content.";
239
- await writeFile(join(testVaultPath, testPath), content);
240
- await expect(fileSystem.writeNote({
241
- path: testPath,
242
- content: undefined,
243
- mode: 'append'
244
- })).rejects.toThrow(/Content is required/);
245
- // Verify the note was NOT corrupted
246
- const note = await fileSystem.readNote(testPath);
247
- expect(note.content).not.toContain("undefined");
248
- expect(note.content).toContain("Original content.");
249
- });
250
- test("patch note handles regex special characters literally", async () => {
251
- const testPath = "test-note.md";
252
- const content = "Price: $10.50 (special)";
253
- await writeFile(join(testVaultPath, testPath), content);
254
- const result = await fileSystem.patchNote({
255
- path: testPath,
256
- oldString: "$10.50",
257
- newString: "$15.75",
258
- replaceAll: false
259
- });
260
- expect(result.success).toBe(true);
261
- const updatedNote = await fileSystem.readNote(testPath);
262
- expect(updatedNote.content).toContain("$15.75");
263
- expect(updatedNote.content).not.toContain("$10.50");
264
- });
265
- test("patch note works with fenced code blocks", async () => {
266
- const testPath = "code-fence-test.md";
267
- const content = "# Example\n\n```rust\nfn main() {\n println!(\"hello\");\n}\n```\n";
268
- await writeFile(join(testVaultPath, testPath), content);
269
- const result = await fileSystem.patchNote({
270
- path: testPath,
271
- oldString: "println!(\"hello\");",
272
- newString: "println!(\"hello world\");",
273
- replaceAll: false
274
- });
275
- expect(result.success).toBe(true);
276
- const updatedNote = await fileSystem.readNote(testPath);
277
- expect(updatedNote.originalContent).toContain("println!(\"hello world\");");
278
- });
279
- test("patch note works with markdown tables", async () => {
280
- const testPath = "table-test.md";
281
- const content = "| Tool | Status |\n|---|---|\n| patch_note | flaky |\n";
282
- await writeFile(join(testVaultPath, testPath), content);
283
- const result = await fileSystem.patchNote({
284
- path: testPath,
285
- oldString: "| patch_note | flaky |",
286
- newString: "| patch_note | stable |",
287
- replaceAll: false
288
- });
289
- expect(result.success).toBe(true);
290
- const updatedNote = await fileSystem.readNote(testPath);
291
- expect(updatedNote.originalContent).toContain("| patch_note | stable |");
292
- });
293
- test("patch note preserves tabs and spaces", async () => {
294
- const testPath = "test-note.md";
295
- const content = "Line with\ttabs\n Line with spaces\n\tTabbed line";
296
- await writeFile(join(testVaultPath, testPath), content);
297
- const result = await fileSystem.patchNote({
298
- path: testPath,
299
- oldString: "tabs",
300
- newString: "TABS",
301
- replaceAll: false
302
- });
303
- expect(result.success).toBe(true);
304
- const updatedNote = await fileSystem.readNote(testPath);
305
- expect(updatedNote.content).toContain("Line with\tTABS");
306
- expect(updatedNote.content).toContain("\tTabbed line");
307
- expect(updatedNote.content).toContain(" Line with spaces");
308
- });
309
- test("patch note is case sensitive", async () => {
310
- const testPath = "test-note.md";
311
- const content = "Hello world, hello again";
312
- await writeFile(join(testVaultPath, testPath), content);
313
- const result = await fileSystem.patchNote({
314
- path: testPath,
315
- oldString: "hello",
316
- newString: "hi",
317
- replaceAll: false
318
- });
319
- expect(result.success).toBe(true);
320
- const updatedNote = await fileSystem.readNote(testPath);
321
- expect(updatedNote.content).toContain("Hello world");
322
- expect(updatedNote.content).toContain("hi again");
323
- });
324
- test("patch note handles many replacements efficiently", async () => {
325
- const testPath = "test-note.md";
326
- const lines = Array.from({ length: 100 }, (_, i) => `Line ${i}: replace_me`);
327
- const content = lines.join("\n");
328
- await writeFile(join(testVaultPath, testPath), content);
329
- const startTime = Date.now();
330
- const result = await fileSystem.patchNote({
331
- path: testPath,
332
- oldString: "replace_me",
333
- newString: "replaced",
334
- replaceAll: true
335
- });
336
- const duration = Date.now() - startTime;
337
- expect(result.success).toBe(true);
338
- expect(result.matchCount).toBe(100);
339
- expect(duration).toBeLessThan(1000);
340
- const updatedNote = await fileSystem.readNote(testPath);
341
- expect(updatedNote.content).not.toContain("replace_me");
342
- expect(updatedNote.content.match(/replaced/g)?.length).toBe(100);
343
- });
344
- test("patch note works with path containing spaces", async () => {
345
- const testPath = "folder name/note with spaces.md";
346
- const content = "# Test Note\n\nOld content here.";
347
- await mkdir(join(testVaultPath, "folder name"), { recursive: true });
348
- await writeFile(join(testVaultPath, testPath), content);
349
- const result = await fileSystem.patchNote({
350
- path: testPath,
351
- oldString: "Old content",
352
- newString: "New content",
353
- replaceAll: false
354
- });
355
- expect(result.success).toBe(true);
356
- const updatedNote = await fileSystem.readNote(testPath);
357
- expect(updatedNote.content).toContain("New content");
358
- });
359
- // ============================================================================
360
- // DELETE TESTS
361
- // ============================================================================
362
- test("delete note with correct confirmation", async () => {
363
- const testPath = "test-note.md";
364
- const content = "# Test Note\n\nThis is a test note to be deleted.";
365
- await writeFile(join(testVaultPath, testPath), content);
366
- const result = await fileSystem.deleteNote({
367
- path: testPath,
368
- confirmPath: testPath
369
- });
370
- expect(result.success).toBe(true);
371
- expect(result.path).toBe(testPath);
372
- expect(result.message).toContain("Successfully deleted");
373
- expect(result.message).toContain("cannot be undone");
374
- });
375
- test("reject deletion with incorrect confirmation", async () => {
376
- const testPath = "test-note.md";
377
- const content = "# Test Note\n\nThis note should not be deleted.";
378
- await writeFile(join(testVaultPath, testPath), content);
379
- const result = await fileSystem.deleteNote({
380
- path: testPath,
381
- confirmPath: "wrong-path.md"
382
- });
383
- expect(result.success).toBe(false);
384
- expect(result.path).toBe(testPath);
385
- expect(result.message).toContain("confirmation path does not match");
386
- const fileStillExists = await fileSystem.exists(testPath);
387
- expect(fileStillExists).toBe(true);
388
- });
389
- test("handle deletion of non-existent file", async () => {
390
- const testPath = "non-existent.md";
391
- const result = await fileSystem.deleteNote({
392
- path: testPath,
393
- confirmPath: testPath
394
- });
395
- expect(result.success).toBe(false);
396
- expect(result.path).toBe(testPath);
397
- expect(result.message).toContain("File not found");
398
- });
399
- test("reject deletion of filtered paths", async () => {
400
- const testPath = ".obsidian/app.json";
401
- const result = await fileSystem.deleteNote({
402
- path: testPath,
403
- confirmPath: testPath
404
- });
405
- expect(result.success).toBe(false);
406
- expect(result.path).toBe(testPath);
407
- expect(result.message).toContain("Access denied");
408
- });
409
- test("handle directory deletion attempt", async () => {
410
- const testPath = "test-directory";
411
- await mkdir(join(testVaultPath, testPath));
412
- const result = await fileSystem.deleteNote({
413
- path: testPath,
414
- confirmPath: testPath
415
- });
416
- expect(result.success).toBe(false);
417
- expect(result.path).toBe(testPath);
418
- expect(result.message).toContain("is not a file");
419
- });
420
- test("delete note with frontmatter", async () => {
421
- const testPath = "note-with-frontmatter.md";
422
- const content = `---
423
- title: Test Note
424
- tags: [test, delete]
425
- ---
426
-
427
- # Test Note
428
-
429
- This note has frontmatter and should be deleted successfully.`;
430
- await writeFile(join(testVaultPath, testPath), content);
431
- const result = await fileSystem.deleteNote({
432
- path: testPath,
433
- confirmPath: testPath
434
- });
435
- expect(result.success).toBe(true);
436
- expect(result.path).toBe(testPath);
437
- expect(result.message).toContain("Successfully deleted");
438
- });
439
- // ============================================================================
440
- // FRONTMATTER INTEGRATION TESTS
441
- // ============================================================================
442
- test("write_note with frontmatter", async () => {
443
- await fileSystem.writeNote({
444
- path: "test.md",
445
- content: "This is test content.",
446
- frontmatter: {
447
- title: "Test Note",
448
- tags: ["test", "example"],
449
- created: "2023-01-01"
450
- }
451
- });
452
- const note = await fileSystem.readNote("test.md");
453
- expect(note.frontmatter.title).toBe("Test Note");
454
- expect(note.frontmatter.tags).toEqual(["test", "example"]);
455
- expect(note.frontmatter.created).toBe("2023-01-01");
456
- expect(note.content.trim()).toBe("This is test content.");
457
- });
458
- test("write_note with append mode preserves frontmatter", async () => {
459
- await fileSystem.writeNote({
460
- path: "append-test.md",
461
- content: "Original content.",
462
- frontmatter: { title: "Original", status: "draft" }
463
- });
464
- await fileSystem.writeNote({
465
- path: "append-test.md",
466
- content: "\nAppended content.",
467
- frontmatter: { updated: "2023-12-01" },
468
- mode: "append"
469
- });
470
- const note = await fileSystem.readNote("append-test.md");
471
- expect(note.frontmatter.title).toBe("Original");
472
- expect(note.frontmatter.status).toBe("draft");
473
- expect(note.frontmatter.updated).toBe("2023-12-01");
474
- expect(note.content.trim()).toBe("Original content.\n\nAppended content.");
475
- });
476
- test("update_frontmatter merges with existing", async () => {
477
- await fileSystem.writeNote({
478
- path: "update-test.md",
479
- content: "Test content.",
480
- frontmatter: {
481
- title: "Original Title",
482
- tags: ["original"],
483
- status: "draft"
484
- }
485
- });
486
- await fileSystem.updateFrontmatter({
487
- path: "update-test.md",
488
- frontmatter: {
489
- title: "Updated Title",
490
- priority: "high"
491
- },
492
- merge: true
493
- });
494
- const note = await fileSystem.readNote("update-test.md");
495
- expect(note.frontmatter.title).toBe("Updated Title");
496
- expect(note.frontmatter.tags).toEqual(["original"]);
497
- expect(note.frontmatter.status).toBe("draft");
498
- expect(note.frontmatter.priority).toBe("high");
499
- expect(note.content.trim()).toBe("Test content.");
500
- });
501
- test("update_frontmatter replaces when merge is false", async () => {
502
- await fileSystem.writeNote({
503
- path: "replace-test.md",
504
- content: "Test content.",
505
- frontmatter: {
506
- title: "Original Title",
507
- tags: ["original"],
508
- status: "draft"
509
- }
510
- });
511
- await fileSystem.updateFrontmatter({
512
- path: "replace-test.md",
513
- frontmatter: {
514
- title: "New Title",
515
- priority: "high"
516
- },
517
- merge: false
518
- });
519
- const note = await fileSystem.readNote("replace-test.md");
520
- expect(note.frontmatter.title).toBe("New Title");
521
- expect(note.frontmatter.priority).toBe("high");
522
- expect(note.frontmatter.tags).toBeUndefined();
523
- expect(note.frontmatter.status).toBeUndefined();
524
- });
525
- test("manage_tags add operation", async () => {
526
- await fileSystem.writeNote({
527
- path: "tags-add-test.md",
528
- content: "Test content.",
529
- frontmatter: {
530
- title: "Test",
531
- tags: ["existing"]
532
- }
533
- });
534
- const result = await fileSystem.manageTags({
535
- path: "tags-add-test.md",
536
- operation: "add",
537
- tags: ["new", "important"]
538
- });
539
- expect(result.success).toBe(true);
540
- expect(result.tags).toEqual(["existing", "new", "important"]);
541
- const note = await fileSystem.readNote("tags-add-test.md");
542
- expect(note.frontmatter.tags).toEqual(["existing", "new", "important"]);
543
- });
544
- test("manage_tags remove operation", async () => {
545
- await fileSystem.writeNote({
546
- path: "tags-remove-test.md",
547
- content: "Test content.",
548
- frontmatter: {
549
- title: "Test",
550
- tags: ["keep", "remove1", "remove2"]
551
- }
552
- });
553
- const result = await fileSystem.manageTags({
554
- path: "tags-remove-test.md",
555
- operation: "remove",
556
- tags: ["remove1", "remove2"]
557
- });
558
- expect(result.success).toBe(true);
559
- expect(result.tags).toEqual(["keep"]);
560
- const note = await fileSystem.readNote("tags-remove-test.md");
561
- expect(note.frontmatter.tags).toEqual(["keep"]);
562
- });
563
- test("manage_tags list operation", async () => {
564
- await fileSystem.writeNote({
565
- path: "tags-list-test.md",
566
- content: "Test content with #inline-tag.",
567
- frontmatter: {
568
- title: "Test",
569
- tags: ["frontmatter-tag"]
570
- }
571
- });
572
- const result = await fileSystem.manageTags({
573
- path: "tags-list-test.md",
574
- operation: "list"
575
- });
576
- expect(result.success).toBe(true);
577
- expect(result.tags).toContain("frontmatter-tag");
578
- expect(result.tags).toContain("inline-tag");
579
- });
580
- test("manage_tags removes tags array when empty", async () => {
581
- await fileSystem.writeNote({
582
- path: "tags-empty-test.md",
583
- content: "Test content.",
584
- frontmatter: {
585
- title: "Test",
586
- tags: ["remove-me"]
587
- }
588
- });
589
- await fileSystem.manageTags({
590
- path: "tags-empty-test.md",
591
- operation: "remove",
592
- tags: ["remove-me"]
593
- });
594
- const note = await fileSystem.readNote("tags-empty-test.md");
595
- expect(note.frontmatter.tags).toBeUndefined();
596
- expect(note.frontmatter.title).toBe("Test");
597
- });
598
- test("frontmatter validation with invalid data", async () => {
599
- await expect(fileSystem.writeNote({
600
- path: "invalid-test.md",
601
- content: "Test content.",
602
- frontmatter: {
603
- title: "Test",
604
- invalidFunction: () => "not allowed"
605
- }
606
- })).rejects.toThrow(/Invalid frontmatter/);
607
- });
608
- test("listDirectory includes non-note files but readNote still blocks them", async () => {
609
- const imagePath = "assets/diagram.png";
610
- await mkdir(join(testVaultPath, "assets"), { recursive: true });
611
- await writeFile(join(testVaultPath, imagePath), "fake-png-content");
612
- const listing = await fileSystem.listDirectory("assets");
613
- expect(listing.files).toContain("diagram.png");
614
- await expect(fileSystem.readNote(imagePath)).rejects.toThrow(/Access denied/);
615
- });
616
- // ============================================================================
617
- // NON-EXISTENT VAULT TESTS
618
- // ============================================================================
619
- test("read from non-existent vault throws error", async () => {
620
- const nonExistentFs = new FileSystemService("/non/existent/vault/path");
621
- await expect(nonExistentFs.readNote("test.md"))
622
- .rejects.toThrow(/File not found|ENOENT/);
623
- });
624
- test("write to non-existent vault creates directories", async () => {
625
- const tempVault = await mkdtemp(join(tmpdir(), "mcpvault-new-vault-"));
626
- const newFs = new FileSystemService(tempVault);
627
- try {
628
- await newFs.writeNote({
629
- path: "new-folder/nested/note.md",
630
- content: "Test content"
631
- });
632
- const note = await newFs.readNote("new-folder/nested/note.md");
633
- expect(note.content).toContain("Test content");
634
- }
635
- finally {
636
- await rm(tempVault, { recursive: true });
637
- }
638
- });
639
- test("list directory in non-existent vault", async () => {
640
- const nonExistentFs = new FileSystemService("/non/existent/vault/path");
641
- await expect(nonExistentFs.listDirectory("/"))
642
- .rejects.toThrow();
643
- });
644
- // ============================================================================
645
- // PATH TRAVERSAL WITH SPECIAL CHARACTERS
646
- // ============================================================================
647
- test("path traversal attempt with encoded dots blocked", async () => {
648
- // Path traversal should be blocked even with URL encoding
649
- await expect(fileSystem.readNote("..%2F..%2Fetc%2Fpasswd"))
650
- .rejects.toThrow(/Path traversal not allowed/);
651
- });
652
- test("path traversal with .. is blocked", async () => {
653
- await expect(fileSystem.readNote("../outside.md"))
654
- .rejects.toThrow(/Path traversal not allowed/);
655
- });
656
- test("path traversal with nested .. is blocked", async () => {
657
- await expect(fileSystem.readNote("folder/../../outside.md"))
658
- .rejects.toThrow(/Path traversal not allowed/);
659
- });
660
- test("path with regex special chars is treated literally", async () => {
661
- const testPath = "folder (copy)/note [1].md";
662
- const content = "# Test with special chars";
663
- await mkdir(join(testVaultPath, "folder (copy)"), { recursive: true });
664
- await writeFile(join(testVaultPath, testPath), content);
665
- const note = await fileSystem.readNote(testPath);
666
- expect(note.content).toContain("Test with special chars");
667
- });
668
- test("path with dollar sign works", async () => {
669
- const testPath = "$special/price$100.md";
670
- const content = "# Price note";
671
- await mkdir(join(testVaultPath, "$special"), { recursive: true });
672
- await writeFile(join(testVaultPath, testPath), content);
673
- const note = await fileSystem.readNote(testPath);
674
- expect(note.content).toContain("Price note");
675
- });
676
- test("path with plus sign works", async () => {
677
- const testPath = "C++/notes.md";
678
- const content = "# C++ notes";
679
- await mkdir(join(testVaultPath, "C++"), { recursive: true });
680
- await writeFile(join(testVaultPath, testPath), content);
681
- const note = await fileSystem.readNote(testPath);
682
- expect(note.content).toContain("C++ notes");
683
- });
684
- test("path with pipe character works", async () => {
685
- const testPath = "choice|option.md";
686
- const content = "# Choice note";
687
- await writeFile(join(testVaultPath, testPath), content);
688
- const note = await fileSystem.readNote(testPath);
689
- expect(note.content).toContain("Choice note");
690
- });
691
- test("delete note with special chars in path", async () => {
692
- const testPath = "folder (archive)/note [old].md";
693
- const content = "# Old note";
694
- await mkdir(join(testVaultPath, "folder (archive)"), { recursive: true });
695
- await writeFile(join(testVaultPath, testPath), content);
696
- const result = await fileSystem.deleteNote({
697
- path: testPath,
698
- confirmPath: testPath
699
- });
700
- expect(result.success).toBe(true);
701
- });
702
- test("move note with special chars in both paths", async () => {
703
- const oldPath = "source (1)/note [a].md";
704
- const newPath = "dest (2)/note [b].md";
705
- const content = "# Moving note";
706
- await mkdir(join(testVaultPath, "source (1)"), { recursive: true });
707
- await mkdir(join(testVaultPath, "dest (2)"), { recursive: true });
708
- await writeFile(join(testVaultPath, oldPath), content);
709
- const result = await fileSystem.moveNote({
710
- oldPath,
711
- newPath
712
- });
713
- expect(result.success).toBe(true);
714
- const note = await fileSystem.readNote(newPath);
715
- expect(note.content).toContain("Moving note");
716
- });
717
- test("move_file moves binary files without corruption", async () => {
718
- const oldPath = "attachments/original image.png";
719
- const newPath = "assets/original image.png";
720
- const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0x10, 0x42]);
721
- await mkdir(join(testVaultPath, "attachments"), { recursive: true });
722
- await writeFile(join(testVaultPath, oldPath), binaryContent);
723
- const result = await fileSystem.moveFile({
724
- oldPath,
725
- newPath,
726
- confirmOldPath: oldPath,
727
- confirmNewPath: newPath
728
- });
729
- expect(result.success).toBe(true);
730
- const moved = await readFile(join(testVaultPath, newPath));
731
- expect(Buffer.compare(moved, binaryContent)).toBe(0);
732
- await expect(readFile(join(testVaultPath, oldPath))).rejects.toMatchObject({ code: "ENOENT" });
733
- });
734
- test("move_file respects overwrite=false", async () => {
735
- const oldPath = "attachments/image.png";
736
- const newPath = "assets/image.png";
737
- await mkdir(join(testVaultPath, "attachments"), { recursive: true });
738
- await mkdir(join(testVaultPath, "assets"), { recursive: true });
739
- await writeFile(join(testVaultPath, oldPath), Buffer.from([0x01, 0x02, 0x03]));
740
- await writeFile(join(testVaultPath, newPath), Buffer.from([0xaa, 0xbb]));
741
- const result = await fileSystem.moveFile({
742
- oldPath,
743
- newPath,
744
- confirmOldPath: oldPath,
745
- confirmNewPath: newPath,
746
- overwrite: false
747
- });
748
- expect(result.success).toBe(false);
749
- expect(result.message).toContain("Target file already exists");
750
- });
751
- test("move_file overwrites existing file when overwrite=true", async () => {
752
- const oldPath = "attachments/image.png";
753
- const newPath = "assets/image.png";
754
- const replacement = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
755
- await mkdir(join(testVaultPath, "attachments"), { recursive: true });
756
- await mkdir(join(testVaultPath, "assets"), { recursive: true });
757
- await writeFile(join(testVaultPath, oldPath), replacement);
758
- await writeFile(join(testVaultPath, newPath), Buffer.from([0x00]));
759
- const result = await fileSystem.moveFile({
760
- oldPath,
761
- newPath,
762
- confirmOldPath: oldPath,
763
- confirmNewPath: newPath,
764
- overwrite: true
765
- });
766
- expect(result.success).toBe(true);
767
- const moved = await readFile(join(testVaultPath, newPath));
768
- expect(Buffer.compare(moved, replacement)).toBe(0);
769
- });
770
- test("move_file rejects directory sources", async () => {
771
- await mkdir(join(testVaultPath, "attachments/folder"), { recursive: true });
772
- const result = await fileSystem.moveFile({
773
- oldPath: "attachments/folder",
774
- newPath: "assets/folder",
775
- confirmOldPath: "attachments/folder",
776
- confirmNewPath: "assets/folder"
777
- });
778
- expect(result.success).toBe(false);
779
- expect(result.message).toContain("supports files only");
780
- });
781
- test("move_file blocks restricted system paths", async () => {
782
- const result = await fileSystem.moveFile({
783
- oldPath: ".obsidian/plugins/data.json",
784
- newPath: "assets/data.json",
785
- confirmOldPath: ".obsidian/plugins/data.json",
786
- confirmNewPath: "assets/data.json"
787
- });
788
- expect(result.success).toBe(false);
789
- expect(result.message).toContain("Access denied");
790
- });
791
- test("move_file requires matching confirmation paths", async () => {
792
- const oldPath = "attachments/check.png";
793
- const newPath = "assets/check.png";
794
- await mkdir(join(testVaultPath, "attachments"), { recursive: true });
795
- await writeFile(join(testVaultPath, oldPath), Buffer.from([0x11, 0x22]));
796
- const result = await fileSystem.moveFile({
797
- oldPath,
798
- newPath,
799
- confirmOldPath: "attachments/other.png",
800
- confirmNewPath: newPath
801
- });
802
- expect(result.success).toBe(false);
803
- expect(result.message).toContain("confirmation paths do not match");
804
- const stillExists = await readFile(join(testVaultPath, oldPath));
805
- expect(Buffer.compare(stillExists, Buffer.from([0x11, 0x22]))).toBe(0);
806
- });
807
- test("patch note with regex special chars in oldString", async () => {
808
- const testPath = "regex-test.md";
809
- const content = "Price: $10.50 (discount)";
810
- await writeFile(join(testVaultPath, testPath), content);
811
- const result = await fileSystem.patchNote({
812
- path: testPath,
813
- oldString: "$10.50 (discount)",
814
- newString: "$15.00 (regular)",
815
- replaceAll: false
816
- });
817
- expect(result.success).toBe(true);
818
- const note = await fileSystem.readNote(testPath);
819
- expect(note.content).toContain("$15.00 (regular)");
820
- });
821
- // Note: searchNotes is in SearchService, not FileSystemService
822
- // Search tests with regex special chars should be in search.test.ts
823
- // ============================================================================
824
- // UNICODE AND INTERNATIONAL PATHS
825
- // ============================================================================
826
- test("handles unicode in file paths", async () => {
827
- const testPath = "日本語/ノート.md";
828
- const content = "# Japanese note";
829
- await mkdir(join(testVaultPath, "日本語"), { recursive: true });
830
- await writeFile(join(testVaultPath, testPath), content);
831
- const note = await fileSystem.readNote(testPath);
832
- expect(note.content).toContain("Japanese note");
833
- });
834
- test("handles emoji in file paths", async () => {
835
- const testPath = "📁/🎉.md";
836
- const content = "# Emoji note";
837
- await mkdir(join(testVaultPath, "📁"), { recursive: true });
838
- await writeFile(join(testVaultPath, testPath), content);
839
- const note = await fileSystem.readNote(testPath);
840
- expect(note.content).toContain("Emoji note");
841
- });
842
- // ============================================================================
843
- // VAULT STATS TESTS
844
- // ============================================================================
845
- test("get vault stats with empty vault", async () => {
846
- const stats = await fileSystem.getVaultStats();
847
- expect(stats.totalNotes).toBe(0);
848
- expect(stats.totalFolders).toBe(0);
849
- expect(stats.totalSize).toBe(0);
850
- expect(stats.recentlyModified).toHaveLength(0);
851
- });
852
- test("get vault stats counts notes and folders", async () => {
853
- await mkdir(join(testVaultPath, "folder1"), { recursive: true });
854
- await mkdir(join(testVaultPath, "folder2/nested"), { recursive: true });
855
- await writeFile(join(testVaultPath, "note1.md"), "# Note 1");
856
- await writeFile(join(testVaultPath, "folder1/note2.md"), "# Note 2");
857
- await writeFile(join(testVaultPath, "folder2/nested/note3.md"), "# Note 3");
858
- const stats = await fileSystem.getVaultStats();
859
- expect(stats.totalNotes).toBe(3);
860
- expect(stats.totalFolders).toBe(3); // folder1, folder2, folder2/nested
861
- expect(stats.totalSize).toBeGreaterThan(0);
862
- });
863
- test("get vault stats returns recently modified files in order", async () => {
864
- // Create files with slight delays to ensure different modification times
865
- await writeFile(join(testVaultPath, "old.md"), "# Old");
866
- await new Promise(resolve => setTimeout(resolve, 10));
867
- await writeFile(join(testVaultPath, "middle.md"), "# Middle");
868
- await new Promise(resolve => setTimeout(resolve, 10));
869
- await writeFile(join(testVaultPath, "recent.md"), "# Recent");
870
- const stats = await fileSystem.getVaultStats(3);
871
- expect(stats.recentlyModified).toHaveLength(3);
872
- expect(stats.recentlyModified[0]?.path).toBe("recent.md");
873
- expect(stats.recentlyModified[1]?.path).toBe("middle.md");
874
- expect(stats.recentlyModified[2]?.path).toBe("old.md");
875
- });
876
- test("get vault stats respects recentCount limit", async () => {
877
- await writeFile(join(testVaultPath, "note1.md"), "# Note 1");
878
- await writeFile(join(testVaultPath, "note2.md"), "# Note 2");
879
- await writeFile(join(testVaultPath, "note3.md"), "# Note 3");
880
- const stats = await fileSystem.getVaultStats(2);
881
- expect(stats.recentlyModified).toHaveLength(2);
882
- });
883
- test("get vault stats excludes filtered paths", async () => {
884
- await mkdir(join(testVaultPath, ".obsidian"), { recursive: true });
885
- await mkdir(join(testVaultPath, ".git"), { recursive: true });
886
- await writeFile(join(testVaultPath, ".obsidian/config.json"), "{}");
887
- await writeFile(join(testVaultPath, ".git/config"), "git config");
888
- await writeFile(join(testVaultPath, "visible.md"), "# Visible");
889
- const stats = await fileSystem.getVaultStats();
890
- expect(stats.totalNotes).toBe(1);
891
- expect(stats.totalFolders).toBe(0); // .obsidian and .git are filtered
892
- expect(stats.recentlyModified.map(f => f.path)).toContain("visible.md");
893
- expect(stats.recentlyModified.map(f => f.path)).not.toContain(".obsidian/config.json");
894
- });
895
- test("get vault stats excludes files matched by custom ** ignored patterns", async () => {
896
- const customFilter = new PathFilter({
897
- ignoredPatterns: ["ignored/**"]
898
- });
899
- const customFileSystem = new FileSystemService(testVaultPath, customFilter);
900
- await mkdir(join(testVaultPath, "ignored"), { recursive: true });
901
- await mkdir(join(testVaultPath, "ignored/nested"), { recursive: true });
902
- await writeFile(join(testVaultPath, "ignored/something.md"), "# Disallowed 1");
903
- await writeFile(join(testVaultPath, "ignored/nested/something.md"), "# Disallowed 2");
904
- await writeFile(join(testVaultPath, "visible.md"), "# Visible");
905
- const stats = await customFileSystem.getVaultStats(10);
906
- const recentPaths = stats.recentlyModified.map(file => file.path);
907
- expect(stats.totalNotes).toBe(1);
908
- expect(recentPaths).toContain("visible.md");
909
- expect(recentPaths).not.toContain("ignored/something.md");
910
- expect(recentPaths).not.toContain("ignored/nested/something.md");
911
- });
912
- test("get vault stats includes notes inside directories that contain dots", async () => {
913
- await mkdir(join(testVaultPath, "2026.03"), { recursive: true });
914
- await writeFile(join(testVaultPath, "2026.03/nested.md"), "# Nested");
915
- await writeFile(join(testVaultPath, "root.md"), "# Root");
916
- const stats = await fileSystem.getVaultStats(10);
917
- const recentPaths = stats.recentlyModified.map(file => file.path);
918
- expect(stats.totalNotes).toBe(2);
919
- expect(stats.totalFolders).toBe(1);
920
- expect(recentPaths).toContain("2026.03/nested.md");
921
- expect(recentPaths).toContain("root.md");
922
- });
923
- test("get vault stats calculates total size correctly", async () => {
924
- const content1 = "# Note 1 with some content";
925
- const content2 = "# Note 2 with more content here";
926
- await writeFile(join(testVaultPath, "note1.md"), content1);
927
- await writeFile(join(testVaultPath, "note2.md"), content2);
928
- const stats = await fileSystem.getVaultStats();
929
- const expectedSize = Buffer.byteLength(content1) + Buffer.byteLength(content2);
930
- expect(stats.totalSize).toBe(expectedSize);
931
- });
932
- // ============================================================================
933
- // ERROR MESSAGE TESTS
934
- // ============================================================================
935
- test("error messages include remediation suggestions for file not found", async () => {
936
- await expect(fileSystem.readNote("nonexistent.md"))
937
- .rejects.toThrow(/list_directory/);
938
- });
939
- test("error messages include remediation suggestions for access denied", async () => {
940
- await expect(fileSystem.readNote(".obsidian/config.json"))
941
- .rejects.toThrow(/restricted/);
942
- });
943
- test("error messages include remediation suggestions for path traversal", async () => {
944
- await expect(fileSystem.readNote("../outside.md"))
945
- .rejects.toThrow(/within the vault/);
946
- });