@symbiosis-lab/moss-plugin-github 1.5.1

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 (38) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +3 -0
  4. package/assets/manifest.json +19 -0
  5. package/e2e/deploy-api.test.ts +1129 -0
  6. package/e2e/moss-cli.test.ts +478 -0
  7. package/features/auth/device-flow.feature +41 -0
  8. package/features/deploy/validation.feature +50 -0
  9. package/features/steps/auth.steps.ts +285 -0
  10. package/features/steps/deploy.steps.ts +354 -0
  11. package/package.json +51 -0
  12. package/src/__tests__/auth-flow.integration.test.ts +738 -0
  13. package/src/__tests__/auth.test.ts +147 -0
  14. package/src/__tests__/configure-domain.test.ts +263 -0
  15. package/src/__tests__/deploy.integration.test.ts +798 -0
  16. package/src/__tests__/git.test.ts +190 -0
  17. package/src/__tests__/github-api.test.ts +761 -0
  18. package/src/__tests__/github-deploy.test.ts +2411 -0
  19. package/src/__tests__/progress-timeout.test.ts +209 -0
  20. package/src/__tests__/repo-setup-progress.test.ts +367 -0
  21. package/src/__tests__/repo-setup.test.ts +370 -0
  22. package/src/__tests__/token.test.ts +152 -0
  23. package/src/__tests__/utils.test.ts +129 -0
  24. package/src/__tests__/workflow.test.ts +146 -0
  25. package/src/auth.ts +588 -0
  26. package/src/constants.ts +7 -0
  27. package/src/git.ts +60 -0
  28. package/src/github-api.ts +601 -0
  29. package/src/github-deploy.ts +593 -0
  30. package/src/main.ts +646 -0
  31. package/src/repo-setup.ts +685 -0
  32. package/src/token.ts +202 -0
  33. package/src/types.ts +91 -0
  34. package/src/utils.ts +108 -0
  35. package/src/workflow.ts +79 -0
  36. package/test-helpers/mock-github-api.ts +217 -0
  37. package/tsconfig.json +20 -0
  38. package/vitest.config.ts +50 -0
