@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,788 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { setupMockTauri, type MockTauriContext } from "@symbiosis-lab/moss-api/testing";
3
+ import {
4
+ loadSocialData,
5
+ saveSocialData,
6
+ mergeSocialData,
7
+ getArticleSocialData,
8
+ getSocialCounts,
9
+ reconcileLegacySocialData,
10
+ mergeCommentsDeduped,
11
+ } from "../social";
12
+ import type {
13
+ MattersSocialData,
14
+ MattersComment,
15
+ MattersDonation,
16
+ MattersAppreciation,
17
+ } from "../types";
18
+
19
+ describe("Social Module", () => {
20
+ let ctx: MockTauriContext;
21
+
22
+ beforeEach(() => {
23
+ ctx = setupMockTauri({ pluginName: "matters-syndicator" });
24
+ });
25
+
26
+ afterEach(() => {
27
+ ctx.cleanup();
28
+ });
29
+
30
+ describe("loadSocialData", () => {
31
+ it("returns empty data structure when file does not exist", async () => {
32
+ const data = await loadSocialData();
33
+
34
+ expect(data.schemaVersion).toBe("1.0.0");
35
+ expect(data.articles).toEqual({});
36
+ expect(data.updatedAt).toBeDefined();
37
+ });
38
+
39
+ it("returns parsed data when file exists", async () => {
40
+ const existingData: MattersSocialData = {
41
+ schemaVersion: "1.0.0",
42
+ updatedAt: "2024-01-01T00:00:00.000Z",
43
+ articles: {
44
+ "abc123": {
45
+ comments: [],
46
+ donations: [],
47
+ appreciations: [],
48
+ },
49
+ },
50
+ };
51
+ ctx.filesystem.setFile(
52
+ `${ctx.projectPath}/.moss/data/social/matters.json`,
53
+ JSON.stringify(existingData)
54
+ );
55
+
56
+ const data = await loadSocialData();
57
+
58
+ expect(data.schemaVersion).toBe("1.0.0");
59
+ expect(data.articles["abc123"]).toBeDefined();
60
+ });
61
+
62
+ it("returns empty data on invalid JSON", async () => {
63
+ ctx.filesystem.setFile(
64
+ `${ctx.projectPath}/.moss/data/social/matters.json`,
65
+ "invalid json {{{"
66
+ );
67
+
68
+ const data = await loadSocialData();
69
+
70
+ expect(data.schemaVersion).toBe("1.0.0");
71
+ expect(data.articles).toEqual({});
72
+ });
73
+
74
+ it("returns empty data when schemaVersion is missing", async () => {
75
+ ctx.filesystem.setFile(
76
+ `${ctx.projectPath}/.moss/data/social/matters.json`,
77
+ JSON.stringify({ articles: {} })
78
+ );
79
+
80
+ const data = await loadSocialData();
81
+
82
+ expect(data.schemaVersion).toBe("1.0.0");
83
+ expect(data.articles).toEqual({});
84
+ });
85
+
86
+ it("returns empty data when articles field is missing", async () => {
87
+ ctx.filesystem.setFile(
88
+ `${ctx.projectPath}/.moss/data/social/matters.json`,
89
+ JSON.stringify({ schemaVersion: "1.0.0" })
90
+ );
91
+
92
+ const data = await loadSocialData();
93
+
94
+ expect(data.schemaVersion).toBe("1.0.0");
95
+ expect(data.articles).toEqual({});
96
+ });
97
+ });
98
+
99
+ describe("saveSocialData", () => {
100
+ it("saves social data to correct path", async () => {
101
+ const data: MattersSocialData = {
102
+ schemaVersion: "1.0.0",
103
+ updatedAt: "2024-01-01T00:00:00.000Z",
104
+ articles: {
105
+ "abc123": {
106
+ comments: [],
107
+ donations: [],
108
+ appreciations: [],
109
+ },
110
+ },
111
+ };
112
+
113
+ await saveSocialData(data);
114
+
115
+ const savedContent = ctx.filesystem.getFile(
116
+ `${ctx.projectPath}/.moss/data/social/matters.json`
117
+ );
118
+ expect(savedContent).toBeDefined();
119
+
120
+ const parsed = JSON.parse(savedContent!.content);
121
+ expect(parsed.schemaVersion).toBe("1.0.0");
122
+ expect(parsed.articles["abc123"]).toBeDefined();
123
+ });
124
+
125
+ it("updates updatedAt timestamp on save", async () => {
126
+ const data: MattersSocialData = {
127
+ schemaVersion: "1.0.0",
128
+ updatedAt: "2024-01-01T00:00:00.000Z",
129
+ articles: {},
130
+ };
131
+
132
+ const beforeSave = new Date().toISOString();
133
+ await saveSocialData(data);
134
+
135
+ const savedContent = ctx.filesystem.getFile(
136
+ `${ctx.projectPath}/.moss/data/social/matters.json`
137
+ );
138
+ const parsed = JSON.parse(savedContent!.content);
139
+
140
+ // updatedAt should be after or equal to beforeSave
141
+ expect(new Date(parsed.updatedAt).getTime()).toBeGreaterThanOrEqual(
142
+ new Date(beforeSave).getTime() - 1000 // Allow 1 second tolerance
143
+ );
144
+ });
145
+ });
146
+
147
+ describe("mergeSocialData", () => {
148
+ const createComment = (id: string, content: string): MattersComment => ({
149
+ id,
150
+ content,
151
+ createdAt: "2024-01-01T00:00:00.000Z",
152
+ state: "active",
153
+ upvotes: 0,
154
+ author: {
155
+ id: "author-1",
156
+ userName: "testuser",
157
+ displayName: "Test User",
158
+ },
159
+ });
160
+
161
+ const createDonation = (id: string): MattersDonation => ({
162
+ id,
163
+ sender: {
164
+ id: "sender-1",
165
+ userName: "donor",
166
+ displayName: "Donor",
167
+ },
168
+ });
169
+
170
+ const createAppreciation = (senderId: string, createdAt: string): MattersAppreciation => ({
171
+ amount: 5,
172
+ createdAt,
173
+ sender: {
174
+ id: senderId,
175
+ userName: "appreciator",
176
+ displayName: "Appreciator",
177
+ },
178
+ });
179
+
180
+ it("adds new article data to empty structure", () => {
181
+ const data: MattersSocialData = {
182
+ schemaVersion: "1.0.0",
183
+ updatedAt: "",
184
+ articles: {},
185
+ };
186
+
187
+ const comments = [createComment("c1", "Comment 1")];
188
+ const donations = [createDonation("d1")];
189
+ const appreciations = [createAppreciation("s1", "2024-01-01T00:00:00.000Z")];
190
+
191
+ mergeSocialData(data, "abc123", comments, donations, appreciations);
192
+
193
+ expect(data.articles["abc123"]).toBeDefined();
194
+ expect(data.articles["abc123"].comments).toHaveLength(1);
195
+ expect(data.articles["abc123"].donations).toHaveLength(1);
196
+ expect(data.articles["abc123"].appreciations).toHaveLength(1);
197
+ });
198
+
199
+ it("merges new comments without duplicates", () => {
200
+ const data: MattersSocialData = {
201
+ schemaVersion: "1.0.0",
202
+ updatedAt: "",
203
+ articles: {
204
+ "abc123": {
205
+ comments: [createComment("c1", "Original")],
206
+ donations: [],
207
+ appreciations: [],
208
+ },
209
+ },
210
+ };
211
+
212
+ const newComments = [
213
+ createComment("c1", "Updated"), // Same ID, should update
214
+ createComment("c2", "New"), // New ID, should add
215
+ ];
216
+
217
+ mergeSocialData(data, "abc123", newComments, [], []);
218
+
219
+ expect(data.articles["abc123"].comments).toHaveLength(2);
220
+ const c1 = data.articles["abc123"].comments.find(c => c.id === "c1");
221
+ expect(c1?.content).toBe("Updated");
222
+ });
223
+
224
+ it("merges new donations without duplicates", () => {
225
+ const data: MattersSocialData = {
226
+ schemaVersion: "1.0.0",
227
+ updatedAt: "",
228
+ articles: {
229
+ "abc123": {
230
+ comments: [],
231
+ donations: [createDonation("d1")],
232
+ appreciations: [],
233
+ },
234
+ },
235
+ };
236
+
237
+ const newDonations = [
238
+ createDonation("d1"), // Same ID, should update
239
+ createDonation("d2"), // New ID, should add
240
+ ];
241
+
242
+ mergeSocialData(data, "abc123", [], newDonations, []);
243
+
244
+ expect(data.articles["abc123"].donations).toHaveLength(2);
245
+ });
246
+
247
+ it("merges appreciations using sender.id + createdAt as key", () => {
248
+ const data: MattersSocialData = {
249
+ schemaVersion: "1.0.0",
250
+ updatedAt: "",
251
+ articles: {
252
+ "abc123": {
253
+ comments: [],
254
+ donations: [],
255
+ appreciations: [createAppreciation("s1", "2024-01-01T00:00:00.000Z")],
256
+ },
257
+ },
258
+ };
259
+
260
+ const newAppreciations = [
261
+ createAppreciation("s1", "2024-01-01T00:00:00.000Z"), // Same key, should update
262
+ createAppreciation("s1", "2024-01-02T00:00:00.000Z"), // Same sender, different time
263
+ createAppreciation("s2", "2024-01-01T00:00:00.000Z"), // Different sender
264
+ ];
265
+
266
+ mergeSocialData(data, "abc123", [], [], newAppreciations);
267
+
268
+ expect(data.articles["abc123"].appreciations).toHaveLength(3);
269
+ });
270
+
271
+ it("preserves existing data when merging empty arrays", () => {
272
+ const data: MattersSocialData = {
273
+ schemaVersion: "1.0.0",
274
+ updatedAt: "",
275
+ articles: {
276
+ "abc123": {
277
+ comments: [createComment("c1", "Existing")],
278
+ donations: [createDonation("d1")],
279
+ appreciations: [createAppreciation("s1", "2024-01-01T00:00:00.000Z")],
280
+ },
281
+ },
282
+ };
283
+
284
+ mergeSocialData(data, "abc123", [], [], []);
285
+
286
+ expect(data.articles["abc123"].comments).toHaveLength(1);
287
+ expect(data.articles["abc123"].donations).toHaveLength(1);
288
+ expect(data.articles["abc123"].appreciations).toHaveLength(1);
289
+ });
290
+
291
+ it("handles multiple articles independently", () => {
292
+ const data: MattersSocialData = {
293
+ schemaVersion: "1.0.0",
294
+ updatedAt: "",
295
+ articles: {
296
+ "abc123": {
297
+ comments: [createComment("c1", "Article 1")],
298
+ donations: [],
299
+ appreciations: [],
300
+ },
301
+ },
302
+ };
303
+
304
+ mergeSocialData(data, "xyz789", [createComment("c2", "Article 2")], [], []);
305
+
306
+ expect(data.articles["abc123"].comments).toHaveLength(1);
307
+ expect(data.articles["xyz789"].comments).toHaveLength(1);
308
+ expect(data.articles["abc123"].comments[0].content).toBe("Article 1");
309
+ expect(data.articles["xyz789"].comments[0].content).toBe("Article 2");
310
+ });
311
+
312
+ it("records lastKnownCommentCount when provided", () => {
313
+ const data: MattersSocialData = {
314
+ schemaVersion: "1.0.0",
315
+ updatedAt: "",
316
+ articles: {},
317
+ };
318
+
319
+ mergeSocialData(data, "abc123", [createComment("c1", "Hi")], [], [], 7);
320
+
321
+ expect(data.articles["abc123"].lastKnownCommentCount).toBe(7);
322
+ });
323
+
324
+ it("preserves prior lastKnownCommentCount when omitted", () => {
325
+ const data: MattersSocialData = {
326
+ schemaVersion: "1.0.0",
327
+ updatedAt: "",
328
+ articles: {
329
+ "abc123": {
330
+ comments: [createComment("c1", "Hi")],
331
+ donations: [],
332
+ appreciations: [],
333
+ lastKnownCommentCount: 5,
334
+ },
335
+ },
336
+ };
337
+
338
+ // No commentCount passed (e.g., a syndicate-time merge)
339
+ mergeSocialData(data, "abc123", [createComment("c2", "Bye")], [], []);
340
+
341
+ expect(data.articles["abc123"].lastKnownCommentCount).toBe(5);
342
+ });
343
+
344
+ it("overwrites prior lastKnownCommentCount when a new value is provided", () => {
345
+ const data: MattersSocialData = {
346
+ schemaVersion: "1.0.0",
347
+ updatedAt: "",
348
+ articles: {
349
+ "abc123": {
350
+ comments: [],
351
+ donations: [],
352
+ appreciations: [],
353
+ lastKnownCommentCount: 3,
354
+ },
355
+ },
356
+ };
357
+
358
+ mergeSocialData(data, "abc123", [createComment("c1", "New")], [], [], 4);
359
+
360
+ expect(data.articles["abc123"].lastKnownCommentCount).toBe(4);
361
+ });
362
+ });
363
+
364
+ describe("getArticleSocialData", () => {
365
+ it("returns article data when it exists", () => {
366
+ const data: MattersSocialData = {
367
+ schemaVersion: "1.0.0",
368
+ updatedAt: "",
369
+ articles: {
370
+ "abc123": {
371
+ comments: [],
372
+ donations: [],
373
+ appreciations: [],
374
+ },
375
+ },
376
+ };
377
+
378
+ const result = getArticleSocialData(data, "abc123");
379
+
380
+ expect(result).toBeDefined();
381
+ expect(result?.comments).toEqual([]);
382
+ });
383
+
384
+ it("returns undefined when article does not exist", () => {
385
+ const data: MattersSocialData = {
386
+ schemaVersion: "1.0.0",
387
+ updatedAt: "",
388
+ articles: {},
389
+ };
390
+
391
+ const result = getArticleSocialData(data, "nonexistent");
392
+
393
+ expect(result).toBeUndefined();
394
+ });
395
+ });
396
+
397
+ describe("getSocialCounts", () => {
398
+ it("returns zero counts for nonexistent article", () => {
399
+ const data: MattersSocialData = {
400
+ schemaVersion: "1.0.0",
401
+ updatedAt: "",
402
+ articles: {},
403
+ };
404
+
405
+ const counts = getSocialCounts(data, "nonexistent");
406
+
407
+ expect(counts.comments).toBe(0);
408
+ expect(counts.donations).toBe(0);
409
+ expect(counts.appreciations).toBe(0);
410
+ expect(counts.totalClaps).toBe(0);
411
+ });
412
+
413
+ it("returns correct counts for existing article", () => {
414
+ const data: MattersSocialData = {
415
+ schemaVersion: "1.0.0",
416
+ updatedAt: "",
417
+ articles: {
418
+ "abc123": {
419
+ comments: [
420
+ {
421
+ id: "c1",
422
+ content: "Comment",
423
+ createdAt: "",
424
+ state: "active",
425
+ upvotes: 0,
426
+ author: { id: "a1", userName: "u1", displayName: "U1" },
427
+ },
428
+ {
429
+ id: "c2",
430
+ content: "Comment 2",
431
+ createdAt: "",
432
+ state: "active",
433
+ upvotes: 0,
434
+ author: { id: "a2", userName: "u2", displayName: "U2" },
435
+ },
436
+ ],
437
+ donations: [
438
+ {
439
+ id: "d1",
440
+ sender: { id: "s1", userName: "donor", displayName: "Donor" },
441
+ },
442
+ ],
443
+ appreciations: [
444
+ {
445
+ amount: 5,
446
+ createdAt: "",
447
+ sender: { id: "s1", userName: "ap1", displayName: "AP1" },
448
+ },
449
+ {
450
+ amount: 10,
451
+ createdAt: "",
452
+ sender: { id: "s2", userName: "ap2", displayName: "AP2" },
453
+ },
454
+ ],
455
+ },
456
+ },
457
+ };
458
+
459
+ const counts = getSocialCounts(data, "abc123");
460
+
461
+ expect(counts.comments).toBe(2);
462
+ expect(counts.donations).toBe(1);
463
+ expect(counts.appreciations).toBe(2);
464
+ expect(counts.totalClaps).toBe(15);
465
+ });
466
+ });
467
+
468
+ // ============================================================================
469
+ // reconcileLegacySocialData
470
+ // ============================================================================
471
+
472
+ describe("reconcileLegacySocialData", () => {
473
+ const makeComment = (id: string): MattersComment => ({
474
+ id,
475
+ content: `comment ${id}`,
476
+ createdAt: "2024-01-01T00:00:00.000Z",
477
+ state: "active",
478
+ upvotes: 0,
479
+ author: { id: "a1", userName: "user", displayName: "User" },
480
+ });
481
+
482
+ it("no-op when legacy file does not exist", async () => {
483
+ const current: MattersSocialData = {
484
+ schemaVersion: "1.0.0",
485
+ updatedAt: "",
486
+ articles: {},
487
+ };
488
+ const migrated = await reconcileLegacySocialData(current, new Map());
489
+ expect(migrated).toBe(false);
490
+ // current unchanged
491
+ expect(Object.keys(current.articles)).toHaveLength(0);
492
+ });
493
+
494
+ it("no-op when migrated-bak already exists (idempotent)", async () => {
495
+ // Place both legacy and migrated-bak in the mock fs.
496
+ const legacyData: MattersSocialData = {
497
+ schemaVersion: "1.0.0",
498
+ updatedAt: "2024-01-01T00:00:00.000Z",
499
+ articles: { "uid-abc": { comments: [makeComment("c1")], donations: [], appreciations: [] } },
500
+ };
501
+ ctx.filesystem.setFile(
502
+ `${ctx.projectPath}/.moss/social/matters.json`,
503
+ JSON.stringify(legacyData)
504
+ );
505
+ ctx.filesystem.setFile(
506
+ `${ctx.projectPath}/.moss/social/matters.json.migrated-bak`,
507
+ JSON.stringify(legacyData)
508
+ );
509
+
510
+ const current: MattersSocialData = {
511
+ schemaVersion: "1.0.0",
512
+ updatedAt: "",
513
+ articles: {},
514
+ };
515
+ const migrated = await reconcileLegacySocialData(current, new Map());
516
+ expect(migrated).toBe(false);
517
+ });
518
+
519
+ it("remaps shortHash keys to uid via provided mapping", async () => {
520
+ const shortHash = "abcd1234";
521
+ const uid = "uid-of-article";
522
+ const legacyData: MattersSocialData = {
523
+ schemaVersion: "1.0.0",
524
+ updatedAt: "2024-01-01T00:00:00.000Z",
525
+ articles: {
526
+ [shortHash]: {
527
+ comments: [makeComment("c1"), makeComment("c2")],
528
+ donations: [],
529
+ appreciations: [],
530
+ lastKnownCommentCount: 2,
531
+ },
532
+ },
533
+ };
534
+ ctx.filesystem.setFile(
535
+ `${ctx.projectPath}/.moss/social/matters.json`,
536
+ JSON.stringify(legacyData)
537
+ );
538
+
539
+ const current: MattersSocialData = {
540
+ schemaVersion: "1.0.0",
541
+ updatedAt: "",
542
+ articles: {},
543
+ };
544
+ const mapping = new Map([[shortHash, uid]]);
545
+ const migrated = await reconcileLegacySocialData(current, mapping);
546
+
547
+ expect(migrated).toBe(true);
548
+ // Entry remapped to uid key
549
+ expect(current.articles[uid]).toBeDefined();
550
+ expect(current.articles[uid].comments).toHaveLength(2);
551
+ // Old shortHash key NOT in current
552
+ expect(current.articles[shortHash]).toBeUndefined();
553
+ });
554
+
555
+ it("deduplicates comments by id, prefers richer (more) side", async () => {
556
+ const uid = "uid-123";
557
+ // Current has 1 comment; legacy has 3 (includes the same c1 + 2 new)
558
+ const legacyData: MattersSocialData = {
559
+ schemaVersion: "1.0.0",
560
+ updatedAt: "",
561
+ articles: {
562
+ [uid]: {
563
+ comments: [makeComment("c1"), makeComment("c2"), makeComment("c3")],
564
+ donations: [],
565
+ appreciations: [],
566
+ },
567
+ },
568
+ };
569
+ ctx.filesystem.setFile(
570
+ `${ctx.projectPath}/.moss/social/matters.json`,
571
+ JSON.stringify(legacyData)
572
+ );
573
+
574
+ const current: MattersSocialData = {
575
+ schemaVersion: "1.0.0",
576
+ updatedAt: "",
577
+ articles: {
578
+ [uid]: {
579
+ comments: [makeComment("c1")],
580
+ donations: [],
581
+ appreciations: [],
582
+ },
583
+ },
584
+ };
585
+ const migrated = await reconcileLegacySocialData(current, new Map());
586
+
587
+ expect(migrated).toBe(true);
588
+ const merged = current.articles[uid].comments;
589
+ // All 3 unique comments (c1 deduplicated, c2+c3 added from legacy)
590
+ expect(merged).toHaveLength(3);
591
+ const ids = merged.map(c => c.id);
592
+ expect(ids).toContain("c1");
593
+ expect(ids).toContain("c2");
594
+ expect(ids).toContain("c3");
595
+ });
596
+
597
+ it("clears lastKnownCommentCount when stored count exceeds actual comments", async () => {
598
+ const uid = "uid-poisoned";
599
+ // Legacy has storedCount=57 but only 1 comment (poisoned entry)
600
+ const legacyData: MattersSocialData = {
601
+ schemaVersion: "1.0.0",
602
+ updatedAt: "",
603
+ articles: {
604
+ [uid]: {
605
+ comments: [makeComment("c1")],
606
+ donations: [],
607
+ appreciations: [],
608
+ lastKnownCommentCount: 57,
609
+ },
610
+ },
611
+ };
612
+ ctx.filesystem.setFile(
613
+ `${ctx.projectPath}/.moss/social/matters.json`,
614
+ JSON.stringify(legacyData)
615
+ );
616
+
617
+ const current: MattersSocialData = {
618
+ schemaVersion: "1.0.0",
619
+ updatedAt: "",
620
+ articles: {},
621
+ };
622
+ await reconcileLegacySocialData(current, new Map());
623
+
624
+ // Poisoned count must be cleared so next sync refetches
625
+ expect(current.articles[uid].lastKnownCommentCount).toBeUndefined();
626
+ });
627
+
628
+ it("preserves lastKnownCommentCount when consistent with actual comments", async () => {
629
+ const uid = "uid-clean";
630
+ const legacyData: MattersSocialData = {
631
+ schemaVersion: "1.0.0",
632
+ updatedAt: "",
633
+ articles: {
634
+ [uid]: {
635
+ comments: [makeComment("c1"), makeComment("c2")],
636
+ donations: [],
637
+ appreciations: [],
638
+ lastKnownCommentCount: 2,
639
+ },
640
+ },
641
+ };
642
+ ctx.filesystem.setFile(
643
+ `${ctx.projectPath}/.moss/social/matters.json`,
644
+ JSON.stringify(legacyData)
645
+ );
646
+
647
+ const current: MattersSocialData = {
648
+ schemaVersion: "1.0.0",
649
+ updatedAt: "",
650
+ articles: {},
651
+ };
652
+ await reconcileLegacySocialData(current, new Map());
653
+
654
+ // Count matches actual comments — must be preserved
655
+ expect(current.articles[uid].lastKnownCommentCount).toBe(2);
656
+ });
657
+
658
+ it("carries over a legacy entry whose key maps to no known shortHash/uid unchanged", async () => {
659
+ // An entry keyed by an arbitrary string that is NOT in the shortHashToUid
660
+ // mapping (e.g., a very old entry keyed by a path or an unrecognised hash)
661
+ // must be preserved as-is so we don't silently drop historical data.
662
+ const unknownKey = "totally-unknown-key-not-in-mapping";
663
+ const legacyData: MattersSocialData = {
664
+ schemaVersion: "1.0.0",
665
+ updatedAt: "2024-01-01T00:00:00.000Z",
666
+ articles: {
667
+ [unknownKey]: {
668
+ comments: [makeComment("c1"), makeComment("c2")],
669
+ donations: [],
670
+ appreciations: [],
671
+ lastKnownCommentCount: 2,
672
+ },
673
+ },
674
+ };
675
+ ctx.filesystem.setFile(
676
+ `${ctx.projectPath}/.moss/social/matters.json`,
677
+ JSON.stringify(legacyData)
678
+ );
679
+
680
+ const current: MattersSocialData = {
681
+ schemaVersion: "1.0.0",
682
+ updatedAt: "",
683
+ articles: {},
684
+ };
685
+ // Empty mapping: the unknown key cannot be remapped.
686
+ const migrated = await reconcileLegacySocialData(current, new Map());
687
+
688
+ expect(migrated).toBe(true);
689
+ // The entry must survive under its original key (fallback path: uid ?? legacyKey).
690
+ expect(current.articles[unknownKey]).toBeDefined();
691
+ expect(current.articles[unknownKey].comments).toHaveLength(2);
692
+ expect(current.articles[unknownKey].lastKnownCommentCount).toBe(2);
693
+ });
694
+
695
+ it("run-twice idempotence: second run from the file state produced by the first run is a no-op", async () => {
696
+ // Set up: legacy file with one article.
697
+ const uid = "uid-idempotent";
698
+ const legacyData: MattersSocialData = {
699
+ schemaVersion: "1.0.0",
700
+ updatedAt: "2024-01-01T00:00:00.000Z",
701
+ articles: {
702
+ [uid]: {
703
+ comments: [makeComment("c1")],
704
+ donations: [],
705
+ appreciations: [],
706
+ lastKnownCommentCount: 1,
707
+ },
708
+ },
709
+ };
710
+ ctx.filesystem.setFile(
711
+ `${ctx.projectPath}/.moss/social/matters.json`,
712
+ JSON.stringify(legacyData)
713
+ );
714
+
715
+ // First run: performs migration.
716
+ const current1: MattersSocialData = {
717
+ schemaVersion: "1.0.0",
718
+ updatedAt: "",
719
+ articles: {},
720
+ };
721
+ const migrated1 = await reconcileLegacySocialData(current1, new Map());
722
+ expect(migrated1).toBe(true);
723
+ expect(current1.articles[uid]).toBeDefined();
724
+
725
+ // The migrated-bak file now exists in the mock FS (written by the first run).
726
+ // Re-load canonical state from the mock FS to simulate the next sync startup.
727
+ const canonicalContent = ctx.filesystem.getFile(
728
+ `${ctx.projectPath}/.moss/data/social/matters.json`
729
+ );
730
+ expect(canonicalContent).toBeDefined();
731
+ const current2: MattersSocialData = JSON.parse(canonicalContent!.content);
732
+
733
+ // Second run: the migrated-bak guard fires — must be a no-op.
734
+ const migrated2 = await reconcileLegacySocialData(current2, new Map());
735
+ expect(migrated2).toBe(false);
736
+
737
+ // Data unchanged after the second run.
738
+ expect(current2.articles[uid]).toBeDefined();
739
+ expect(current2.articles[uid].comments).toHaveLength(1);
740
+ });
741
+ });
742
+
743
+ // ============================================================================
744
+ // mergeCommentsDeduped (unit)
745
+ // ============================================================================
746
+
747
+ describe("mergeCommentsDeduped", () => {
748
+ const makeComment = (id: string): MattersComment => ({
749
+ id,
750
+ content: `comment ${id}`,
751
+ createdAt: "2024-01-01T00:00:00.000Z",
752
+ state: "active",
753
+ upvotes: 0,
754
+ author: { id: "a1", userName: "user", displayName: "User" },
755
+ });
756
+
757
+ it("returns union of both arrays, deduped by id", () => {
758
+ const current = [makeComment("c1"), makeComment("c2")];
759
+ const legacy = [makeComment("c2"), makeComment("c3")];
760
+ const result = mergeCommentsDeduped(current, legacy);
761
+ expect(result).toHaveLength(3);
762
+ const ids = result.map(c => c.id);
763
+ expect(ids).toContain("c1");
764
+ expect(ids).toContain("c2");
765
+ expect(ids).toContain("c3");
766
+ });
767
+
768
+ it("current version of duplicate wins (current iterated first)", () => {
769
+ const current = [{ ...makeComment("c1"), content: "current version" }];
770
+ const legacy = [{ ...makeComment("c1"), content: "legacy version" }];
771
+ const result = mergeCommentsDeduped(current, legacy);
772
+ expect(result).toHaveLength(1);
773
+ expect(result[0].content).toBe("current version");
774
+ });
775
+
776
+ it("handles empty current", () => {
777
+ const legacy = [makeComment("c1"), makeComment("c2")];
778
+ const result = mergeCommentsDeduped([], legacy);
779
+ expect(result).toHaveLength(2);
780
+ });
781
+
782
+ it("handles empty legacy", () => {
783
+ const current = [makeComment("c1")];
784
+ const result = mergeCommentsDeduped(current, []);
785
+ expect(result).toHaveLength(1);
786
+ });
787
+ });
788
+ });