1import * as Result from "effect/Result";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 decodeAwardEmojiJson,
6 decodeRepositoryBlobsJson,
7 decodeCommitsJson,
8 decodeMergeRequestDetailJson,
9 decodeMergeRequestDiffsJson,
10 decodeMergeRequestListJson,
11 decodeNotesJson,
12 decodeOwnAwardIdJson,
13 decodeViewerJson,
14 gitLabAwardName,
15} from "./gitLabMergeRequestJson.ts";
16
17function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {
18 return JSON.stringify(
19 entries.map((entry) => ({
20 iid: 1,
21 title: "Add the merge requests page",
22 web_url: "https://gitlab.com/acme/web/-/merge_requests/1",
23 source_branch: "feat/page",
24 target_branch: "main",
25 created_at: "2026-07-01T00:00:00Z",
26 updated_at: "2026-07-02T00:00:00Z",
27 ...entry,
28 })),
29 );
30}
31
32function detailJson(entry: Record<string, unknown>): string {
33 return JSON.stringify({
34 iid: 1,
35 title: "Add the merge requests page",
36 web_url: "https://gitlab.com/acme/web/-/merge_requests/1",
37 source_branch: "feat/page",
38 target_branch: "main",
39 created_at: "2026-07-01T00:00:00Z",
40 updated_at: "2026-07-02T00:00:00Z",
41 ...entry,
42 });
43}
44
45function expectSuccess<A>(result: Result.Result<A, unknown>): A {
46 expect(Result.isSuccess(result)).toBe(true);
47 if (!Result.isSuccess(result)) throw new Error("expected a successful decode");
48 return result.success;
49}
50
51describe("decodeMergeRequestListJson", () => {
52 it("reads a merge request as a change request", () => {
53 const batch = expectSuccess(
54 decodeMergeRequestListJson(
55 listJson([
56 {
57 iid: 42,
58 author: { username: "bilal", name: "Bilal" },
59 state: "opened",
60 merge_status: "can_be_merged",
61 draft: false,
62 reviewers: [{ username: "julius" }],
63 labels: ["backend", " "],
64 },
65 ]),
66 ),
67 );
68
69 expect(batch.items).toHaveLength(1);
70 expect(batch.items[0]).toMatchObject({
71 number: 42,
72 author: { login: "bilal", name: "Bilal" },
73 headBranch: "feat/page",
74 baseBranch: "main",
75 state: "open",
76 isDraft: false,
77 mergeability: "mergeable",
78 reviewRequestLogins: ["julius"],
79 labels: [{ name: "backend", color: null }],
80 });
81 });
82
83 it("reports no line counts, which GitLab does not expose", () => {
84 const batch = expectSuccess(decodeMergeRequestListJson(listJson([{}])));
85
86 expect(batch.items[0]).toMatchObject({ additions: 0, deletions: 0 });
87 });
88
89 it("treats a merged timestamp as merged whatever the state says", () => {
90 const batch = expectSuccess(
91 decodeMergeRequestListJson(
92 listJson([{ state: "opened", merged_at: "2026-07-03T00:00:00Z" }]),
93 ),
94 );
95
96 expect(batch.items[0]?.state).toBe("merged");
97 });
98
99 it("keeps a locked merge request open", () => {
100 const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ state: "locked" }])));
101
102 expect(batch.items[0]?.state).toBe("open");
103 });
104
105 it("reads the legacy draft flag", () => {
106 const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ work_in_progress: true }])));
107
108 expect(batch.items[0]?.isDraft).toBe(true);
109 });
110
111 it("calls a conflicted merge request conflicting even while the merge check is pending", () => {
112 const batch = expectSuccess(
113 decodeMergeRequestListJson(listJson([{ merge_status: "checking", has_conflicts: true }])),
114 );
115
116 expect(batch.items[0]?.mergeability).toBe("conflicting");
117 });
118
119 it("leaves an unfinished merge check unknown", () => {
120 const batch = expectSuccess(
121 decodeMergeRequestListJson(listJson([{ merge_status: "checking" }])),
122 );
123
124 expect(batch.items[0]?.mergeability).toBe("unknown");
125 });
126
127 it("skips a malformed row but still counts it, so paging does not stop early", () => {
128 const batch = expectSuccess(
129 decodeMergeRequestListJson(
130 JSON.stringify([{ iid: "not a number" }, ...JSON.parse(listJson([{}]))]),
131 ),
132 );
133
134 expect(batch.items).toHaveLength(1);
135 expect(batch.rawIndexes).toEqual([1]);
136 expect(batch.rawCount).toBe(2);
137 });
138});
139
140describe("decodeMergeRequestDetailJson", () => {
141 it("reads the description, file count and pipeline", () => {
142 const detail = expectSuccess(
143 decodeMergeRequestDetailJson(
144 detailJson({
145 description: "Ships the page.",
146 changes_count: "3",
147 reviewers: [{ username: "julius", name: "Julius" }],
148 head_pipeline: {
149 status: "success",
150 web_url: "https://gitlab.com/acme/web/-/pipelines/9",
151 source: "merge_request_event",
152 },
153 }),
154 ),
155 );
156
157 expect(detail.body).toBe("Ships the page.");
158 expect(detail.changedFiles).toBe(3);
159 expect(detail.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]);
160 expect(detail.checks).toEqual([
161 {
162 name: "Pipeline",
163 status: "success",
164 description: "merge_request_event",
165 url: "https://gitlab.com/acme/web/-/pipelines/9",
166 },
167 ]);
168 });
169
170 it("reads an uncounted change set as its floor", () => {
171 const detail = expectSuccess(
172 decodeMergeRequestDetailJson(detailJson({ changes_count: "1000+" })),
173 );
174
175 expect(detail.changedFiles).toBe(1000);
176 });
177
178 it("falls back to no file count when GitLab omits one", () => {
179 const detail = expectSuccess(decodeMergeRequestDetailJson(detailJson({})));
180
181 expect(detail.changedFiles).toBe(0);
182 });
183
184 it("reads the head commit, and says nothing where GitLab omitted it", () => {
185 const headSha = (entry: Record<string, unknown>) =>
186 expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).headSha;
187 const sha = "afce4da4d5fe7d2fd0647b5b864526ea0a2827cb";
188
189 expect(headSha({ sha })).toBe(sha);
190 // A new merge request can report its head before GitLab has computed `diff_refs`.
191 expect(headSha({ sha, diff_refs: null })).toBe(sha);
192 expect(headSha({ sha, merge_commit_sha: "0".repeat(40) })).toBe(sha);
193 expect(headSha({})).toBeUndefined();
194 expect(headSha({ sha: null })).toBeUndefined();
195 expect(headSha({ sha: " " })).toBeUndefined();
196 });
197
198 it("reads either auto-merge field, and says nothing where GitLab named neither", () => {
199 const armed = (entry: Record<string, unknown>) =>
200 expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).autoMergeEnabled;
201
202 expect(armed({ merge_when_pipeline_succeeds: true })).toBe(true);
203 // The newer name for the same fact, which older GitLab installs do not send.
204 expect(armed({ auto_merge_enabled: true })).toBe(true);
205 expect(armed({ merge_when_pipeline_succeeds: false })).toBe(false);
206 // Absent is GitLab not saying, which the page must not read as "not armed".
207 expect(armed({})).toBeUndefined();
208 });
209
210 it("keeps the squash choice stored with auto-merge", () => {
211 expect(
212 expectSuccess(
213 decodeMergeRequestDetailJson(
214 detailJson({ auto_merge_enabled: true, squash_on_merge: true }),
215 ),
216 ),
217 ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" });
218
219 expect(
220 expectSuccess(
221 decodeMergeRequestDetailJson(
222 detailJson({
223 auto_merge_enabled: true,
224 squash: true,
225 squash_on_merge: false,
226 }),
227 ),
228 ).autoMergeMethod,
229 ).toBeUndefined();
230 });
231
232 it("keeps a divergence GitLab did not count apart from a divergence of none", () => {
233 const behind = (entry: Record<string, unknown>) =>
234 expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits;
235
236 expect(behind({ diverged_commits_count: 3 })).toBe(3);
237 // Counted and found level, which is the one answer that entitles the page to say so.
238 expect(behind({ diverged_commits_count: 0 })).toBe(0);
239 // An install that does not answer, and a null where the answer would have gone, are both
240 // silence: reading either as zero would tell a stale branch it is current.
241 expect(behind({})).toBeUndefined();
242 expect(behind({ diverged_commits_count: null })).toBeUndefined();
243 });
244
245 it("maps a pipeline waiting on a person to neutral, not failure", () => {
246 const detail = expectSuccess(
247 decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })),
248 );
249
250 expect(detail.checks[0]?.status).toBe("neutral");
251 });
252});
253
254describe("decodeViewerJson", () => {
255 it("reads the signed-in username", () => {
256 expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: "bilal" })))).toBe("bilal");
257 });
258
259 it("returns nothing when the account has no username", () => {
260 expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: " " })))).toBeNull();
261 });
262});
263
264describe("decodeNotesJson", () => {
265 it("keeps comments and drops GitLab's own activity notes", () => {
266 const notes = expectSuccess(
267 decodeNotesJson(
268 JSON.stringify([
269 {
270 id: 1,
271 body: "assigned to @bilal",
272 system: true,
273 created_at: "2026-07-01T00:00:00Z",
274 },
275 {
276 id: 2,
277 body: "Looks good.",
278 author: { username: "julius" },
279 created_at: "2026-07-02T00:00:00Z",
280 },
281 { id: 3, body: " ", created_at: "2026-07-03T00:00:00Z" },
282 ]),
283 ),
284 );
285
286 expect(notes.comments).toHaveLength(1);
287 expect(notes.comments[0]).toMatchObject({
288 id: "2",
289 kind: "issue-comment",
290 body: "Looks good.",
291 });
292 // The raw count keeps the dropped notes visible to the caller, which needs them to page.
293 expect(notes.rawCount).toBe(3);
294 });
295
296 it("reads a line note as a review comment on its file", () => {
297 const notes = expectSuccess(
298 decodeNotesJson(
299 JSON.stringify([
300 {
301 id: 7,
302 type: "DiffNote",
303 body: "Rename this.",
304 created_at: "2026-07-02T00:00:00Z",
305 position: { new_path: "src/app.ts", old_path: "src/old.ts" },
306 },
307 ]),
308 ),
309 );
310
311 expect(notes.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" });
312 });
313
314 it("falls back to the old path for a note on a deleted line", () => {
315 const notes = expectSuccess(
316 decodeNotesJson(
317 JSON.stringify([
318 {
319 id: 8,
320 type: "DiffNote",
321 body: "Gone.",
322 created_at: "2026-07-02T00:00:00Z",
323 position: { new_path: null, old_path: "src/old.ts" },
324 },
325 ]),
326 ),
327 );
328
329 expect(notes.comments[0]?.path).toBe("src/old.ts");
330 });
331});
332
333describe("decodeCommitsJson", () => {
334 it("returns commits oldest first", () => {
335 const commits = expectSuccess(
336 decodeCommitsJson(
337 JSON.stringify([
338 { id: "bbb", title: "second", committed_date: "2026-07-02T00:00:00Z" },
339 { id: "aaa", title: "first", committed_date: "2026-07-01T00:00:00Z" },
340 ]),
341 ),
342 );
343
344 expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]);
345 });
346
347 it("skips commits whose id is empty", () => {
348 const commits = expectSuccess(
349 decodeCommitsJson(
350 JSON.stringify([
351 { id: " ", title: "invalid", committed_date: "2026-07-02T00:00:00Z" },
352 { id: "aaa", committed_date: "2026-07-01T00:00:00Z" },
353 ]),
354 ),
355 );
356
357 expect(commits.map((commit) => commit.oid)).toEqual(["aaa"]);
358 });
359
360 it("falls back to the creation timestamp when there is no commit date", () => {
361 const commits = expectSuccess(
362 decodeCommitsJson(
363 JSON.stringify([
364 {
365 id: "aaa",
366 created_at: "2026-07-01T00:00:00+08:00",
367 author_name: "Ada Lovelace",
368 author_email: "ada@example.com",
369 },
370 ]),
371 ),
372 );
373
374 expect(commits[0]).toMatchObject({
375 oid: "aaa",
376 committedDate: "2026-07-01T00:00:00+08:00",
377 authors: [{ login: "Ada Lovelace", name: "Ada Lovelace", avatarUrl: null }],
378 });
379 });
380
381 it("carries commit additions and deletions when GitLab returns stats", () => {
382 const commits = expectSuccess(
383 decodeCommitsJson(
384 JSON.stringify([
385 {
386 id: "aaa",
387 committed_date: "2026-07-01T00:00:00Z",
388 stats: { additions: 21, deletions: 8, total: 29 },
389 },
390 ]),
391 ),
392 );
393
394 expect(commits[0]).toMatchObject({ additions: 21, deletions: 8 });
395 });
396});
397
398describe("decodeMergeRequestDiffsJson", () => {
399 it("quotes literal backslashes without interpreting them as escapes", () => {
400 const result = expectSuccess(
401 decodeMergeRequestDiffsJson(
402 JSON.stringify([
403 {
404 old_path: String.raw`src\notes.ts`,
405 new_path: String.raw`src\notes.ts`,
406 diff: "@@ -1 +1 @@\n-old\n+new",
407 },
408 ]),
409 ),
410 );
411
412 expect(result.patch).toBe(
413 [
414 String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`,
415 String.raw`--- "a/src\\notes.ts"`,
416 String.raw`+++ "b/src\\notes.ts"`,
417 "@@ -1 +1 @@",
418 "-old",
419 "+new",
420 "",
421 ].join("\n"),
422 );
423 });
424
425 it("preserves spaces and literal backslashes in both rename paths", () => {
426 const result = expectSuccess(
427 decodeMergeRequestDiffsJson(
428 JSON.stringify([
429 {
430 old_path: String.raw` old\name.ts `,
431 new_path: String.raw` new\name.ts `,
432 renamed_file: true,
433 diff: "",
434 },
435 ]),
436 ),
437 );
438
439 expect(result.patch).toBe(
440 [
441 String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`,
442 String.raw`rename from " old\\name.ts "`,
443 String.raw`rename to " new\\name.ts "`,
444 String.raw`--- "a/ old\\name.ts "`,
445 String.raw`+++ "b/ new\\name.ts "`,
446 ].join("\n"),
447 );
448 });
449
450 it("assembles a unified patch GitLab does not return", () => {
451 const result = expectSuccess(
452 decodeMergeRequestDiffsJson(
453 JSON.stringify([
454 {
455 old_path: "src/app.ts",
456 new_path: "src/app.ts",
457 diff: "@@ -1 +1 @@\n-old\n+new\n",
458 },
459 ]),
460 ),
461 );
462
463 expect(result.patch).toBe(
464 [
465 "diff --git a/src/app.ts b/src/app.ts",
466 "--- a/src/app.ts",
467 "+++ b/src/app.ts",
468 "@@ -1 +1 @@",
469 "-old",
470 "+new",
471 "",
472 ].join("\n"),
473 );
474 expect(result.truncated).toBe(false);
475 });
476
477 it("points a new file at /dev/null on the left and a deleted file on the right", () => {
478 const result = expectSuccess(
479 decodeMergeRequestDiffsJson(
480 JSON.stringify([
481 {
482 old_path: "src/new.ts",
483 new_path: "src/new.ts",
484 new_file: true,
485 b_mode: "100755",
486 diff: "@@ -0,0 +1 @@\n+hello\n",
487 },
488 {
489 old_path: "src/gone.ts",
490 new_path: "src/gone.ts",
491 deleted_file: true,
492 diff: "@@ -1 +0,0 @@\n-bye\n",
493 },
494 ]),
495 ),
496 );
497
498 expect(result.patch).toContain("new file mode 100755");
499 expect(result.patch).toContain("--- /dev/null");
500 expect(result.patch).toContain("deleted file mode 100644");
501 expect(result.patch).toContain("+++ /dev/null");
502 });
503
504 it("records a rename so the patch names both paths", () => {
505 const result = expectSuccess(
506 decodeMergeRequestDiffsJson(
507 JSON.stringify([
508 { old_path: "src/old.ts", new_path: "src/new.ts", renamed_file: true, diff: "" },
509 ]),
510 ),
511 );
512
513 expect(result.patch).toContain("rename from src/old.ts");
514 expect(result.patch).toContain("rename to src/new.ts");
515 });
516
517 it("reports truncation for a file GitLab refused to inline", () => {
518 const result = expectSuccess(
519 decodeMergeRequestDiffsJson(
520 JSON.stringify([{ old_path: "big.bin", new_path: "big.bin", diff: "", too_large: true }]),
521 ),
522 );
523
524 expect(result.truncated).toBe(true);
525 expect(result.patch).toContain("diff --git a/big.bin b/big.bin");
526 });
527
528 it("reports how many files GitLab returned, so the caller can page", () => {
529 const result = expectSuccess(
530 decodeMergeRequestDiffsJson(
531 JSON.stringify(
532 Array.from({ length: 3 }, (_, index) => ({
533 old_path: `src/${index}.ts`,
534 new_path: `src/${index}.ts`,
535 diff: "@@ -1 +1 @@\n-a\n+b\n",
536 })),
537 ),
538 ),
539 );
540
541 expect(result.rawCount).toBe(3);
542 expect(result.truncated).toBe(false);
543 expect(result.patch).toContain("src/2.ts");
544 });
545
546 it("fails when GitLab did not return a list", () => {
547 expect(Result.isFailure(decodeMergeRequestDiffsJson('{"message":"404"}'))).toBe(true);
548 });
549});
550
551describe("merge request viewer fields", () => {
552 it("carries GitLab's own answer for whether this viewer can merge", () => {
553 expect(
554 expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: false } })))
555 .viewerCanMerge,
556 ).toBe(false);
557 expect(
558 expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: true } })))
559 .viewerCanMerge,
560 ).toBe(true);
561 });
562
563 it("leaves merging permitted where GitLab answered without the field", () => {
564 // Only the single-merge-request endpoint carries `user`, and an install that answers without
565 // it has said nothing about the viewer rather than said no.
566 expect(expectSuccess(decodeMergeRequestDetailJson(detailJson({}))).viewerCanMerge).toBe(true);
567 expect(
568 expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: null }))).viewerCanMerge,
569 ).toBe(true);
570 });
571});
572
573describe("decodeAwardEmojiJson", () => {
574 it("reads MR-level awards, keys per-note awards by the REST id inside their gid, and ignores an award outside the eight", () => {
575 const result = expectSuccess(
576 decodeAwardEmojiJson(
577 JSON.stringify({
578 data: {
579 currentUser: { username: "bilal" },
580 project: {
581 mergeRequest: {
582 awardEmoji: {
583 nodes: [
584 { name: "thumbsup", user: { username: "bilal" } },
585 { name: "thumbsup", user: { username: "julius" } },
586 ],
587 },
588 notes: {
589 pageInfo: { hasNextPage: false, endCursor: null },
590 nodes: [
591 {
592 id: "gid://gitlab/DiffNote/42",
593 awardEmoji: { nodes: [{ name: "heart", user: { username: "julius" } }] },
594 },
595 {
596 id: "gid://gitlab/Note/7",
597 // Not one of the eight the contract carries.
598 awardEmoji: {
599 nodes: [{ name: "partyparrot", user: { username: "bilal" } }],
600 },
601 },
602 ],
603 },
604 },
605 },
606 },
607 }),
608 ),
609 );
610
611 // `bilal` is `currentUser`, so the group they are in reads back as reacted, but their own
612 // username is left out of `actors` — the page names them "You" instead — while `count` still
613 // counts them; `julius` alone does not turn a group's own `viewerHasReacted` on.
614 expect(result.reactions).toEqual([
615 { content: "thumbs-up", count: 2, actors: ["julius"], viewerHasReacted: true },
616 ]);
617 // Note 7's only award named nobody the eight recognise, so it carries no reactions and is
618 // left out of the map rather than kept empty.
619 expect([...result.reactionsByNoteId]).toEqual([
620 ["42", [{ content: "heart", count: 1, actors: ["julius"], viewerHasReacted: false }]],
621 ]);
622 expect(result.nextCursor).toBeNull();
623 });
624
625 it("hands back a cursor when GitLab has more notes to page", () => {
626 const result = expectSuccess(
627 decodeAwardEmojiJson(
628 JSON.stringify({
629 data: {
630 currentUser: null,
631 project: {
632 mergeRequest: {
633 awardEmoji: { nodes: [] },
634 notes: { pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, nodes: [] },
635 },
636 },
637 },
638 }),
639 ),
640 );
641
642 expect(result.nextCursor).toBe("Y3Vyc29yOjE");
643 });
644
645 it("matches the viewer's username case-insensitively, since GitLab is not consistent about case", () => {
646 const result = expectSuccess(
647 decodeAwardEmojiJson(
648 JSON.stringify({
649 data: {
650 currentUser: { username: "Bilal" },
651 project: {
652 mergeRequest: {
653 awardEmoji: {
654 nodes: [
655 { name: "heart", user: { username: "bilal" } },
656 { name: "heart", user: { username: "julius" } },
657 ],
658 },
659 notes: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
660 },
661 },
662 },
663 }),
664 ),
665 );
666
667 expect(result.reactions).toEqual([
668 { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true },
669 ]);
670 });
671});
672
673describe("decodeOwnAwardIdJson", () => {
674 const awards = JSON.stringify([
675 { id: 101, name: "thumbsup", user: { username: "julius" } },
676 { id: 102, name: "thumbsup", user: { username: "bilal" } },
677 ]);
678
679 it("finds the reader's own award of that name, which is the one a removal deletes", () => {
680 expect(
681 expectSuccess(decodeOwnAwardIdJson(awards, { content: "thumbs-up", viewer: "bilal" })),
682 ).toBe(102);
683 });
684
685 it("returns nothing where the reader has no award of that name", () => {
686 expect(
687 expectSuccess(decodeOwnAwardIdJson(awards, { content: "heart", viewer: "bilal" })),
688 ).toBeNull();
689 });
690});
691
692describe("gitLabAwardName", () => {
693 it("spells the contents whose GitLab award name is not their own kebab-case", () => {
694 expect(gitLabAwardName("thumbs-up")).toBe("thumbsup");
695 expect(gitLabAwardName("laugh")).toBe("laughing");
696 expect(gitLabAwardName("hooray")).toBe("tada");
697 });
698});
699
700describe("decodeRepositoryBlobsJson", () => {
701 /** Null is the query going unanswered, which these cases are not about. */
702 function expectBlobs(result: Result.Result<ReadonlyMap<string, string> | null, unknown>) {
703 const blobs = expectSuccess(result);
704 expect(blobs).not.toBe(null);
705 if (blobs === null) throw new Error("expected an answered blobs query");
706 return blobs;
707 }
708
709 it("reads a blob id per path", () => {
710 const blobs = expectBlobs(
711 decodeRepositoryBlobsJson(
712 JSON.stringify({
713 data: {
714 project: {
715 repository: {
716 blobs: {
717 nodes: [
718 { path: "src/a.ts", oid: "aaa111" },
719 { path: "src/b.ts", oid: "bbb222" },
720 ],
721 },
722 },
723 },
724 },
725 }),
726 ),
727 );
728
729 expect([...blobs]).toEqual([
730 ["src/a.ts", "aaa111"],
731 ["src/b.ts", "bbb222"],
732 ]);
733 });
734
735 it("leaves out a node missing either half, which names no version", () => {
736 const blobs = expectBlobs(
737 decodeRepositoryBlobsJson(
738 JSON.stringify({
739 data: {
740 project: {
741 repository: {
742 blobs: {
743 nodes: [
744 { path: "src/a.ts", oid: null },
745 { path: null, oid: "bbb222" },
746 null,
747 { path: "src/c.ts", oid: "ccc333" },
748 ],
749 },
750 },
751 },
752 },
753 }),
754 ),
755 );
756
757 expect([...blobs]).toEqual([["src/c.ts", "ccc333"]]);
758 });
759
760 it("keys a blob by the path the host spelled, spaces and all", () => {
761 // A leading or trailing space is a legal part of a file's name. Trimmed here, the id lands
762 // under a key the asked-for path is not spelled with, and the caller fills that path in as
763 // the empty revision: a mark on the file then never compares against the real head blob.
764 const blobs = expectBlobs(
765 decodeRepositoryBlobsJson(
766 JSON.stringify({
767 data: {
768 project: {
769 repository: {
770 blobs: {
771 nodes: [
772 { path: " leading.ts", oid: "aaa111" },
773 { path: "trailing.ts ", oid: "bbb222" },
774 { path: " ", oid: "ccc333" },
775 { path: "", oid: "ddd444" },
776 ],
777 },
778 },
779 },
780 },
781 }),
782 ),
783 );
784
785 expect([...blobs]).toEqual([
786 [" leading.ts", "aaa111"],
787 ["trailing.ts ", "bbb222"],
788 // A name that is only spaces is one Git carries too, so it is a path like any other.
789 [" ", "ccc333"],
790 ]);
791 });
792
793 it("tells a project the reader cannot see from a revision with none of the files", () => {
794 // Null is the query going unanswered. Read as an empty answer it would say the head has none
795 // of the asked-for files, which reports every file a reader has cleared as changed.
796 expect(
797 expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))),
798 ).toBe(null);
799 expect(
800 expectSuccess(
801 decodeRepositoryBlobsJson(JSON.stringify({ data: { project: { repository: null } } })),
802 ),
803 ).toBe(null);
804 expect(
805 expectSuccess(
806 decodeRepositoryBlobsJson(
807 JSON.stringify({ data: { project: { repository: { blobs: { nodes: [] } } } } }),
808 ),
809 ),
810 ).toEqual(new Map());
811 });
812
813 it("fails on output that is not the query's shape", () => {
814 expect(Result.isSuccess(decodeRepositoryBlobsJson("not json"))).toBe(false);
815 expect(Result.isSuccess(decodeRepositoryBlobsJson(JSON.stringify({ errors: [] })))).toBe(false);
816 });
817});