@symbiosis-lab/moss-plugin-matters 1.4.2

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +1 -0
  4. package/assets/manifest.json +36 -0
  5. package/codegen.ts +26 -0
  6. package/e2e/moss-cli.test.ts +338 -0
  7. package/features/api/fetch-articles.feature +39 -0
  8. package/features/auth/wallet-auth.feature +27 -0
  9. package/features/download/retry-logic.feature +36 -0
  10. package/features/download/self-correcting.feature +83 -0
  11. package/features/download/worker-pool.feature +29 -0
  12. package/features/social/fetch-social-data.feature +40 -0
  13. package/features/steps/api.steps.ts +180 -0
  14. package/features/steps/download.steps.ts +423 -0
  15. package/features/steps/incremental-sync.steps.ts +105 -0
  16. package/features/steps/self-correcting.steps.ts +575 -0
  17. package/features/steps/social.steps.ts +257 -0
  18. package/features/steps/syndication.steps.ts +264 -0
  19. package/features/steps/wallet-auth.steps.ts +185 -0
  20. package/features/sync/article-sync.feature +49 -0
  21. package/features/sync/homepage-grid.feature +43 -0
  22. package/features/sync/incremental-sync.feature +28 -0
  23. package/features/syndication/create-draft.feature +35 -0
  24. package/package.json +58 -0
  25. package/src/__generated__/schema.graphql +4289 -0
  26. package/src/__generated__/types.ts +5355 -0
  27. package/src/__tests__/api.test.ts +678 -0
  28. package/src/__tests__/auth-route.test.ts +38 -0
  29. package/src/__tests__/auth-routing.test.ts +462 -0
  30. package/src/__tests__/auto-detect.test.ts +412 -0
  31. package/src/__tests__/binding-guard.test.ts +256 -0
  32. package/src/__tests__/config.test.ts +212 -0
  33. package/src/__tests__/converter.test.ts +289 -0
  34. package/src/__tests__/credential.test.ts +332 -0
  35. package/src/__tests__/domain.test.ts +341 -0
  36. package/src/__tests__/downloader.test.ts +679 -0
  37. package/src/__tests__/folder-detection.test.ts +289 -0
  38. package/src/__tests__/force-fresh-login.test.ts +236 -0
  39. package/src/__tests__/main.test.ts +2437 -0
  40. package/src/__tests__/progress.test.ts +93 -0
  41. package/src/__tests__/session.test.ts +375 -0
  42. package/src/__tests__/social-integration.test.ts +386 -0
  43. package/src/__tests__/social-sync-logic.test.ts +107 -0
  44. package/src/__tests__/social.test.ts +788 -0
  45. package/src/__tests__/sync.test.ts +1273 -0
  46. package/src/__tests__/syndication-toast-law.test.ts +649 -0
  47. package/src/__tests__/syndication.test.ts +125 -0
  48. package/src/__tests__/test-profile-escape.test.ts +209 -0
  49. package/src/__tests__/url-detect.test.ts +79 -0
  50. package/src/__tests__/utils.test.ts +226 -0
  51. package/src/api.ts +1366 -0
  52. package/src/auth-route.ts +38 -0
  53. package/src/config.ts +80 -0
  54. package/src/converter.ts +305 -0
  55. package/src/credential.ts +329 -0
  56. package/src/domain.ts +183 -0
  57. package/src/downloader.ts +761 -0
  58. package/src/main.ts +2092 -0
  59. package/src/progress.ts +89 -0
  60. package/src/queries/user.graphql +85 -0
  61. package/src/queries/viewer.graphql +104 -0
  62. package/src/social.ts +413 -0
  63. package/src/sync.ts +818 -0
  64. package/src/types.ts +477 -0
  65. package/src/url-detect.ts +49 -0
  66. package/src/utils.ts +305 -0
  67. package/test-fixtures/syndication-test-site/input/index.md +8 -0
  68. package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
  69. package/test-helpers/TEST_ACCOUNT.md +151 -0
  70. package/test-helpers/api-client.ts +252 -0
  71. package/test-helpers/fixtures/articles.ts +147 -0
  72. package/test-helpers/wallet-auth.ts +305 -0
  73. package/test-setup/e2e.ts +93 -0
  74. package/tsconfig.json +23 -0
  75. package/vitest.config.ts +39 -0
