@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,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
|
+
});
|