@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,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct HTTP client for E2E tests against Matters API
|
|
3
|
+
* Uses fetch directly without Tauri IPC
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface GraphQLResponse<T> {
|
|
7
|
+
data?: T;
|
|
8
|
+
errors?: Array<{ message: string }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_ENDPOINT = "https://server.matters.icu/graphql";
|
|
12
|
+
|
|
13
|
+
export async function graphqlQuery<T>(
|
|
14
|
+
query: string,
|
|
15
|
+
variables?: Record<string, unknown>,
|
|
16
|
+
endpoint = DEFAULT_ENDPOINT
|
|
17
|
+
): Promise<T> {
|
|
18
|
+
const response = await fetch(endpoint, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: {
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify({ query, variables }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const result: GraphQLResponse<T> = await response.json();
|
|
31
|
+
|
|
32
|
+
if (result.errors && result.errors.length > 0) {
|
|
33
|
+
throw new Error(`GraphQL error: ${result.errors[0].message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!result.data) {
|
|
37
|
+
throw new Error("No data returned from GraphQL query");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result.data;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Pre-defined queries for testing
|
|
44
|
+
|
|
45
|
+
export const USER_ARTICLES_QUERY = `
|
|
46
|
+
query UserArticles($userName: String!, $after: String) {
|
|
47
|
+
user(input: { userName: $userName }) {
|
|
48
|
+
id
|
|
49
|
+
userName
|
|
50
|
+
articles(input: { first: 50, after: $after, filter: { state: active } }) {
|
|
51
|
+
totalCount
|
|
52
|
+
pageInfo {
|
|
53
|
+
endCursor
|
|
54
|
+
hasNextPage
|
|
55
|
+
}
|
|
56
|
+
edges {
|
|
57
|
+
node {
|
|
58
|
+
id
|
|
59
|
+
title
|
|
60
|
+
slug
|
|
61
|
+
shortHash
|
|
62
|
+
content
|
|
63
|
+
summary
|
|
64
|
+
createdAt
|
|
65
|
+
revisedAt
|
|
66
|
+
tags {
|
|
67
|
+
id
|
|
68
|
+
content
|
|
69
|
+
}
|
|
70
|
+
cover
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
export const USER_PROFILE_QUERY = `
|
|
79
|
+
query UserProfile($userName: String!) {
|
|
80
|
+
user(input: { userName: $userName }) {
|
|
81
|
+
id
|
|
82
|
+
userName
|
|
83
|
+
displayName
|
|
84
|
+
info {
|
|
85
|
+
description
|
|
86
|
+
profileCover
|
|
87
|
+
}
|
|
88
|
+
avatar
|
|
89
|
+
settings {
|
|
90
|
+
language
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
export const USER_COLLECTIONS_QUERY = `
|
|
97
|
+
query UserCollections($userName: String!, $after: String) {
|
|
98
|
+
user(input: { userName: $userName }) {
|
|
99
|
+
id
|
|
100
|
+
collections(input: { first: 50, after: $after }) {
|
|
101
|
+
totalCount
|
|
102
|
+
pageInfo {
|
|
103
|
+
endCursor
|
|
104
|
+
hasNextPage
|
|
105
|
+
}
|
|
106
|
+
edges {
|
|
107
|
+
node {
|
|
108
|
+
id
|
|
109
|
+
title
|
|
110
|
+
description
|
|
111
|
+
cover
|
|
112
|
+
articles(input: { first: 100 }) {
|
|
113
|
+
edges {
|
|
114
|
+
node {
|
|
115
|
+
id
|
|
116
|
+
shortHash
|
|
117
|
+
title
|
|
118
|
+
slug
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
// Convenience functions for common operations
|
|
130
|
+
|
|
131
|
+
export interface UserArticle {
|
|
132
|
+
id: string;
|
|
133
|
+
title: string;
|
|
134
|
+
slug: string;
|
|
135
|
+
shortHash: string;
|
|
136
|
+
content: string;
|
|
137
|
+
summary: string;
|
|
138
|
+
createdAt: string;
|
|
139
|
+
revisedAt?: string;
|
|
140
|
+
cover?: string;
|
|
141
|
+
tags?: Array<{ id: string; content: string }>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface UserProfile {
|
|
145
|
+
id: string;
|
|
146
|
+
userName: string;
|
|
147
|
+
displayName: string;
|
|
148
|
+
avatar?: string;
|
|
149
|
+
info?: {
|
|
150
|
+
description?: string;
|
|
151
|
+
profileCover?: string;
|
|
152
|
+
};
|
|
153
|
+
settings?: {
|
|
154
|
+
language?: string;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface UserCollection {
|
|
159
|
+
id: string;
|
|
160
|
+
title: string;
|
|
161
|
+
description?: string;
|
|
162
|
+
cover?: string;
|
|
163
|
+
articles: {
|
|
164
|
+
edges?: Array<{
|
|
165
|
+
node: {
|
|
166
|
+
id: string;
|
|
167
|
+
shortHash: string;
|
|
168
|
+
title: string;
|
|
169
|
+
slug: string;
|
|
170
|
+
};
|
|
171
|
+
}>;
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function fetchUserArticles(
|
|
176
|
+
userName: string,
|
|
177
|
+
endpoint = DEFAULT_ENDPOINT
|
|
178
|
+
): Promise<UserArticle[]> {
|
|
179
|
+
const allArticles: UserArticle[] = [];
|
|
180
|
+
let cursor: string | undefined;
|
|
181
|
+
|
|
182
|
+
do {
|
|
183
|
+
const data = await graphqlQuery<{
|
|
184
|
+
user?: {
|
|
185
|
+
articles: {
|
|
186
|
+
edges?: Array<{ node: UserArticle }>;
|
|
187
|
+
pageInfo: { endCursor?: string; hasNextPage: boolean };
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
}>(USER_ARTICLES_QUERY, { userName, after: cursor }, endpoint);
|
|
191
|
+
|
|
192
|
+
if (!data.user) {
|
|
193
|
+
throw new Error(`User not found: ${userName}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const edges = data.user.articles.edges ?? [];
|
|
197
|
+
for (const edge of edges) {
|
|
198
|
+
allArticles.push(edge.node);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
cursor = data.user.articles.pageInfo.hasNextPage
|
|
202
|
+
? data.user.articles.pageInfo.endCursor
|
|
203
|
+
: undefined;
|
|
204
|
+
} while (cursor);
|
|
205
|
+
|
|
206
|
+
return allArticles;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function fetchUserProfile(
|
|
210
|
+
userName: string,
|
|
211
|
+
endpoint = DEFAULT_ENDPOINT
|
|
212
|
+
): Promise<UserProfile | null> {
|
|
213
|
+
const data = await graphqlQuery<{
|
|
214
|
+
user?: UserProfile;
|
|
215
|
+
}>(USER_PROFILE_QUERY, { userName }, endpoint);
|
|
216
|
+
|
|
217
|
+
return data.user ?? null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function fetchUserCollections(
|
|
221
|
+
userName: string,
|
|
222
|
+
endpoint = DEFAULT_ENDPOINT
|
|
223
|
+
): Promise<UserCollection[]> {
|
|
224
|
+
const allCollections: UserCollection[] = [];
|
|
225
|
+
let cursor: string | undefined;
|
|
226
|
+
|
|
227
|
+
do {
|
|
228
|
+
const data = await graphqlQuery<{
|
|
229
|
+
user?: {
|
|
230
|
+
collections: {
|
|
231
|
+
edges?: Array<{ node: UserCollection }>;
|
|
232
|
+
pageInfo: { endCursor?: string; hasNextPage: boolean };
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
}>(USER_COLLECTIONS_QUERY, { userName, after: cursor }, endpoint);
|
|
236
|
+
|
|
237
|
+
if (!data.user) {
|
|
238
|
+
throw new Error(`User not found: ${userName}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const edges = data.user.collections.edges ?? [];
|
|
242
|
+
for (const edge of edges) {
|
|
243
|
+
allCollections.push(edge.node);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
cursor = data.user.collections.pageInfo.hasNextPage
|
|
247
|
+
? data.user.collections.pageInfo.endCursor
|
|
248
|
+
: undefined;
|
|
249
|
+
} while (cursor);
|
|
250
|
+
|
|
251
|
+
return allCollections;
|
|
252
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sample article fixtures for testing
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { MattersArticle, MattersDraft, MattersCollection, MattersUserProfile } from "../../src/types";
|
|
6
|
+
|
|
7
|
+
export const sampleArticle: MattersArticle = {
|
|
8
|
+
id: "QXJ0aWNsZToxMjM0NQ==",
|
|
9
|
+
title: "Test Article Title",
|
|
10
|
+
slug: "test-article-title",
|
|
11
|
+
shortHash: "abc123",
|
|
12
|
+
content: "<p>This is the article content with <strong>bold</strong> and <em>italic</em> text.</p>",
|
|
13
|
+
summary: "A brief summary of the article",
|
|
14
|
+
createdAt: "2024-01-15T10:30:00Z",
|
|
15
|
+
revisedAt: "2024-01-20T15:45:00Z",
|
|
16
|
+
cover: "https://assets.matters.town/processed/abc123-cover.jpg",
|
|
17
|
+
tags: [
|
|
18
|
+
{ id: "VGFnOjE=", content: "Technology" },
|
|
19
|
+
{ id: "VGFnOjI=", content: "Testing" },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const sampleArticleWithImages: MattersArticle = {
|
|
24
|
+
id: "QXJ0aWNsZTo2Nzg5MA==",
|
|
25
|
+
title: "Article with Images",
|
|
26
|
+
slug: "article-with-images",
|
|
27
|
+
shortHash: "def456",
|
|
28
|
+
content: `
|
|
29
|
+
<p>Introduction paragraph.</p>
|
|
30
|
+
<figure class="image">
|
|
31
|
+
<img src="https://assets.matters.town/processed/image1.jpg" alt="First image">
|
|
32
|
+
<figcaption>Caption for first image</figcaption>
|
|
33
|
+
</figure>
|
|
34
|
+
<p>Middle paragraph.</p>
|
|
35
|
+
<figure class="image">
|
|
36
|
+
<img src="https://assets.matters.town/processed/image2.png" alt="Second image">
|
|
37
|
+
</figure>
|
|
38
|
+
<p>Conclusion paragraph.</p>
|
|
39
|
+
`,
|
|
40
|
+
summary: "An article with embedded images",
|
|
41
|
+
createdAt: "2024-02-01T08:00:00Z",
|
|
42
|
+
tags: [],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const sampleDraft: MattersDraft = {
|
|
46
|
+
id: "RHJhZnQ6OTg3NjU=",
|
|
47
|
+
title: "Draft in Progress",
|
|
48
|
+
content: "<p>This is a draft that is still being written.</p>",
|
|
49
|
+
summary: "Draft summary",
|
|
50
|
+
createdAt: "2024-02-10T12:00:00Z",
|
|
51
|
+
updatedAt: "2024-02-11T14:30:00Z",
|
|
52
|
+
tags: ["WIP", "Ideas"],
|
|
53
|
+
cover: undefined,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const sampleCollection: MattersCollection = {
|
|
57
|
+
id: "Q29sbGVjdGlvbjoxMjM=",
|
|
58
|
+
title: "My Best Articles",
|
|
59
|
+
description: "A collection of my favorite articles",
|
|
60
|
+
cover: "https://assets.matters.town/processed/collection-cover.jpg",
|
|
61
|
+
articles: [
|
|
62
|
+
{ id: "1", shortHash: "abc123", title: "Test Article Title", slug: "test-article-title" },
|
|
63
|
+
{ id: "2", shortHash: "def456", title: "Article with Images", slug: "article-with-images" },
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const sampleUserProfile: MattersUserProfile = {
|
|
68
|
+
userName: "testuser",
|
|
69
|
+
displayName: "Test User",
|
|
70
|
+
description: "A test user for unit tests",
|
|
71
|
+
avatar: "https://assets.matters.town/avatar/test.jpg",
|
|
72
|
+
profileCover: "https://assets.matters.town/cover/test.jpg",
|
|
73
|
+
language: "en",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const sampleChineseUserProfile: MattersUserProfile = {
|
|
77
|
+
userName: "zhongwen",
|
|
78
|
+
displayName: "Traditional Chinese User",
|
|
79
|
+
description: "Traditional Chinese User Description",
|
|
80
|
+
avatar: "https://assets.matters.town/avatar/zh.jpg",
|
|
81
|
+
language: "zh_hant",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Create multiple articles for pagination testing
|
|
85
|
+
export function createMultipleArticles(count: number): MattersArticle[] {
|
|
86
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
87
|
+
id: `QXJ0aWNsZToke2krMX0=`,
|
|
88
|
+
title: `Article ${i + 1}`,
|
|
89
|
+
slug: `article-${i + 1}`,
|
|
90
|
+
shortHash: `hash${i + 1}`,
|
|
91
|
+
content: `<p>Content for article ${i + 1}</p>`,
|
|
92
|
+
summary: `Summary for article ${i + 1}`,
|
|
93
|
+
createdAt: new Date(Date.now() - i * 86400000).toISOString(),
|
|
94
|
+
tags: [{ id: `VGFnOiR7aX0=`, content: `Tag${i % 5}` }],
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Create articles that belong to multiple collections (for file mode testing)
|
|
99
|
+
export function createMultiCollectionArticles(): {
|
|
100
|
+
articles: MattersArticle[];
|
|
101
|
+
collections: MattersCollection[];
|
|
102
|
+
} {
|
|
103
|
+
const articles: MattersArticle[] = [
|
|
104
|
+
{
|
|
105
|
+
id: "1",
|
|
106
|
+
title: "Shared Article",
|
|
107
|
+
slug: "shared-article",
|
|
108
|
+
shortHash: "shared1",
|
|
109
|
+
content: "<p>This article belongs to multiple collections</p>",
|
|
110
|
+
summary: "Shared article summary",
|
|
111
|
+
createdAt: "2024-01-01T00:00:00Z",
|
|
112
|
+
tags: [],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "2",
|
|
116
|
+
title: "Exclusive Article",
|
|
117
|
+
slug: "exclusive-article",
|
|
118
|
+
shortHash: "excl1",
|
|
119
|
+
content: "<p>This article belongs to only one collection</p>",
|
|
120
|
+
summary: "Exclusive article summary",
|
|
121
|
+
createdAt: "2024-01-02T00:00:00Z",
|
|
122
|
+
tags: [],
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const collections: MattersCollection[] = [
|
|
127
|
+
{
|
|
128
|
+
id: "col1",
|
|
129
|
+
title: "Collection A",
|
|
130
|
+
description: "First collection",
|
|
131
|
+
articles: [
|
|
132
|
+
{ id: "1", shortHash: "shared1", title: "Shared Article", slug: "shared-article" },
|
|
133
|
+
{ id: "2", shortHash: "excl1", title: "Exclusive Article", slug: "exclusive-article" },
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "col2",
|
|
138
|
+
title: "Collection B",
|
|
139
|
+
description: "Second collection",
|
|
140
|
+
articles: [
|
|
141
|
+
{ id: "1", shortHash: "shared1", title: "Shared Article", slug: "shared-article" },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
return { articles, collections };
|
|
147
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ethereum Wallet Authentication for E2E Testing
|
|
3
|
+
*
|
|
4
|
+
* Uses Ethereum wallet signature to authenticate with Matters.icu (test environment).
|
|
5
|
+
* This allows programmatic login without email verification.
|
|
6
|
+
*
|
|
7
|
+
* Authentication Flow (EIP-4361 Sign-In with Ethereum):
|
|
8
|
+
* 1. Generate signing message with wallet address
|
|
9
|
+
* 2. Sign the message with private key
|
|
10
|
+
* 3. Submit signature to login/signup
|
|
11
|
+
* 4. Receive auth token
|
|
12
|
+
*
|
|
13
|
+
* Environment Variables:
|
|
14
|
+
* - MATTERS_TEST_WALLET_PRIVATE_KEY: Ethereum private key for test account
|
|
15
|
+
* - MATTERS_TEST_ENDPOINT: GraphQL endpoint (default: https://server.matters.icu/graphql)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { graphqlQuery } from "./api-client";
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Configuration
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
const DEFAULT_ENDPOINT = "https://server.matters.icu/graphql";
|
|
25
|
+
|
|
26
|
+
function getEnv(key: string): string | undefined {
|
|
27
|
+
return process.env[key];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// GraphQL Mutations
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
const GENERATE_SIGNING_MESSAGE_MUTATION = `
|
|
35
|
+
mutation GenerateSigningMessage($input: GenerateSigningMessageInput!) {
|
|
36
|
+
generateSigningMessage(input: $input) {
|
|
37
|
+
nonce
|
|
38
|
+
purpose
|
|
39
|
+
signingMessage
|
|
40
|
+
createdAt
|
|
41
|
+
expiredAt
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const WALLET_LOGIN_MUTATION = `
|
|
47
|
+
mutation WalletLogin($input: WalletLoginInput!) {
|
|
48
|
+
walletLogin(input: $input) {
|
|
49
|
+
auth
|
|
50
|
+
token
|
|
51
|
+
type
|
|
52
|
+
user {
|
|
53
|
+
id
|
|
54
|
+
userName
|
|
55
|
+
displayName
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Types
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
interface GenerateSigningMessageResponse {
|
|
66
|
+
generateSigningMessage: {
|
|
67
|
+
nonce: string;
|
|
68
|
+
purpose: string;
|
|
69
|
+
signingMessage: string;
|
|
70
|
+
createdAt: string;
|
|
71
|
+
expiredAt: string;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface WalletLoginResponse {
|
|
76
|
+
walletLogin: {
|
|
77
|
+
auth: boolean;
|
|
78
|
+
token: string | null;
|
|
79
|
+
type: "Login" | "Signup" | "LinkAccount";
|
|
80
|
+
user: {
|
|
81
|
+
id: string;
|
|
82
|
+
userName: string;
|
|
83
|
+
displayName: string;
|
|
84
|
+
} | null;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface WalletAuthResult {
|
|
89
|
+
token: string;
|
|
90
|
+
user: {
|
|
91
|
+
id: string;
|
|
92
|
+
userName: string;
|
|
93
|
+
displayName: string;
|
|
94
|
+
};
|
|
95
|
+
type: "Login" | "Signup" | "LinkAccount";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Wallet Utilities
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Simple Ethereum signing implementation using Web Crypto API
|
|
104
|
+
* Note: For production, use ethers.js or viem
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
// keccak256 hash function (simplified for personal_sign)
|
|
108
|
+
// In real implementation, use ethers.js or viem
|
|
109
|
+
async function signMessage(message: string, privateKey: string): Promise<string> {
|
|
110
|
+
// For now, we'll use dynamic import of ethers since it's commonly available
|
|
111
|
+
// In CI, we'll need to ensure ethers is installed as a dev dependency
|
|
112
|
+
try {
|
|
113
|
+
// Try to use ethers.js if available
|
|
114
|
+
const { Wallet } = await import("ethers");
|
|
115
|
+
const wallet = new Wallet(privateKey);
|
|
116
|
+
return wallet.signMessage(message);
|
|
117
|
+
} catch {
|
|
118
|
+
throw new Error(
|
|
119
|
+
"ethers.js is required for wallet signing. Install with: npm install --save-dev ethers"
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Derive Ethereum address from private key
|
|
126
|
+
*/
|
|
127
|
+
async function getAddressFromPrivateKey(privateKey: string): Promise<string> {
|
|
128
|
+
try {
|
|
129
|
+
const { Wallet } = await import("ethers");
|
|
130
|
+
const wallet = new Wallet(privateKey);
|
|
131
|
+
return wallet.address;
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error(
|
|
134
|
+
"ethers.js is required for address derivation. Install with: npm install --save-dev ethers"
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ============================================================================
|
|
140
|
+
// Authentication Functions
|
|
141
|
+
// ============================================================================
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Authenticate with Matters using Ethereum wallet
|
|
145
|
+
*
|
|
146
|
+
* @param privateKey - Ethereum private key (hex string, with or without 0x prefix)
|
|
147
|
+
* @param endpoint - GraphQL endpoint (default: matters.icu test environment)
|
|
148
|
+
* @returns Authentication result with token and user info
|
|
149
|
+
*/
|
|
150
|
+
export async function walletLogin(
|
|
151
|
+
privateKey?: string,
|
|
152
|
+
endpoint = DEFAULT_ENDPOINT
|
|
153
|
+
): Promise<WalletAuthResult> {
|
|
154
|
+
// Get private key from param or environment
|
|
155
|
+
const key = privateKey || getEnv("MATTERS_TEST_WALLET_PRIVATE_KEY");
|
|
156
|
+
if (!key) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
"Private key required. Pass as parameter or set MATTERS_TEST_WALLET_PRIVATE_KEY environment variable."
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Normalize private key (ensure 0x prefix)
|
|
163
|
+
const normalizedKey = key.startsWith("0x") ? key : `0x${key}`;
|
|
164
|
+
|
|
165
|
+
// Step 1: Get wallet address
|
|
166
|
+
const address = await getAddressFromPrivateKey(normalizedKey);
|
|
167
|
+
console.log(`🔐 Authenticating with wallet: ${address}`);
|
|
168
|
+
|
|
169
|
+
// Step 2: Generate signing message
|
|
170
|
+
console.log(" Generating signing message...");
|
|
171
|
+
const signingData = await graphqlQuery<GenerateSigningMessageResponse>(
|
|
172
|
+
GENERATE_SIGNING_MESSAGE_MUTATION,
|
|
173
|
+
{
|
|
174
|
+
input: {
|
|
175
|
+
address,
|
|
176
|
+
purpose: "login",
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
endpoint
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const { nonce, signingMessage } = signingData.generateSigningMessage;
|
|
183
|
+
console.log(` Nonce: ${nonce}`);
|
|
184
|
+
|
|
185
|
+
// Step 3: Sign the message
|
|
186
|
+
console.log(" Signing message...");
|
|
187
|
+
const signature = await signMessage(signingMessage, normalizedKey);
|
|
188
|
+
console.log(` Signature: ${signature.substring(0, 20)}...`);
|
|
189
|
+
|
|
190
|
+
// Step 4: Login with signature
|
|
191
|
+
console.log(" Submitting login...");
|
|
192
|
+
const loginData = await graphqlQuery<WalletLoginResponse>(
|
|
193
|
+
WALLET_LOGIN_MUTATION,
|
|
194
|
+
{
|
|
195
|
+
input: {
|
|
196
|
+
ethAddress: address,
|
|
197
|
+
nonce,
|
|
198
|
+
signature,
|
|
199
|
+
signedMessage: signingMessage,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
endpoint
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const { auth, token, type, user } = loginData.walletLogin;
|
|
206
|
+
|
|
207
|
+
if (!auth || !token || !user) {
|
|
208
|
+
throw new Error("Login failed: auth returned false or no token received");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log(` ✅ ${type}: @${user.userName}`);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
token,
|
|
215
|
+
user,
|
|
216
|
+
type,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Create an authenticated GraphQL query function
|
|
222
|
+
*
|
|
223
|
+
* @param token - Authentication token from walletLogin
|
|
224
|
+
* @param endpoint - GraphQL endpoint
|
|
225
|
+
* @returns Function that makes authenticated GraphQL queries
|
|
226
|
+
*/
|
|
227
|
+
export function createAuthenticatedClient(
|
|
228
|
+
token: string,
|
|
229
|
+
endpoint = DEFAULT_ENDPOINT
|
|
230
|
+
) {
|
|
231
|
+
return async function authenticatedQuery<T>(
|
|
232
|
+
query: string,
|
|
233
|
+
variables?: Record<string, unknown>
|
|
234
|
+
): Promise<T> {
|
|
235
|
+
const response = await fetch(endpoint, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: {
|
|
238
|
+
"Content-Type": "application/json",
|
|
239
|
+
"x-access-token": token,
|
|
240
|
+
},
|
|
241
|
+
body: JSON.stringify({ query, variables }),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface GraphQLResponse<T> {
|
|
249
|
+
data?: T;
|
|
250
|
+
errors?: Array<{ message: string }>;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const result: GraphQLResponse<T> = await response.json();
|
|
254
|
+
|
|
255
|
+
if (result.errors && result.errors.length > 0) {
|
|
256
|
+
throw new Error(`GraphQL error: ${result.errors[0].message}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!result.data) {
|
|
260
|
+
throw new Error("No data returned from GraphQL query");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return result.data;
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ============================================================================
|
|
268
|
+
// Test Utilities
|
|
269
|
+
// ============================================================================
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Get or create a test account
|
|
273
|
+
*
|
|
274
|
+
* If the wallet has never been used, it will create a new account (Signup).
|
|
275
|
+
* Otherwise, it will log in to the existing account (Login).
|
|
276
|
+
*/
|
|
277
|
+
export async function getTestAccount(
|
|
278
|
+
endpoint = DEFAULT_ENDPOINT
|
|
279
|
+
): Promise<WalletAuthResult> {
|
|
280
|
+
return walletLogin(undefined, endpoint);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Generate a new random wallet for testing
|
|
285
|
+
*
|
|
286
|
+
* Returns the private key and address.
|
|
287
|
+
* Note: The account won't exist on Matters until first login.
|
|
288
|
+
*/
|
|
289
|
+
export async function generateTestWallet(): Promise<{
|
|
290
|
+
privateKey: string;
|
|
291
|
+
address: string;
|
|
292
|
+
}> {
|
|
293
|
+
try {
|
|
294
|
+
const { Wallet } = await import("ethers");
|
|
295
|
+
const wallet = Wallet.createRandom();
|
|
296
|
+
return {
|
|
297
|
+
privateKey: wallet.privateKey,
|
|
298
|
+
address: wallet.address,
|
|
299
|
+
};
|
|
300
|
+
} catch {
|
|
301
|
+
throw new Error(
|
|
302
|
+
"ethers.js is required. Install with: npm install --save-dev ethers"
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|