@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,93 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { overallProgress } from "../progress";
3
+
4
+ describe("overallProgress", () => {
5
+ it("returns 0 at start of first phase", () => {
6
+ expect(overallProgress("authentication", 0, 1)).toBe(0);
7
+ });
8
+
9
+ it("returns 100 at end of last phase", () => {
10
+ expect(overallProgress("complete", 1, 1)).toBe(100);
11
+ });
12
+
13
+ it("monotonically increases across all phase boundaries", () => {
14
+ const values = [
15
+ overallProgress("authentication", 0, 1),
16
+ overallProgress("authentication", 1, 1),
17
+ overallProgress("fetching_articles", 0, 1),
18
+ overallProgress("fetching_articles", 1, 1),
19
+ overallProgress("fetching_drafts", 0, 1),
20
+ overallProgress("fetching_drafts", 1, 1),
21
+ overallProgress("fetching_collections", 0, 1),
22
+ overallProgress("fetching_collections", 1, 1),
23
+ overallProgress("fetching_profile", 0, 1),
24
+ overallProgress("fetching_profile", 1, 1),
25
+ overallProgress("syncing", 0, 10),
26
+ overallProgress("syncing", 5, 10),
27
+ overallProgress("syncing", 10, 10),
28
+ overallProgress("downloading_media", 0, 50),
29
+ overallProgress("downloading_media", 25, 50),
30
+ overallProgress("downloading_media", 50, 50),
31
+ overallProgress("rewriting_links", 0, 1),
32
+ overallProgress("rewriting_links", 1, 1),
33
+ overallProgress("fetching_social", 0, 20),
34
+ overallProgress("fetching_social", 10, 20),
35
+ overallProgress("fetching_social", 20, 20),
36
+ overallProgress("complete", 1, 1),
37
+ ];
38
+
39
+ for (let i = 1; i < values.length; i++) {
40
+ expect(values[i]).toBeGreaterThanOrEqual(values[i - 1]);
41
+ }
42
+ });
43
+
44
+ it("handles zero total gracefully", () => {
45
+ const result = overallProgress("downloading_media", 0, 0);
46
+ expect(result).toBeGreaterThanOrEqual(0);
47
+ expect(result).toBeLessThanOrEqual(100);
48
+ });
49
+
50
+ it("handles unknown phase gracefully", () => {
51
+ const result = overallProgress("unknown_phase", 5, 10);
52
+ expect(result).toBeGreaterThanOrEqual(0);
53
+ expect(result).toBeLessThanOrEqual(100);
54
+ });
55
+
56
+ it("returns intermediate values within a phase", () => {
57
+ const start = overallProgress("downloading_media", 0, 100);
58
+ const mid = overallProgress("downloading_media", 50, 100);
59
+ const end = overallProgress("downloading_media", 100, 100);
60
+
61
+ expect(mid).toBeGreaterThan(start);
62
+ expect(end).toBeGreaterThan(mid);
63
+ });
64
+
65
+ it("treats sub-phases as part of their parent phase", () => {
66
+ // syncing_homepage, syncing_collections, syncing_articles, syncing_drafts
67
+ // should all map to the "syncing" weight band
68
+ const syncStart = overallProgress("syncing", 0, 10);
69
+ const homepageProgress = overallProgress("syncing_homepage", 1, 10);
70
+ const collectionsProgress = overallProgress("syncing_collections", 3, 10);
71
+ const articlesProgress = overallProgress("syncing_articles", 7, 10);
72
+ const draftsProgress = overallProgress("syncing_drafts", 9, 10);
73
+ const syncEnd = overallProgress("syncing", 10, 10);
74
+
75
+ // All syncing sub-phases should fall within the syncing band
76
+ expect(homepageProgress).toBeGreaterThanOrEqual(syncStart);
77
+ expect(homepageProgress).toBeLessThanOrEqual(syncEnd);
78
+ expect(collectionsProgress).toBeGreaterThanOrEqual(syncStart);
79
+ expect(articlesProgress).toBeGreaterThanOrEqual(syncStart);
80
+ expect(draftsProgress).toBeGreaterThanOrEqual(syncStart);
81
+ });
82
+
83
+ it("never exceeds 100", () => {
84
+ // Even with current > total
85
+ const result = overallProgress("complete", 5, 1);
86
+ expect(result).toBeLessThanOrEqual(100);
87
+ });
88
+
89
+ it("never goes below 0", () => {
90
+ const result = overallProgress("authentication", 0, 100);
91
+ expect(result).toBeGreaterThanOrEqual(0);
92
+ });
93
+ });
@@ -0,0 +1,375 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock the SDK exactly like api.test.ts does (api.ts imports it at module top).
4
+ vi.mock("@symbiosis-lab/moss-api", async () => {
5
+ const actual = await vi.importActual("@symbiosis-lab/moss-api");
6
+ return {
7
+ ...actual,
8
+ getPluginCookie: vi.fn(),
9
+ httpPost: vi.fn(),
10
+ pluginFileExists: vi.fn(),
11
+ readPluginFile: vi.fn(),
12
+ writePluginFile: vi.fn(),
13
+ };
14
+ });
15
+
16
+ import { decodeJwtExpiryMs } from "../credential";
17
+
18
+ /** Build an unsigned JWT with the given payload (header/sig are ignored by the decoder). */
19
+ function fakeJwt(payload: Record<string, unknown>): string {
20
+ const b64url = (obj: Record<string, unknown>) =>
21
+ Buffer.from(JSON.stringify(obj)).toString("base64url");
22
+ return `${b64url({ alg: "HS256", typ: "JWT" })}.${b64url(payload)}.fakesig`;
23
+ }
24
+
25
+ describe("decodeJwtExpiryMs", () => {
26
+ it("returns exp in milliseconds for a JWT with numeric exp", () => {
27
+ expect(decodeJwtExpiryMs(fakeJwt({ exp: 1777777777, id: "x" }))).toBe(1777777777000);
28
+ });
29
+
30
+ it("returns null when exp is missing", () => {
31
+ expect(decodeJwtExpiryMs(fakeJwt({ id: "x" }))).toBeNull();
32
+ });
33
+
34
+ it("returns null when exp is not a number", () => {
35
+ expect(decodeJwtExpiryMs(fakeJwt({ exp: "tomorrow" }))).toBeNull();
36
+ });
37
+
38
+ it("returns null for a non-JWT opaque token", () => {
39
+ expect(decodeJwtExpiryMs("not-a-jwt-token")).toBeNull();
40
+ });
41
+
42
+ it("returns null for a JWT with an undecodable payload", () => {
43
+ expect(decodeJwtExpiryMs("aGVhZGVy.!!!notbase64!!!.sig")).toBeNull();
44
+ });
45
+
46
+ it("decodes base64url payloads (- and _ characters, no padding)", () => {
47
+ const jwt = fakeJwt({ exp: 2000000000, u: "??>>" });
48
+ expect(decodeJwtExpiryMs(jwt)).toBe(2000000000000);
49
+ });
50
+ });
51
+
52
+ import {
53
+ getSessionState,
54
+ markSessionInvalidated,
55
+ shouldNudgeSessionExpired,
56
+ loadStoredToken,
57
+ saveStoredToken,
58
+ clearTokenCache,
59
+ captureLogin,
60
+ } from "../credential";
61
+ import {
62
+ getPluginCookie,
63
+ pluginFileExists,
64
+ readPluginFile,
65
+ writePluginFile,
66
+ } from "@symbiosis-lab/moss-api";
67
+
68
+ const FUTURE = Math.floor(Date.now() / 1000) + 90 * 24 * 3600;
69
+ const PAST = Math.floor(Date.now() / 1000) - 24 * 3600;
70
+ const WITHIN_SKEW = Math.floor(Date.now() / 1000) + 30; // < 60s skew margin
71
+
72
+ function mockAuthFile(record: Record<string, unknown> | null) {
73
+ vi.mocked(pluginFileExists).mockResolvedValue(record !== null);
74
+ vi.mocked(readPluginFile).mockResolvedValue(JSON.stringify(record ?? {}));
75
+ }
76
+
77
+ describe("getSessionState", () => {
78
+ beforeEach(() => {
79
+ vi.clearAllMocks();
80
+ clearTokenCache();
81
+ });
82
+
83
+ it("returns 'none' when no auth file exists", async () => {
84
+ mockAuthFile(null);
85
+ expect(await getSessionState()).toBe("none");
86
+ });
87
+
88
+ it("returns 'none' when the record has no accessToken", async () => {
89
+ mockAuthFile({ savedAt: "2026-01-01" });
90
+ expect(await getSessionState()).toBe("none");
91
+ });
92
+
93
+ it("returns 'valid' for an unexpired JWT", async () => {
94
+ mockAuthFile({ accessToken: fakeJwt({ exp: FUTURE }) });
95
+ expect(await getSessionState()).toBe("valid");
96
+ });
97
+
98
+ it("returns 'expired' for an expired JWT, with an honest log line", async () => {
99
+ const logSpy = vi.spyOn(console, "log");
100
+ mockAuthFile({ accessToken: fakeJwt({ exp: PAST }) });
101
+ expect(await getSessionState()).toBe("expired");
102
+ expect(logSpy.mock.calls.flat().join("\n")).toContain("EXPIRED");
103
+ logSpy.mockRestore();
104
+ });
105
+
106
+ it("returns 'expired' for a JWT expiring within the 60s skew margin", async () => {
107
+ mockAuthFile({ accessToken: fakeJwt({ exp: WITHIN_SKEW }) });
108
+ expect(await getSessionState()).toBe("expired");
109
+ });
110
+
111
+ it("returns 'expired' when invalidatedAt is stamped, even if exp is future", async () => {
112
+ mockAuthFile({
113
+ accessToken: fakeJwt({ exp: FUTURE }),
114
+ invalidatedAt: "2026-06-10T03:00:00.000Z",
115
+ });
116
+ expect(await getSessionState()).toBe("expired");
117
+ });
118
+
119
+ it("returns 'valid' for an undecodable token (runtime backstop will catch it)", async () => {
120
+ mockAuthFile({ accessToken: "opaque-non-jwt-token" });
121
+ expect(await getSessionState()).toBe("valid");
122
+ });
123
+ });
124
+
125
+ describe("loadStoredToken dead-token filtering", () => {
126
+ beforeEach(() => {
127
+ vi.clearAllMocks();
128
+ clearTokenCache();
129
+ });
130
+
131
+ it("returns the token for a valid record", async () => {
132
+ const token = fakeJwt({ exp: FUTURE });
133
+ mockAuthFile({ accessToken: token });
134
+ expect(await loadStoredToken()).toBe(token);
135
+ });
136
+
137
+ it("returns null for an expired JWT (login flow must not 'find' a dead token)", async () => {
138
+ mockAuthFile({ accessToken: fakeJwt({ exp: PAST }) });
139
+ expect(await loadStoredToken()).toBeNull();
140
+ });
141
+
142
+ it("returns null for an invalidatedAt-stamped record", async () => {
143
+ mockAuthFile({
144
+ accessToken: fakeJwt({ exp: FUTURE }),
145
+ invalidatedAt: "2026-06-10T03:00:00.000Z",
146
+ });
147
+ expect(await loadStoredToken()).toBeNull();
148
+ });
149
+
150
+ it("returns an opaque non-JWT token unchanged (cannot judge locally)", async () => {
151
+ mockAuthFile({ accessToken: "opaque-non-jwt-token" });
152
+ expect(await loadStoredToken()).toBe("opaque-non-jwt-token");
153
+ });
154
+ });
155
+
156
+ describe("captureLogin cookie-branch dead-token filter (login poll)", () => {
157
+ beforeEach(() => {
158
+ vi.clearAllMocks();
159
+ clearTokenCache();
160
+ vi.mocked(writePluginFile).mockResolvedValue(undefined);
161
+ });
162
+
163
+ it("rejects an expired-exp cookie: resolves null and does NOT write auth.json", async () => {
164
+ mockAuthFile(null); // no stored record
165
+ vi.mocked(getPluginCookie).mockResolvedValue([
166
+ { name: "__access_token", value: fakeJwt({ exp: PAST }) },
167
+ ]);
168
+ expect(await captureLogin()).toBeNull();
169
+ expect(vi.mocked(writePluginFile)).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it("rejects a cookie identical to the invalidatedAt-stamped record's token", async () => {
173
+ // Server-revoked token: future exp, cookie still live in the shared
174
+ // WebKit store. The exp check can't catch it; identity to the stamped
175
+ // record must.
176
+ const revoked = fakeJwt({ exp: FUTURE });
177
+ mockAuthFile({ accessToken: revoked, invalidatedAt: "2026-06-10T03:00:00.000Z" });
178
+ vi.mocked(getPluginCookie).mockResolvedValue([
179
+ { name: "__access_token", value: revoked },
180
+ ]);
181
+ expect(await captureLogin()).toBeNull();
182
+ expect(vi.mocked(writePluginFile)).not.toHaveBeenCalled();
183
+ });
184
+
185
+ it("accepts a fresh future-exp cookie different from the stamped token and persists it", async () => {
186
+ const revoked = fakeJwt({ exp: FUTURE, id: "old" });
187
+ const fresh = fakeJwt({ exp: FUTURE, id: "new" });
188
+ mockAuthFile({ accessToken: revoked, invalidatedAt: "2026-06-10T03:00:00.000Z" });
189
+ vi.mocked(getPluginCookie).mockResolvedValue([
190
+ { name: "__access_token", value: fresh },
191
+ ]);
192
+ expect(await captureLogin()).toBe(fresh);
193
+ expect(vi.mocked(writePluginFile)).toHaveBeenCalledWith(
194
+ "auth.json",
195
+ expect.stringContaining(fresh)
196
+ );
197
+ });
198
+ });
199
+
200
+ describe("markSessionInvalidated", () => {
201
+ beforeEach(() => {
202
+ vi.clearAllMocks();
203
+ clearTokenCache();
204
+ });
205
+
206
+ it("skips the file write when there is no token to invalidate", async () => {
207
+ mockAuthFile(null);
208
+ await markSessionInvalidated();
209
+ expect(vi.mocked(writePluginFile)).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it("stamps invalidatedAt while preserving the token", async () => {
213
+ const token = fakeJwt({ exp: FUTURE });
214
+ mockAuthFile({ accessToken: token });
215
+ vi.mocked(writePluginFile).mockResolvedValue(undefined);
216
+
217
+ await markSessionInvalidated();
218
+
219
+ const [file, content] = vi.mocked(writePluginFile).mock.calls[0];
220
+ expect(file).toBe("auth.json");
221
+ const written = JSON.parse(content as string);
222
+ expect(written.accessToken).toBe(token);
223
+ expect(typeof written.invalidatedAt).toBe("string");
224
+ });
225
+
226
+ it("saveStoredToken clears previous stamps (fresh login resets)", async () => {
227
+ vi.mocked(writePluginFile).mockResolvedValue(undefined);
228
+ await saveStoredToken("fresh-token");
229
+ const [, content] = vi.mocked(writePluginFile).mock.calls[0];
230
+ const written = JSON.parse(content as string);
231
+ expect(written.accessToken).toBe("fresh-token");
232
+ expect(written.invalidatedAt).toBeUndefined();
233
+ expect(written.nudgedAt).toBeUndefined();
234
+ });
235
+ });
236
+
237
+ describe("shouldNudgeSessionExpired (persisted once-per-expiry-event throttle)", () => {
238
+ beforeEach(() => {
239
+ vi.clearAllMocks();
240
+ clearTokenCache();
241
+ });
242
+
243
+ it("nudges the first time and stamps nudgedAt", async () => {
244
+ mockAuthFile({ accessToken: fakeJwt({ exp: PAST }) });
245
+ vi.mocked(writePluginFile).mockResolvedValue(undefined);
246
+ expect(await shouldNudgeSessionExpired()).toBe(true);
247
+ const [file, content] = vi.mocked(writePluginFile).mock.calls[0];
248
+ expect(file).toBe("auth.json");
249
+ expect(JSON.parse(content as string).nudgedAt).toBeTruthy();
250
+ });
251
+
252
+ it("does not nudge again once nudgedAt is stamped", async () => {
253
+ mockAuthFile({ accessToken: fakeJwt({ exp: PAST }), nudgedAt: "2026-06-10T03:00:00.000Z" });
254
+ expect(await shouldNudgeSessionExpired()).toBe(false);
255
+ expect(vi.mocked(writePluginFile)).not.toHaveBeenCalled();
256
+ });
257
+
258
+ it("does not nudge when there is no session at all", async () => {
259
+ mockAuthFile(null);
260
+ expect(await shouldNudgeSessionExpired()).toBe(false);
261
+ });
262
+ });
263
+
264
+ import { MattersAuthError, graphqlQuery, graphqlQueryPublic } from "../api";
265
+ import { httpPost } from "@symbiosis-lab/moss-api";
266
+
267
+ function mockHttpResponse(status: number, bodyObj: unknown) {
268
+ const text = JSON.stringify(bodyObj);
269
+ vi.mocked(httpPost).mockResolvedValue({
270
+ status,
271
+ ok: status >= 200 && status < 300,
272
+ contentType: "application/json",
273
+ body: new TextEncoder().encode(text),
274
+ text: () => text,
275
+ });
276
+ }
277
+
278
+ const TOKEN_INVALID_BODY = {
279
+ errors: [{ message: "token invalid", extensions: { code: "TOKEN_INVALID" } }],
280
+ };
281
+
282
+ describe("graphqlQuery auth-error detection", () => {
283
+ beforeEach(() => {
284
+ vi.clearAllMocks();
285
+ clearTokenCache();
286
+ mockAuthFile({ accessToken: fakeJwt({ exp: FUTURE }) });
287
+ vi.mocked(writePluginFile).mockResolvedValue(undefined);
288
+ });
289
+
290
+ it("throws MattersAuthError on 500 + TOKEN_INVALID body (real Matters shape)", async () => {
291
+ mockHttpResponse(500, TOKEN_INVALID_BODY);
292
+ await expect(graphqlQuery("query { viewer { id } }")).rejects.toBeInstanceOf(MattersAuthError);
293
+ });
294
+
295
+ it("stamps invalidatedAt when an auth error is detected", async () => {
296
+ mockHttpResponse(500, TOKEN_INVALID_BODY);
297
+ await expect(graphqlQuery("query { viewer { id } }")).rejects.toThrow();
298
+ const writes = vi.mocked(writePluginFile).mock.calls.filter(([f]) => f === "auth.json");
299
+ expect(writes.length).toBe(1);
300
+ expect(JSON.parse(writes[0][1] as string).invalidatedAt).toBeTruthy();
301
+ });
302
+
303
+ it("throws MattersAuthError on 200 + UNAUTHENTICATED errors array", async () => {
304
+ mockHttpResponse(200, {
305
+ errors: [{ message: "unauthenticated", extensions: { code: "UNAUTHENTICATED" } }],
306
+ data: null,
307
+ });
308
+ await expect(graphqlQuery("query { viewer { id } }")).rejects.toBeInstanceOf(MattersAuthError);
309
+ });
310
+
311
+ it("throws a generic error carrying a body snippet for non-auth failures", async () => {
312
+ mockHttpResponse(502, { error: "upstream connect error before downstream thing" });
313
+ await expect(graphqlQuery("query { viewer { id } }")).rejects.toThrow(
314
+ /GraphQL request failed \(502\): .*upstream connect error/
315
+ );
316
+ });
317
+
318
+ it("still throws the first GraphQL error message for 200 + non-auth errors", async () => {
319
+ mockHttpResponse(200, {
320
+ errors: [{ message: "invalid globalId", extensions: { code: "BAD_USER_INPUT" } }],
321
+ data: null,
322
+ });
323
+ await expect(graphqlQuery("query { viewer { id } }")).rejects.toThrow("invalid globalId");
324
+ });
325
+
326
+ it("returns data unchanged on success", async () => {
327
+ mockHttpResponse(200, { data: { viewer: { id: "abc" } } });
328
+ await expect(graphqlQuery("query { viewer { id } }")).resolves.toEqual({
329
+ viewer: { id: "abc" },
330
+ });
331
+ });
332
+
333
+ it("keeps body evidence when a 200 response is not JSON", async () => {
334
+ const text = "<html>oops</html>";
335
+ vi.mocked(httpPost).mockResolvedValue({
336
+ status: 200,
337
+ ok: true,
338
+ contentType: "text/html",
339
+ body: new TextEncoder().encode(text),
340
+ text: () => text,
341
+ });
342
+ await expect(graphqlQuery("query { viewer { id } }")).rejects.toThrow(/oops/);
343
+ });
344
+ });
345
+
346
+ describe("graphqlQueryPublic (token-less path)", () => {
347
+ beforeEach(() => {
348
+ vi.clearAllMocks();
349
+ clearTokenCache();
350
+ mockAuthFile({ accessToken: fakeJwt({ exp: FUTURE }) }); // a valid session exists...
351
+ vi.mocked(writePluginFile).mockResolvedValue(undefined);
352
+ });
353
+
354
+ it("auth-code body does NOT stamp the session and is NOT a MattersAuthError", async () => {
355
+ mockHttpResponse(500, TOKEN_INVALID_BODY);
356
+ const err = await graphqlQueryPublic("query { user { id } }").catch((e) => e);
357
+ expect(err).toBeInstanceOf(Error);
358
+ expect(err).not.toBeInstanceOf(MattersAuthError);
359
+ expect(vi.mocked(writePluginFile)).not.toHaveBeenCalled(); // valid session untouched
360
+ });
361
+
362
+ it("carries a body snippet on failures", async () => {
363
+ mockHttpResponse(502, { error: "bad gateway from upstream" });
364
+ await expect(graphqlQueryPublic("query { user { id } }")).rejects.toThrow(
365
+ /GraphQL request failed \(502\): .*bad gateway/
366
+ );
367
+ });
368
+
369
+ it("returns data on success", async () => {
370
+ mockHttpResponse(200, { data: { user: { id: "u1" } } });
371
+ await expect(graphqlQueryPublic("query { user { id } }")).resolves.toEqual({
372
+ user: { id: "u1" },
373
+ });
374
+ });
375
+ });