This repository has no description
1import { describe, expect, it, vi } from "vitest";
2import {
3 resolveRepoByName,
4 sortTreeEntries,
5 toBranchSummary,
6 toCommitSummary,
7 toTagSummary,
8 toTreeEntrySummary,
9 treeEntryKind,
10 type BranchEntry,
11 type LogCommit,
12 type TagEntry,
13 type TreeEntrySummary
14} from "./repo";
15import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client";
16
17const jsonResponse = (body: unknown): Response =>
18 new Response(JSON.stringify(body), {
19 status: 200,
20 headers: { "content-type": "application/json" }
21 });
22
23const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext =>
24 createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock });
25
26describe("treeEntryKind", () => {
27 it("maps git file modes to entry kinds", () => {
28 expect(treeEntryKind("0040000")).toBe("directory");
29 expect(treeEntryKind("040000")).toBe("directory");
30 expect(treeEntryKind("0100644")).toBe("file");
31 expect(treeEntryKind("0100755")).toBe("file");
32 expect(treeEntryKind("0120000")).toBe("symlink");
33 expect(treeEntryKind("0160000")).toBe("submodule");
34 });
35});
36
37describe("sortTreeEntries", () => {
38 it("puts directories and submodules before files, then sorts by name", () => {
39 const entry = (name: string, kind: TreeEntrySummary["kind"]): TreeEntrySummary => ({
40 name,
41 kind,
42 size: 0
43 });
44 const sorted = sortTreeEntries([
45 entry("readme.md", "file"),
46 entry("src", "directory"),
47 entry(".gitignore", "file"),
48 entry("vendor", "submodule")
49 ]);
50 expect(sorted.map((item) => item.name)).toEqual(["src", "vendor", ".gitignore", "readme.md"]);
51 });
52});
53
54describe("toCommitSummary", () => {
55 // `this` is where the hex hash lives, `hash` is a byte array on the wire
56 const commit: LogCommit = {
57 this: "0c4d0e9b07940033721395a434b5873f0fb9e6c8",
58 author: { Name: "Ada", Email: "ada@example.com", When: "2026-07-01T10:00:00Z" },
59 committer: { Name: "Ada", Email: "ada@example.com", When: "2026-07-02T10:00:00Z" },
60 message: "web: add repo index\n\nWith a longer body.\n",
61 change_id: "abc123"
62 };
63
64 it("splits the subject from the body and shortens the hash", () => {
65 const summary = toCommitSummary(commit);
66 expect(summary).toMatchObject({
67 hash: "0c4d0e9b07940033721395a434b5873f0fb9e6c8",
68 shortHash: "0c4d0e9b",
69 subject: "web: add repo index",
70 body: "With a longer body.",
71 authorName: "Ada",
72 when: "2026-07-02T10:00:00Z",
73 changeId: "abc123"
74 });
75 });
76
77 it("leaves the body empty for single-line messages", () => {
78 const summary = toCommitSummary({ ...commit, message: "one liner\n" });
79 expect(summary.body).toBe("");
80 expect(summary.subject).toBe("one liner");
81 });
82});
83
84describe("toBranchSummary", () => {
85 it("reads the nested reference and the go-git commit fields", () => {
86 const branch: BranchEntry = {
87 reference: { name: "master", hash: "ff3a3678" },
88 commit: { Committer: { Name: "Ada", Email: "a@b.c", When: "2026-07-02T10:00:00Z" } },
89 is_default: true
90 };
91 expect(toBranchSummary(branch)).toEqual({
92 name: "master",
93 hash: "ff3a3678",
94 when: "2026-07-02T10:00:00Z",
95 isDefault: true
96 });
97 });
98
99 it("treats a missing is_default as not default", () => {
100 expect(toBranchSummary({ reference: { name: "topic", hash: "abc" } }).isDefault).toBe(false);
101 });
102});
103
104describe("toTagSummary", () => {
105 // an annotated tag's own hash is the tag object, the commit is in Target
106 it("reads the inlined reference and follows an annotated tag to its commit", () => {
107 const tag: TagEntry = {
108 name: "v1.0.0",
109 hash: "63fa1d4b",
110 message: "release",
111 tag: {
112 Tagger: { Name: "Ada", Email: "a@b.c", When: "2026-07-01T10:00:00Z" },
113 Target: [75, 78, 254, 37]
114 }
115 };
116 expect(toTagSummary(tag)).toEqual({
117 name: "v1.0.0",
118 hash: "63fa1d4b",
119 commitHash: "4b4efe25",
120 when: "2026-07-01T10:00:00Z",
121 message: "release"
122 });
123 });
124
125 it("a lightweight tag is its own commit", () => {
126 const summary = toTagSummary({ name: "v1.0.0", hash: "eebb477b" });
127 expect(summary.commitHash).toBe("eebb477b");
128 });
129});
130
131describe("toTreeEntrySummary", () => {
132 it("carries the last commit over from snake_case", () => {
133 expect(
134 toTreeEntrySummary({
135 name: "flake.nix",
136 mode: "0100644",
137 size: 213,
138 last_commit: { hash: "f0b11b85", message: "init", when: "2026-07-01T10:00:00Z" }
139 })
140 ).toEqual({
141 name: "flake.nix",
142 kind: "file",
143 size: 213,
144 lastCommitHash: "f0b11b85",
145 lastCommitWhen: "2026-07-01T10:00:00Z",
146 lastCommitMessage: "init"
147 });
148 });
149});
150
151describe("resolveRepoByName", () => {
152 const owner = "did:plc:owner";
153
154 it("asks bobbin for the owner and name", async () => {
155 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
156 jsonResponse({
157 uri: `at://${owner}/sh.tangled.repo/3lzg6enurmo22`,
158 value: { $type: "sh.tangled.repo", knot: "knot.test", name: "infra" }
159 })
160 );
161 const view = await resolveRepoByName(makeCtx(fetchMock), owner, "infra");
162 expect(view?.uri).toBe(`at://${owner}/sh.tangled.repo/3lzg6enurmo22`);
163 const url = new URL(String(fetchMock.mock.calls[0][0]));
164 expect(url.pathname).toBe("/xrpc/sh.tangled.repo.getRepoByName");
165 expect(url.searchParams.get("owner")).toBe(owner);
166 expect(url.searchParams.get("name")).toBe("infra");
167 });
168
169 it("returns null when the owner has no such repo", async () => {
170 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
171 new Response(JSON.stringify({ error: "RecordNotFound" }), {
172 status: 404,
173 headers: { "content-type": "application/json" }
174 })
175 );
176 expect(await resolveRepoByName(makeCtx(fetchMock), owner, "missing")).toBeNull();
177 });
178
179 it("propagates anything that is not a miss", async () => {
180 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
181 new Response(JSON.stringify({ error: "UpstreamFailed" }), {
182 status: 502,
183 headers: { "content-type": "application/json" }
184 })
185 );
186 await expect(resolveRepoByName(makeCtx(fetchMock), owner, "infra")).rejects.toBeInstanceOf(
187 ClientResponseError
188 );
189 });
190});