profileShare

rasmusjy / profileshare

Read-only snapshot

No repository description.

main default branch 54 files Expires Sep 13, 2026, 9:06 AM
github-client.test.js 18,445 bytes
1 import { afterEach, describe, expect, it, vi } from "vitest";
2 import { createGitHubClient } from "../src/github.js";
3
4 function json(data, init = {}) {
5 return new Response(JSON.stringify(data), {
6 status: 200,
7 headers: { "Content-Type": "application/json" },
8 ...init,
9 });
10 }
11
12 function client() {
13 return createGitHubClient({
14 clientId: "client",
15 clientSecret: "secret",
16 appSlug: "profileshare",
17 baseUrl: "http://profileshare.test",
18 });
19 }
20
21 describe("production GitHub client", () => {
22 afterEach(() => vi.unstubAllGlobals());
23
24 it("provides an actionable message when GitHub App permissions are missing", async () => {
25 vi.stubGlobal("fetch", vi.fn(async () => json(
26 { message: "Resource not accessible by integration" },
27 { status: 403 },
28 )));
29 await expect(client().getViewer("token")).rejects.toMatchObject({
30 publicMessage: expect.stringContaining("read-only Contents and Metadata"),
31 });
32 });
33
34 it("snapshots the code even when issues and pull requests are forbidden", async () => {
35 // Contents and Metadata only: the installation can read the repository and
36 // its files, but every issue and pull request endpoint answers 403. The
37 // snapshot has to survive that, because the code is the point of it.
38 vi.stubGlobal("fetch", vi.fn(async (url) => {
39 const path = String(url);
40 if (path.includes("/issues") || path.includes("/pulls") || path.includes("/releases")) {
41 return json({ message: "Resource not accessible by integration" }, { status: 403 });
42 }
43 if (path.endsWith("/repos/rasmusjy/demo")) {
44 return json({ id: 7, name: "demo", full_name: "rasmusjy/demo", default_branch: "main", private: true });
45 }
46 if (path.includes("/commits/main")) {
47 return json({ sha: "abc", commit: { message: "init", tree: { sha: "t1" }, author: { date: "2026-01-01T00:00:00Z" } } });
48 }
49 if (path.includes("/branches")) return json([{ name: "main", commit: { sha: "abc" } }]);
50 if (path.includes("/languages")) return json({ TypeScript: 100 });
51 if (path.includes("/git/trees")) return json({ tree: [] });
52 return json([]);
53 }));
54
55 const [repo] = await client().snapshotRepositories("token", [
56 { id: 7, name: "demo", fullName: "rasmusjy/demo" },
57 ]);
58
59 expect(repo.name).toBe("demo");
60 expect(repo.issues).toEqual([]);
61 expect(repo.pullRequests).toEqual([]);
62 expect(repo.releases).toEqual([]);
63 });
64
65 it("still fails the snapshot when the rate limit is the reason for the 403", async () => {
66 // A rate-limit 403 is temporary. Swallowing it would quietly produce a
67 // snapshot with no issues in it and no sign that anything went wrong.
68 vi.stubGlobal("fetch", vi.fn(async (url) => {
69 const path = String(url);
70 if (path.includes("/issues")) {
71 return new Response(JSON.stringify({ message: "rate limited" }), {
72 status: 403,
73 headers: { "Content-Type": "application/json", "x-ratelimit-remaining": "0" },
74 });
75 }
76 if (path.endsWith("/repos/rasmusjy/demo")) {
77 return json({ id: 7, name: "demo", full_name: "rasmusjy/demo", default_branch: "main" });
78 }
79 if (path.includes("/commits/main")) {
80 return json({ sha: "abc", commit: { message: "init", tree: { sha: "t1" }, author: { date: "2026-01-01T00:00:00Z" } } });
81 }
82 if (path.includes("/branches")) return json([{ name: "main", commit: { sha: "abc" } }]);
83 if (path.includes("/languages")) return json({});
84 if (path.includes("/git/trees")) return json({ tree: [] });
85 return json([]);
86 }));
87
88 await expect(
89 client().snapshotRepositories("token", [{ id: 7, name: "demo", fullName: "rasmusjy/demo" }]),
90 ).rejects.toThrow(/rate limit/i);
91 });
92
93 it("snapshots an empty repository without requesting a branch, tree, or commits", async () => {
94 const calls = [];
95 vi.stubGlobal("fetch", vi.fn(async (url) => {
96 calls.push(String(url));
97 return json({
98 id: 1,
99 name: "empty",
100 full_name: "owner/empty",
101 description: null,
102 language: null,
103 default_branch: null,
104 });
105 }));
106 const snapshots = await client().snapshotRepositories("token", [{
107 id: 1,
108 fullName: "owner/empty",
109 defaultBranch: null,
110 }]);
111 expect(snapshots[0].files).toEqual([]);
112 expect(snapshots[0].commits).toEqual([]);
113 expect(calls).toHaveLength(1);
114 });
115
116 it("anchors files and commit history to one resolved head SHA", async () => {
117 const calls = [];
118 vi.stubGlobal("fetch", vi.fn(async (url) => {
119 const target = new URL(url);
120 calls.push(`${target.pathname}${target.search}`);
121 if (target.pathname === "/repos/owner/project") {
122 return json({
123 id: 2,
124 name: "project",
125 full_name: "owner/project",
126 description: null,
127 language: "JavaScript",
128 default_branch: "main",
129 });
130 }
131 if (target.pathname.endsWith("/commits/main")) {
132 return json({ sha: "fixed-head", commit: { tree: { sha: "fixed-tree" } } });
133 }
134 if (target.pathname.endsWith("/branches")) {
135 return json([{ name: "main", commit: { sha: "fixed-head" }, protected: true }]);
136 }
137 if (target.pathname.endsWith("/tags")) return json([]);
138 if (target.pathname.endsWith("/issues")) return json([]);
139 if (target.pathname.endsWith("/pulls")) return json([]);
140 if (target.pathname.endsWith("/releases")) return json([]);
141 if (target.pathname.endsWith("/languages")) return json({});
142 if (target.pathname.endsWith("/git/trees/fixed-tree")) {
143 return json({ truncated: false, tree: [] });
144 }
145 if (target.pathname.endsWith("/commits")) return json([]);
146 throw new Error(`Unexpected request: ${target}`);
147 }));
148 const snapshots = await client().snapshotRepositories("token", [{
149 id: 2,
150 fullName: "owner/project",
151 defaultBranch: "main",
152 }]);
153 expect(snapshots[0].headSha).toBe("fixed-head");
154 expect(calls).toContain("/repos/owner/project/git/trees/fixed-tree?recursive=1");
155 expect(calls.some((call) => call.includes("/commits?sha=fixed-head"))).toBe(true);
156 });
157
158 it("freezes refs, releases, and repository metadata with deduplicated file content", async () => {
159 const calls = [];
160 vi.stubGlobal("fetch", vi.fn(async (url) => {
161 const target = new URL(url);
162 calls.push(`${target.pathname}${target.search}`);
163 if (target.pathname === "/repos/owner/project") {
164 return json({
165 id: 2,
166 name: "project",
167 full_name: "owner/project",
168 description: null,
169 language: "JavaScript",
170 homepage: "https://project.test",
171 topics: ["tooling"],
172 license: { name: "MIT License", spdx_id: "MIT" },
173 stargazers_count: 9,
174 forks_count: 2,
175 subscribers_count: 3,
176 archived: false,
177 default_branch: "main",
178 });
179 }
180 if (target.pathname.endsWith("/commits/main")) {
181 return json({ sha: "main-head", commit: { tree: { sha: "main-tree" } } });
182 }
183 if (target.pathname.endsWith("/branches")) {
184 return json([
185 { name: "main", commit: { sha: "main-head" }, protected: true },
186 { name: "feature", commit: { sha: "feature-head" }, protected: false },
187 ]);
188 }
189 if (target.pathname.endsWith("/tags")) {
190 return json([{ name: "v1.0.0", commit: { sha: "tag-head" } }]);
191 }
192 if (target.pathname.endsWith("/issues")) return json([]);
193 if (target.pathname.endsWith("/pulls")) return json([]);
194 if (target.pathname.endsWith("/releases")) {
195 return json([{
196 id: 10,
197 tag_name: "v1.0.0",
198 target_commitish: "main",
199 name: "First release",
200 body: "Release notes",
201 draft: false,
202 prerelease: false,
203 author: { login: "owner", avatar_url: "" },
204 created_at: "2026-01-01T00:00:00Z",
205 published_at: "2026-01-02T00:00:00Z",
206 assets: [{
207 id: 11,
208 name: "project.zip",
209 label: null,
210 size: 100,
211 download_count: 4,
212 content_type: "application/zip",
213 }],
214 }]);
215 }
216 if (target.pathname.endsWith("/languages")) return json({ JavaScript: 700, HTML: 300 });
217 if (target.pathname.endsWith("/commits/feature-head")) {
218 return json({ sha: "feature-head", commit: { tree: { sha: "feature-tree" } } });
219 }
220 if (target.pathname.endsWith("/commits/tag-head")) {
221 return json({ sha: "tag-head", commit: { tree: { sha: "tag-tree" } } });
222 }
223 if (target.pathname.endsWith("/git/trees/main-tree")) {
224 return json({
225 truncated: false,
226 tree: [
227 { type: "blob", path: "README.md", sha: "shared-blob", size: 4 },
228 { type: "blob", path: "logo.png", sha: "image-blob", size: 68 },
229 ],
230 });
231 }
232 if (target.pathname.endsWith("/git/trees/feature-tree")) {
233 return json({ truncated: false, tree: [{ type: "blob", path: "feature.js", sha: "feature-blob", size: 7 }] });
234 }
235 if (target.pathname.endsWith("/git/trees/tag-tree")) {
236 return json({ truncated: false, tree: [{ type: "blob", path: "README.md", sha: "shared-blob", size: 4 }] });
237 }
238 if (target.pathname.endsWith("/git/blobs/shared-blob")) {
239 return json({ encoding: "base64", content: Buffer.from("same").toString("base64") });
240 }
241 if (target.pathname.endsWith("/git/blobs/feature-blob")) {
242 return json({ encoding: "base64", content: Buffer.from("feature").toString("base64") });
243 }
244 if (target.pathname.endsWith("/git/blobs/image-blob")) {
245 return json({
246 encoding: "base64",
247 content: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2V6sAAAAASUVORK5CYII=",
248 });
249 }
250 if (target.pathname.endsWith("/commits") && target.searchParams.has("sha")) {
251 const sha = target.searchParams.get("sha");
252 return json([{
253 sha: `${sha}-commit`,
254 commit: {
255 message: `Commit ${sha}`,
256 author: { name: "Owner", date: "2026-01-01T00:00:00Z" },
257 },
258 }]);
259 }
260 if (/\/commits\/[^/]+-commit$/.test(target.pathname)) {
261 return json({ stats: { additions: 1, deletions: 0 }, files: [] });
262 }
263 throw new Error(`Unexpected request: ${target}`);
264 }));
265
266 const [snapshot] = await client().snapshotRepositories("token", [{
267 id: 2,
268 fullName: "owner/project",
269 defaultBranch: "main",
270 }]);
271
272 expect(snapshot.branches.map((branch) => branch.name)).toEqual(["main", "feature"]);
273 expect(snapshot.tags).toEqual([{ name: "v1.0.0", sha: "tag-head" }]);
274 expect(snapshot.files[0].content).toBe("same");
275 expect(snapshot.files[1]).toMatchObject({
276 path: "logo.png",
277 content: null,
278 mediaType: "image/png",
279 binaryContent: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2V6sAAAAASUVORK5CYII=",
280 });
281 expect(snapshot.refSnapshots.find((item) => item.sha === "feature-head").files[0].content).toBe("feature");
282 expect(snapshot.refSnapshots.find((item) => item.sha === "tag-head").files[0].content).toBe("same");
283 expect(calls.filter((call) => call.includes("/git/blobs/"))).toHaveLength(3);
284 expect(snapshot.releases[0].assets[0].name).toBe("project.zip");
285 expect(snapshot.languages).toEqual([
286 { name: "JavaScript", bytes: 700, percent: 70 },
287 { name: "HTML", bytes: 300, percent: 30 },
288 ]);
289 expect(snapshot.license.spdxId).toBe("MIT");
290 expect(snapshot.stargazersCount).toBe(9);
291 });
292
293 it("captures issue conversations and pull request reviews", async () => {
294 vi.stubGlobal("fetch", vi.fn(async (url) => {
295 const target = new URL(url);
296 if (target.pathname === "/repos/owner/project") {
297 return json({
298 id: 2,
299 name: "project",
300 full_name: "owner/project",
301 description: null,
302 language: "JavaScript",
303 default_branch: "main",
304 });
305 }
306 if (target.pathname.endsWith("/commits/main")) {
307 return json({ sha: "head", commit: { tree: { sha: "tree" } } });
308 }
309 if (target.pathname.endsWith("/branches")) {
310 return json([{ name: "main", commit: { sha: "head" }, protected: false }]);
311 }
312 if (target.pathname.endsWith("/tags")) return json([]);
313 if (target.pathname.endsWith("/issues")) {
314 return json([{
315 number: 7,
316 title: "Keyboard navigation",
317 body: "Use the repository without a mouse.",
318 state: "open",
319 state_reason: null,
320 locked: false,
321 user: { login: "owner", avatar_url: "https://avatars.test/owner" },
322 labels: [{ name: "accessibility", color: "0969da" }],
323 created_at: "2026-01-01T00:00:00Z",
324 updated_at: "2026-01-02T00:00:00Z",
325 closed_at: null,
326 }]);
327 }
328 if (target.pathname.endsWith("/pulls")) {
329 return json([{ number: 8 }]);
330 }
331 if (target.pathname.endsWith("/releases")) return json([]);
332 if (target.pathname.endsWith("/languages")) return json({});
333 if (target.pathname.endsWith("/git/trees/tree")) {
334 return json({ truncated: false, tree: [] });
335 }
336 if (target.pathname.endsWith("/commits") && target.searchParams.has("sha")) return json([]);
337 if (target.pathname.endsWith("/issues/7/comments")) {
338 return json([{
339 id: 71,
340 body: "Tab order is fixed.",
341 user: { login: "reviewer", avatar_url: "" },
342 created_at: "2026-01-02T00:00:00Z",
343 updated_at: "2026-01-02T00:00:00Z",
344 }]);
345 }
346 if (target.pathname.endsWith("/pulls/8")) {
347 return json({
348 number: 8,
349 title: "Add keyboard shortcuts",
350 body: "Adds focused navigation.",
351 state: "open",
352 draft: false,
353 merged_at: null,
354 mergeable_state: "clean",
355 user: { login: "owner", avatar_url: "" },
356 labels: [],
357 base: { ref: "main" },
358 head: { ref: "keyboard" },
359 created_at: "2026-01-01T00:00:00Z",
360 updated_at: "2026-01-03T00:00:00Z",
361 closed_at: null,
362 additions: 4,
363 deletions: 1,
364 changed_files: 1,
365 commits: 2,
366 });
367 }
368 if (target.pathname.endsWith("/issues/8/comments")) {
369 return json([{
370 id: 81,
371 body: "Ready for review.",
372 user: { login: "owner", avatar_url: "" },
373 created_at: "2026-01-02T00:00:00Z",
374 updated_at: "2026-01-02T00:00:00Z",
375 }]);
376 }
377 if (target.pathname.endsWith("/pulls/8/reviews")) {
378 return json([{
379 id: 82,
380 body: "Approved.",
381 state: "APPROVED",
382 user: { login: "reviewer", avatar_url: "" },
383 submitted_at: "2026-01-03T00:00:00Z",
384 }]);
385 }
386 if (target.pathname.endsWith("/pulls/8/comments")) return json([]);
387 if (target.pathname.endsWith("/pulls/8/files")) {
388 return json([{
389 filename: "src/index.js",
390 status: "modified",
391 additions: 4,
392 deletions: 1,
393 changes: 5,
394 patch: "+shortcut",
395 }]);
396 }
397 throw new Error(`Unexpected request: ${target}`);
398 }));
399
400 const [snapshot] = await client().snapshotRepositories("token", [{
401 id: 2,
402 fullName: "owner/project",
403 defaultBranch: "main",
404 }]);
405
406 expect(snapshot.issues[0].comments[0].body).toBe("Tab order is fixed.");
407 expect(snapshot.issues[0].labels[0].name).toBe("accessibility");
408 expect(snapshot.pullRequests[0].conversation.map((item) => item.body)).toEqual([
409 "Ready for review.",
410 "Approved.",
411 ]);
412 expect(snapshot.pullRequests[0].files[0].patch).toBe("+shortcut");
413 });
414
415 it("collects every changed-file page for a large commit", async () => {
416 vi.stubGlobal("fetch", vi.fn(async (url) => {
417 const target = new URL(url);
418 if (target.pathname === "/repos/owner/project") {
419 return json({
420 id: 2,
421 name: "project",
422 full_name: "owner/project",
423 description: null,
424 language: "JavaScript",
425 default_branch: "main",
426 });
427 }
428 if (target.pathname.endsWith("/commits/main")) {
429 return json({ sha: "head", commit: { tree: { sha: "tree" } } });
430 }
431 if (target.pathname.endsWith("/branches")) {
432 return json([{ name: "main", commit: { sha: "head" }, protected: false }]);
433 }
434 if (target.pathname.endsWith("/tags")) return json([]);
435 if (target.pathname.endsWith("/issues")) return json([]);
436 if (target.pathname.endsWith("/pulls")) return json([]);
437 if (target.pathname.endsWith("/releases")) return json([]);
438 if (target.pathname.endsWith("/languages")) return json({});
439 if (target.pathname.endsWith("/git/trees/tree")) return json({ truncated: false, tree: [] });
440 if (target.pathname.endsWith("/commits") && !target.searchParams.has("page")) {
441 throw new Error("Pagination parameters expected");
442 }
443 if (target.pathname.endsWith("/commits") && target.searchParams.has("sha")) {
444 return json(target.searchParams.get("page") === "1"
445 ? [{ sha: "commit", commit: { message: "Large change", author: { name: "Owner", date: "2026-01-01T00:00:00Z" } } }]
446 : []);
447 }
448 if (target.pathname.endsWith("/commits/commit")) {
449 const page = Number(target.searchParams.get("page"));
450 return json({
451 stats: { additions: 101, deletions: 0 },
452 files: Array.from({ length: page === 1 ? 100 : 1 }, (_, index) => ({
453 filename: `file-${page}-${index}.js`,
454 status: "modified",
455 additions: 1,
456 deletions: 0,
457 patch: "+line",
458 })),
459 });
460 }
461 throw new Error(`Unexpected request: ${target}`);
462 }));
463 const snapshots = await client().snapshotRepositories("token", [{
464 id: 2,
465 fullName: "owner/project",
466 defaultBranch: "main",
467 }]);
468 expect(snapshots[0].commits[0].files).toHaveLength(101);
469 });
470 });
471