@@ -0,0 +1,1273 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { isRemoteNewer } from "../sync";
3
+
4
+ describe("isRemoteNewer", () => {
5
+ it("returns true when local is undefined", () => {
6
+ expect(isRemoteNewer(undefined, "2024-01-01")).toBe(true);
7
+ });
8
+
9
+ it("returns false when remote is undefined", () => {
10
+ expect(isRemoteNewer("2024-01-01", undefined)).toBe(false);
11
+ });
12
+
13
+ it("returns true when remote is newer", () => {
14
+ expect(isRemoteNewer("2024-01-01", "2024-01-02")).toBe(true);
15
+ });
16
+
17
+ it("returns false when local is newer", () => {
18
+ expect(isRemoteNewer("2024-01-02", "2024-01-01")).toBe(false);
19
+ });
20
+
21
+ it("returns false when dates are equal", () => {
22
+ expect(isRemoteNewer("2024-01-01", "2024-01-01")).toBe(false);
23
+ });
24
+
25
+ it("handles ISO date strings with time", () => {
26
+ expect(isRemoteNewer("2024-01-01T10:00:00Z", "2024-01-01T12:00:00Z")).toBe(true);
27
+ expect(isRemoteNewer("2024-01-01T12:00:00Z", "2024-01-01T10:00:00Z")).toBe(false);
28
+ });
29
+
30
+ it("returns true when both are undefined (local missing means should update)", () => {
31
+ // When local is undefined, we should update regardless of remote
32
+ expect(isRemoteNewer(undefined, undefined)).toBe(true);
33
+ });
34
+
35
+ // Tests for real Matters.town date formats
36
+ describe("Matters.town date formats", () => {
37
+ it("handles full ISO format with milliseconds", () => {
38
+ // Real Matters API format: "2025-05-09T18:32:27.769Z"
39
+ expect(isRemoteNewer(
40
+ "2025-05-09T18:32:27.769Z",
41
+ "2025-05-09T18:32:27.769Z"
42
+ )).toBe(false);
43
+
44
+ expect(isRemoteNewer(
45
+ "2025-05-08T21:10:51.834Z",
46
+ "2025-05-09T18:32:27.769Z"
47
+ )).toBe(true);
48
+ });
49
+
50
+ it("handles comparison between different precision levels", () => {
51
+ // Local might have different precision than remote
52
+ expect(isRemoteNewer(
53
+ "2025-05-09T18:32:27Z",
54
+ "2025-05-09T18:32:27.769Z"
55
+ )).toBe(true); // Remote is technically later by 769ms
56
+ });
57
+
58
+ it("handles date-only vs full ISO comparison", () => {
59
+ // Edge case: local file has date-only, remote has full timestamp
60
+ expect(isRemoteNewer(
61
+ "2025-05-09",
62
+ "2025-05-09T18:32:27.769Z"
63
+ )).toBe(true); // Date-only is treated as 00:00:00
64
+ });
65
+
66
+ it("handles same day with remote later time", () => {
67
+ expect(isRemoteNewer(
68
+ "2025-05-09T00:00:00.000Z",
69
+ "2025-05-09T18:32:27.769Z"
70
+ )).toBe(true);
71
+ });
72
+ });
73
+ });
74
+
75
+ describe("syncToLocalFiles", () => {
76
+ it("exports syncToLocalFiles function", async () => {
77
+ const module = await import("../sync");
78
+ expect(typeof module.syncToLocalFiles).toBe("function");
79
+ });
80
+ });
81
+
82
+ // ============================================================================
83
+ // Homepage Grid Generation Tests
84
+ // ============================================================================
85
+
86
+ describe("syncToLocalFiles - homepage grid from pinned works", () => {
87
+ let ctx: MockTauriContext;
88
+
89
+ beforeEach(() => {
90
+ ctx = setupMockTauri({ projectPath: "/test-project" });
91
+ });
92
+
93
+ afterEach(() => {
94
+ ctx.cleanup();
95
+ });
96
+
97
+ it("should generate :::grid 3 homepage when pinnedWorks has collections", async () => {
98
+ const { syncToLocalFiles } = await import("../sync");
99
+ const result = await syncToLocalFiles(
100
+ [], // no articles
101
+ [], // no drafts
102
+ [
103
+ { id: "c1", title: "Travel Notes", description: "My travels", articles: [], cover: "https://example.com/cover.jpg" },
104
+ { id: "c2", title: "Tech Essays", description: "Tech writing", articles: [], cover: undefined },
105
+ ],
106
+ "testuser",
107
+ {},
108
+ {
109
+ displayName: "Test User",
110
+ userName: "testuser",
111
+ description: "Hello world",
112
+ pinnedWorks: [
113
+ { id: "c1", type: "collection", title: "Travel Notes", cover: "https://example.com/cover.jpg" },
114
+ { id: "c2", type: "collection", title: "Tech Essays", cover: undefined },
115
+ ],
116
+ }
117
+ );
118
+
119
+ expect(result.result.created).toBeGreaterThanOrEqual(1);
120
+ const homepage = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
121
+ expect(homepage).toBeDefined();
122
+ expect(homepage).toContain(":::grid 3");
123
+ expect(homepage).toContain("[Travel Notes](/articles/travel-notes/)");
124
+ expect(homepage).toContain("[Tech Essays](/articles/tech-essays/)");
125
+ // Grid CELLS must be separated by moss's canonical `+++` divider, NOT `:::`
126
+ // (a lone `:::` is the grid CLOSER — using it between cells prematurely
127
+ // closes the grid and corrupts the homepage). Regression guard for B1.
128
+ expect(homepage).toMatch(
129
+ /\[Travel Notes\]\([^)]*\)\n\+\+\+\n\[Tech Essays\]\([^)]*\)/
130
+ );
131
+ // The grid must open with :::grid and close with a single ::: — exactly two
132
+ // `:::` occurrences (open marker prefix + closer), no `:::` between cells.
133
+ expect((homepage!.match(/^:::$/gm) || []).length).toBe(1);
134
+ expect(homepage).toContain(":::");
135
+ });
136
+
137
+ it("forwards sync progress to the onProgress reporter (unified task, not the dropped legacy path)", async () => {
138
+ const { syncToLocalFiles } = await import("../sync");
139
+ const onProgress = vi.fn();
140
+ await syncToLocalFiles(
141
+ [], // no articles
142
+ [], // no drafts
143
+ [], // no collections
144
+ "testuser",
145
+ {},
146
+ { displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] },
147
+ null, // homepageFile
148
+ null, // folderName
149
+ onProgress,
150
+ );
151
+ // The per-item sync now drives the unified import task (was the legacy
152
+ // reportProgress path, which the panel dropped for the `process` hook).
153
+ expect(onProgress).toHaveBeenCalledWith(
154
+ "syncing_homepage",
155
+ expect.any(Number),
156
+ 100,
157
+ expect.any(String),
158
+ );
159
+ // …carrying a real (non-zero) overall fraction, not a constant 0.
160
+ const homepageCall = onProgress.mock.calls.find((c) => c[0] === "syncing_homepage");
161
+ expect(homepageCall?.[1]).toBeGreaterThan(0);
162
+ });
163
+
164
+ it("should generate :::grid 3 homepage with pinned articles", async () => {
165
+ const { syncToLocalFiles } = await import("../sync");
166
+ const result = await syncToLocalFiles(
167
+ [
168
+ {
169
+ id: "a1", title: "My Article", slug: "my-article", shortHash: "abc123",
170
+ content: "<p>Content</p>", summary: "Summary",
171
+ createdAt: "2024-01-01T00:00:00Z", tags: [],
172
+ },
173
+ ],
174
+ [],
175
+ [],
176
+ "testuser",
177
+ {},
178
+ {
179
+ displayName: "Test User",
180
+ userName: "testuser",
181
+ description: "Bio text",
182
+ pinnedWorks: [
183
+ { id: "a1", type: "article", title: "My Article", slug: "my-article", shortHash: "abc123" },
184
+ ],
185
+ }
186
+ );
187
+
188
+ const homepage = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
189
+ expect(homepage).toBeDefined();
190
+ expect(homepage).toContain(":::grid 3");
191
+ expect(homepage).toContain("[My Article](/articles/my-article/)");
192
+ });
193
+
194
+ it("should generate :::grid 3 homepage with mixed pinned works", async () => {
195
+ const { syncToLocalFiles } = await import("../sync");
196
+ await syncToLocalFiles(
197
+ [
198
+ {
199
+ id: "a1", title: "Standalone Article", slug: "standalone-article", shortHash: "hash1",
200
+ content: "<p>Content</p>", summary: "Summary",
201
+ createdAt: "2024-01-01T00:00:00Z", tags: [],
202
+ },
203
+ ],
204
+ [],
205
+ [
206
+ { id: "c1", title: "My Collection", description: "Desc", articles: [], cover: undefined },
207
+ ],
208
+ "testuser",
209
+ {},
210
+ {
211
+ displayName: "Test User",
212
+ userName: "testuser",
213
+ description: "Bio",
214
+ pinnedWorks: [
215
+ { id: "c1", type: "collection", title: "My Collection" },
216
+ { id: "a1", type: "article", title: "Standalone Article", slug: "standalone-article", shortHash: "hash1" },
217
+ ],
218
+ }
219
+ );
220
+
221
+ const homepage = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
222
+ expect(homepage).toContain(":::grid 3");
223
+ expect(homepage).toContain("[My Collection](/articles/my-collection/)");
224
+ expect(homepage).toContain("[Standalone Article](/articles/standalone-article/)");
225
+ });
226
+
227
+ it("should generate plain homepage when pinnedWorks is empty", async () => {
228
+ const { syncToLocalFiles } = await import("../sync");
229
+ await syncToLocalFiles(
230
+ [], [], [], "testuser", {},
231
+ { displayName: "Test User", userName: "testuser", description: "Just a bio", pinnedWorks: [] }
232
+ );
233
+
234
+ const homepage = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
235
+ expect(homepage).toBeDefined();
236
+ expect(homepage).not.toContain(":::grid");
237
+ expect(homepage).toContain("Just a bio");
238
+ });
239
+
240
+ it("should still skip homepage when index.md already exists even with pinnedWorks", async () => {
241
+ ctx.filesystem.setFile(`${ctx.projectPath}/index.md`, "---\ntitle: \"Existing\"\n---\n\nMy custom homepage");
242
+
243
+ const { syncToLocalFiles } = await import("../sync");
244
+ const result = await syncToLocalFiles(
245
+ [], [], [], "testuser", {},
246
+ {
247
+ displayName: "Test User",
248
+ userName: "testuser",
249
+ description: "Bio",
250
+ pinnedWorks: [
251
+ { id: "c1", type: "collection", title: "Pinned Collection" },
252
+ ],
253
+ }
254
+ );
255
+
256
+ expect(result.result.skipped).toBeGreaterThanOrEqual(1);
257
+ const homepage = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
258
+ expect(homepage).toContain("My custom homepage");
259
+ expect(homepage).not.toContain(":::grid");
260
+ });
261
+
262
+ it("should link pinned article to its collection folder when in a collection", async () => {
263
+ const { syncToLocalFiles } = await import("../sync");
264
+ await syncToLocalFiles(
265
+ [
266
+ {
267
+ id: "a1", title: "Article In Collection", slug: "article-in-collection", shortHash: "hash1",
268
+ content: "<p>Content</p>", summary: "Summary",
269
+ createdAt: "2024-01-01T00:00:00Z", tags: [],
270
+ },
271
+ ],
272
+ [],
273
+ [
274
+ {
275
+ id: "c1", title: "My Series", description: "A series",
276
+ articles: [{ id: "a1", shortHash: "hash1", title: "Article In Collection", slug: "article-in-collection" }],
277
+ cover: undefined,
278
+ },
279
+ ],
280
+ "testuser",
281
+ {},
282
+ {
283
+ displayName: "Test User",
284
+ userName: "testuser",
285
+ description: "Bio",
286
+ pinnedWorks: [
287
+ { id: "a1", type: "article", title: "Article In Collection", slug: "article-in-collection", shortHash: "hash1" },
288
+ ],
289
+ }
290
+ );
291
+
292
+ const homepage = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
293
+ expect(homepage).toContain(":::grid 3");
294
+ // Article should link to its collection folder path
295
+ expect(homepage).toContain("[Article In Collection](/articles/my-series/article-in-collection/)");
296
+ });
297
+ });
298
+
299
+ // ============================================================================
300
+ // Homepage skip when moss detects existing home file
301
+ // ============================================================================
302
+
303
+ describe("syncToLocalFiles - skip homepage when homepageFile is set", () => {
304
+ let ctx: MockTauriContext;
305
+
306
+ beforeEach(() => {
307
+ ctx = setupMockTauri({ projectPath: "/test-project" });
308
+ });
309
+
310
+ afterEach(() => {
311
+ ctx.cleanup();
312
+ });
313
+
314
+ it("should skip homepage creation when homepageFile indicates an existing home file", async () => {
315
+ // moss detected "刘果.md" as the home file — Matters should NOT create index.md
316
+ const { syncToLocalFiles } = await import("../sync");
317
+ const result = await syncToLocalFiles(
318
+ [], [], [], "testuser", {},
319
+ { displayName: "Test User", userName: "testuser", description: "Bio", pinnedWorks: [] },
320
+ "刘果.md", // homepageFile — moss already found a home file
321
+ );
322
+
323
+ // Homepage should be skipped
324
+ expect(result.result.skipped).toBeGreaterThanOrEqual(1);
325
+ // index.md should NOT be created
326
+ const indexFile = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`);
327
+ expect(indexFile).toBeUndefined();
328
+ });
329
+
330
+ it("should skip homepage creation when homepageFile is index.md", async () => {
331
+ // moss detected "index.md" as the home file — even if readFile would fail,
332
+ // the homepageFile flag should short-circuit
333
+ const { syncToLocalFiles } = await import("../sync");
334
+ const result = await syncToLocalFiles(
335
+ [], [], [], "testuser", {},
336
+ { displayName: "Test User", userName: "testuser", description: "Bio", pinnedWorks: [] },
337
+ "index.md",
338
+ );
339
+
340
+ expect(result.result.skipped).toBeGreaterThanOrEqual(1);
341
+ });
342
+
343
+ it("should still create homepage when homepageFile is null", async () => {
344
+ // No home file detected by moss — Matters should create index.md as before
345
+ const { syncToLocalFiles } = await import("../sync");
346
+ const result = await syncToLocalFiles(
347
+ [], [], [], "testuser", {},
348
+ { displayName: "Test User", userName: "testuser", description: "Bio", pinnedWorks: [] },
349
+ null,
350
+ );
351
+
352
+ expect(result.result.created).toBeGreaterThanOrEqual(1);
353
+ const indexFile = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`);
354
+ expect(indexFile).toBeDefined();
355
+ });
356
+
357
+ it("should still create homepage when homepageFile is undefined (backwards compat)", async () => {
358
+ // homepageFile not passed at all — existing behavior preserved
359
+ const { syncToLocalFiles } = await import("../sync");
360
+ const result = await syncToLocalFiles(
361
+ [], [], [], "testuser", {},
362
+ { displayName: "Test User", userName: "testuser", description: "Bio", pinnedWorks: [] },
363
+ );
364
+
365
+ expect(result.result.created).toBeGreaterThanOrEqual(1);
366
+ const indexFile = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`);
367
+ expect(indexFile).toBeDefined();
368
+ });
369
+ });
370
+
371
+ // ============================================================================
372
+ // Self-named home + home:true marker (bug #2)
373
+ // Generated homes follow moss's folder-home convention: a self-named
374
+ // `<folder>.md` file carrying a `home: true` frontmatter marker.
375
+ // ============================================================================
376
+
377
+ describe("syncToLocalFiles - self-named home with home:true marker", () => {
378
+ let ctx: MockTauriContext;
379
+
380
+ beforeEach(() => {
381
+ ctx = setupMockTauri({ projectPath: "/test-project" });
382
+ });
383
+
384
+ afterEach(() => {
385
+ ctx.cleanup();
386
+ });
387
+
388
+ const baseProfile = {
389
+ displayName: "Test User",
390
+ userName: "testuser",
391
+ description: "Bio",
392
+ pinnedWorks: [],
393
+ };
394
+
395
+ it("creates a self-named <folder>.md home carrying home:true when none exists", async () => {
396
+ const { syncToLocalFiles } = await import("../sync");
397
+ const result = await syncToLocalFiles(
398
+ [], [], [], "testuser", {}, baseProfile,
399
+ null, // homepageFile — moss detected none
400
+ "刘果", // folderName — root folder basename
401
+ );
402
+
403
+ expect(result.result.created).toBeGreaterThanOrEqual(1);
404
+ const home = ctx.filesystem.getFile(`${ctx.projectPath}/刘果.md`)?.content;
405
+ expect(home).toBeDefined();
406
+ expect(home).toContain("home: true");
407
+ // Feature C: homepage title must use the vault folder name, not the Matters displayName.
408
+ expect(home).toContain('title: "刘果"');
409
+ // Must NOT create a competing index.md.
410
+ expect(ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)).toBeUndefined();
411
+ });
412
+
413
+ it("falls back to index.md (still carrying home:true) when folderName is absent", async () => {
414
+ const { syncToLocalFiles } = await import("../sync");
415
+ await syncToLocalFiles([], [], [], "testuser", {}, baseProfile, null);
416
+
417
+ const home = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
418
+ expect(home).toBeDefined();
419
+ expect(home).toContain("home: true");
420
+ // When folderName is null/undefined, the homepage title falls back to the
421
+ // Matters profile displayName (the `folderName ?? profile.displayName` branch).
422
+ expect(home).toContain('title: "Test User"');
423
+ });
424
+
425
+ it("does not overwrite an existing self-named home", async () => {
426
+ const { syncToLocalFiles } = await import("../sync");
427
+ ctx.filesystem.setFile(`${ctx.projectPath}/刘果.md`, "---\nhome: true\n---\n# Mine\n");
428
+
429
+ const result = await syncToLocalFiles(
430
+ [], [], [], "testuser", {}, baseProfile,
431
+ null, // moss didn't flag it, but the file is on disk
432
+ "刘果",
433
+ );
434
+
435
+ expect(result.result.skipped).toBeGreaterThanOrEqual(1);
436
+ expect(ctx.filesystem.getFile(`${ctx.projectPath}/刘果.md`)?.content).toContain("# Mine");
437
+ });
438
+ });
439
+
440
+ // ============================================================================
441
+ // Folder-mode Collection Order Tests
442
+ // ============================================================================
443
+
444
+ describe("syncToLocalFiles - folder-mode collection order", () => {
445
+ let ctx: MockTauriContext;
446
+
447
+ beforeEach(() => {
448
+ ctx = setupMockTauri({ projectPath: "/test-project" });
449
+ });
450
+
451
+ afterEach(() => {
452
+ ctx.cleanup();
453
+ });
454
+
455
+ it("should generate order field with bare slugs in folder mode", async () => {
456
+ const { syncToLocalFiles } = await import("../sync");
457
+ await syncToLocalFiles(
458
+ [
459
+ {
460
+ id: "a1", title: "First Article", slug: "first-article", shortHash: "hash1",
461
+ content: "<p>First</p>", summary: "First",
462
+ createdAt: "2024-01-01T00:00:00Z", tags: [],
463
+ },
464
+ {
465
+ id: "a2", title: "Second Article", slug: "second-article", shortHash: "hash2",
466
+ content: "<p>Second</p>", summary: "Second",
467
+ createdAt: "2024-01-02T00:00:00Z", tags: [],
468
+ },
469
+ ],
470
+ [],
471
+ [{
472
+ id: "c1",
473
+ title: "My Collection",
474
+ description: "Collection desc",
475
+ cover: undefined,
476
+ articles: [
477
+ { id: "a1", shortHash: "hash1", title: "First Article", slug: "first-article" },
478
+ { id: "a2", shortHash: "hash2", title: "Second Article", slug: "second-article" },
479
+ ],
480
+ }],
481
+ "testuser",
482
+ {},
483
+ { displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
484
+ );
485
+
486
+ const collectionIndex = ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/my-collection.md`)?.content;
487
+ expect(collectionIndex).toBeDefined();
488
+ expect(collectionIndex).toContain("order:");
489
+ expect(collectionIndex).toContain("first-article");
490
+ expect(collectionIndex).toContain("second-article");
491
+ // In folder mode, order should NOT have full paths
492
+ expect(collectionIndex).not.toContain("posts/");
493
+ });
494
+
495
+ it("creates a self-named collection home <slug>.md carrying home:true in folder mode", async () => {
496
+ const { syncToLocalFiles } = await import("../sync");
497
+ await syncToLocalFiles(
498
+ [
499
+ {
500
+ id: "a1", title: "First Article", slug: "first-article", shortHash: "hash1",
501
+ content: "<p>First</p>", summary: "First",
502
+ createdAt: "2024-01-01T00:00:00Z", tags: [],
503
+ },
504
+ ],
505
+ [],
506
+ [{
507
+ id: "c1",
508
+ title: "My Collection",
509
+ description: "Collection desc",
510
+ cover: undefined,
511
+ articles: [
512
+ { id: "a1", shortHash: "hash1", title: "First Article", slug: "first-article" },
513
+ ],
514
+ }],
515
+ "testuser",
516
+ {},
517
+ { displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
518
+ );
519
+
520
+ const home = ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/my-collection.md`)?.content;
521
+ expect(home).toBeDefined();
522
+ expect(home).toContain("home: true");
523
+ expect(home).toContain("order:");
524
+ // No competing index.md in the collection folder.
525
+ expect(ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/index.md`)).toBeUndefined();
526
+ });
527
+
528
+ it("should preserve article ordering from Matters API", async () => {
529
+ const { syncToLocalFiles } = await import("../sync");
530
+ await syncToLocalFiles(
531
+ [
532
+ {
533
+ id: "a1", title: "Third", slug: "third", shortHash: "h3",
534
+ content: "<p>3</p>", summary: "3", createdAt: "2024-01-03T00:00:00Z", tags: [],
535
+ },
536
+ {
537
+ id: "a2", title: "First", slug: "first", shortHash: "h1",
538
+ content: "<p>1</p>", summary: "1", createdAt: "2024-01-01T00:00:00Z", tags: [],
539
+ },
540
+ {
541
+ id: "a3", title: "Second", slug: "second", shortHash: "h2",
542
+ content: "<p>2</p>", summary: "2", createdAt: "2024-01-02T00:00:00Z", tags: [],
543
+ },
544
+ ],
545
+ [],
546
+ [{
547
+ id: "c1",
548
+ title: "Ordered Collection",
549
+ description: "",
550
+ cover: undefined,
551
+ articles: [
552
+ // Matters API returns articles in specific order
553
+ { id: "a2", shortHash: "h1", title: "First", slug: "first" },
554
+ { id: "a3", shortHash: "h2", title: "Second", slug: "second" },
555
+ { id: "a1", shortHash: "h3", title: "Third", slug: "third" },
556
+ ],
557
+ }],
558
+ "testuser",
559
+ {},
560
+ { displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
561
+ );
562
+
563
+ const collectionIndex = ctx.filesystem.getFile(`${ctx.projectPath}/articles/ordered-collection/ordered-collection.md`)?.content;
564
+ expect(collectionIndex).toBeDefined();
565
+ // Order should match Matters API order: first, second, third
566
+ const orderMatch = collectionIndex!.match(/order:\n([\s\S]*?)---/);
567
+ expect(orderMatch).toBeTruthy();
568
+ const orderLines = orderMatch![1].trim().split("\n").map((l: string) => l.trim());
569
+ expect(orderLines[0]).toContain("first");
570
+ expect(orderLines[1]).toContain("second");
571
+ expect(orderLines[2]).toContain("third");
572
+ });
573
+ });
574
+
575
+ // ============================================================================
576
+ // Integration Tests with Mock Tauri
577
+ // ============================================================================
578
+
579
+ import { vi, beforeEach, afterEach } from "vitest";
580
+ import { setupMockTauri, type MockTauriContext } from "@symbiosis-lab/moss-api/testing";
581
+
582
+ // ============================================================================
583
+ // Tag whitespace trimming (B10)
584
+ // ============================================================================
585
+
586
+ describe("syncToLocalFiles - tag whitespace trimming", () => {
587
+ let ctx: MockTauriContext;
588
+
589
+ beforeEach(() => {
590
+ ctx = setupMockTauri({ projectPath: "/test-project" });
591
+ });
592
+
593
+ afterEach(() => {
594
+ ctx.cleanup();
595
+ });
596
+
597
+ it("trims leading/trailing whitespace from article tags and drops empties (B10)", async () => {
598
+ const { syncToLocalFiles } = await import("../sync");
599
+ await syncToLocalFiles(
600
+ [
601
+ {
602
+ id: "a1",
603
+ title: "Tagged Article",
604
+ slug: "tagged-article",
605
+ shortHash: "tag123",
606
+ content: "<p>Content</p>",
607
+ summary: "Summary",
608
+ createdAt: "2024-01-01T00:00:00Z",
609
+ // Matters can hand us tags with surrounding whitespace + an all-blank one.
610
+ tags: [
611
+ { id: "t1", content: " tag " },
612
+ { id: "t2", content: "React\t" },
613
+ { id: "t3", content: " " },
614
+ ],
615
+ },
616
+ ],
617
+ [],
618
+ [],
619
+ "testuser",
620
+ {},
621
+ { displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
622
+ );
623
+
624
+ const article = ctx.filesystem.getFile(
625
+ `${ctx.projectPath}/articles/tagged-article.md`
626
+ )?.content;
627
+ expect(article).toBeDefined();
628
+ // Emitted trimmed — exact list entries, never the padded form.
629
+ expect(article).toContain(' - "tag"');
630
+ expect(article).toContain(' - "React"');
631
+ expect(article).not.toContain(' - " tag "');
632
+ expect(article).not.toContain('React\t');
633
+ // The all-whitespace tag is dropped entirely (no empty list entry).
634
+ expect(article).not.toContain(' - ""');
635
+ });
636
+ });
637
+
638
+ describe("syncToLocalFiles - skip unchanged content", () => {
639
+ let ctx: MockTauriContext;
640
+
641
+ beforeEach(() => {
642
+ ctx = setupMockTauri({ projectPath: "/test-project" });
643
+ });
644
+
645
+ afterEach(() => {
646
+ ctx.cleanup();
647
+ });
648
+
649
+ it("should skip homepage when content is unchanged", async () => {
650
+ // Setup: Create existing homepage with same content that would be generated
651
+ // The generateFrontmatter function creates frontmatter in a specific format
652
+ const existingHomepage = `---
653
+ title: "Test User"
654
+ ---
655
+
656
+ Test bio`;
657
+ ctx.filesystem.setFile(`${ctx.projectPath}/index.md`, existingHomepage);
658
+
659
+ const { syncToLocalFiles } = await import("../sync");
660
+ const result = await syncToLocalFiles(
661
+ [], // no articles
662
+ [], // no drafts
663
+ [], // no collections
664
+ "testuser",
665
+ {},
666
+ { displayName: "Test User", userName: "testuser", description: "Test bio", pinnedWorks: [] }
667
+ );
668
+
669
+ // Homepage should be skipped, not created
670
+ expect(result.result.skipped).toBeGreaterThanOrEqual(1);
671
+ expect(result.result.created).toBe(0);
672
+ });
673
+
674
+ it("should skip homepage when local file already exists", async () => {
675
+ // Setup: Create existing homepage with DIFFERENT content
676
+ const existingHomepage = `---
677
+ title: "Old Name"
678
+ ---
679
+
680
+ Old bio`;
681
+ ctx.filesystem.setFile(`${ctx.projectPath}/index.md`, existingHomepage);
682
+
683
+ const { syncToLocalFiles } = await import("../sync");
684
+ const result = await syncToLocalFiles(
685
+ [], // no articles
686
+ [], // no drafts
687
+ [], // no collections
688
+ "testuser",
689
+ {},
690
+ { displayName: "New Name", userName: "testuser", description: "New bio", pinnedWorks: [] }
691
+ );
692
+
693
+ // Homepage should be skipped (local file preserved)
694
+ expect(result.result.skipped).toBeGreaterThanOrEqual(1);
695
+
696
+ // Verify local content was NOT overwritten
697
+ const preservedContent = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
698
+ expect(preservedContent).toContain("Old Name");
699
+ expect(preservedContent).toContain("Old bio");
700
+ });
701
+
702
+ it("should skip collection when content is unchanged", async () => {
703
+ // Setup: Create existing collection with same content that sync would generate
704
+ const existingCollection = `---
705
+ title: "Test Collection"
706
+ description: "Collection description"
707
+ ---
708
+
709
+ Collection description`;
710
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/test-collection/index.md`, existingCollection);
711
+
712
+ const { syncToLocalFiles } = await import("../sync");
713
+ const result = await syncToLocalFiles(
714
+ [], // no articles
715
+ [], // no drafts
716
+ [{
717
+ id: "1",
718
+ title: "Test Collection",
719
+ description: "Collection description",
720
+ articles: [],
721
+ cover: null
722
+ }],
723
+ "testuser",
724
+ {},
725
+ { displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
726
+ );
727
+
728
+ // Collection should be skipped if content matches
729
+ // result.skipped should include the collection
730
+ expect(result.result.skipped).toBeGreaterThanOrEqual(1);
731
+ });
732
+
733
+ it("should skip collection when local file already exists", async () => {
734
+ // Setup: Create existing collection with DIFFERENT content
735
+ const existingCollection = `---
736
+ title: "Old Collection Name"
737
+ ---
738
+
739
+ Old description`;
740
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/test-collection/index.md`, existingCollection);
741
+
742
+ const { syncToLocalFiles } = await import("../sync");
743
+ const result = await syncToLocalFiles(
744
+ [], // no articles
745
+ [], // no drafts
746
+ [{
747
+ id: "1",
748
+ title: "Test Collection",
749
+ description: "New description",
750
+ articles: [],
751
+ cover: null
752
+ }],
753
+ "testuser",
754
+ {},
755
+ { displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
756
+ );
757
+
758
+ // Collection should be skipped (local file preserved)
759
+ expect(result.result.skipped).toBeGreaterThanOrEqual(1);
760
+
761
+ // Verify local content was NOT overwritten
762
+ const preservedContent = ctx.filesystem.getFile(`${ctx.projectPath}/articles/test-collection/index.md`)?.content;
763
+ expect(preservedContent).toContain("Old Collection Name");
764
+ expect(preservedContent).toContain("Old description");
765
+ });
766
+
767
+ it("should skip article when file has been renamed but still has syndicated URL", async () => {
768
+ // Setup: Article exists locally at a RENAMED path, but has the original syndicated URL
769
+ const renamedArticle = `---
770
+ title: "My Better Title"
771
+ date: "2024-01-01T00:00:00Z"
772
+ syndicated:
773
+ - "https://matters.town/@testuser/original-title-abc123"
774
+ ---
775
+
776
+ Article content`;
777
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/my-better-title.md`, renamedArticle);
778
+
779
+ const { syncToLocalFiles } = await import("../sync");
780
+ const result = await syncToLocalFiles(
781
+ [
782
+ {
783
+ id: "a1",
784
+ title: "Original Title",
785
+ slug: "original-title",
786
+ shortHash: "abc123",
787
+ content: "<p>Article content</p>",
788
+ summary: "Summary",
789
+ createdAt: "2024-01-01T00:00:00Z",
790
+ tags: [],
791
+ },
792
+ ],
793
+ [],
794
+ [],
795
+ "testuser",
796
+ {},
797
+ {
798
+ displayName: "Test User",
799
+ userName: "testuser",
800
+ description: "",
801
+ pinnedWorks: [],
802
+ }
803
+ );
804
+
805
+ // Article should be skipped (not duplicated at articles/original-title.md)
806
+ // skipped count includes homepage (already doesn't exist, so homepage is created)
807
+ // The article itself should be skipped
808
+ const articleFile = ctx.filesystem.getFile(`${ctx.projectPath}/articles/original-title.md`);
809
+ expect(articleFile).toBeUndefined();
810
+
811
+ // The renamed file should still exist untouched
812
+ const renamedFile = ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-better-title.md`);
813
+ expect(renamedFile).toBeDefined();
814
+ });
815
+
816
+ it("should use actual local path in articlePathMap when file is renamed", async () => {
817
+ // Setup: Article exists at a renamed path
818
+ const renamedArticle = `---
819
+ title: "Renamed Article"
820
+ syndicated:
821
+ - "https://matters.town/@testuser/some-slug-xyz789"
822
+ ---
823
+
824
+ Content`;
825
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/custom-name.md`, renamedArticle);
826
+
827
+ const { syncToLocalFiles } = await import("../sync");
828
+ const result = await syncToLocalFiles(
829
+ [
830
+ {
831
+ id: "a1",
832
+ title: "Some Slug",
833
+ slug: "some-slug",
834
+ shortHash: "xyz789",
835
+ content: "<p>Content</p>",
836
+ summary: "Summary",
837
+ createdAt: "2024-01-01T00:00:00Z",
838
+ tags: [],
839
+ },
840
+ ],
841
+ [],
842
+ [],
843
+ "testuser",
844
+ {},
845
+ { displayName: "Test", userName: "testuser", description: "", pinnedWorks: [] }
846
+ );
847
+
848
+ // articlePathMap should map to the actual renamed path, not the computed one
849
+ const mattersUrl = "https://matters.town/@testuser/some-slug-xyz789";
850
+ expect(result.articlePathMap.get(mattersUrl)).toBe("articles/custom-name.md");
851
+ expect(result.articlePathMap.get("xyz789")).toBe("articles/custom-name.md");
852
+ });
853
+ });
854
+
855
+ // ============================================================================
856
+ // Collection skip when folder already has a home file
857
+ // ============================================================================
858
+
859
+ describe("syncToLocalFiles - skip collection index when folder has home file", () => {
860
+ let ctx: MockTauriContext;
861
+
862
+ beforeEach(() => {
863
+ ctx = setupMockTauri({ projectPath: "/test-project" });
864
+ });
865
+
866
+ afterEach(() => {
867
+ ctx.cleanup();
868
+ });
869
+
870
+ it("should skip collection index.md when folder has a self-named note", async () => {
871
+ // Simulate: user has articles/my-collection/my-collection.md (Obsidian folder note)
872
+ ctx.filesystem.setFile(
873
+ `${ctx.projectPath}/articles/my-collection/my-collection.md`,
874
+ "---\ntitle: \"My Collection\"\n---\n\nUser's custom content"
875
+ );
876
+
877
+ const { syncToLocalFiles } = await import("../sync");
878
+ const result = await syncToLocalFiles(
879
+ [], // no articles
880
+ [], // no drafts
881
+ [{
882
+ id: "c1",
883
+ title: "My Collection",
884
+ description: "Collection desc",
885
+ articles: [],
886
+ cover: null,
887
+ }],
888
+ "testuser",
889
+ {},
890
+ { displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
891
+ );
892
+
893
+ // Collection should be skipped — self-named note is the home file
894
+ expect(result.result.skipped).toBeGreaterThanOrEqual(1);
895
+ // index.md should NOT be created
896
+ const indexFile = ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/index.md`);
897
+ expect(indexFile).toBeUndefined();
898
+ // Self-named note should be untouched
899
+ const selfNamed = ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/my-collection.md`);
900
+ expect(selfNamed?.content).toContain("User's custom content");
901
+ });
902
+
903
+ it("should still create collection index.md when folder has no home file", async () => {
904
+ // No pre-existing files in the collection folder
905
+ const { syncToLocalFiles } = await import("../sync");
906
+ const result = await syncToLocalFiles(
907
+ [], [], [],
908
+ "testuser", {},
909
+ { displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
910
+ );
911
+
912
+ // With no collections, nothing to test — this is a control case
913
+ expect(result.result.created).toBeGreaterThanOrEqual(0);
914
+ });
915
+
916
+ it("should still create the self-named collection home when folder only has non-home files", async () => {
917
+ // Folder has an article but no home file
918
+ ctx.filesystem.setFile(
919
+ `${ctx.projectPath}/articles/my-collection/some-article.md`,
920
+ "---\ntitle: \"Some Article\"\n---\n\nArticle content"
921
+ );
922
+
923
+ const { syncToLocalFiles } = await import("../sync");
924
+ const result = await syncToLocalFiles(
925
+ [
926
+ {
927
+ id: "a1", title: "Some Article", slug: "some-article", shortHash: "hash1",
928
+ content: "<p>Article content</p>", summary: "Summary",
929
+ createdAt: "2024-01-01T00:00:00Z", tags: [],
930
+ },
931
+ ],
932
+ [],
933
+ [{
934
+ id: "c1",
935
+ title: "My Collection",
936
+ description: "Collection desc",
937
+ articles: [{ id: "a1", shortHash: "hash1", title: "Some Article", slug: "some-article" }],
938
+ cover: null,
939
+ }],
940
+ "testuser",
941
+ {},
942
+ { displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
943
+ );
944
+
945
+ // Self-named collection home SHOULD be created since some-article.md is not a home file
946
+ const home = ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/my-collection.md`);
947
+ expect(home).toBeDefined();
948
+ expect(home?.content).toContain("home: true");
949
+ // and no competing index.md
950
+ expect(ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/index.md`)).toBeUndefined();
951
+ });
952
+ });
953
+
954
+ // ============================================================================
955
+ // scanLocalArticles Tests
956
+ // ============================================================================
957
+
958
+ describe("scanLocalArticles", () => {
959
+ let ctx: MockTauriContext;
960
+
961
+ beforeEach(() => {
962
+ ctx = setupMockTauri({ projectPath: "/test-project" });
963
+ });
964
+
965
+ afterEach(() => {
966
+ ctx.cleanup();
967
+ });
968
+
969
+ it("finds articles with Matters syndicated URLs", async () => {
970
+ const articleContent = `---
971
+ title: "Test Article"
972
+ syndicated:
973
+ - "https://matters.town/@testuser/test-article-abc123"
974
+ ---
975
+
976
+ Article content`;
977
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/test-article.md`, articleContent);
978
+
979
+ const { scanLocalArticles } = await import("../sync");
980
+ const articles = await scanLocalArticles();
981
+
982
+ expect(articles).toHaveLength(1);
983
+ expect(articles[0].shortHash).toBe("abc123");
984
+ expect(articles[0].title).toBe("Test Article");
985
+ });
986
+
987
+ it("finds articles syndicated with a /a/ short-link URL", async () => {
988
+ const articleContent = `---
989
+ title: "Short Link Article"
990
+ uid: 9136b141
991
+ syndicated:
992
+ - "https://matters.town/a/aj5szksg7ppa"
993
+ ---
994
+
995
+ Article content`;
996
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/short-link.md`, articleContent);
997
+
998
+ const { scanLocalArticles } = await import("../sync");
999
+ const articles = await scanLocalArticles();
1000
+
1001
+ expect(articles).toHaveLength(1);
1002
+ expect(articles[0].shortHash).toBe("aj5szksg7ppa");
1003
+ expect(articles[0].uid).toBe("9136b141");
1004
+ });
1005
+
1006
+ it("warns and skips a Matters URL whose shortHash cannot be parsed", async () => {
1007
+ const articleContent = `---
1008
+ title: "Unparseable Syndication"
1009
+ syndicated:
1010
+ - "https://matters.town/@testuser/nohyphenslug"
1011
+ ---
1012
+
1013
+ Article content`;
1014
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/unparseable.md`, articleContent);
1015
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
1016
+
1017
+ const { scanLocalArticles } = await import("../sync");
1018
+ const articles = await scanLocalArticles();
1019
+
1020
+ expect(articles).toHaveLength(0); // excluded, not silently included
1021
+ expect(warn).toHaveBeenCalledWith(
1022
+ expect.stringContaining("could not extract shortHash")
1023
+ );
1024
+ warn.mockRestore();
1025
+ });
1026
+
1027
+ it("ignores files without syndicated field", async () => {
1028
+ const articleContent = `---
1029
+ title: "Local Only Article"
1030
+ ---
1031
+
1032
+ Article content`;
1033
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/local-article.md`, articleContent);
1034
+
1035
+ const { scanLocalArticles } = await import("../sync");
1036
+ const articles = await scanLocalArticles();
1037
+
1038
+ expect(articles).toHaveLength(0);
1039
+ });
1040
+
1041
+ it("ignores files with non-Matters syndicated URLs", async () => {
1042
+ const articleContent = `---
1043
+ title: "Cross-posted Article"
1044
+ syndicated:
1045
+ - "https://dev.to/testuser/article"
1046
+ ---
1047
+
1048
+ Article content`;
1049
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/devto-article.md`, articleContent);
1050
+
1051
+ const { scanLocalArticles } = await import("../sync");
1052
+ const articles = await scanLocalArticles();
1053
+
1054
+ expect(articles).toHaveLength(0);
1055
+ });
1056
+
1057
+ it("skips index.md and README.md", async () => {
1058
+ const indexContent = `---
1059
+ title: "Homepage"
1060
+ syndicated:
1061
+ - "https://matters.town/@testuser/home-abc123"
1062
+ ---`;
1063
+ ctx.filesystem.setFile(`${ctx.projectPath}/index.md`, indexContent);
1064
+ ctx.filesystem.setFile(`${ctx.projectPath}/README.md`, indexContent);
1065
+
1066
+ const { scanLocalArticles } = await import("../sync");
1067
+ const articles = await scanLocalArticles();
1068
+
1069
+ expect(articles).toHaveLength(0);
1070
+ });
1071
+
1072
+ it("skips _drafts folder", async () => {
1073
+ const draftContent = `---
1074
+ title: "Draft Article"
1075
+ syndicated:
1076
+ - "https://matters.town/@testuser/draft-abc123"
1077
+ ---`;
1078
+ ctx.filesystem.setFile(`${ctx.projectPath}/_drafts/draft.md`, draftContent);
1079
+
1080
+ const { scanLocalArticles } = await import("../sync");
1081
+ const articles = await scanLocalArticles();
1082
+
1083
+ expect(articles).toHaveLength(0);
1084
+ });
1085
+
1086
+ it("returns uid from frontmatter when present", async () => {
1087
+ const articleContent = `---
1088
+ title: "Article With UID"
1089
+ uid: "abc123-def456"
1090
+ syndicated:
1091
+ - "https://matters.town/@testuser/test-article-abc123"
1092
+ ---
1093
+
1094
+ Article content`;
1095
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/test-article.md`, articleContent);
1096
+
1097
+ const { scanLocalArticles } = await import("../sync");
1098
+ const articles = await scanLocalArticles();
1099
+
1100
+ expect(articles).toHaveLength(1);
1101
+ expect(articles[0].uid).toBe("abc123-def456");
1102
+ expect(articles[0].shortHash).toBe("abc123");
1103
+ });
1104
+
1105
+ it("returns null uid when not present in frontmatter", async () => {
1106
+ const articleContent = `---
1107
+ title: "Article Without UID"
1108
+ syndicated:
1109
+ - "https://matters.town/@testuser/test-article-xyz789"
1110
+ ---
1111
+
1112
+ Article content`;
1113
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/test-article.md`, articleContent);
1114
+
1115
+ const { scanLocalArticles } = await import("../sync");
1116
+ const articles = await scanLocalArticles();
1117
+
1118
+ expect(articles).toHaveLength(1);
1119
+ expect(articles[0].uid).toBeNull();
1120
+ expect(articles[0].shortHash).toBe("xyz789");
1121
+ });
1122
+
1123
+ it("returns uid for each article independently", async () => {
1124
+ const articleWithUid = `---
1125
+ title: "Has UID"
1126
+ uid: "uid-111"
1127
+ syndicated:
1128
+ - "https://matters.town/@testuser/has-uid-aaa111"
1129
+ ---
1130
+
1131
+ Content`;
1132
+ const articleWithoutUid = `---
1133
+ title: "No UID"
1134
+ syndicated:
1135
+ - "https://matters.town/@testuser/no-uid-bbb222"
1136
+ ---
1137
+
1138
+ Content`;
1139
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/has-uid.md`, articleWithUid);
1140
+ ctx.filesystem.setFile(`${ctx.projectPath}/articles/no-uid.md`, articleWithoutUid);
1141
+
1142
+ const { scanLocalArticles } = await import("../sync");
1143
+ const articles = await scanLocalArticles();
1144
+
1145
+ expect(articles).toHaveLength(2);
1146
+ const withUid = articles.find(a => a.shortHash === "aaa111");
1147
+ const withoutUid = articles.find(a => a.shortHash === "bbb222");
1148
+ expect(withUid?.uid).toBe("uid-111");
1149
+ expect(withoutUid?.uid).toBeNull();
1150
+ });
1151
+ });
1152
+
1153
+ // ============================================================================
1154
+ // detectBoundUser Tests
1155
+ // ============================================================================
1156
+
1157
+ describe("detectBoundUser", () => {
1158
+ let ctx: MockTauriContext;
1159
+
1160
+ beforeEach(() => {
1161
+ ctx = setupMockTauri({ projectPath: "/test-project" });
1162
+ });
1163
+
1164
+ afterEach(() => {
1165
+ ctx.cleanup();
1166
+ });
1167
+
1168
+ it("returns username from Matters syndication URL", async () => {
1169
+ ctx.filesystem.setFile(
1170
+ `${ctx.projectPath}/articles/test-article.md`,
1171
+ `---
1172
+ title: "Test Article"
1173
+ syndicated:
1174
+ - "https://matters.town/@alice/test-article-abc123"
1175
+ ---
1176
+
1177
+ Article content`
1178
+ );
1179
+
1180
+ const { detectBoundUser } = await import("../sync");
1181
+ const userName = await detectBoundUser();
1182
+
1183
+ expect(userName).toBe("alice");
1184
+ });
1185
+
1186
+ it("returns null when no Matters content exists", async () => {
1187
+ ctx.filesystem.setFile(
1188
+ `${ctx.projectPath}/articles/local-article.md`,
1189
+ `---
1190
+ title: "Local Only"
1191
+ ---
1192
+
1193
+ Content`
1194
+ );
1195
+
1196
+ const { detectBoundUser } = await import("../sync");
1197
+ const userName = await detectBoundUser();
1198
+
1199
+ expect(userName).toBeNull();
1200
+ });
1201
+
1202
+ it("returns null when syndication URLs are from other platforms", async () => {
1203
+ ctx.filesystem.setFile(
1204
+ `${ctx.projectPath}/articles/devto-article.md`,
1205
+ `---
1206
+ title: "Cross-posted"
1207
+ syndicated:
1208
+ - "https://dev.to/alice/article-123"
1209
+ ---
1210
+
1211
+ Content`
1212
+ );
1213
+
1214
+ const { detectBoundUser } = await import("../sync");
1215
+ const userName = await detectBoundUser();
1216
+
1217
+ expect(userName).toBeNull();
1218
+ });
1219
+
1220
+ it("returns null when no markdown files exist", async () => {
1221
+ // Empty project
1222
+ const { detectBoundUser } = await import("../sync");
1223
+ const userName = await detectBoundUser();
1224
+
1225
+ expect(userName).toBeNull();
1226
+ });
1227
+
1228
+ it("ignores hidden and underscore folders", async () => {
1229
+ ctx.filesystem.setFile(
1230
+ `${ctx.projectPath}/.hidden/article.md`,
1231
+ `---
1232
+ title: "Hidden"
1233
+ syndicated:
1234
+ - "https://matters.town/@alice/hidden-abc123"
1235
+ ---
1236
+
1237
+ Content`
1238
+ );
1239
+ ctx.filesystem.setFile(
1240
+ `${ctx.projectPath}/_drafts/draft.md`,
1241
+ `---
1242
+ title: "Draft"
1243
+ syndicated:
1244
+ - "https://matters.town/@alice/draft-def456"
1245
+ ---
1246
+
1247
+ Content`
1248
+ );
1249
+
1250
+ const { detectBoundUser } = await import("../sync");
1251
+ const userName = await detectBoundUser();
1252
+
1253
+ expect(userName).toBeNull();
1254
+ });
1255
+
1256
+ it("extracts username from matters.town URL with @ prefix", async () => {
1257
+ ctx.filesystem.setFile(
1258
+ `${ctx.projectPath}/posts/my-post.md`,
1259
+ `---
1260
+ title: "My Post"
1261
+ syndicated:
1262
+ - "https://matters.town/@bob_writer/my-post-xyz789"
1263
+ ---
1264
+
1265
+ Content`
1266
+ );
1267
+
1268
+ const { detectBoundUser } = await import("../sync");
1269
+ const userName = await detectBoundUser();
1270
+
1271
+ expect(userName).toBe("bob_writer");
1272
+ });
1273
+ });