@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.
- package/CHANGELOG.md +30 -0
- package/README.md +18 -0
- package/assets/icon.svg +3 -0
- package/assets/manifest.json +19 -0
- package/e2e/deploy-api.test.ts +1129 -0
- package/e2e/moss-cli.test.ts +478 -0
- package/features/auth/device-flow.feature +41 -0
- package/features/deploy/validation.feature +50 -0
- package/features/steps/auth.steps.ts +285 -0
- package/features/steps/deploy.steps.ts +354 -0
- package/package.json +51 -0
- package/src/__tests__/auth-flow.integration.test.ts +738 -0
- package/src/__tests__/auth.test.ts +147 -0
- package/src/__tests__/configure-domain.test.ts +263 -0
- package/src/__tests__/deploy.integration.test.ts +798 -0
- package/src/__tests__/git.test.ts +190 -0
- package/src/__tests__/github-api.test.ts +761 -0
- package/src/__tests__/github-deploy.test.ts +2411 -0
- package/src/__tests__/progress-timeout.test.ts +209 -0
- package/src/__tests__/repo-setup-progress.test.ts +367 -0
- package/src/__tests__/repo-setup.test.ts +370 -0
- package/src/__tests__/token.test.ts +152 -0
- package/src/__tests__/utils.test.ts +129 -0
- package/src/__tests__/workflow.test.ts +146 -0
- package/src/auth.ts +588 -0
- package/src/constants.ts +7 -0
- package/src/git.ts +60 -0
- package/src/github-api.ts +601 -0
- package/src/github-deploy.ts +593 -0
- package/src/main.ts +646 -0
- package/src/repo-setup.ts +685 -0
- package/src/token.ts +202 -0
- package/src/types.ts +91 -0
- package/src/utils.ts +108 -0
- package/src/workflow.ts +79 -0
- package/test-helpers/mock-github-api.ts +217 -0
- package/tsconfig.json +20 -0
- 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
|
+
});
|