@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,257 @@
1
+ import { loadFeature, describeFeature } from "@amiceli/vitest-cucumber";
2
+ import { expect } from "vitest";
3
+ import { apiConfig, fetchArticleComments, fetchArticleDonations, fetchArticleAppreciations } from "../../src/api";
4
+ import { mergeSocialData } from "../../src/social";
5
+ import type { MattersComment, MattersDonation, MattersAppreciation, MattersSocialData, ArticleSocialData } from "../../src/types";
6
+
7
+ const feature = await loadFeature("features/social/fetch-social-data.feature");
8
+
9
+ describeFeature(feature, ({ Scenario, Background }) => {
10
+ // Test state
11
+ let testShortHash: string;
12
+ let comments: MattersComment[] = [];
13
+ let donations: MattersDonation[] = [];
14
+ let appreciations: MattersAppreciation[] = [];
15
+ let socialData: MattersSocialData;
16
+ let existingSocialData: MattersSocialData;
17
+
18
+ Background(({ Given }) => {
19
+ Given("I am using the Matters test environment", () => {
20
+ apiConfig.endpoint = "https://server.matters.icu/graphql";
21
+ });
22
+ });
23
+
24
+ Scenario("Fetch comments for an article", ({ Given, When, Then, And }) => {
25
+ Given("I have a test article shortHash", () => {
26
+ // Use a known article with comments from the test user
27
+ testShortHash = process.env.MATTERS_TEST_ARTICLE_HASH || "bafyreiaooe6jzxbf2tbpvtue6mcb6g523lp7srroxjrlahflb3m2hpfog4";
28
+ });
29
+
30
+ When("I fetch comments for the article", async () => {
31
+ comments = await fetchArticleComments(testShortHash);
32
+ });
33
+
34
+ Then("I should receive an array of comments", () => {
35
+ expect(Array.isArray(comments)).toBe(true);
36
+ });
37
+
38
+ And("each comment should have id, content, createdAt, and author", () => {
39
+ // If there are comments, verify their structure
40
+ for (const comment of comments) {
41
+ expect(comment).toHaveProperty("id");
42
+ expect(comment).toHaveProperty("content");
43
+ expect(comment).toHaveProperty("createdAt");
44
+ expect(comment).toHaveProperty("author");
45
+ expect(comment.author).toHaveProperty("id");
46
+ expect(comment.author).toHaveProperty("userName");
47
+ }
48
+ });
49
+ });
50
+
51
+ Scenario("Fetch donations for an article", ({ Given, When, Then, And }) => {
52
+ Given("I have a test article shortHash", () => {
53
+ testShortHash = process.env.MATTERS_TEST_ARTICLE_HASH || "bafyreiaooe6jzxbf2tbpvtue6mcb6g523lp7srroxjrlahflb3m2hpfog4";
54
+ });
55
+
56
+ When("I fetch donations for the article", async () => {
57
+ donations = await fetchArticleDonations(testShortHash);
58
+ });
59
+
60
+ Then("I should receive an array of donations", () => {
61
+ expect(Array.isArray(donations)).toBe(true);
62
+ });
63
+
64
+ And("each donation should have id and sender details", () => {
65
+ for (const donation of donations) {
66
+ expect(donation).toHaveProperty("id");
67
+ expect(donation).toHaveProperty("sender");
68
+ expect(donation.sender).toHaveProperty("id");
69
+ expect(donation.sender).toHaveProperty("userName");
70
+ }
71
+ });
72
+ });
73
+
74
+ Scenario("Fetch appreciations for an article", ({ Given, When, Then, And }) => {
75
+ Given("I have a test article shortHash", () => {
76
+ testShortHash = process.env.MATTERS_TEST_ARTICLE_HASH || "bafyreiaooe6jzxbf2tbpvtue6mcb6g523lp7srroxjrlahflb3m2hpfog4";
77
+ });
78
+
79
+ When("I fetch appreciations for the article", async () => {
80
+ appreciations = await fetchArticleAppreciations(testShortHash);
81
+ });
82
+
83
+ Then("I should receive an array of appreciations", () => {
84
+ expect(Array.isArray(appreciations)).toBe(true);
85
+ });
86
+
87
+ And("each appreciation should have amount, createdAt, and sender", () => {
88
+ for (const appreciation of appreciations) {
89
+ expect(appreciation).toHaveProperty("amount");
90
+ expect(typeof appreciation.amount).toBe("number");
91
+ expect(appreciation).toHaveProperty("createdAt");
92
+ expect(appreciation).toHaveProperty("sender");
93
+ expect(appreciation.sender).toHaveProperty("id");
94
+ }
95
+ });
96
+ });
97
+
98
+ Scenario("Save social data to .moss/social/matters.json", ({ Given, When, Then, And }) => {
99
+ Given("I have fetched social data for an article", async () => {
100
+ testShortHash = process.env.MATTERS_TEST_ARTICLE_HASH || "bafyreiaooe6jzxbf2tbpvtue6mcb6g523lp7srroxjrlahflb3m2hpfog4";
101
+ comments = await fetchArticleComments(testShortHash);
102
+ donations = await fetchArticleDonations(testShortHash);
103
+ appreciations = await fetchArticleAppreciations(testShortHash);
104
+ });
105
+
106
+ When("I save the social data", () => {
107
+ // Create social data structure (without actually saving to filesystem in tests)
108
+ socialData = {
109
+ schemaVersion: "1.0.0",
110
+ updatedAt: new Date().toISOString(),
111
+ articles: {},
112
+ };
113
+ mergeSocialData(socialData, testShortHash, comments, donations, appreciations);
114
+ });
115
+
116
+ Then("the file .moss/social/matters.json should exist", () => {
117
+ // In e2e tests, we verify the data structure rather than file system
118
+ expect(socialData).toBeDefined();
119
+ });
120
+
121
+ And('it should contain the schemaVersion "1.0.0"', () => {
122
+ expect(socialData.schemaVersion).toBe("1.0.0");
123
+ });
124
+
125
+ And("it should contain data for the article shortHash", () => {
126
+ expect(socialData.articles[testShortHash]).toBeDefined();
127
+ const articleData = socialData.articles[testShortHash];
128
+ expect(articleData).toHaveProperty("comments");
129
+ expect(articleData).toHaveProperty("donations");
130
+ expect(articleData).toHaveProperty("appreciations");
131
+ });
132
+ });
133
+
134
+ Scenario("Merge new social data with existing", ({ Given, When, Then, And }) => {
135
+ Given("I have existing social data for an article", () => {
136
+ testShortHash = "test-article-hash";
137
+ existingSocialData = {
138
+ schemaVersion: "1.0.0",
139
+ updatedAt: new Date().toISOString(),
140
+ articles: {
141
+ [testShortHash]: {
142
+ comments: [
143
+ {
144
+ id: "existing-comment-1",
145
+ content: "Existing comment",
146
+ createdAt: "2024-01-01T00:00:00Z",
147
+ state: "active" as const,
148
+ upvotes: 5,
149
+ author: {
150
+ id: "author-1",
151
+ userName: "testuser",
152
+ displayName: "Test User",
153
+ },
154
+ },
155
+ ],
156
+ donations: [
157
+ {
158
+ id: "existing-donation-1",
159
+ sender: {
160
+ id: "donor-1",
161
+ userName: "donor",
162
+ displayName: "Donor",
163
+ },
164
+ },
165
+ ],
166
+ appreciations: [],
167
+ },
168
+ },
169
+ };
170
+ });
171
+
172
+ And("I fetch new social data", () => {
173
+ // Simulate new data with one existing item and one new item
174
+ comments = [
175
+ {
176
+ id: "existing-comment-1", // Same ID - should be updated
177
+ content: "Updated comment content",
178
+ createdAt: "2024-01-01T00:00:00Z",
179
+ state: "active" as const,
180
+ upvotes: 10, // Updated upvotes
181
+ author: {
182
+ id: "author-1",
183
+ userName: "testuser",
184
+ displayName: "Test User",
185
+ },
186
+ },
187
+ {
188
+ id: "new-comment-1", // New comment
189
+ content: "New comment",
190
+ createdAt: "2024-06-01T00:00:00Z",
191
+ state: "active" as const,
192
+ upvotes: 2,
193
+ author: {
194
+ id: "author-2",
195
+ userName: "newuser",
196
+ displayName: "New User",
197
+ },
198
+ },
199
+ ];
200
+ donations = [
201
+ {
202
+ id: "new-donation-1",
203
+ sender: {
204
+ id: "donor-2",
205
+ userName: "newdonor",
206
+ displayName: "New Donor",
207
+ },
208
+ },
209
+ ];
210
+ appreciations = [
211
+ {
212
+ amount: 5,
213
+ createdAt: "2024-06-01T00:00:00Z",
214
+ sender: {
215
+ id: "sender-1",
216
+ userName: "appreciator",
217
+ displayName: "Appreciator",
218
+ },
219
+ },
220
+ ];
221
+ });
222
+
223
+ When("I merge the social data", () => {
224
+ mergeSocialData(existingSocialData, testShortHash, comments, donations, appreciations);
225
+ socialData = existingSocialData;
226
+ });
227
+
228
+ Then("new items should be added", () => {
229
+ const articleData = socialData.articles[testShortHash] as ArticleSocialData;
230
+ // Should have both the updated existing comment and the new comment
231
+ expect(articleData.comments.length).toBe(2);
232
+ // Should have both existing and new donation
233
+ expect(articleData.donations.length).toBe(2);
234
+ // Should have the new appreciation
235
+ expect(articleData.appreciations.length).toBe(1);
236
+ });
237
+
238
+ And("existing items should be preserved", () => {
239
+ const articleData = socialData.articles[testShortHash] as ArticleSocialData;
240
+ // Existing donation should still be there
241
+ const existingDonation = articleData.donations.find(d => d.id === "existing-donation-1");
242
+ expect(existingDonation).toBeDefined();
243
+ });
244
+
245
+ And("no items should be duplicated", () => {
246
+ const articleData = socialData.articles[testShortHash] as ArticleSocialData;
247
+ // Check no duplicate IDs in comments
248
+ const commentIds = articleData.comments.map(c => c.id);
249
+ const uniqueCommentIds = new Set(commentIds);
250
+ expect(commentIds.length).toBe(uniqueCommentIds.size);
251
+
252
+ // The existing comment should have been updated, not duplicated
253
+ const updatedComment = articleData.comments.find(c => c.id === "existing-comment-1");
254
+ expect(updatedComment?.upvotes).toBe(10); // Updated value
255
+ });
256
+ });
257
+ });
@@ -0,0 +1,264 @@
1
+ import { loadFeature, describeFeature } from "@amiceli/vitest-cucumber";
2
+ import { expect, vi } from "vitest";
3
+ import { walletLogin, createAuthenticatedClient, type WalletAuthResult } from "../../test-helpers/wallet-auth";
4
+ import { PUT_DRAFT_MUTATION, GET_DRAFT_QUERY } from "../../src/api";
5
+
6
+ const feature = await loadFeature("features/syndication/create-draft.feature");
7
+
8
+ const TEST_ENDPOINT = "https://server.matters.icu/graphql";
9
+
10
+ // Mock types for draft operations
11
+ interface DraftInput {
12
+ title: string;
13
+ content: string;
14
+ tags?: string[];
15
+ summary?: string;
16
+ }
17
+
18
+ interface Draft {
19
+ id: string;
20
+ title: string;
21
+ content?: string;
22
+ publishState: string;
23
+ article?: {
24
+ id: string;
25
+ shortHash: string;
26
+ slug: string;
27
+ } | null;
28
+ }
29
+
30
+ describeFeature(feature, ({ Scenario, Background }) => {
31
+ // Test state
32
+ let authResult: WalletAuthResult;
33
+ let authenticatedQuery: <T>(query: string, variables?: Record<string, unknown>) => Promise<T>;
34
+ let articleData: DraftInput;
35
+ let canonicalUrl: string;
36
+ let addCanonicalLink: boolean;
37
+ let createdDraft: Draft | null = null;
38
+ let fetchedDraft: Draft | null = null;
39
+ let syndicatedUrl: string | undefined;
40
+ let shouldSkip: boolean;
41
+
42
+ Background(({ Given }) => {
43
+ Given("I am authenticated with the Matters test environment", async () => {
44
+ const privateKey = process.env.MATTERS_TEST_WALLET_PRIVATE_KEY;
45
+
46
+ if (!privateKey) {
47
+ // Skip tests if no private key is configured
48
+ console.warn("⚠️ MATTERS_TEST_WALLET_PRIVATE_KEY not set - using mock auth");
49
+ // Create a mock auth result for testing structure
50
+ authResult = {
51
+ token: "mock-token",
52
+ user: { id: "mock-id", userName: "mockuser", displayName: "Mock User" },
53
+ type: "Login",
54
+ };
55
+ authenticatedQuery = vi.fn().mockResolvedValue({});
56
+ return;
57
+ }
58
+
59
+ authResult = await walletLogin(privateKey, TEST_ENDPOINT);
60
+ authenticatedQuery = createAuthenticatedClient(authResult.token, TEST_ENDPOINT);
61
+ });
62
+ });
63
+
64
+ Scenario("Create draft via API", ({ Given, When, Then, And }) => {
65
+ Given('I have an article with title "E2E Test Article"', () => {
66
+ articleData = {
67
+ title: `E2E Test Article - ${Date.now()}`, // Unique title to avoid conflicts
68
+ content: "<p>This is test content for e2e testing of the Matters plugin.</p>",
69
+ tags: ["test", "e2e"],
70
+ summary: "Test article for e2e testing",
71
+ };
72
+ });
73
+
74
+ And("the article has content and tags", () => {
75
+ expect(articleData.content).toBeDefined();
76
+ expect(articleData.tags).toBeDefined();
77
+ expect(articleData.tags!.length).toBeGreaterThan(0);
78
+ });
79
+
80
+ When("I create a draft on Matters", async () => {
81
+ if (!process.env.MATTERS_TEST_WALLET_PRIVATE_KEY) {
82
+ // Mock response for structure testing
83
+ createdDraft = {
84
+ id: "mock-draft-id",
85
+ title: articleData.title,
86
+ content: articleData.content,
87
+ publishState: "unpublished",
88
+ article: null,
89
+ };
90
+ return;
91
+ }
92
+
93
+ interface PutDraftResponse {
94
+ putDraft: Draft;
95
+ }
96
+
97
+ const response = await authenticatedQuery<PutDraftResponse>(PUT_DRAFT_MUTATION, {
98
+ input: {
99
+ title: articleData.title,
100
+ content: articleData.content,
101
+ tags: articleData.tags,
102
+ summary: articleData.summary,
103
+ },
104
+ });
105
+
106
+ createdDraft = response.putDraft;
107
+ });
108
+
109
+ Then("a draft should be created with the correct title", () => {
110
+ expect(createdDraft).not.toBeNull();
111
+ expect(createdDraft!.title).toBe(articleData.title);
112
+ });
113
+
114
+ And('the draft should have publishState "unpublished"', () => {
115
+ expect(createdDraft!.publishState).toBe("unpublished");
116
+ });
117
+
118
+ And("I should receive a draft ID", () => {
119
+ expect(createdDraft!.id).toBeDefined();
120
+ expect(createdDraft!.id.length).toBeGreaterThan(0);
121
+ });
122
+ });
123
+
124
+ Scenario("Draft includes canonical link", ({ Given, When, Then, And }) => {
125
+ Given('I have an article with canonical URL "https://my-site.com/test-article"', () => {
126
+ canonicalUrl = "https://my-site.com/test-article";
127
+ articleData = {
128
+ title: `Canonical Test - ${Date.now()}`,
129
+ content: "<p>Original article content.</p>",
130
+ tags: ["test"],
131
+ };
132
+ });
133
+
134
+ And("add_canonical_link is enabled", () => {
135
+ addCanonicalLink = true;
136
+ });
137
+
138
+ When("I create a draft on Matters", async () => {
139
+ // Build content with canonical link
140
+ const contentWithCanonical = addCanonicalLink
141
+ ? `${articleData.content}\n\n<hr/><p>Originally published at <a href="${canonicalUrl}">${canonicalUrl}</a></p>`
142
+ : articleData.content;
143
+
144
+ if (!process.env.MATTERS_TEST_WALLET_PRIVATE_KEY) {
145
+ createdDraft = {
146
+ id: "mock-draft-canonical",
147
+ title: articleData.title,
148
+ content: contentWithCanonical,
149
+ publishState: "unpublished",
150
+ article: null,
151
+ };
152
+ return;
153
+ }
154
+
155
+ interface PutDraftResponse {
156
+ putDraft: Draft;
157
+ }
158
+
159
+ const response = await authenticatedQuery<PutDraftResponse>(PUT_DRAFT_MUTATION, {
160
+ input: {
161
+ title: articleData.title,
162
+ content: contentWithCanonical,
163
+ tags: articleData.tags,
164
+ },
165
+ });
166
+
167
+ createdDraft = response.putDraft;
168
+ });
169
+
170
+ Then("the draft content should contain the canonical URL", () => {
171
+ expect(createdDraft).not.toBeNull();
172
+ expect(createdDraft!.content).toContain(canonicalUrl);
173
+ });
174
+
175
+ And("it should be formatted as a link at the end", () => {
176
+ expect(createdDraft!.content).toContain(`href="${canonicalUrl}"`);
177
+ });
178
+ });
179
+
180
+ Scenario("Fetch draft by ID", ({ Given, When, Then, And }) => {
181
+ Given("I have created a draft on Matters", async () => {
182
+ if (!process.env.MATTERS_TEST_WALLET_PRIVATE_KEY) {
183
+ createdDraft = {
184
+ id: "mock-draft-fetch-test",
185
+ title: "Fetch Test Draft",
186
+ publishState: "unpublished",
187
+ article: null,
188
+ };
189
+ return;
190
+ }
191
+
192
+ // Create a draft first
193
+ interface PutDraftResponse {
194
+ putDraft: Draft;
195
+ }
196
+
197
+ const response = await authenticatedQuery<PutDraftResponse>(PUT_DRAFT_MUTATION, {
198
+ input: {
199
+ title: `Fetch Test - ${Date.now()}`,
200
+ content: "<p>Test content for fetch test.</p>",
201
+ },
202
+ });
203
+
204
+ createdDraft = response.putDraft;
205
+ });
206
+
207
+ When("I fetch the draft by ID", async () => {
208
+ if (!process.env.MATTERS_TEST_WALLET_PRIVATE_KEY) {
209
+ fetchedDraft = createdDraft;
210
+ return;
211
+ }
212
+
213
+ interface GetDraftResponse {
214
+ node: Draft | null;
215
+ }
216
+
217
+ const response = await authenticatedQuery<GetDraftResponse>(GET_DRAFT_QUERY, {
218
+ id: createdDraft!.id,
219
+ });
220
+
221
+ fetchedDraft = response.node;
222
+ });
223
+
224
+ Then("I should receive the draft details", () => {
225
+ expect(fetchedDraft).not.toBeNull();
226
+ });
227
+
228
+ And("the draft should have the correct title", () => {
229
+ expect(fetchedDraft!.title).toBe(createdDraft!.title);
230
+ });
231
+
232
+ And("the publishState should be present", () => {
233
+ expect(fetchedDraft!.publishState).toBeDefined();
234
+ });
235
+ });
236
+
237
+ Scenario("Skip already syndicated articles", ({ Given, When, Then, And }) => {
238
+ Given("I have an article with syndicated URL for Matters", () => {
239
+ syndicatedUrl = "https://matters.town/@testuser/test-article-abc123";
240
+ articleData = {
241
+ title: "Already Syndicated Article",
242
+ content: "<p>This article is already on Matters.</p>",
243
+ };
244
+ });
245
+
246
+ When("I check if the article should be syndicated", () => {
247
+ // Check if syndicatedUrl contains matters.town
248
+ shouldSkip = syndicatedUrl !== undefined && syndicatedUrl.includes("matters.town");
249
+ });
250
+
251
+ Then("the article should be skipped", () => {
252
+ expect(shouldSkip).toBe(true);
253
+ });
254
+
255
+ And("no new draft should be created", () => {
256
+ // In the actual implementation, we would not call createDraft
257
+ // Here we verify the skip logic works correctly
258
+ if (shouldSkip) {
259
+ // Draft creation was skipped as expected
260
+ expect(shouldSkip).toBe(true);
261
+ }
262
+ });
263
+ });
264
+ });
@@ -0,0 +1,185 @@
1
+ import { loadFeature, describeFeature } from "@amiceli/vitest-cucumber";
2
+ import { expect } from "vitest";
3
+ import {
4
+ walletLogin,
5
+ createAuthenticatedClient,
6
+ generateTestWallet,
7
+ type WalletAuthResult,
8
+ } from "../../test-helpers/wallet-auth";
9
+ import { graphqlQuery } from "../../test-helpers/api-client";
10
+
11
+ const feature = await loadFeature("features/auth/wallet-auth.feature");
12
+
13
+ const TEST_ENDPOINT = "https://server.matters.icu/graphql";
14
+
15
+ // GraphQL queries for testing
16
+ const GENERATE_SIGNING_MESSAGE_MUTATION = `
17
+ mutation GenerateSigningMessage($input: GenerateSigningMessageInput!) {
18
+ generateSigningMessage(input: $input) {
19
+ nonce
20
+ purpose
21
+ signingMessage
22
+ createdAt
23
+ expiredAt
24
+ }
25
+ }
26
+ `;
27
+
28
+ const VIEWER_QUERY = `
29
+ query Viewer {
30
+ viewer {
31
+ id
32
+ userName
33
+ displayName
34
+ }
35
+ }
36
+ `;
37
+
38
+ interface GenerateSigningMessageResponse {
39
+ generateSigningMessage: {
40
+ nonce: string;
41
+ purpose: string;
42
+ signingMessage: string;
43
+ createdAt: string;
44
+ expiredAt: string;
45
+ };
46
+ }
47
+
48
+ interface ViewerResponse {
49
+ viewer: {
50
+ id: string;
51
+ userName: string;
52
+ displayName: string;
53
+ } | null;
54
+ }
55
+
56
+ describeFeature(feature, ({ Scenario, Background }) => {
57
+ // Test state
58
+ let testAddress: string;
59
+ let testPrivateKey: string;
60
+ let signingMessageResponse: GenerateSigningMessageResponse["generateSigningMessage"];
61
+ let authResult: WalletAuthResult;
62
+ let authToken: string;
63
+ let authenticatedQuery: <T>(query: string, variables?: Record<string, unknown>) => Promise<T>;
64
+
65
+ Background(({ Given }) => {
66
+ Given("I am using the Matters test environment", () => {
67
+ // Test endpoint is already set to matters.icu
68
+ });
69
+ });
70
+
71
+ Scenario("Login with valid wallet signature", ({ Given, When, Then, And }) => {
72
+ Given("I have a valid Ethereum private key", async () => {
73
+ testPrivateKey = process.env.MATTERS_TEST_WALLET_PRIVATE_KEY || "";
74
+
75
+ if (!testPrivateKey) {
76
+ // Generate a test wallet for structure verification
77
+ const wallet = await generateTestWallet();
78
+ testPrivateKey = wallet.privateKey;
79
+ testAddress = wallet.address;
80
+ console.warn("⚠️ Using generated test wallet - full auth flow may create new account");
81
+ }
82
+ });
83
+
84
+ When("I complete the wallet login flow", async () => {
85
+ authResult = await walletLogin(testPrivateKey, TEST_ENDPOINT);
86
+ });
87
+
88
+ Then("I should receive an auth token", () => {
89
+ expect(authResult.token).toBeDefined();
90
+ expect(authResult.token.length).toBeGreaterThan(0);
91
+ });
92
+
93
+ And("I should receive my user info", () => {
94
+ expect(authResult.user).toBeDefined();
95
+ expect(authResult.user.id).toBeDefined();
96
+ expect(authResult.user.userName).toBeDefined();
97
+ });
98
+
99
+ And('the type should be "Login" or "Signup"', () => {
100
+ expect(["Login", "Signup", "LinkAccount"]).toContain(authResult.type);
101
+ });
102
+ });
103
+
104
+ Scenario("Generate signing message", ({ Given, When, Then, And }) => {
105
+ Given("I have a valid Ethereum address", async () => {
106
+ // Generate or use configured wallet
107
+ const privateKey = process.env.MATTERS_TEST_WALLET_PRIVATE_KEY;
108
+
109
+ if (privateKey) {
110
+ const { Wallet } = await import("ethers");
111
+ const wallet = new Wallet(privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`);
112
+ testAddress = wallet.address;
113
+ } else {
114
+ const wallet = await generateTestWallet();
115
+ testAddress = wallet.address;
116
+ }
117
+ });
118
+
119
+ When("I request a signing message for login", async () => {
120
+ const response = await graphqlQuery<GenerateSigningMessageResponse>(
121
+ GENERATE_SIGNING_MESSAGE_MUTATION,
122
+ {
123
+ input: {
124
+ address: testAddress,
125
+ purpose: "login",
126
+ },
127
+ },
128
+ TEST_ENDPOINT
129
+ );
130
+
131
+ signingMessageResponse = response.generateSigningMessage;
132
+ });
133
+
134
+ Then("I should receive a nonce", () => {
135
+ expect(signingMessageResponse.nonce).toBeDefined();
136
+ expect(signingMessageResponse.nonce.length).toBeGreaterThan(0);
137
+ });
138
+
139
+ And("I should receive a signingMessage", () => {
140
+ expect(signingMessageResponse.signingMessage).toBeDefined();
141
+ expect(signingMessageResponse.signingMessage.length).toBeGreaterThan(0);
142
+ });
143
+
144
+ And("the message should contain the address", () => {
145
+ // EIP-4361 signing messages include the wallet address
146
+ expect(signingMessageResponse.signingMessage.toLowerCase()).toContain(
147
+ testAddress.toLowerCase()
148
+ );
149
+ });
150
+ });
151
+
152
+ Scenario("Create authenticated client", ({ Given, When, Then, And }) => {
153
+ Given("I have completed wallet login", async () => {
154
+ const privateKey = process.env.MATTERS_TEST_WALLET_PRIVATE_KEY;
155
+
156
+ if (!privateKey) {
157
+ const wallet = await generateTestWallet();
158
+ testPrivateKey = wallet.privateKey;
159
+ } else {
160
+ testPrivateKey = privateKey;
161
+ }
162
+
163
+ authResult = await walletLogin(testPrivateKey, TEST_ENDPOINT);
164
+ });
165
+
166
+ And("I have an auth token", () => {
167
+ authToken = authResult.token;
168
+ expect(authToken).toBeDefined();
169
+ expect(authToken.length).toBeGreaterThan(0);
170
+ });
171
+
172
+ When("I create an authenticated client", () => {
173
+ authenticatedQuery = createAuthenticatedClient(authToken, TEST_ENDPOINT);
174
+ });
175
+
176
+ Then("the client should be able to make authenticated requests", async () => {
177
+ // Make an authenticated query to verify the client works
178
+ const response = await authenticatedQuery<ViewerResponse>(VIEWER_QUERY);
179
+
180
+ expect(response.viewer).not.toBeNull();
181
+ expect(response.viewer!.id).toBeDefined();
182
+ expect(response.viewer!.userName).toBe(authResult.user.userName);
183
+ });
184
+ });
185
+ });