@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,678 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ GRAPHQL_ENDPOINT,
4
+ ARTICLES_QUERY,
5
+ DRAFTS_QUERY,
6
+ COLLECTIONS_QUERY,
7
+ PROFILE_QUERY,
8
+ ARTICLE_COMMENTS_QUERY,
9
+ ARTICLE_DONATIONS_QUERY,
10
+ ARTICLE_APPRECIATIONS_QUERY,
11
+ PUT_DRAFT_MUTATION,
12
+ PUT_COLLECTION_MUTATION,
13
+ USER_ARTICLES_QUERY,
14
+ USER_COLLECTIONS_QUERY,
15
+ USER_PROFILE_QUERY,
16
+ apiConfig,
17
+ fetchAllDraftsSince,
18
+ fetchArticleComments,
19
+ fetchAllArticleCommentCounts,
20
+ VIEWER_ARTICLE_COMMENT_COUNTS_QUERY,
21
+ USER_ARTICLE_COMMENT_COUNTS_QUERY,
22
+ } from "../api";
23
+ import { clearTokenCache } from "../credential";
24
+ import type { MattersDraft } from "../types";
25
+
26
+ // Mock the SDK's getPluginCookie, httpPost, and plugin storage
27
+ vi.mock("@symbiosis-lab/moss-api", async () => {
28
+ const actual = await vi.importActual("@symbiosis-lab/moss-api");
29
+ return {
30
+ ...actual,
31
+ getPluginCookie: vi.fn(),
32
+ httpPost: vi.fn(),
33
+ pluginFileExists: vi.fn(),
34
+ readPluginFile: vi.fn(),
35
+ writePluginFile: vi.fn(),
36
+ };
37
+ });
38
+
39
+ import { httpPost, pluginFileExists, readPluginFile } from "@symbiosis-lab/moss-api";
40
+
41
+ describe("API Constants", () => {
42
+ it("has correct GraphQL endpoint", () => {
43
+ expect(GRAPHQL_ENDPOINT).toBe("https://server.matters.town/graphql");
44
+ });
45
+
46
+ it("ARTICLES_QUERY includes required fields", () => {
47
+ expect(ARTICLES_QUERY).toContain("MePublishedArticles");
48
+ expect(ARTICLES_QUERY).toContain("articles");
49
+ expect(ARTICLES_QUERY).toContain("title");
50
+ expect(ARTICLES_QUERY).toContain("content");
51
+ expect(ARTICLES_QUERY).toContain("shortHash");
52
+ expect(ARTICLES_QUERY).toContain("pageInfo");
53
+ });
54
+
55
+ it("DRAFTS_QUERY includes required fields", () => {
56
+ expect(DRAFTS_QUERY).toContain("MeDrafts");
57
+ expect(DRAFTS_QUERY).toContain("drafts");
58
+ expect(DRAFTS_QUERY).toContain("title");
59
+ expect(DRAFTS_QUERY).toContain("content");
60
+ });
61
+
62
+ it("COLLECTIONS_QUERY includes required fields", () => {
63
+ expect(COLLECTIONS_QUERY).toContain("MeCollections");
64
+ expect(COLLECTIONS_QUERY).toContain("collections");
65
+ expect(COLLECTIONS_QUERY).toContain("articles");
66
+ });
67
+ });
68
+
69
+ describe("clearTokenCache", () => {
70
+ it("clears the token cache without error", () => {
71
+ expect(() => clearTokenCache()).not.toThrow();
72
+ });
73
+ });
74
+
75
+ // Note: Integration tests for graphqlQuery, fetchAllArticles, etc. would require
76
+ // mocking the global fetch and window.__TAURI__ objects. These are better suited
77
+ // for integration tests with a proper test harness.
78
+
79
+ describe("GraphQL Query Structure", () => {
80
+ it("ARTICLES_QUERY requests pagination with 50 items", () => {
81
+ expect(ARTICLES_QUERY).toContain("first: 50");
82
+ });
83
+
84
+ it("DRAFTS_QUERY requests pagination with 50 items", () => {
85
+ expect(DRAFTS_QUERY).toContain("first: 50");
86
+ });
87
+
88
+ it("COLLECTIONS_QUERY requests pagination with 50 items", () => {
89
+ expect(COLLECTIONS_QUERY).toContain("first: 50");
90
+ });
91
+
92
+ it("COLLECTIONS_QUERY requests up to 100 articles per collection", () => {
93
+ expect(COLLECTIONS_QUERY).toContain("first: 100");
94
+ });
95
+
96
+ it("ARTICLES_QUERY filters by active state", () => {
97
+ expect(ARTICLES_QUERY).toContain("state: active");
98
+ });
99
+ });
100
+
101
+ describe("Social Data Queries", () => {
102
+ it("ARTICLE_COMMENTS_QUERY includes required fields", () => {
103
+ expect(ARTICLE_COMMENTS_QUERY).toContain("ArticleComments");
104
+ expect(ARTICLE_COMMENTS_QUERY).toContain("comments");
105
+ expect(ARTICLE_COMMENTS_QUERY).toContain("shortHash");
106
+ expect(ARTICLE_COMMENTS_QUERY).toContain("content");
107
+ expect(ARTICLE_COMMENTS_QUERY).toContain("createdAt");
108
+ expect(ARTICLE_COMMENTS_QUERY).toContain("author");
109
+ expect(ARTICLE_COMMENTS_QUERY).toContain("replyTo");
110
+ });
111
+
112
+ it("ARTICLE_DONATIONS_QUERY includes required fields", () => {
113
+ expect(ARTICLE_DONATIONS_QUERY).toContain("ArticleDonations");
114
+ expect(ARTICLE_DONATIONS_QUERY).toContain("donations");
115
+ expect(ARTICLE_DONATIONS_QUERY).toContain("shortHash");
116
+ expect(ARTICLE_DONATIONS_QUERY).toContain("sender");
117
+ expect(ARTICLE_DONATIONS_QUERY).toContain("userName");
118
+ });
119
+
120
+ it("ARTICLE_APPRECIATIONS_QUERY includes required fields", () => {
121
+ expect(ARTICLE_APPRECIATIONS_QUERY).toContain("ArticleAppreciations");
122
+ expect(ARTICLE_APPRECIATIONS_QUERY).toContain("appreciationsReceived");
123
+ expect(ARTICLE_APPRECIATIONS_QUERY).toContain("shortHash");
124
+ expect(ARTICLE_APPRECIATIONS_QUERY).toContain("amount");
125
+ expect(ARTICLE_APPRECIATIONS_QUERY).toContain("sender");
126
+ });
127
+
128
+ it("Social queries use pagination with 50 items", () => {
129
+ expect(ARTICLE_COMMENTS_QUERY).toContain("first: 50");
130
+ expect(ARTICLE_DONATIONS_QUERY).toContain("first: 50");
131
+ expect(ARTICLE_APPRECIATIONS_QUERY).toContain("first: 50");
132
+ });
133
+ });
134
+
135
+ describe("Syndication Mutations", () => {
136
+ it("PUT_DRAFT_MUTATION includes required fields", () => {
137
+ expect(PUT_DRAFT_MUTATION).toContain("PutDraft");
138
+ expect(PUT_DRAFT_MUTATION).toContain("putDraft");
139
+ expect(PUT_DRAFT_MUTATION).toContain("PutDraftInput");
140
+ expect(PUT_DRAFT_MUTATION).toContain("id");
141
+ expect(PUT_DRAFT_MUTATION).toContain("title");
142
+ expect(PUT_DRAFT_MUTATION).toContain("publishState");
143
+ expect(PUT_DRAFT_MUTATION).toContain("article");
144
+ });
145
+
146
+ it("PUT_COLLECTION_MUTATION includes required fields", () => {
147
+ expect(PUT_COLLECTION_MUTATION).toContain("PutCollection");
148
+ expect(PUT_COLLECTION_MUTATION).toContain("putCollection");
149
+ expect(PUT_COLLECTION_MUTATION).toContain("PutCollectionInput");
150
+ expect(PUT_COLLECTION_MUTATION).toContain("id");
151
+ expect(PUT_COLLECTION_MUTATION).toContain("title");
152
+ });
153
+ });
154
+
155
+ describe("User Queries (Public)", () => {
156
+ it("USER_ARTICLES_QUERY includes required fields", () => {
157
+ expect(USER_ARTICLES_QUERY).toContain("UserArticles");
158
+ expect(USER_ARTICLES_QUERY).toContain("$userName: String!");
159
+ expect(USER_ARTICLES_QUERY).toContain("articles");
160
+ expect(USER_ARTICLES_QUERY).toContain("shortHash");
161
+ expect(USER_ARTICLES_QUERY).toContain("createdAt");
162
+ expect(USER_ARTICLES_QUERY).toContain("revisedAt");
163
+ });
164
+
165
+ it("USER_COLLECTIONS_QUERY includes required fields", () => {
166
+ expect(USER_COLLECTIONS_QUERY).toContain("UserCollections");
167
+ expect(USER_COLLECTIONS_QUERY).toContain("$userName: String!");
168
+ expect(USER_COLLECTIONS_QUERY).toContain("collections");
169
+ expect(USER_COLLECTIONS_QUERY).toContain("articles");
170
+ });
171
+
172
+ it("USER_PROFILE_QUERY includes required fields", () => {
173
+ expect(USER_PROFILE_QUERY).toContain("UserProfile");
174
+ expect(USER_PROFILE_QUERY).toContain("$userName: String!");
175
+ expect(USER_PROFILE_QUERY).toContain("displayName");
176
+ expect(USER_PROFILE_QUERY).toContain("avatar");
177
+ });
178
+
179
+ it("USER_PROFILE_QUERY does NOT include settings (private field)", () => {
180
+ // settings { language } is a private field that causes authorization errors
181
+ // for unauthenticated public user queries
182
+ expect(USER_PROFILE_QUERY).not.toMatch(/settings\s*\{/);
183
+ });
184
+ });
185
+
186
+ describe("Pinned Works in Profile Queries", () => {
187
+ it("PROFILE_QUERY includes pinnedWorks with inline fragments", () => {
188
+ expect(PROFILE_QUERY).toContain("pinnedWorks");
189
+ expect(PROFILE_QUERY).toContain("... on Article");
190
+ expect(PROFILE_QUERY).toContain("slug");
191
+ expect(PROFILE_QUERY).toContain("shortHash");
192
+ });
193
+
194
+ it("USER_PROFILE_QUERY includes pinnedWorks with inline fragments", () => {
195
+ expect(USER_PROFILE_QUERY).toContain("pinnedWorks");
196
+ expect(USER_PROFILE_QUERY).toContain("... on Article");
197
+ expect(USER_PROFILE_QUERY).toContain("slug");
198
+ expect(USER_PROFILE_QUERY).toContain("shortHash");
199
+ });
200
+ });
201
+
202
+ describe("API Configuration", () => {
203
+ it("has default endpoint for production", () => {
204
+ expect(apiConfig.endpoint).toBe("https://server.matters.town/graphql");
205
+ });
206
+
207
+ it("has default queryMode as viewer", () => {
208
+ expect(apiConfig.queryMode).toBe("viewer");
209
+ });
210
+
211
+ it("has default testUserName", () => {
212
+ expect(apiConfig.testUserName).toBeDefined();
213
+ });
214
+
215
+ it("allows endpoint to be changed", () => {
216
+ const originalEndpoint = apiConfig.endpoint;
217
+ apiConfig.endpoint = "https://server.matters.icu/graphql";
218
+ expect(apiConfig.endpoint).toBe("https://server.matters.icu/graphql");
219
+ apiConfig.endpoint = originalEndpoint; // Reset
220
+ });
221
+
222
+ it("allows queryMode to be changed", () => {
223
+ const originalMode = apiConfig.queryMode;
224
+ apiConfig.queryMode = "user";
225
+ expect(apiConfig.queryMode).toBe("user");
226
+ apiConfig.queryMode = originalMode; // Reset
227
+ });
228
+ });
229
+
230
+ describe("fetchAllDraftsSince", () => {
231
+ const mockPluginFileExists = vi.mocked(pluginFileExists);
232
+ const mockReadPluginFile = vi.mocked(readPluginFile);
233
+ const mockHttpPost = vi.mocked(httpPost);
234
+
235
+ // Sample drafts with different creation dates
236
+ const sampleDrafts: MattersDraft[] = [
237
+ {
238
+ id: "draft-1",
239
+ title: "Old Draft",
240
+ content: "<p>Old content</p>",
241
+ createdAt: "2024-01-01T00:00:00Z",
242
+ },
243
+ {
244
+ id: "draft-2",
245
+ title: "Mid Draft",
246
+ content: "<p>Mid content</p>",
247
+ summary: "A mid-period draft",
248
+ createdAt: "2024-06-15T12:00:00Z",
249
+ tags: ["test"],
250
+ },
251
+ {
252
+ id: "draft-3",
253
+ title: "Recent Draft",
254
+ content: "<p>Recent content</p>",
255
+ createdAt: "2025-01-10T08:30:00Z",
256
+ cover: "https://example.com/cover.jpg",
257
+ },
258
+ ];
259
+
260
+ /**
261
+ * Helper: build a mock httpPost response that returns a single page of drafts.
262
+ * Mimics the GraphQL response shape for ViewerDraftsResponse.
263
+ */
264
+ function mockDraftsResponse(drafts: MattersDraft[]) {
265
+ const responseBody = JSON.stringify({
266
+ data: {
267
+ viewer: {
268
+ id: "viewer-1",
269
+ drafts: {
270
+ pageInfo: { hasNextPage: false, endCursor: null },
271
+ edges: drafts.map((d) => ({ node: d })),
272
+ },
273
+ },
274
+ },
275
+ });
276
+ return {
277
+ status: 200,
278
+ ok: true,
279
+ contentType: "application/json",
280
+ body: new Uint8Array(),
281
+ text: () => responseBody,
282
+ };
283
+ }
284
+
285
+ beforeEach(() => {
286
+ clearTokenCache();
287
+ mockPluginFileExists.mockReset();
288
+ mockReadPluginFile.mockReset();
289
+ mockHttpPost.mockReset();
290
+
291
+ // Default: authenticated via stored token
292
+ mockPluginFileExists.mockResolvedValue(true);
293
+ mockReadPluginFile.mockResolvedValue(JSON.stringify({ accessToken: "test-token" }));
294
+
295
+ // Default: return all sample drafts
296
+ mockHttpPost.mockResolvedValue(mockDraftsResponse(sampleDrafts));
297
+ });
298
+
299
+ it("returns all drafts when no since parameter is provided", async () => {
300
+ const result = await fetchAllDraftsSince();
301
+
302
+ expect(result).toHaveLength(3);
303
+ expect(result.map((d) => d.id)).toEqual(["draft-1", "draft-2", "draft-3"]);
304
+ });
305
+
306
+ it("filters drafts by createdAt > since when since is provided", async () => {
307
+ // since is between draft-1 (2024-01-01) and draft-2 (2024-06-15)
308
+ const result = await fetchAllDraftsSince("2024-03-01T00:00:00Z");
309
+
310
+ // draft-2 and draft-3 are after the since date
311
+ expect(result).toHaveLength(2);
312
+ const ids = result.map((d) => d.id);
313
+ expect(ids).toContain("draft-2");
314
+ expect(ids).toContain("draft-3");
315
+ expect(ids).not.toContain("draft-1");
316
+ });
317
+
318
+ it("returns empty array when all drafts are before since", async () => {
319
+ // since is in the future, so no drafts should match
320
+ const result = await fetchAllDraftsSince("2026-01-01T00:00:00Z");
321
+
322
+ expect(result).toHaveLength(0);
323
+ expect(result).toEqual([]);
324
+ });
325
+
326
+ it("excludes draft with exact createdAt === since (strict >)", async () => {
327
+ // Use the exact createdAt of draft-2 as the since timestamp
328
+ const result = await fetchAllDraftsSince("2024-06-15T12:00:00Z");
329
+
330
+ // draft-2 has createdAt exactly equal to since, so it must be excluded
331
+ const ids = result.map((d) => d.id);
332
+ expect(ids).not.toContain("draft-2");
333
+
334
+ // Only draft-3 is strictly after
335
+ expect(result).toHaveLength(1);
336
+ expect(ids).toContain("draft-3");
337
+ });
338
+ });
339
+
340
+ describe("fetchArticleComments", () => {
341
+ const mockHttpPost = vi.mocked(httpPost);
342
+
343
+ function makeCommentNode(id: string, content: string) {
344
+ return {
345
+ id,
346
+ content,
347
+ createdAt: "2024-06-01T00:00:00Z",
348
+ state: "active",
349
+ upvotes: 0,
350
+ author: { id: "a1", userName: "user1", displayName: "User 1", avatar: null },
351
+ replyTo: null,
352
+ };
353
+ }
354
+
355
+ function mockCommentsResponse(
356
+ comments: ReturnType<typeof makeCommentNode>[],
357
+ hasNextPage: boolean,
358
+ endCursor: string | null = null
359
+ ) {
360
+ const responseBody = JSON.stringify({
361
+ data: {
362
+ article: {
363
+ id: "article-1",
364
+ shortHash: "abc123",
365
+ comments: {
366
+ totalCount: comments.length,
367
+ pageInfo: { endCursor, hasNextPage },
368
+ edges: comments.map((c) => ({ node: c })),
369
+ },
370
+ },
371
+ },
372
+ });
373
+ return {
374
+ status: 200,
375
+ ok: true,
376
+ contentType: "application/json",
377
+ body: new Uint8Array(),
378
+ text: () => responseBody,
379
+ };
380
+ }
381
+
382
+ beforeEach(() => {
383
+ clearTokenCache();
384
+ mockHttpPost.mockReset();
385
+ });
386
+
387
+ it("stops pagination early when all comments on a page are already known", async () => {
388
+ // Page 1: two comments that are already known, with hasNextPage=true
389
+ const page1Comments = [makeCommentNode("c1", "Comment 1"), makeCommentNode("c2", "Comment 2")];
390
+ mockHttpPost.mockResolvedValueOnce(mockCommentsResponse(page1Comments, true, "cursor1"));
391
+
392
+ // Page 2 should NOT be fetched
393
+ const page2Comments = [makeCommentNode("c3", "Comment 3")];
394
+ mockHttpPost.mockResolvedValueOnce(mockCommentsResponse(page2Comments, false));
395
+
396
+ const knownIds = new Set(["c1", "c2"]);
397
+ const result = await fetchArticleComments("abc123", knownIds);
398
+
399
+ // Should return the known comments from page 1 but NOT fetch page 2
400
+ expect(result).toHaveLength(2);
401
+ expect(mockHttpPost).toHaveBeenCalledTimes(1);
402
+ });
403
+
404
+ it("fetches all pages when no knownIds provided", async () => {
405
+ const page1Comments = [makeCommentNode("c1", "Comment 1")];
406
+ mockHttpPost.mockResolvedValueOnce(mockCommentsResponse(page1Comments, true, "cursor1"));
407
+
408
+ const page2Comments = [makeCommentNode("c2", "Comment 2")];
409
+ mockHttpPost.mockResolvedValueOnce(mockCommentsResponse(page2Comments, false));
410
+
411
+ const result = await fetchArticleComments("abc123");
412
+
413
+ expect(result).toHaveLength(2);
414
+ expect(result[0].id).toBe("c1");
415
+ expect(result[1].id).toBe("c2");
416
+ expect(mockHttpPost).toHaveBeenCalledTimes(2);
417
+ });
418
+
419
+ it("fetches next page when some comments on current page are new", async () => {
420
+ // Page 1: c1 is known, c2 is new — should continue to page 2
421
+ const page1Comments = [makeCommentNode("c1", "Comment 1"), makeCommentNode("c2", "Comment 2")];
422
+ mockHttpPost.mockResolvedValueOnce(mockCommentsResponse(page1Comments, true, "cursor1"));
423
+
424
+ const page2Comments = [makeCommentNode("c3", "Comment 3")];
425
+ mockHttpPost.mockResolvedValueOnce(mockCommentsResponse(page2Comments, false));
426
+
427
+ const knownIds = new Set(["c1"]); // only c1 is known
428
+ const result = await fetchArticleComments("abc123", knownIds);
429
+
430
+ expect(result).toHaveLength(3);
431
+ expect(mockHttpPost).toHaveBeenCalledTimes(2);
432
+ });
433
+
434
+ describe("sinceTimestamp filtering", () => {
435
+ function makeCommentNodeWithDate(id: string, content: string, createdAt: string) {
436
+ return {
437
+ id,
438
+ content,
439
+ createdAt,
440
+ state: "active",
441
+ upvotes: 0,
442
+ author: { id: "a1", userName: "user1", displayName: "User 1", avatar: null },
443
+ replyTo: null,
444
+ };
445
+ }
446
+
447
+ it("filters out comments older than sinceTimestamp", async () => {
448
+ const comments = [
449
+ makeCommentNodeWithDate("c1", "New comment", "2025-03-01T00:00:00Z"),
450
+ makeCommentNodeWithDate("c2", "Old comment", "2025-01-01T00:00:00Z"),
451
+ ];
452
+ mockHttpPost.mockResolvedValueOnce(mockCommentsResponse(comments, false));
453
+
454
+ const result = await fetchArticleComments("abc123", undefined, "2025-02-01T00:00:00Z");
455
+
456
+ expect(result).toHaveLength(1);
457
+ expect(result[0].id).toBe("c1");
458
+ });
459
+
460
+ it("returns all comments when sinceTimestamp is undefined", async () => {
461
+ const comments = [
462
+ makeCommentNodeWithDate("c1", "New comment", "2025-03-01T00:00:00Z"),
463
+ makeCommentNodeWithDate("c2", "Old comment", "2025-01-01T00:00:00Z"),
464
+ ];
465
+ mockHttpPost.mockResolvedValueOnce(mockCommentsResponse(comments, false));
466
+
467
+ const result = await fetchArticleComments("abc123", undefined, undefined);
468
+
469
+ expect(result).toHaveLength(2);
470
+ });
471
+
472
+ it("stops pagination when oldest comment on page is before sinceTimestamp", async () => {
473
+ // Page 1: newest-first, last comment is older than sinceTimestamp
474
+ const page1Comments = [
475
+ makeCommentNodeWithDate("c1", "New", "2025-03-15T00:00:00Z"),
476
+ makeCommentNodeWithDate("c2", "Old", "2025-01-15T00:00:00Z"),
477
+ ];
478
+ mockHttpPost.mockResolvedValueOnce(mockCommentsResponse(page1Comments, true, "cursor1"));
479
+
480
+ // Page 2 should NOT be fetched
481
+ const page2Comments = [
482
+ makeCommentNodeWithDate("c3", "Very old", "2024-06-01T00:00:00Z"),
483
+ ];
484
+ mockHttpPost.mockResolvedValueOnce(mockCommentsResponse(page2Comments, false));
485
+
486
+ const result = await fetchArticleComments("abc123", undefined, "2025-02-01T00:00:00Z");
487
+
488
+ // Only c1 (after sinceTimestamp) should be returned, c2 filtered out
489
+ expect(result).toHaveLength(1);
490
+ expect(result[0].id).toBe("c1");
491
+ // Should NOT have fetched page 2
492
+ expect(mockHttpPost).toHaveBeenCalledTimes(1);
493
+ });
494
+
495
+ it("excludes comments with createdAt exactly equal to sinceTimestamp", async () => {
496
+ const comments = [
497
+ makeCommentNodeWithDate("c1", "Exact match", "2025-02-01T00:00:00Z"),
498
+ makeCommentNodeWithDate("c2", "Newer", "2025-03-01T00:00:00Z"),
499
+ ];
500
+ mockHttpPost.mockResolvedValueOnce(mockCommentsResponse(comments, false));
501
+
502
+ const result = await fetchArticleComments("abc123", undefined, "2025-02-01T00:00:00Z");
503
+
504
+ expect(result).toHaveLength(1);
505
+ expect(result[0].id).toBe("c2");
506
+ });
507
+ });
508
+ });
509
+
510
+ describe("Comment-count discovery queries", () => {
511
+ it("VIEWER_ARTICLE_COMMENT_COUNTS_QUERY selects shortHash + commentCount only", () => {
512
+ expect(VIEWER_ARTICLE_COMMENT_COUNTS_QUERY).toContain("ViewerArticleCommentCounts");
513
+ expect(VIEWER_ARTICLE_COMMENT_COUNTS_QUERY).toContain("viewer");
514
+ expect(VIEWER_ARTICLE_COMMENT_COUNTS_QUERY).toContain("shortHash");
515
+ expect(VIEWER_ARTICLE_COMMENT_COUNTS_QUERY).toContain("commentCount");
516
+ // Lightweight: must not pull title/content/etc.
517
+ expect(VIEWER_ARTICLE_COMMENT_COUNTS_QUERY).not.toContain("title");
518
+ expect(VIEWER_ARTICLE_COMMENT_COUNTS_QUERY).not.toContain("content");
519
+ });
520
+
521
+ it("USER_ARTICLE_COMMENT_COUNTS_QUERY mirrors viewer query for public access", () => {
522
+ expect(USER_ARTICLE_COMMENT_COUNTS_QUERY).toContain("UserArticleCommentCounts");
523
+ expect(USER_ARTICLE_COMMENT_COUNTS_QUERY).toContain("user(input:");
524
+ expect(USER_ARTICLE_COMMENT_COUNTS_QUERY).toContain("shortHash");
525
+ expect(USER_ARTICLE_COMMENT_COUNTS_QUERY).toContain("commentCount");
526
+ expect(USER_ARTICLE_COMMENT_COUNTS_QUERY).not.toContain("title");
527
+ });
528
+ });
529
+
530
+ describe("fetchAllArticleCommentCounts", () => {
531
+ const mockHttpPost = vi.mocked(httpPost);
532
+
533
+ function makeViewerPage(
534
+ nodes: Array<{ shortHash: string; commentCount: number }>,
535
+ hasNextPage: boolean,
536
+ endCursor: string | null = null
537
+ ) {
538
+ return {
539
+ status: 200,
540
+ ok: true,
541
+ contentType: "application/json",
542
+ body: new Uint8Array(),
543
+ text: () => JSON.stringify({
544
+ data: {
545
+ viewer: {
546
+ id: "viewer-1",
547
+ articles: {
548
+ pageInfo: { endCursor, hasNextPage },
549
+ edges: nodes.map((node) => ({ node })),
550
+ },
551
+ },
552
+ },
553
+ }),
554
+ };
555
+ }
556
+
557
+ function makeUserPage(
558
+ nodes: Array<{ shortHash: string; commentCount: number }>,
559
+ hasNextPage: boolean,
560
+ endCursor: string | null = null
561
+ ) {
562
+ return {
563
+ status: 200,
564
+ ok: true,
565
+ contentType: "application/json",
566
+ body: new Uint8Array(),
567
+ text: () => JSON.stringify({
568
+ data: {
569
+ user: {
570
+ id: "user-1",
571
+ articles: {
572
+ pageInfo: { endCursor, hasNextPage },
573
+ edges: nodes.map((node) => ({ node })),
574
+ },
575
+ },
576
+ },
577
+ }),
578
+ };
579
+ }
580
+
581
+ beforeEach(() => {
582
+ clearTokenCache();
583
+ mockHttpPost.mockReset();
584
+ });
585
+
586
+ it("returns a map of shortHash → commentCount in viewer mode", async () => {
587
+ const originalMode = apiConfig.queryMode;
588
+ apiConfig.queryMode = "viewer";
589
+ try {
590
+ mockHttpPost.mockResolvedValueOnce(
591
+ makeViewerPage(
592
+ [
593
+ { shortHash: "abc123", commentCount: 4 },
594
+ { shortHash: "def456", commentCount: 0 },
595
+ ],
596
+ false
597
+ )
598
+ );
599
+
600
+ const counts = await fetchAllArticleCommentCounts();
601
+
602
+ expect(counts.size).toBe(2);
603
+ expect(counts.get("abc123")).toBe(4);
604
+ expect(counts.get("def456")).toBe(0);
605
+ expect(mockHttpPost).toHaveBeenCalledTimes(1);
606
+ } finally {
607
+ apiConfig.queryMode = originalMode;
608
+ }
609
+ });
610
+
611
+ it("paginates through multiple pages until hasNextPage=false", async () => {
612
+ const originalMode = apiConfig.queryMode;
613
+ apiConfig.queryMode = "viewer";
614
+ try {
615
+ mockHttpPost.mockResolvedValueOnce(
616
+ makeViewerPage([{ shortHash: "p1a", commentCount: 1 }], true, "cursor1")
617
+ );
618
+ mockHttpPost.mockResolvedValueOnce(
619
+ makeViewerPage([{ shortHash: "p2a", commentCount: 2 }], true, "cursor2")
620
+ );
621
+ mockHttpPost.mockResolvedValueOnce(
622
+ makeViewerPage([{ shortHash: "p3a", commentCount: 3 }], false)
623
+ );
624
+
625
+ const counts = await fetchAllArticleCommentCounts();
626
+
627
+ expect(counts.size).toBe(3);
628
+ expect(counts.get("p1a")).toBe(1);
629
+ expect(counts.get("p2a")).toBe(2);
630
+ expect(counts.get("p3a")).toBe(3);
631
+ expect(mockHttpPost).toHaveBeenCalledTimes(3);
632
+ } finally {
633
+ apiConfig.queryMode = originalMode;
634
+ }
635
+ });
636
+
637
+ it("uses USER query in user mode", async () => {
638
+ const originalMode = apiConfig.queryMode;
639
+ const originalUser = apiConfig.testUserName;
640
+ apiConfig.queryMode = "user";
641
+ apiConfig.testUserName = "Matty";
642
+ try {
643
+ mockHttpPost.mockResolvedValueOnce(
644
+ makeUserPage([{ shortHash: "xyz", commentCount: 7 }], false)
645
+ );
646
+
647
+ const counts = await fetchAllArticleCommentCounts();
648
+
649
+ expect(counts.get("xyz")).toBe(7);
650
+ // User mode should not require an x-access-token header
651
+ const callArgs = mockHttpPost.mock.calls[0];
652
+ expect(callArgs[2]?.headers ?? {}).not.toHaveProperty("x-access-token");
653
+ } finally {
654
+ apiConfig.queryMode = originalMode;
655
+ apiConfig.testUserName = originalUser;
656
+ }
657
+ });
658
+
659
+ it("returns empty map when user is not found in user mode", async () => {
660
+ const originalMode = apiConfig.queryMode;
661
+ apiConfig.queryMode = "user";
662
+ try {
663
+ mockHttpPost.mockResolvedValueOnce({
664
+ status: 200,
665
+ ok: true,
666
+ contentType: "application/json",
667
+ body: new Uint8Array(),
668
+ text: () => JSON.stringify({ data: { user: null } }),
669
+ });
670
+
671
+ const counts = await fetchAllArticleCommentCounts();
672
+
673
+ expect(counts.size).toBe(0);
674
+ } finally {
675
+ apiConfig.queryMode = originalMode;
676
+ }
677
+ });
678
+ });