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