@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.
- package/CHANGELOG.md +88 -0
- package/README.md +18 -0
- package/assets/icon.svg +1 -0
- package/assets/manifest.json +36 -0
- package/codegen.ts +26 -0
- package/e2e/moss-cli.test.ts +338 -0
- package/features/api/fetch-articles.feature +39 -0
- package/features/auth/wallet-auth.feature +27 -0
- package/features/download/retry-logic.feature +36 -0
- package/features/download/self-correcting.feature +83 -0
- package/features/download/worker-pool.feature +29 -0
- package/features/social/fetch-social-data.feature +40 -0
- package/features/steps/api.steps.ts +180 -0
- package/features/steps/download.steps.ts +423 -0
- package/features/steps/incremental-sync.steps.ts +105 -0
- package/features/steps/self-correcting.steps.ts +575 -0
- package/features/steps/social.steps.ts +257 -0
- package/features/steps/syndication.steps.ts +264 -0
- package/features/steps/wallet-auth.steps.ts +185 -0
- package/features/sync/article-sync.feature +49 -0
- package/features/sync/homepage-grid.feature +43 -0
- package/features/sync/incremental-sync.feature +28 -0
- package/features/syndication/create-draft.feature +35 -0
- package/package.json +58 -0
- package/src/__generated__/schema.graphql +4289 -0
- package/src/__generated__/types.ts +5355 -0
- package/src/__tests__/api.test.ts +678 -0
- package/src/__tests__/auth-route.test.ts +38 -0
- package/src/__tests__/auth-routing.test.ts +462 -0
- package/src/__tests__/auto-detect.test.ts +412 -0
- package/src/__tests__/binding-guard.test.ts +256 -0
- package/src/__tests__/config.test.ts +212 -0
- package/src/__tests__/converter.test.ts +289 -0
- package/src/__tests__/credential.test.ts +332 -0
- package/src/__tests__/domain.test.ts +341 -0
- package/src/__tests__/downloader.test.ts +679 -0
- package/src/__tests__/folder-detection.test.ts +289 -0
- package/src/__tests__/force-fresh-login.test.ts +236 -0
- package/src/__tests__/main.test.ts +2437 -0
- package/src/__tests__/progress.test.ts +93 -0
- package/src/__tests__/session.test.ts +375 -0
- package/src/__tests__/social-integration.test.ts +386 -0
- package/src/__tests__/social-sync-logic.test.ts +107 -0
- package/src/__tests__/social.test.ts +788 -0
- package/src/__tests__/sync.test.ts +1273 -0
- package/src/__tests__/syndication-toast-law.test.ts +649 -0
- package/src/__tests__/syndication.test.ts +125 -0
- package/src/__tests__/test-profile-escape.test.ts +209 -0
- package/src/__tests__/url-detect.test.ts +79 -0
- package/src/__tests__/utils.test.ts +226 -0
- package/src/api.ts +1366 -0
- package/src/auth-route.ts +38 -0
- package/src/config.ts +80 -0
- package/src/converter.ts +305 -0
- package/src/credential.ts +329 -0
- package/src/domain.ts +183 -0
- package/src/downloader.ts +761 -0
- package/src/main.ts +2092 -0
- package/src/progress.ts +89 -0
- package/src/queries/user.graphql +85 -0
- package/src/queries/viewer.graphql +104 -0
- package/src/social.ts +413 -0
- package/src/sync.ts +818 -0
- package/src/types.ts +477 -0
- package/src/url-detect.ts +49 -0
- package/src/utils.ts +305 -0
- package/test-fixtures/syndication-test-site/input/index.md +8 -0
- package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
- package/test-helpers/TEST_ACCOUNT.md +151 -0
- package/test-helpers/api-client.ts +252 -0
- package/test-helpers/fixtures/articles.ts +147 -0
- package/test-helpers/wallet-auth.ts +305 -0
- package/test-setup/e2e.ts +93 -0
- package/tsconfig.json +23 -0
- 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
|
+
});
|