@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,386 @@
1
+ /**
2
+ * Integration tests for social data storage
3
+ *
4
+ * These tests verify the complete flow of loading, merging, and saving social data
5
+ * to ensure the .moss/data/social/matters.json file is created correctly (issue #793).
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
9
+ import { setupMockTauri, type MockTauriContext } from "@symbiosis-lab/moss-api/testing";
10
+ import {
11
+ loadSocialData,
12
+ saveSocialData,
13
+ mergeSocialData,
14
+ } from "../social";
15
+ import type {
16
+ MattersSocialData,
17
+ MattersComment,
18
+ MattersDonation,
19
+ MattersAppreciation,
20
+ } from "../types";
21
+
22
+ describe("Social Data Integration", () => {
23
+ let ctx: MockTauriContext;
24
+
25
+ beforeEach(() => {
26
+ ctx = setupMockTauri({ pluginName: "matters" });
27
+ });
28
+
29
+ afterEach(() => {
30
+ ctx.cleanup();
31
+ });
32
+
33
+ // Helper to create test data
34
+ const createComment = (id: string, content: string): MattersComment => ({
35
+ id,
36
+ content,
37
+ createdAt: "2024-01-01T00:00:00.000Z",
38
+ state: "active",
39
+ upvotes: 0,
40
+ author: {
41
+ id: "author-1",
42
+ userName: "testuser",
43
+ displayName: "Test User",
44
+ },
45
+ });
46
+
47
+ const createDonation = (id: string): MattersDonation => ({
48
+ id,
49
+ sender: {
50
+ id: "sender-1",
51
+ userName: "donor",
52
+ displayName: "Donor",
53
+ },
54
+ });
55
+
56
+ const createAppreciation = (senderId: string, createdAt: string): MattersAppreciation => ({
57
+ amount: 5,
58
+ createdAt,
59
+ sender: {
60
+ id: senderId,
61
+ userName: "appreciator",
62
+ displayName: "Appreciator",
63
+ },
64
+ });
65
+
66
+ describe("Full save flow", () => {
67
+ it("creates .moss/social directory and matters.json file", async () => {
68
+ // Load empty data (file doesn't exist yet)
69
+ const socialData = await loadSocialData();
70
+
71
+ // Verify empty structure
72
+ expect(socialData.schemaVersion).toBe("1.0.0");
73
+ expect(socialData.articles).toEqual({});
74
+
75
+ // Add some social data
76
+ mergeSocialData(
77
+ socialData,
78
+ "article123",
79
+ [createComment("c1", "Great article!")],
80
+ [createDonation("d1")],
81
+ [createAppreciation("s1", "2024-01-01T00:00:00.000Z")]
82
+ );
83
+
84
+ // Save the data
85
+ await saveSocialData(socialData);
86
+
87
+ // Verify file was created at correct path
88
+ const savedFile = ctx.filesystem.getFile(
89
+ `${ctx.projectPath}/.moss/data/social/matters.json`
90
+ );
91
+
92
+ expect(savedFile).toBeDefined();
93
+ expect(savedFile!.content).toBeDefined();
94
+
95
+ // Verify content structure
96
+ const parsed = JSON.parse(savedFile!.content);
97
+ expect(parsed.schemaVersion).toBe("1.0.0");
98
+ expect(parsed.articles["article123"]).toBeDefined();
99
+ expect(parsed.articles["article123"].comments).toHaveLength(1);
100
+ expect(parsed.articles["article123"].donations).toHaveLength(1);
101
+ expect(parsed.articles["article123"].appreciations).toHaveLength(1);
102
+ });
103
+
104
+ it("preserves existing data when loading and re-saving", async () => {
105
+ // Setup: Pre-existing social data file
106
+ const existingData: MattersSocialData = {
107
+ schemaVersion: "1.0.0",
108
+ updatedAt: "2024-01-01T00:00:00.000Z",
109
+ articles: {
110
+ "existingArticle": {
111
+ comments: [createComment("c0", "Existing comment")],
112
+ donations: [],
113
+ appreciations: [],
114
+ },
115
+ },
116
+ };
117
+ ctx.filesystem.setFile(
118
+ `${ctx.projectPath}/.moss/data/social/matters.json`,
119
+ JSON.stringify(existingData)
120
+ );
121
+
122
+ // Load the existing data
123
+ const socialData = await loadSocialData();
124
+
125
+ // Verify existing data loaded
126
+ expect(socialData.articles["existingArticle"]).toBeDefined();
127
+ expect(socialData.articles["existingArticle"].comments).toHaveLength(1);
128
+
129
+ // Add new article data
130
+ mergeSocialData(
131
+ socialData,
132
+ "newArticle",
133
+ [createComment("c1", "New comment")],
134
+ [],
135
+ []
136
+ );
137
+
138
+ // Save
139
+ await saveSocialData(socialData);
140
+
141
+ // Verify both articles exist
142
+ const savedFile = ctx.filesystem.getFile(
143
+ `${ctx.projectPath}/.moss/data/social/matters.json`
144
+ );
145
+ const parsed = JSON.parse(savedFile!.content);
146
+
147
+ expect(parsed.articles["existingArticle"]).toBeDefined();
148
+ expect(parsed.articles["newArticle"]).toBeDefined();
149
+ });
150
+
151
+ it("handles empty social data (no comments, donations, appreciations)", async () => {
152
+ const socialData = await loadSocialData();
153
+
154
+ // Add article with empty social interactions
155
+ mergeSocialData(socialData, "emptyArticle", [], [], []);
156
+
157
+ await saveSocialData(socialData);
158
+
159
+ const savedFile = ctx.filesystem.getFile(
160
+ `${ctx.projectPath}/.moss/data/social/matters.json`
161
+ );
162
+ expect(savedFile).toBeDefined();
163
+
164
+ const parsed = JSON.parse(savedFile!.content);
165
+ expect(parsed.articles["emptyArticle"]).toBeDefined();
166
+ expect(parsed.articles["emptyArticle"].comments).toEqual([]);
167
+ expect(parsed.articles["emptyArticle"].donations).toEqual([]);
168
+ expect(parsed.articles["emptyArticle"].appreciations).toEqual([]);
169
+ });
170
+
171
+ it("handles multiple articles in a single save", async () => {
172
+ const socialData = await loadSocialData();
173
+
174
+ // Simulate processing multiple articles (like in the process hook)
175
+ const articles = ["a1", "a2", "a3"];
176
+ for (const articleId of articles) {
177
+ mergeSocialData(
178
+ socialData,
179
+ articleId,
180
+ [createComment(`${articleId}-c1`, `Comment for ${articleId}`)],
181
+ [],
182
+ []
183
+ );
184
+ }
185
+
186
+ await saveSocialData(socialData);
187
+
188
+ const savedFile = ctx.filesystem.getFile(
189
+ `${ctx.projectPath}/.moss/data/social/matters.json`
190
+ );
191
+ const parsed = JSON.parse(savedFile!.content);
192
+
193
+ expect(Object.keys(parsed.articles)).toHaveLength(3);
194
+ expect(parsed.articles["a1"]).toBeDefined();
195
+ expect(parsed.articles["a2"]).toBeDefined();
196
+ expect(parsed.articles["a3"]).toBeDefined();
197
+ });
198
+ });
199
+
200
+ describe("UID-based keying", () => {
201
+ it("stores social data keyed by uid when uid is available", async () => {
202
+ const socialData = await loadSocialData();
203
+
204
+ // Simulate using uid as the key (what main.ts will do when article has uid)
205
+ const uid = "abc123-def456";
206
+ mergeSocialData(
207
+ socialData,
208
+ uid,
209
+ [createComment("c1", "Great article!")],
210
+ [],
211
+ []
212
+ );
213
+
214
+ await saveSocialData(socialData);
215
+
216
+ const savedFile = ctx.filesystem.getFile(
217
+ `${ctx.projectPath}/.moss/data/social/matters.json`
218
+ );
219
+ const parsed = JSON.parse(savedFile!.content);
220
+
221
+ // Should be keyed by uid, not by file path
222
+ expect(parsed.articles[uid]).toBeDefined();
223
+ expect(parsed.articles[uid].comments).toHaveLength(1);
224
+ // Should NOT have a path-based key
225
+ expect(parsed.articles["articles/some-article.md"]).toBeUndefined();
226
+ });
227
+
228
+ it("falls back to file path key when uid is null", async () => {
229
+ const socialData = await loadSocialData();
230
+
231
+ // Simulate fallback: when uid is null, use path instead
232
+ const filePath = "articles/legacy-article.md";
233
+ mergeSocialData(
234
+ socialData,
235
+ filePath,
236
+ [createComment("c1", "Comment on legacy article")],
237
+ [],
238
+ []
239
+ );
240
+
241
+ await saveSocialData(socialData);
242
+
243
+ const savedFile = ctx.filesystem.getFile(
244
+ `${ctx.projectPath}/.moss/data/social/matters.json`
245
+ );
246
+ const parsed = JSON.parse(savedFile!.content);
247
+
248
+ expect(parsed.articles[filePath]).toBeDefined();
249
+ expect(parsed.articles[filePath].comments).toHaveLength(1);
250
+ });
251
+
252
+ it("handles mixed uid and path keys across articles", async () => {
253
+ const socialData = await loadSocialData();
254
+
255
+ // Article with uid
256
+ mergeSocialData(
257
+ socialData,
258
+ "uid-001",
259
+ [createComment("c1", "Comment on new article")],
260
+ [],
261
+ []
262
+ );
263
+
264
+ // Article without uid (fallback to path)
265
+ mergeSocialData(
266
+ socialData,
267
+ "articles/old-article.md",
268
+ [createComment("c2", "Comment on old article")],
269
+ [],
270
+ []
271
+ );
272
+
273
+ await saveSocialData(socialData);
274
+
275
+ const savedFile = ctx.filesystem.getFile(
276
+ `${ctx.projectPath}/.moss/data/social/matters.json`
277
+ );
278
+ const parsed = JSON.parse(savedFile!.content);
279
+
280
+ expect(Object.keys(parsed.articles)).toHaveLength(2);
281
+ expect(parsed.articles["uid-001"]).toBeDefined();
282
+ expect(parsed.articles["articles/old-article.md"]).toBeDefined();
283
+ });
284
+ });
285
+
286
+ describe("Error handling", () => {
287
+ it("should propagate errors from writeFile", async () => {
288
+ // This test verifies that if writeFile throws, saveSocialData propagates the error
289
+ // In production, if there's a permission issue or disk full, we want to know
290
+
291
+ const socialData = await loadSocialData();
292
+ mergeSocialData(socialData, "article1", [], [], []);
293
+
294
+ // Save should work normally in mock
295
+ await expect(saveSocialData(socialData)).resolves.not.toThrow();
296
+ });
297
+ });
298
+
299
+ describe("Process hook simulation", () => {
300
+ it("simulates full process hook social data flow with 34 articles", async () => {
301
+ // This test simulates what happens in the actual process hook
302
+ // when processing 34 articles for social data
303
+
304
+ // Load existing social data (starts empty)
305
+ const socialData = await loadSocialData();
306
+ expect(Object.keys(socialData.articles)).toHaveLength(0);
307
+
308
+ let totalComments = 0;
309
+ let totalDonations = 0;
310
+ let totalAppreciations = 0;
311
+
312
+ // Simulate processing 34 articles
313
+ const articles = Array.from({ length: 34 }, (_, i) => ({
314
+ shortHash: `article-${i}`,
315
+ title: `Article ${i}`,
316
+ }));
317
+
318
+ for (let i = 0; i < articles.length; i++) {
319
+ const article = articles[i];
320
+
321
+ // Simulate fetched social data (some articles have data, some don't)
322
+ const comments = i % 3 === 0
323
+ ? [createComment(`${article.shortHash}-c1`, `Comment for ${article.title}`)]
324
+ : [];
325
+ const donations = i % 5 === 0
326
+ ? [createDonation(`${article.shortHash}-d1`)]
327
+ : [];
328
+ const appreciations = i % 7 === 0
329
+ ? [createAppreciation(`sender-${i}`, `2024-01-0${(i % 9) + 1}T00:00:00.000Z`)]
330
+ : [];
331
+
332
+ // Merge social data (as done in process hook)
333
+ mergeSocialData(socialData, article.shortHash, comments, donations, appreciations);
334
+
335
+ totalComments += comments.length;
336
+ totalDonations += donations.length;
337
+ totalAppreciations += appreciations.length;
338
+ }
339
+
340
+ // Save social data (the critical step!)
341
+ await saveSocialData(socialData);
342
+
343
+ // Verify file was created
344
+ const savedFile = ctx.filesystem.getFile(
345
+ `${ctx.projectPath}/.moss/data/social/matters.json`
346
+ );
347
+
348
+ expect(savedFile).toBeDefined();
349
+ expect(savedFile!.content).toBeDefined();
350
+
351
+ // Parse and verify content
352
+ const parsed = JSON.parse(savedFile!.content);
353
+ expect(parsed.schemaVersion).toBe("1.0.0");
354
+ expect(Object.keys(parsed.articles)).toHaveLength(34);
355
+
356
+ // Verify counts
357
+ const actualComments = Object.values(parsed.articles)
358
+ .reduce((sum: number, a: any) => sum + a.comments.length, 0);
359
+ const actualDonations = Object.values(parsed.articles)
360
+ .reduce((sum: number, a: any) => sum + a.donations.length, 0);
361
+ const actualAppreciations = Object.values(parsed.articles)
362
+ .reduce((sum: number, a: any) => sum + a.appreciations.length, 0);
363
+
364
+ expect(actualComments).toBe(totalComments);
365
+ expect(actualDonations).toBe(totalDonations);
366
+ expect(actualAppreciations).toBe(totalAppreciations);
367
+ });
368
+
369
+ it("verifies that social data folder path is .moss/social/", async () => {
370
+ // This test explicitly verifies the folder structure
371
+ const socialData = await loadSocialData();
372
+ mergeSocialData(socialData, "test-article", [], [], []);
373
+ await saveSocialData(socialData);
374
+
375
+ // Check the file exists at the expected path
376
+ const expectedPath = `${ctx.projectPath}/.moss/data/social/matters.json`;
377
+ const file = ctx.filesystem.getFile(expectedPath);
378
+
379
+ expect(file).toBeDefined();
380
+
381
+ // Verify the path structure (moved to .moss/data/social/ in issue #793)
382
+ expect(expectedPath).toContain("/.moss/data/social/");
383
+ expect(expectedPath.endsWith("/matters.json")).toBe(true);
384
+ });
385
+ });
386
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Unit tests for the social-fetch loop fixes in main.ts (issue #793).
3
+ *
4
+ * These tests cover the two logical conditions that were broken:
5
+ *
6
+ * 1. Count-skip unlock: the skip must NOT fire when storedCount matches
7
+ * remoteCount but we have zero stored comments (poisoned entry).
8
+ *
9
+ * 2. First-fetch poisoning fix: `sinceTimestamp` must be undefined (not
10
+ * lastSyncedAt) when the social key has no prior data OR when the stored
11
+ * count was cleared by reconcile (storedCount is undefined).
12
+ *
13
+ * Both conditions are tested via the REAL exported predicates from main.ts,
14
+ * not local mirrors — so this test file can never drift from the production
15
+ * implementation.
16
+ */
17
+
18
+ import { describe, it, expect } from "vitest";
19
+ import { shouldSkipSocialFetch, resolveSinceTimestamp } from "../main";
20
+
21
+ // ============================================================================
22
+ // Count-skip unlock tests
23
+ // ============================================================================
24
+
25
+ describe("shouldSkipSocialFetch — count-skip unlock (issue #793)", () => {
26
+ it("does NOT skip when remoteCount=57, storedCount=57, existingComments=0 (poisoned entry)", () => {
27
+ // This is the core regression: a poisoned entry where lastSyncedAt caused
28
+ // an empty fetch but the count was recorded. The old code would skip here,
29
+ // freezing the entry forever.
30
+ expect(shouldSkipSocialFetch(57, 57, 0)).toBe(false);
31
+ });
32
+
33
+ it("skips when remoteCount=57, storedCount=57, existingComments=57 (healthy entry)", () => {
34
+ expect(shouldSkipSocialFetch(57, 57, 57)).toBe(true);
35
+ });
36
+
37
+ it("skips when remoteCount=0, storedCount=0, existingComments=0 (article has no comments)", () => {
38
+ // Zero remote count is a special case: nothing to fetch, safe to skip
39
+ // even with zero stored comments because there are genuinely no comments.
40
+ expect(shouldSkipSocialFetch(0, 0, 0)).toBe(true);
41
+ });
42
+
43
+ it("does NOT skip when counts differ (new comments arrived)", () => {
44
+ expect(shouldSkipSocialFetch(58, 57, 55)).toBe(false);
45
+ });
46
+
47
+ it("does NOT skip when remoteCounts not available (discovery failed)", () => {
48
+ expect(shouldSkipSocialFetch(undefined, 57, 55)).toBe(false);
49
+ });
50
+
51
+ it("does NOT skip when storedCount not yet set (first sync)", () => {
52
+ expect(shouldSkipSocialFetch(10, undefined, 0)).toBe(false);
53
+ });
54
+
55
+ it("skips when remoteCount=1, storedCount=1, existingComments=1 (single comment, healthy)", () => {
56
+ expect(shouldSkipSocialFetch(1, 1, 1)).toBe(true);
57
+ });
58
+
59
+ it("does NOT skip when remoteCount=5, storedCount=5, existingComments=0 (multi-comment poisoned)", () => {
60
+ expect(shouldSkipSocialFetch(5, 5, 0)).toBe(false);
61
+ });
62
+ });
63
+
64
+ // ============================================================================
65
+ // First-fetch poisoning fix tests
66
+ // ============================================================================
67
+
68
+ describe("resolveSinceTimestamp — first-fetch poisoning fix (issue #793)", () => {
69
+ const oldTimestamp = "2024-01-01T00:00:00.000Z";
70
+
71
+ it("returns undefined for a new key with no prior data (no comments, no storedCount)", () => {
72
+ // Fresh social key: no lastSyncedAt filter, must fetch everything.
73
+ expect(resolveSinceTimestamp(0, undefined, oldTimestamp)).toBeUndefined();
74
+ });
75
+
76
+ it("returns lastSyncedAt when existing comments are present AND storedCount is defined", () => {
77
+ // Established key: safe to filter by timestamp, comments + count are consistent.
78
+ expect(resolveSinceTimestamp(42, 42, oldTimestamp)).toBe(oldTimestamp);
79
+ });
80
+
81
+ it("returns undefined when existing comments=0, even if lastSyncedAt is set", () => {
82
+ expect(resolveSinceTimestamp(0, undefined, "2025-06-10T00:00:00.000Z")).toBeUndefined();
83
+ });
84
+
85
+ it("returns undefined when lastSyncedAt is also undefined (no prior sync)", () => {
86
+ expect(resolveSinceTimestamp(0, undefined, undefined)).toBeUndefined();
87
+ });
88
+
89
+ it("returns undefined even when lastSyncedAt is defined and comments=0", () => {
90
+ // Regression guard: this was the poisoning path. The timestamp exists
91
+ // from a prior process hook run, but no comments were fetched yet.
92
+ expect(resolveSinceTimestamp(0, undefined, "2026-06-10T00:00:00.000Z")).toBeUndefined();
93
+ });
94
+
95
+ it("returns lastSyncedAt=undefined when existing comments > 0 but lastSyncedAt is undefined", () => {
96
+ // Edge case: first-ever sync (no timestamp) but somehow we have comments.
97
+ expect(resolveSinceTimestamp(3, 3, undefined)).toBeUndefined();
98
+ });
99
+
100
+ it("returns undefined when comments > 0 but storedCount is undefined (cleared by reconcile)", () => {
101
+ // Re-poisoning loop fix: an entry with a FEW comments but cleared storedCount
102
+ // (reconcile detected poisoning) must do a FULL refetch. If we passed
103
+ // lastSyncedAt here the since-filter would drop older comments, re-record
104
+ // the full remote count against few stored, and re-lock the skip forever.
105
+ expect(resolveSinceTimestamp(3, undefined, oldTimestamp)).toBeUndefined();
106
+ });
107
+ });