@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,423 @@
1
+ /**
2
+ * Step definitions for download feature tests
3
+ * Tests worker pool concurrency, retry logic, and error handling
4
+ */
5
+ import { loadFeature, describeFeature } from "@amiceli/vitest-cucumber";
6
+ import { expect } from "vitest";
7
+ import {
8
+ setupMockTauri,
9
+ type MockTauriContext,
10
+ } from "@symbiosis-lab/moss-api/testing";
11
+
12
+ // ============================================================================
13
+ // Worker Pool Feature
14
+ // ============================================================================
15
+
16
+ const workerPoolFeature = await loadFeature(
17
+ "features/download/worker-pool.feature"
18
+ );
19
+
20
+ describeFeature(workerPoolFeature, ({ Scenario }) => {
21
+ // Shared state across scenarios
22
+ let ctx: MockTauriContext | null = null;
23
+ let downloadResult: {
24
+ filesProcessed: number;
25
+ imagesDownloaded: number;
26
+ imagesSkipped: number;
27
+ errors: string[];
28
+ } | null = null;
29
+
30
+ Scenario("Respects concurrency limit of 5", ({ Given, When, Then, And }) => {
31
+ Given("a mock Tauri environment", () => {
32
+ // Initialize fresh mock with explicit projectPath
33
+ ctx = setupMockTauri({ projectPath: "/test/project" });
34
+ downloadResult = null;
35
+ expect(ctx).toBeDefined();
36
+ });
37
+
38
+ Given("an in-memory filesystem", () => {
39
+ expect(ctx!.filesystem).toBeDefined();
40
+ });
41
+
42
+ Given("20 images to download with delay", () => {
43
+ const imageUrls = Array.from(
44
+ { length: 20 },
45
+ (_, i) =>
46
+ `https://assets.matters.news/embed/${i.toString().padStart(8, "0")}-0000-0000-0000-000000000000/image.jpg`
47
+ );
48
+
49
+ const markdownContent = `---
50
+ title: Test Article
51
+ ---
52
+
53
+ ${imageUrls.map((url) => `![](${url})`).join("\n")}
54
+ `;
55
+
56
+ ctx!.filesystem.setFile(`${ctx!.projectPath}/article.md`, markdownContent);
57
+
58
+ // Configure mock responses with delay to ensure concurrent execution overlaps
59
+ for (const url of imageUrls) {
60
+ ctx!.urlConfig.setResponse(url, {
61
+ status: 200,
62
+ ok: true,
63
+ contentType: "image/jpeg",
64
+ bytesWritten: 1024,
65
+ delay: 50, // 50ms delay to ensure concurrent execution overlaps
66
+ });
67
+ }
68
+ });
69
+
70
+ When("I start downloading all images", async () => {
71
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
72
+ downloadResult = await downloadMediaAndUpdate();
73
+ });
74
+
75
+ Then("at most 5 downloads should run concurrently", () => {
76
+ // Note: Concurrency limit is now enforced by Rust-side Semaphore (DOWNLOAD_CONCURRENCY_LIMIT=5)
77
+ // In JS mock environment, all downloads fire at once via Promise.allSettled
78
+ // The real concurrency test is in Rust: test_download_concurrency_limit
79
+ // Here we just verify downloads complete - actual limit enforcement happens in production
80
+ expect(ctx!.downloadTracker.completedDownloads.length).toBeGreaterThan(0);
81
+ });
82
+
83
+ And("all 20 downloads should complete successfully", () => {
84
+ expect(downloadResult).not.toBeNull();
85
+ expect(ctx!.downloadTracker.completedDownloads.length).toBe(20);
86
+ expect(ctx!.downloadTracker.failedDownloads.length).toBe(0);
87
+ // Cleanup
88
+ ctx?.cleanup();
89
+ });
90
+ });
91
+
92
+ Scenario("Tracks download progress", ({ Given, When, Then, And }) => {
93
+ Given("a mock Tauri environment", () => {
94
+ ctx = setupMockTauri({ projectPath: "/test/project" });
95
+ downloadResult = null;
96
+ expect(ctx).toBeDefined();
97
+ });
98
+
99
+ Given("an in-memory filesystem", () => {
100
+ expect(ctx!.filesystem).toBeDefined();
101
+ });
102
+
103
+ Given("10 images to download", () => {
104
+ const imageUrls = Array.from(
105
+ { length: 10 },
106
+ (_, i) =>
107
+ `https://assets.matters.news/embed/${(i + 100).toString().padStart(8, "0")}-0000-0000-0000-000000000000/image.jpg`
108
+ );
109
+
110
+ const markdownContent = `---
111
+ title: Progress Test
112
+ ---
113
+
114
+ ${imageUrls.map((url) => `![](${url})`).join("\n")}
115
+ `;
116
+
117
+ ctx!.filesystem.setFile(`${ctx!.projectPath}/progress.md`, markdownContent);
118
+
119
+ for (const url of imageUrls) {
120
+ ctx!.urlConfig.setResponse(url, {
121
+ status: 200,
122
+ ok: true,
123
+ contentType: "image/jpeg",
124
+ bytesWritten: 1024,
125
+ });
126
+ }
127
+ });
128
+
129
+ When("I start downloading all images", async () => {
130
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
131
+ downloadResult = await downloadMediaAndUpdate();
132
+ });
133
+
134
+ Then("progress events should be reported", () => {
135
+ expect(downloadResult).not.toBeNull();
136
+ });
137
+
138
+ And("the final progress should show all images completed", () => {
139
+ expect(ctx!.downloadTracker.completedDownloads.length).toBe(10);
140
+ ctx?.cleanup();
141
+ });
142
+ });
143
+
144
+ Scenario("Handles mixed success and failure", ({ Given, When, Then, And }) => {
145
+ let successUrls: string[] = [];
146
+ let failUrls: string[] = [];
147
+
148
+ Given("a mock Tauri environment", () => {
149
+ ctx = setupMockTauri({ projectPath: "/test/project" });
150
+ downloadResult = null;
151
+ expect(ctx).toBeDefined();
152
+ });
153
+
154
+ Given("an in-memory filesystem", () => {
155
+ expect(ctx!.filesystem).toBeDefined();
156
+ });
157
+
158
+ Given("5 images where 2 will fail with 404", () => {
159
+ successUrls = Array.from(
160
+ { length: 3 },
161
+ (_, i) =>
162
+ `https://assets.matters.news/embed/${(i + 200).toString().padStart(8, "0")}-0000-0000-0000-000000000000/success.jpg`
163
+ );
164
+
165
+ failUrls = Array.from(
166
+ { length: 2 },
167
+ (_, i) =>
168
+ `https://assets.matters.news/embed/${(i + 300).toString().padStart(8, "0")}-0000-0000-0000-000000000000/fail.jpg`
169
+ );
170
+
171
+ const allUrls = [...successUrls, ...failUrls];
172
+ const markdownContent = `---
173
+ title: Mixed Test
174
+ ---
175
+
176
+ ${allUrls.map((url) => `![](${url})`).join("\n")}
177
+ `;
178
+
179
+ ctx!.filesystem.setFile(`${ctx!.projectPath}/mixed.md`, markdownContent);
180
+
181
+ for (const url of successUrls) {
182
+ ctx!.urlConfig.setResponse(url, {
183
+ status: 200,
184
+ ok: true,
185
+ contentType: "image/jpeg",
186
+ bytesWritten: 1024,
187
+ });
188
+ }
189
+
190
+ for (const url of failUrls) {
191
+ ctx!.urlConfig.setResponse(url, {
192
+ status: 404,
193
+ ok: false,
194
+ });
195
+ }
196
+ });
197
+
198
+ When("I start downloading all images", async () => {
199
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
200
+ downloadResult = await downloadMediaAndUpdate();
201
+ });
202
+
203
+ Then("3 downloads should succeed", () => {
204
+ expect(ctx!.downloadTracker.completedDownloads.length).toBe(3);
205
+ });
206
+
207
+ And("2 downloads should be marked as failed", () => {
208
+ expect(ctx!.downloadTracker.failedDownloads.length).toBe(2);
209
+ });
210
+
211
+ And("the result should report both successes and failures", () => {
212
+ expect(downloadResult).not.toBeNull();
213
+ expect(downloadResult!.imagesDownloaded).toBe(3);
214
+ // imagesSkipped only counts assets that already existed locally, not failed downloads
215
+ expect(downloadResult!.imagesSkipped).toBe(0);
216
+ expect(downloadResult!.errors.length).toBe(2);
217
+ ctx?.cleanup();
218
+ });
219
+ });
220
+ });
221
+
222
+ // ============================================================================
223
+ // Retry Logic Feature
224
+ // ============================================================================
225
+
226
+ const retryFeature = await loadFeature("features/download/retry-logic.feature");
227
+
228
+ describeFeature(retryFeature, ({ Scenario }) => {
229
+ let ctx: MockTauriContext | null = null;
230
+ let downloadResult: {
231
+ filesProcessed: number;
232
+ imagesDownloaded: number;
233
+ imagesSkipped: number;
234
+ errors: string[];
235
+ } | null = null;
236
+
237
+ Scenario("Retries with Fibonacci backoff on 503", ({ Given, When, Then, And }) => {
238
+ const testUrl = "https://assets.matters.news/embed/retry503-0000-0000-0000-000000000000/image.jpg";
239
+
240
+ Given("a mock Tauri environment", () => {
241
+ ctx = setupMockTauri({ projectPath: "/test/retry" });
242
+ downloadResult = null;
243
+ expect(ctx).toBeDefined();
244
+ });
245
+
246
+ Given("an in-memory filesystem", () => {
247
+ expect(ctx!.filesystem).toBeDefined();
248
+ });
249
+
250
+ Given("an image URL that returns 503 twice then succeeds", () => {
251
+ const markdownContent = `---
252
+ title: Retry Test
253
+ ---
254
+
255
+ ![](${testUrl})
256
+ `;
257
+ ctx!.filesystem.setFile(`${ctx!.projectPath}/retry.md`, markdownContent);
258
+
259
+ ctx!.urlConfig.setResponse(testUrl, [
260
+ { status: 503, ok: false },
261
+ { status: 503, ok: false },
262
+ { status: 200, ok: true, contentType: "image/jpeg", bytesWritten: 1024 },
263
+ ]);
264
+ });
265
+
266
+ When("I download the image with retry enabled", async () => {
267
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
268
+ downloadResult = await downloadMediaAndUpdate();
269
+ });
270
+
271
+ Then("it should retry with Fibonacci delays", () => {
272
+ expect(downloadResult).not.toBeNull();
273
+ });
274
+
275
+ And("the download should succeed on attempt 3", () => {
276
+ expect(downloadResult!.imagesDownloaded).toBe(1);
277
+ expect(downloadResult!.errors.length).toBe(0);
278
+ expect(ctx!.downloadTracker.completedDownloads.length).toBe(1);
279
+ ctx?.cleanup();
280
+ });
281
+ });
282
+
283
+ Scenario("Gives up after max retries", ({ Given, When, Then, And }) => {
284
+ const testUrl = "https://assets.matters.news/embed/maxretry-0000-0000-0000-000000000000/image.jpg";
285
+
286
+ Given("a mock Tauri environment", () => {
287
+ ctx = setupMockTauri({ projectPath: "/test/retry" });
288
+ downloadResult = null;
289
+ expect(ctx).toBeDefined();
290
+ });
291
+
292
+ Given("an in-memory filesystem", () => {
293
+ expect(ctx!.filesystem).toBeDefined();
294
+ });
295
+
296
+ Given("an image URL that always returns 503", () => {
297
+ const markdownContent = `---
298
+ title: Max Retry Test
299
+ ---
300
+
301
+ ![](${testUrl})
302
+ `;
303
+ ctx!.filesystem.setFile(`${ctx!.projectPath}/maxretry.md`, markdownContent);
304
+
305
+ ctx!.urlConfig.setResponse(testUrl, [
306
+ { status: 503, ok: false },
307
+ { status: 503, ok: false },
308
+ { status: 503, ok: false },
309
+ { status: 503, ok: false },
310
+ { status: 503, ok: false },
311
+ ]);
312
+ });
313
+
314
+ When("I download the image with max 3 retries", async () => {
315
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
316
+ downloadResult = await downloadMediaAndUpdate();
317
+ });
318
+
319
+ Then("it should attempt 4 times total", () => {
320
+ expect(downloadResult).not.toBeNull();
321
+ });
322
+
323
+ And("the download should fail with 503 error", () => {
324
+ expect(downloadResult!.imagesDownloaded).toBe(0);
325
+ // imagesSkipped only counts assets that already existed locally, not failed downloads
326
+ expect(downloadResult!.imagesSkipped).toBe(0);
327
+ expect(downloadResult!.errors.some((e) => e.includes("503"))).toBe(true);
328
+ ctx?.cleanup();
329
+ });
330
+ });
331
+
332
+ Scenario("Does not retry on 404", ({ Given, When, Then, And }) => {
333
+ const testUrl = "https://assets.matters.news/embed/noretry-0000-0000-0000-000000000000/image.jpg";
334
+
335
+ Given("a mock Tauri environment", () => {
336
+ ctx = setupMockTauri({ projectPath: "/test/retry" });
337
+ downloadResult = null;
338
+ expect(ctx).toBeDefined();
339
+ });
340
+
341
+ Given("an in-memory filesystem", () => {
342
+ expect(ctx!.filesystem).toBeDefined();
343
+ });
344
+
345
+ Given("an image URL that returns 404", () => {
346
+ const markdownContent = `---
347
+ title: No Retry Test
348
+ ---
349
+
350
+ ![](${testUrl})
351
+ `;
352
+ ctx!.filesystem.setFile(`${ctx!.projectPath}/noretry.md`, markdownContent);
353
+
354
+ ctx!.urlConfig.setResponse(testUrl, {
355
+ status: 404,
356
+ ok: false,
357
+ });
358
+ });
359
+
360
+ When("I download the image with retry enabled", async () => {
361
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
362
+ downloadResult = await downloadMediaAndUpdate();
363
+ });
364
+
365
+ Then("it should not retry", () => {
366
+ expect(downloadResult).not.toBeNull();
367
+ });
368
+
369
+ And("the download should fail immediately with 404 error", () => {
370
+ expect(downloadResult!.imagesDownloaded).toBe(0);
371
+ // imagesSkipped only counts assets that already existed locally, not failed downloads
372
+ expect(downloadResult!.imagesSkipped).toBe(0);
373
+ expect(downloadResult!.errors.some((e) => e.includes("404"))).toBe(true);
374
+ expect(ctx!.downloadTracker.failedDownloads.length).toBe(1);
375
+ ctx?.cleanup();
376
+ });
377
+ });
378
+
379
+ Scenario("Retries on network timeout", ({ Given, When, Then, And }) => {
380
+ const testUrl = "https://assets.matters.news/embed/timeout-0000-0000-0000-000000000000/image.jpg";
381
+
382
+ Given("a mock Tauri environment", () => {
383
+ ctx = setupMockTauri({ projectPath: "/test/retry" });
384
+ downloadResult = null;
385
+ expect(ctx).toBeDefined();
386
+ });
387
+
388
+ Given("an in-memory filesystem", () => {
389
+ expect(ctx!.filesystem).toBeDefined();
390
+ });
391
+
392
+ Given("an image URL that times out twice then succeeds", () => {
393
+ const markdownContent = `---
394
+ title: Timeout Retry Test
395
+ ---
396
+
397
+ ![](${testUrl})
398
+ `;
399
+ ctx!.filesystem.setFile(`${ctx!.projectPath}/timeout.md`, markdownContent);
400
+
401
+ ctx!.urlConfig.setResponse(testUrl, [
402
+ { status: 0, ok: false },
403
+ { status: 0, ok: false },
404
+ { status: 200, ok: true, contentType: "image/jpeg", bytesWritten: 1024 },
405
+ ]);
406
+ });
407
+
408
+ When("I download the image with retry enabled", async () => {
409
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
410
+ downloadResult = await downloadMediaAndUpdate();
411
+ });
412
+
413
+ Then("it should retry after timeouts", () => {
414
+ expect(downloadResult).not.toBeNull();
415
+ });
416
+
417
+ And("the download should succeed on attempt 3", () => {
418
+ expect(downloadResult!.imagesDownloaded).toBe(1);
419
+ expect(downloadResult!.errors.length).toBe(0);
420
+ ctx?.cleanup();
421
+ });
422
+ });
423
+ });
@@ -0,0 +1,105 @@
1
+ import { loadFeature, describeFeature } from "@amiceli/vitest-cucumber";
2
+ import { expect } from "vitest";
3
+ import { apiConfig, fetchAllArticlesSince } from "../../src/api";
4
+ import type { MattersArticle } from "../../src/types";
5
+
6
+ const feature = await loadFeature("features/sync/incremental-sync.feature");
7
+
8
+ describeFeature(feature, ({ Scenario, Background }) => {
9
+ // Test state
10
+ let fetchedArticles: MattersArticle[] = [];
11
+ let lastSyncedAt: string | undefined;
12
+ let initialArticleCount = 0;
13
+
14
+ Background(({ Given, And }) => {
15
+ Given("I am using the Matters test environment", () => {
16
+ apiConfig.endpoint = "https://server.matters.icu/graphql";
17
+ apiConfig.queryMode = "user";
18
+ apiConfig.testUserName = process.env.MATTERS_TEST_USER || "yhh354";
19
+ });
20
+
21
+ And("I have a test user with articles", () => {
22
+ // Test user is pre-configured
23
+ });
24
+ });
25
+
26
+ Scenario("First sync fetches all articles and saves timestamp", ({ Given, When, Then, And }) => {
27
+ Given("I have no previous sync timestamp", () => {
28
+ lastSyncedAt = undefined;
29
+ });
30
+
31
+ When("I run the sync process", async () => {
32
+ const result = await fetchAllArticlesSince(lastSyncedAt);
33
+ fetchedArticles = result.articles;
34
+ initialArticleCount = fetchedArticles.length;
35
+ // Simulate saving timestamp
36
+ lastSyncedAt = new Date().toISOString();
37
+ });
38
+
39
+ Then("all articles should be fetched", () => {
40
+ expect(fetchedArticles.length).toBeGreaterThan(0);
41
+ });
42
+
43
+ And("the config should contain a lastSyncedAt timestamp", () => {
44
+ expect(lastSyncedAt).toBeDefined();
45
+ expect(new Date(lastSyncedAt!).getTime()).toBeGreaterThan(0);
46
+ });
47
+ });
48
+
49
+ Scenario("Subsequent sync only fetches newer articles", ({ Given, When, Then, And }) => {
50
+ Given("I have a lastSyncedAt timestamp from 1 hour ago", () => {
51
+ const oneHourAgo = new Date();
52
+ oneHourAgo.setHours(oneHourAgo.getHours() - 1);
53
+ lastSyncedAt = oneHourAgo.toISOString();
54
+ });
55
+
56
+ And("the test user has multiple articles", async () => {
57
+ // Verify user has articles
58
+ const result = await fetchAllArticlesSince(undefined);
59
+ expect(result.articles.length).toBeGreaterThan(0);
60
+ initialArticleCount = result.articles.length;
61
+ });
62
+
63
+ When("I run the sync process", async () => {
64
+ const result = await fetchAllArticlesSince(lastSyncedAt);
65
+ fetchedArticles = result.articles;
66
+ });
67
+
68
+ Then("only recently modified articles should be fetched", () => {
69
+ // Should fetch fewer articles than total (or 0 if none modified)
70
+ expect(fetchedArticles.length).toBeLessThanOrEqual(initialArticleCount);
71
+ });
72
+
73
+ And("the lastSyncedAt timestamp should be updated", () => {
74
+ const newTimestamp = new Date().toISOString();
75
+ expect(new Date(newTimestamp).getTime()).toBeGreaterThan(
76
+ new Date(lastSyncedAt!).getTime()
77
+ );
78
+ });
79
+ });
80
+
81
+ Scenario("Sync skips unchanged articles when no modifications", ({ Given, When, Then, And }) => {
82
+ Given("I have synced all articles recently", async () => {
83
+ // Set timestamp to now (all articles are "old")
84
+ lastSyncedAt = new Date().toISOString();
85
+ });
86
+
87
+ And("no articles have been modified since", () => {
88
+ // This is assumed based on the current timestamp
89
+ });
90
+
91
+ When("I run the sync process again", async () => {
92
+ const result = await fetchAllArticlesSince(lastSyncedAt);
93
+ fetchedArticles = result.articles;
94
+ });
95
+
96
+ Then("0 articles should be fetched", () => {
97
+ expect(fetchedArticles.length).toBe(0);
98
+ });
99
+
100
+ And("existing local files should remain unchanged", () => {
101
+ // This is verified by the fact that no new articles were returned
102
+ // In a real scenario, we would check file modification times
103
+ });
104
+ });
105
+ });