This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / api / repo.test.ts
9.5 kB 291 lines
1import { describe, expect, it, vi } from "vitest"; 2import { 3 coAuthorsFrom, 4 logFor, 5 repoNameOf, 6 resolveRepoByName, 7 sortTreeEntries, 8 toBranchSummary, 9 toCommitDetail, 10 toCommitSummary, 11 toTagSummary, 12 toTreeEntrySummary, 13 treeEntryKind, 14 type BranchEntry, 15 type LogCommit, 16 type TagEntry, 17 type TreeEntrySummary 18} from "./repo"; 19import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client"; 20import type { NiceCommit } from "./diff"; 21import type { RecordView, RepoRecord } from "./records"; 22 23const jsonResponse = (body: unknown): Response => 24 new Response(JSON.stringify(body), { 25 status: 200, 26 headers: { "content-type": "application/json" } 27 }); 28 29const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext => 30 createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); 31 32describe("repoNameOf", () => { 33 const view = (uri: string, name?: string): RecordView<RepoRecord> => ({ 34 uri: uri as RecordView<RepoRecord>["uri"], 35 value: { $type: "sh.tangled.repo", createdAt: "", knot: "knot.test", name } 36 }); 37 38 it("prefers the record's cosmetic name over a tid rkey", () => { 39 expect(repoNameOf(view("at://did:plc:o/sh.tangled.repo/3lzg6enurmo22", "infra"))).toBe("infra"); 40 }); 41 42 it("falls back to the rkey for unnamed records", () => { 43 expect(repoNameOf(view("at://did:plc:o/sh.tangled.repo/infra"))).toBe("infra"); 44 }); 45}); 46 47describe("treeEntryKind", () => { 48 it("maps git file modes to entry kinds", () => { 49 expect(treeEntryKind("0040000")).toBe("directory"); 50 expect(treeEntryKind("040000")).toBe("directory"); 51 expect(treeEntryKind("0100644")).toBe("file"); 52 expect(treeEntryKind("0100755")).toBe("file"); 53 expect(treeEntryKind("0120000")).toBe("symlink"); 54 expect(treeEntryKind("0160000")).toBe("submodule"); 55 }); 56}); 57 58describe("sortTreeEntries", () => { 59 it("puts directories and submodules before files, then sorts by name", () => { 60 const entry = (name: string, kind: TreeEntrySummary["kind"]): TreeEntrySummary => ({ 61 name, 62 kind, 63 size: 0 64 }); 65 const sorted = sortTreeEntries([ 66 entry("readme.md", "file"), 67 entry("src", "directory"), 68 entry(".gitignore", "file"), 69 entry("vendor", "submodule") 70 ]); 71 expect(sorted.map((item) => item.name)).toEqual(["src", "vendor", ".gitignore", "readme.md"]); 72 }); 73}); 74 75describe("toCommitSummary", () => { 76 // `this` is where the hex hash lives, `hash` is a byte array on the wire 77 const commit: LogCommit = { 78 this: "0c4d0e9b07940033721395a434b5873f0fb9e6c8", 79 author: { Name: "Ada", Email: "ada@example.com", When: "2026-07-01T10:00:00Z" }, 80 committer: { Name: "Ada", Email: "ada@example.com", When: "2026-07-02T10:00:00Z" }, 81 message: "web: add repo index\n\nWith a longer body.\n", 82 change_id: "abc123" 83 }; 84 85 it("splits the subject from the body and shortens the hash", () => { 86 const summary = toCommitSummary(commit); 87 expect(summary).toMatchObject({ 88 hash: "0c4d0e9b07940033721395a434b5873f0fb9e6c8", 89 shortHash: "0c4d0e9b", 90 subject: "web: add repo index", 91 body: "With a longer body.", 92 authorName: "Ada", 93 when: "2026-07-02T10:00:00Z", 94 changeId: "abc123" 95 }); 96 }); 97 98 it("leaves the body empty for single-line messages", () => { 99 const summary = toCommitSummary({ ...commit, message: "one liner\n" }); 100 expect(summary.body).toBe(""); 101 expect(summary.subject).toBe("one liner"); 102 }); 103}); 104 105describe("toCommitDetail", () => { 106 const commit: NiceCommit = { 107 this: "0c4d0e9b07940033721395a434b5873f0fb9e6c8", 108 author: { Name: "Ada", Email: "ada@example.com", When: "2026-07-01T10:00:00Z" }, 109 committer: { Name: "Grace", Email: "grace@example.com", When: "2026-07-02T10:00:00Z" }, 110 message: 111 "web: add commit page\n\nA longer body.\n\nCo-authored-by: Alan Turing <alan@example.com>\n" 112 }; 113 114 it("carries both signatures and stamps co-authors with the committer's When", () => { 115 expect(toCommitDetail(commit)).toEqual({ 116 hash: "0c4d0e9b07940033721395a434b5873f0fb9e6c8", 117 shortHash: "0c4d0e9b", 118 subject: "web: add commit page", 119 body: "A longer body.\n\nCo-authored-by: Alan Turing <alan@example.com>", 120 authorName: "Ada", 121 authorEmail: "ada@example.com", 122 authorWhen: "2026-07-01T10:00:00Z", 123 committerName: "Grace", 124 committerEmail: "grace@example.com", 125 committerWhen: "2026-07-02T10:00:00Z", 126 coAuthors: [{ name: "Alan Turing", email: "alan@example.com" }] 127 }); 128 }); 129 130 it("fills empty strings for a bare commit", () => { 131 expect(toCommitDetail({})).toEqual({ 132 hash: "", 133 shortHash: "", 134 subject: "", 135 body: "", 136 authorName: "", 137 authorEmail: "", 138 authorWhen: "", 139 committerName: "", 140 committerEmail: "", 141 committerWhen: "", 142 coAuthors: [] 143 }); 144 }); 145}); 146 147describe("coAuthorsFrom", () => { 148 it("stamps every co-author with the passed When, like go's Commit.CoAuthors", () => { 149 const message = 150 "subject\n\nCo-authored-by: Ada Lovelace <ada@example.com>\nCo-authored-by: Alan Turing <alan@example.com>\n"; 151 expect(coAuthorsFrom(message, "2026-07-02T10:00:00Z")).toEqual([ 152 { Name: "Ada Lovelace", Email: "ada@example.com", When: "2026-07-02T10:00:00Z" }, 153 { Name: "Alan Turing", Email: "alan@example.com", When: "2026-07-02T10:00:00Z" } 154 ]); 155 }); 156 157 it("dedupes by email and matches the trailer case-insensitively", () => { 158 const message = 159 "subject\n\nco-authored-by: Ada <ada@example.com>\nCo-Authored-By: Ada Again <ada@example.com>\n"; 160 expect(coAuthorsFrom(message, "w")).toEqual([ 161 { Name: "Ada", Email: "ada@example.com", When: "w" } 162 ]); 163 }); 164}); 165 166describe("logFor", () => { 167 it("passes the cursor through to the knot", async () => { 168 const fetchMock = vi 169 .fn<typeof globalThis.fetch>() 170 .mockResolvedValue(jsonResponse({ commits: [], total: 42 })); 171 await logFor(makeCtx(fetchMock), "at://did:plc:o/sh.tangled.repo/abc", "master", 20, "40"); 172 const url = new URL(String(fetchMock.mock.calls[0][0])); 173 expect(url.pathname).toBe("/xrpc/sh.tangled.repo.log"); 174 expect(url.searchParams.get("ref")).toBe("master"); 175 expect(url.searchParams.get("limit")).toBe("20"); 176 expect(url.searchParams.get("cursor")).toBe("40"); 177 }); 178}); 179 180describe("toBranchSummary", () => { 181 it("reads the nested reference and the go-git commit fields", () => { 182 const branch: BranchEntry = { 183 reference: { name: "master", hash: "ff3a3678" }, 184 commit: { 185 Committer: { Name: "Ada", Email: "a@b.c", When: "2026-07-02T10:00:00Z" }, 186 Message: "the tip commit" 187 }, 188 is_default: true 189 }; 190 expect(toBranchSummary(branch)).toEqual({ 191 name: "master", 192 hash: "ff3a3678", 193 when: "2026-07-02T10:00:00Z", 194 isDefault: true, 195 message: "the tip commit" 196 }); 197 }); 198 199 it("treats a missing is_default as not default", () => { 200 expect(toBranchSummary({ reference: { name: "topic", hash: "abc" } }).isDefault).toBe(false); 201 }); 202}); 203 204describe("toTagSummary", () => { 205 // an annotated tag's own hash is the tag object, the commit is in Target 206 it("reads the inlined reference and follows an annotated tag to its commit", () => { 207 const tag: TagEntry = { 208 name: "v1.0.0", 209 hash: "63fa1d4b", 210 message: "release", 211 tag: { 212 Tagger: { Name: "Ada", Email: "a@b.c", When: "2026-07-01T10:00:00Z" }, 213 Target: [75, 78, 254, 37] 214 } 215 }; 216 expect(toTagSummary(tag)).toEqual({ 217 name: "v1.0.0", 218 hash: "63fa1d4b", 219 commitHash: "4b4efe25", 220 when: "2026-07-01T10:00:00Z", 221 message: "release", 222 taggerName: "Ada" 223 }); 224 }); 225 226 it("a lightweight tag is its own commit", () => { 227 const summary = toTagSummary({ name: "v1.0.0", hash: "eebb477b" }); 228 expect(summary.commitHash).toBe("eebb477b"); 229 }); 230}); 231 232describe("toTreeEntrySummary", () => { 233 it("carries the last commit over from snake_case", () => { 234 expect( 235 toTreeEntrySummary({ 236 name: "flake.nix", 237 mode: "0100644", 238 size: 213, 239 last_commit: { hash: "f0b11b85", message: "init", when: "2026-07-01T10:00:00Z" } 240 }) 241 ).toEqual({ 242 name: "flake.nix", 243 kind: "file", 244 size: 213, 245 lastCommitHash: "f0b11b85", 246 lastCommitWhen: "2026-07-01T10:00:00Z", 247 lastCommitMessage: "init" 248 }); 249 }); 250}); 251 252describe("resolveRepoByName", () => { 253 const owner = "did:plc:owner"; 254 255 it("asks bobbin for the owner and name", async () => { 256 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( 257 jsonResponse({ 258 uri: `at://${owner}/sh.tangled.repo/3lzg6enurmo22`, 259 value: { $type: "sh.tangled.repo", knot: "knot.test", name: "infra" } 260 }) 261 ); 262 const view = await resolveRepoByName(makeCtx(fetchMock), owner, "infra"); 263 expect(view?.uri).toBe(`at://${owner}/sh.tangled.repo/3lzg6enurmo22`); 264 const url = new URL(String(fetchMock.mock.calls[0][0])); 265 expect(url.pathname).toBe("/xrpc/sh.tangled.repo.getRepoByName"); 266 expect(url.searchParams.get("owner")).toBe(owner); 267 expect(url.searchParams.get("name")).toBe("infra"); 268 }); 269 270 it("returns null when the owner has no such repo", async () => { 271 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( 272 new Response(JSON.stringify({ error: "RecordNotFound" }), { 273 status: 404, 274 headers: { "content-type": "application/json" } 275 }) 276 ); 277 expect(await resolveRepoByName(makeCtx(fetchMock), owner, "missing")).toBeNull(); 278 }); 279 280 it("propagates anything that is not a miss", async () => { 281 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( 282 new Response(JSON.stringify({ error: "UpstreamFailed" }), { 283 status: 502, 284 headers: { "content-type": "application/json" } 285 }) 286 ); 287 await expect(resolveRepoByName(makeCtx(fetchMock), owner, "infra")).rejects.toBeInstanceOf( 288 ClientResponseError 289 ); 290 }); 291});