@@ -0,0 +1,761 @@
1
+ /**
2
+ * Tests for GitHub API Module
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import {
7
+ getAuthenticatedUser,
8
+ checkRepoNameAvailable,
9
+ createRepository,
10
+ isValidRepoName,
11
+ ensurePagesSource,
12
+ setCustomDomain,
13
+ type GitHubUser,
14
+ type CreatedRepository,
15
+ } from "../github-api";
16
+
17
+ // Mock fetch globally
18
+ const mockFetch = vi.fn();
19
+ global.fetch = mockFetch;
20
+
21
+ // github-api.ts no longer imports from utils — no mock needed
22
+
23
+ describe("GitHub API", () => {
24
+ beforeEach(() => {
25
+ mockFetch.mockReset();
26
+ });
27
+
28
+ describe("isValidRepoName", () => {
29
+ it("accepts valid repo names", () => {
30
+ expect(isValidRepoName("my-repo")).toBe(true);
31
+ expect(isValidRepoName("my_repo")).toBe(true);
32
+ expect(isValidRepoName("my.repo")).toBe(true);
33
+ expect(isValidRepoName("MyRepo123")).toBe(true);
34
+ expect(isValidRepoName("a")).toBe(true);
35
+ expect(isValidRepoName("123")).toBe(true);
36
+ });
37
+
38
+ it("rejects empty names", () => {
39
+ expect(isValidRepoName("")).toBe(false);
40
+ });
41
+
42
+ it("rejects names starting with a period", () => {
43
+ expect(isValidRepoName(".hidden")).toBe(false);
44
+ });
45
+
46
+ it("rejects names with invalid characters", () => {
47
+ expect(isValidRepoName("my repo")).toBe(false);
48
+ expect(isValidRepoName("my/repo")).toBe(false);
49
+ expect(isValidRepoName("my@repo")).toBe(false);
50
+ expect(isValidRepoName("my#repo")).toBe(false);
51
+ });
52
+
53
+ it("rejects names longer than 100 characters", () => {
54
+ expect(isValidRepoName("a".repeat(101))).toBe(false);
55
+ expect(isValidRepoName("a".repeat(100))).toBe(true);
56
+ });
57
+ });
58
+
59
+ describe("getAuthenticatedUser", () => {
60
+ it("returns user information on success", async () => {
61
+ const mockUser: GitHubUser = {
62
+ login: "testuser",
63
+ id: 12345,
64
+ avatar_url: "https://github.com/testuser.png",
65
+ html_url: "https://github.com/testuser",
66
+ name: "Test User",
67
+ };
68
+
69
+ mockFetch.mockResolvedValueOnce({
70
+ ok: true,
71
+ json: () => Promise.resolve(mockUser),
72
+ });
73
+
74
+ const user = await getAuthenticatedUser("test-token");
75
+
76
+ expect(user).toEqual(mockUser);
77
+ expect(mockFetch).toHaveBeenCalledWith(
78
+ "https://api.github.com/user",
79
+ expect.objectContaining({
80
+ headers: expect.objectContaining({
81
+ Authorization: "Bearer test-token",
82
+ }),
83
+ })
84
+ );
85
+ });
86
+
87
+ it("throws error on invalid token", async () => {
88
+ mockFetch.mockResolvedValueOnce({
89
+ ok: false,
90
+ status: 401,
91
+ });
92
+
93
+ await expect(getAuthenticatedUser("bad-token")).rejects.toThrow(
94
+ "Invalid or expired token"
95
+ );
96
+ });
97
+
98
+ it("throws error on other failures", async () => {
99
+ mockFetch.mockResolvedValueOnce({
100
+ ok: false,
101
+ status: 500,
102
+ });
103
+
104
+ await expect(getAuthenticatedUser("test-token")).rejects.toThrow(
105
+ "Failed to get user: 500"
106
+ );
107
+ });
108
+ });
109
+
110
+ describe("checkRepoNameAvailable", () => {
111
+ beforeEach(() => {
112
+ // Mock getAuthenticatedUser response for all tests
113
+ mockFetch.mockImplementation((url: string) => {
114
+ if (url === "https://api.github.com/user") {
115
+ return Promise.resolve({
116
+ ok: true,
117
+ json: () =>
118
+ Promise.resolve({
119
+ login: "testuser",
120
+ id: 12345,
121
+ avatar_url: "",
122
+ html_url: "",
123
+ }),
124
+ });
125
+ }
126
+ return Promise.resolve({ ok: false, status: 500 });
127
+ });
128
+ });
129
+
130
+ it("returns available=true when repo doesn't exist", async () => {
131
+ mockFetch.mockImplementation((url: string) => {
132
+ if (url === "https://api.github.com/user") {
133
+ return Promise.resolve({
134
+ ok: true,
135
+ json: () => Promise.resolve({ login: "testuser" }),
136
+ });
137
+ }
138
+ if (url === "https://api.github.com/repos/testuser/new-repo") {
139
+ return Promise.resolve({ ok: false, status: 404 });
140
+ }
141
+ return Promise.resolve({ ok: false, status: 500 });
142
+ });
143
+
144
+ const result = await checkRepoNameAvailable("new-repo", "test-token");
145
+
146
+ expect(result.available).toBe(true);
147
+ });
148
+
149
+ it("returns available=false when repo exists", async () => {
150
+ mockFetch.mockImplementation((url: string) => {
151
+ if (url === "https://api.github.com/user") {
152
+ return Promise.resolve({
153
+ ok: true,
154
+ json: () => Promise.resolve({ login: "testuser" }),
155
+ });
156
+ }
157
+ if (url === "https://api.github.com/repos/testuser/existing-repo") {
158
+ return Promise.resolve({ ok: true });
159
+ }
160
+ return Promise.resolve({ ok: false, status: 500 });
161
+ });
162
+
163
+ const result = await checkRepoNameAvailable("existing-repo", "test-token");
164
+
165
+ expect(result.available).toBe(false);
166
+ expect(result.reason).toBe("exists");
167
+ });
168
+
169
+ it("returns available=false for invalid name without API call", async () => {
170
+ const result = await checkRepoNameAvailable("invalid name", "test-token");
171
+
172
+ expect(result.available).toBe(false);
173
+ expect(result.reason).toBe("invalid");
174
+ // Should not have made any API calls for invalid name
175
+ expect(mockFetch).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it("handles API errors gracefully", async () => {
179
+ mockFetch.mockImplementation((url: string) => {
180
+ if (url === "https://api.github.com/user") {
181
+ return Promise.resolve({
182
+ ok: true,
183
+ json: () => Promise.resolve({ login: "testuser" }),
184
+ });
185
+ }
186
+ return Promise.resolve({ ok: false, status: 500 });
187
+ });
188
+
189
+ const result = await checkRepoNameAvailable("some-repo", "test-token");
190
+
191
+ expect(result.available).toBe(false);
192
+ expect(result.reason).toBe("error");
193
+ });
194
+ });
195
+
196
+ describe("createRepository", () => {
197
+ it("creates a repository successfully", async () => {
198
+ const mockRepo = {
199
+ name: "my-new-repo",
200
+ full_name: "testuser/my-new-repo",
201
+ html_url: "https://github.com/testuser/my-new-repo",
202
+ ssh_url: "git@github.com:testuser/my-new-repo.git",
203
+ clone_url: "https://github.com/testuser/my-new-repo.git",
204
+ };
205
+
206
+ mockFetch.mockResolvedValueOnce({
207
+ ok: true,
208
+ json: () => Promise.resolve(mockRepo),
209
+ });
210
+
211
+ const result = await createRepository("my-new-repo", "test-token");
212
+
213
+ expect(result).toEqual({
214
+ name: "my-new-repo",
215
+ fullName: "testuser/my-new-repo",
216
+ htmlUrl: "https://github.com/testuser/my-new-repo",
217
+ sshUrl: "git@github.com:testuser/my-new-repo.git",
218
+ cloneUrl: "https://github.com/testuser/my-new-repo.git",
219
+ });
220
+
221
+ expect(mockFetch).toHaveBeenCalledWith(
222
+ "https://api.github.com/user/repos",
223
+ expect.objectContaining({
224
+ method: "POST",
225
+ body: expect.stringContaining('"name":"my-new-repo"'),
226
+ })
227
+ );
228
+ });
229
+
230
+ it("includes description when provided", async () => {
231
+ mockFetch.mockResolvedValueOnce({
232
+ ok: true,
233
+ json: () =>
234
+ Promise.resolve({
235
+ name: "my-repo",
236
+ full_name: "user/my-repo",
237
+ html_url: "",
238
+ ssh_url: "",
239
+ clone_url: "",
240
+ }),
241
+ });
242
+
243
+ await createRepository("my-repo", "test-token", "My description");
244
+
245
+ const [, options] = mockFetch.mock.calls[0];
246
+ const body = JSON.parse(options.body);
247
+ expect(body.description).toBe("My description");
248
+ });
249
+
250
+ it("creates public repositories", async () => {
251
+ mockFetch.mockResolvedValueOnce({
252
+ ok: true,
253
+ json: () =>
254
+ Promise.resolve({
255
+ name: "my-repo",
256
+ full_name: "user/my-repo",
257
+ html_url: "",
258
+ ssh_url: "",
259
+ clone_url: "",
260
+ }),
261
+ });
262
+
263
+ await createRepository("my-repo", "test-token");
264
+
265
+ const [, options] = mockFetch.mock.calls[0];
266
+ const body = JSON.parse(options.body);
267
+ expect(body.private).toBe(false);
268
+ });
269
+
270
+ it("creates repository without auto_init (no useless initial commit)", async () => {
271
+ mockFetch.mockResolvedValueOnce({
272
+ ok: true,
273
+ json: () =>
274
+ Promise.resolve({
275
+ name: "my-repo",
276
+ full_name: "user/my-repo",
277
+ html_url: "",
278
+ ssh_url: "",
279
+ clone_url: "",
280
+ }),
281
+ });
282
+
283
+ await createRepository("my-repo", "test-token");
284
+
285
+ const [, options] = mockFetch.mock.calls[0];
286
+ const body = JSON.parse(options.body);
287
+ expect(body.auto_init).toBe(false);
288
+ });
289
+
290
+ it("throws error on failure", async () => {
291
+ mockFetch.mockResolvedValueOnce({
292
+ ok: false,
293
+ status: 422,
294
+ json: () =>
295
+ Promise.resolve({
296
+ message: "Repository creation failed: Name already exists",
297
+ }),
298
+ });
299
+
300
+ await expect(
301
+ createRepository("existing-repo", "test-token")
302
+ ).rejects.toThrow("Repository creation failed: Name already exists");
303
+ });
304
+ });
305
+
306
+ // ============================================================================
307
+ // Feature 21: checkPagesStatus() tests
308
+ // ============================================================================
309
+ describe("checkPagesStatus", () => {
310
+ // Import will fail until we implement the function
311
+ let checkPagesStatus: (
312
+ owner: string,
313
+ repo: string,
314
+ token: string
315
+ ) => Promise<{ status: string; url: string }>;
316
+
317
+ beforeEach(async () => {
318
+ const module = await import("../github-api");
319
+ checkPagesStatus = module.checkPagesStatus;
320
+ mockFetch.mockReset();
321
+ });
322
+
323
+ it("returns 'built' when site is live", async () => {
324
+ mockFetch.mockResolvedValueOnce({
325
+ ok: true,
326
+ json: () => Promise.resolve({ status: "built" }),
327
+ });
328
+
329
+ const result = await checkPagesStatus("testuser", "testuser.github.io", "test-token");
330
+
331
+ expect(result.status).toBe("built");
332
+ expect(result.url).toBe("https://testuser.github.io/");
333
+ expect(mockFetch).toHaveBeenCalledWith(
334
+ "https://api.github.com/repos/testuser/testuser.github.io/pages/builds/latest",
335
+ expect.objectContaining({
336
+ headers: expect.objectContaining({
337
+ Authorization: "Bearer test-token",
338
+ }),
339
+ })
340
+ );
341
+ });
342
+
343
+ it("returns 'building' when deployment in progress", async () => {
344
+ mockFetch.mockResolvedValueOnce({
345
+ ok: true,
346
+ json: () => Promise.resolve({ status: "building" }),
347
+ });
348
+
349
+ const result = await checkPagesStatus("testuser", "my-repo", "test-token");
350
+
351
+ expect(result.status).toBe("building");
352
+ expect(result.url).toBe("https://testuser.github.io/my-repo");
353
+ });
354
+
355
+ it("returns 'errored' when deployment failed", async () => {
356
+ mockFetch.mockResolvedValueOnce({
357
+ ok: true,
358
+ json: () => Promise.resolve({ status: "errored" }),
359
+ });
360
+
361
+ const result = await checkPagesStatus("testuser", "my-repo", "test-token");
362
+
363
+ expect(result.status).toBe("errored");
364
+ });
365
+
366
+ it("returns 'unknown' on 404 (no Pages configured)", async () => {
367
+ mockFetch.mockResolvedValueOnce({
368
+ ok: false,
369
+ status: 404,
370
+ });
371
+
372
+ const result = await checkPagesStatus("testuser", "my-repo", "test-token");
373
+
374
+ expect(result.status).toBe("unknown");
375
+ expect(result.url).toBe("");
376
+ });
377
+
378
+ it("returns 'unknown' on network error", async () => {
379
+ mockFetch.mockRejectedValueOnce(new Error("Network error"));
380
+
381
+ const result = await checkPagesStatus("testuser", "my-repo", "test-token");
382
+
383
+ expect(result.status).toBe("unknown");
384
+ expect(result.url).toBe("");
385
+ });
386
+
387
+ it("generates correct URL for root repo (username.github.io)", async () => {
388
+ mockFetch.mockResolvedValueOnce({
389
+ ok: true,
390
+ json: () => Promise.resolve({ status: "built" }),
391
+ });
392
+
393
+ const result = await checkPagesStatus("testuser", "testuser.github.io", "test-token");
394
+
395
+ // Root repo URL should have trailing slash, no repo path
396
+ expect(result.url).toBe("https://testuser.github.io/");
397
+ });
398
+
399
+ it("generates correct URL for project repo", async () => {
400
+ mockFetch.mockResolvedValueOnce({
401
+ ok: true,
402
+ json: () => Promise.resolve({ status: "built" }),
403
+ });
404
+
405
+ const result = await checkPagesStatus("testuser", "my-project", "test-token");
406
+
407
+ // Project repo URL should include repo name as path
408
+ expect(result.url).toBe("https://testuser.github.io/my-project");
409
+ });
410
+
411
+ // Bug 2: commit field extraction
412
+ it("returns commit SHA from API response", async () => {
413
+ mockFetch.mockResolvedValueOnce({
414
+ ok: true,
415
+ json: () => Promise.resolve({ status: "built", commit: "abc123def456" }),
416
+ });
417
+
418
+ const result = await checkPagesStatus("testuser", "my-repo", "test-token");
419
+
420
+ expect(result.commit).toBe("abc123def456");
421
+ });
422
+
423
+ it("returns undefined commit when API response has no commit field", async () => {
424
+ mockFetch.mockResolvedValueOnce({
425
+ ok: true,
426
+ json: () => Promise.resolve({ status: "building" }),
427
+ });
428
+
429
+ const result = await checkPagesStatus("testuser", "my-repo", "test-token");
430
+
431
+ expect(result.commit).toBeUndefined();
432
+ });
433
+
434
+ // Bug 3: error field extraction
435
+ it("returns error message from API response when build errored", async () => {
436
+ mockFetch.mockResolvedValueOnce({
437
+ ok: true,
438
+ json: () => Promise.resolve({
439
+ status: "errored",
440
+ error: { message: "Build failed: invalid config" },
441
+ }),
442
+ });
443
+
444
+ const result = await checkPagesStatus("testuser", "my-repo", "test-token");
445
+
446
+ expect(result.status).toBe("errored");
447
+ expect(result.error).toBe("Build failed: invalid config");
448
+ });
449
+
450
+ it("returns undefined error when no error object in response", async () => {
451
+ mockFetch.mockResolvedValueOnce({
452
+ ok: true,
453
+ json: () => Promise.resolve({ status: "built" }),
454
+ });
455
+
456
+ const result = await checkPagesStatus("testuser", "my-repo", "test-token");
457
+
458
+ expect(result.error).toBeUndefined();
459
+ });
460
+ });
461
+
462
+ // ============================================================================
463
+ // Feature 20: checkRepoExists() tests
464
+ // ============================================================================
465
+ describe("checkRepoExists", () => {
466
+ // Import will fail until we implement the function
467
+ let checkRepoExists: (owner: string, name: string, token: string) => Promise<boolean>;
468
+
469
+ beforeEach(async () => {
470
+ // Dynamic import to get the function
471
+ const module = await import("../github-api");
472
+ checkRepoExists = module.checkRepoExists;
473
+ mockFetch.mockReset();
474
+ });
475
+
476
+ it("returns true when repo exists (200 response)", async () => {
477
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
478
+
479
+ const exists = await checkRepoExists("testuser", "testuser.github.io", "test-token");
480
+
481
+ expect(exists).toBe(true);
482
+ expect(mockFetch).toHaveBeenCalledWith(
483
+ "https://api.github.com/repos/testuser/testuser.github.io",
484
+ expect.objectContaining({
485
+ headers: expect.objectContaining({
486
+ Authorization: "Bearer test-token",
487
+ }),
488
+ })
489
+ );
490
+ });
491
+
492
+ it("returns false when repo doesn't exist (404 response)", async () => {
493
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
494
+
495
+ const exists = await checkRepoExists("testuser", "testuser.github.io", "test-token");
496
+
497
+ expect(exists).toBe(false);
498
+ });
499
+
500
+ it("returns false on network error", async () => {
501
+ mockFetch.mockRejectedValueOnce(new Error("Network error"));
502
+
503
+ const exists = await checkRepoExists("testuser", "testuser.github.io", "test-token");
504
+
505
+ expect(exists).toBe(false);
506
+ });
507
+
508
+ it("returns false on other HTTP errors", async () => {
509
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
510
+
511
+ const exists = await checkRepoExists("testuser", "testuser.github.io", "test-token");
512
+
513
+ expect(exists).toBe(false);
514
+ });
515
+ });
516
+
517
+ // ============================================================================
518
+ // ensurePagesSource() tests
519
+ // ============================================================================
520
+ describe("ensurePagesSource", () => {
521
+ it("creates Pages when not enabled (404)", async () => {
522
+ // GET /pages → 404 (not enabled)
523
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
524
+ // POST /pages → 201 (created)
525
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 201 });
526
+
527
+ const result = await ensurePagesSource("testuser", "my-repo", "test-token", "gh-pages");
528
+
529
+ expect(result).toEqual({ configured: true, wasCreated: true });
530
+
531
+ // Verify GET was called first
532
+ expect(mockFetch).toHaveBeenCalledWith(
533
+ "https://api.github.com/repos/testuser/my-repo/pages",
534
+ expect.objectContaining({
535
+ headers: expect.objectContaining({
536
+ Authorization: "Bearer test-token",
537
+ }),
538
+ })
539
+ );
540
+
541
+ // Verify POST was called with correct source
542
+ expect(mockFetch).toHaveBeenCalledWith(
543
+ "https://api.github.com/repos/testuser/my-repo/pages",
544
+ expect.objectContaining({
545
+ method: "POST",
546
+ body: JSON.stringify({ source: { branch: "gh-pages", path: "/" } }),
547
+ })
548
+ );
549
+ });
550
+
551
+ it("updates Pages when source branch is wrong", async () => {
552
+ // GET /pages → 200, source is main
553
+ mockFetch.mockResolvedValueOnce({
554
+ ok: true,
555
+ status: 200,
556
+ json: () => Promise.resolve({ source: { branch: "main", path: "/" } }),
557
+ });
558
+ // PUT /pages → 200 (updated)
559
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
560
+
561
+ const result = await ensurePagesSource("testuser", "my-repo", "test-token", "gh-pages");
562
+
563
+ expect(result).toEqual({ configured: true, wasCreated: false });
564
+
565
+ // Verify PUT was called to update
566
+ expect(mockFetch).toHaveBeenCalledWith(
567
+ "https://api.github.com/repos/testuser/my-repo/pages",
568
+ expect.objectContaining({
569
+ method: "PUT",
570
+ body: JSON.stringify({ source: { branch: "gh-pages", path: "/" } }),
571
+ })
572
+ );
573
+ });
574
+
575
+ it("no-ops when Pages already configured correctly", async () => {
576
+ // GET /pages → 200, source is already gh-pages
577
+ mockFetch.mockResolvedValueOnce({
578
+ ok: true,
579
+ status: 200,
580
+ json: () => Promise.resolve({ source: { branch: "gh-pages", path: "/" } }),
581
+ });
582
+
583
+ const result = await ensurePagesSource("testuser", "my-repo", "test-token", "gh-pages");
584
+
585
+ expect(result).toEqual({ configured: true, wasCreated: false });
586
+ // Only one call (GET), no PUT
587
+ expect(mockFetch).toHaveBeenCalledTimes(1);
588
+ });
589
+
590
+ it("returns configured: false when POST fails", async () => {
591
+ // GET /pages → 404
592
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
593
+ // POST /pages → 422 (error)
594
+ mockFetch.mockResolvedValueOnce({
595
+ ok: false,
596
+ status: 422,
597
+ text: () => Promise.resolve("Validation failed"),
598
+ });
599
+
600
+ const result = await ensurePagesSource("testuser", "my-repo", "test-token", "gh-pages");
601
+
602
+ expect(result).toEqual({ configured: false, wasCreated: false });
603
+ });
604
+
605
+ it("returns configured: false when PUT fails", async () => {
606
+ // GET /pages → 200, wrong branch
607
+ mockFetch.mockResolvedValueOnce({
608
+ ok: true,
609
+ status: 200,
610
+ json: () => Promise.resolve({ source: { branch: "main", path: "/" } }),
611
+ });
612
+ // PUT /pages → 500 (error)
613
+ mockFetch.mockResolvedValueOnce({
614
+ ok: false,
615
+ status: 500,
616
+ text: () => Promise.resolve("Internal server error"),
617
+ });
618
+
619
+ const result = await ensurePagesSource("testuser", "my-repo", "test-token", "gh-pages");
620
+
621
+ expect(result).toEqual({ configured: false, wasCreated: false });
622
+ });
623
+
624
+ it("returns configured: false on network error", async () => {
625
+ mockFetch.mockRejectedValueOnce(new Error("Network error"));
626
+
627
+ const result = await ensurePagesSource("testuser", "my-repo", "test-token", "gh-pages");
628
+
629
+ expect(result).toEqual({ configured: false, wasCreated: false });
630
+ });
631
+ });
632
+
633
+ // ============================================================================
634
+ // getRepoSshUrl() tests
635
+ // ============================================================================
636
+ describe("getRepoSshUrl", () => {
637
+ let getRepoSshUrl: (owner: string, repo: string, token: string) => Promise<string>;
638
+
639
+ beforeEach(async () => {
640
+ const module = await import("../github-api");
641
+ getRepoSshUrl = module.getRepoSshUrl;
642
+ mockFetch.mockReset();
643
+ });
644
+
645
+ it("returns ssh_url from the API response", async () => {
646
+ mockFetch.mockResolvedValueOnce({
647
+ ok: true,
648
+ json: () => Promise.resolve({
649
+ ssh_url: "git@github.com:alice/alice.github.io.git",
650
+ }),
651
+ });
652
+
653
+ const sshUrl = await getRepoSshUrl("alice", "alice.github.io", "test-token");
654
+
655
+ expect(sshUrl).toBe("git@github.com:alice/alice.github.io.git");
656
+ expect(mockFetch).toHaveBeenCalledWith(
657
+ "https://api.github.com/repos/alice/alice.github.io",
658
+ expect.objectContaining({
659
+ headers: expect.objectContaining({
660
+ Authorization: "Bearer test-token",
661
+ }),
662
+ })
663
+ );
664
+ });
665
+
666
+ it("throws when repo is not found (404)", async () => {
667
+ mockFetch.mockResolvedValueOnce({
668
+ ok: false,
669
+ status: 404,
670
+ });
671
+
672
+ await expect(
673
+ getRepoSshUrl("alice", "nonexistent", "test-token")
674
+ ).rejects.toThrow("Repo not found: alice/nonexistent");
675
+ });
676
+
677
+ it("throws on other HTTP errors", async () => {
678
+ mockFetch.mockResolvedValueOnce({
679
+ ok: false,
680
+ status: 500,
681
+ });
682
+
683
+ await expect(
684
+ getRepoSshUrl("alice", "alice.github.io", "test-token")
685
+ ).rejects.toThrow("Repo not found: alice/alice.github.io");
686
+ });
687
+ });
688
+
689
+ // ============================================================================
690
+ // setCustomDomain() — 404 retry behavior
691
+ // ============================================================================
692
+ describe("setCustomDomain", () => {
693
+ const pagesUrl = "https://api.github.com/repos/testuser/my-repo/pages";
694
+
695
+ it("returns true when first PUT succeeds", async () => {
696
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
697
+
698
+ const result = await setCustomDomain("testuser", "my-repo", "test-token", "example.com");
699
+
700
+ expect(result).toBe(true);
701
+ expect(mockFetch).toHaveBeenCalledTimes(1);
702
+ });
703
+
704
+ it("returns true when first PUT returns 404 and retry succeeds", async () => {
705
+ // First PUT (with https_enforced) → 404
706
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
707
+ // Retry PUT (without https_enforced) → 200
708
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
709
+
710
+ const result = await setCustomDomain("testuser", "my-repo", "test-token", "example.com");
711
+
712
+ expect(result).toBe(true);
713
+ expect(mockFetch).toHaveBeenCalledTimes(2);
714
+ });
715
+
716
+ it("returns true when both PUTs return 404 (cert not yet provisioned)", async () => {
717
+ // First PUT (with https_enforced) → 404
718
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
719
+ // Retry PUT (without https_enforced) → 404
720
+ mockFetch.mockResolvedValueOnce({
721
+ ok: false,
722
+ status: 404,
723
+ text: () => Promise.resolve('{"message":"The certificate does not exist yet"}'),
724
+ });
725
+
726
+ const result = await setCustomDomain("testuser", "my-repo", "test-token", "example.com");
727
+
728
+ // Should NOT throw — CNAME is set despite the 404
729
+ expect(result).toBe(true);
730
+ });
731
+
732
+ it("throws when first PUT returns 404 and retry returns 500", async () => {
733
+ // First PUT (with https_enforced) → 404
734
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
735
+ // Retry PUT (without https_enforced) → 500
736
+ mockFetch.mockResolvedValueOnce({
737
+ ok: false,
738
+ status: 500,
739
+ text: () => Promise.resolve("Internal Server Error"),
740
+ });
741
+
742
+ await expect(
743
+ setCustomDomain("testuser", "my-repo", "test-token", "example.com")
744
+ ).rejects.toThrow("GitHub Pages API error (500)");
745
+ });
746
+
747
+ it("throws when first PUT returns non-retryable error", async () => {
748
+ // First PUT → 403 (not retryable)
749
+ mockFetch.mockResolvedValueOnce({
750
+ ok: false,
751
+ status: 403,
752
+ text: () => Promise.resolve("Forbidden"),
753
+ });
754
+
755
+ await expect(
756
+ setCustomDomain("testuser", "my-repo", "test-token", "example.com")
757
+ ).rejects.toThrow("GitHub Pages API error (403)");
758
+ expect(mockFetch).toHaveBeenCalledTimes(1);
759
+ });
760
+ });
761
+ });