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.4 kB 286 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: { Committer: { Name: "Ada", Email: "a@b.c", When: "2026-07-02T10:00:00Z" } }, 185 is_default: true 186 }; 187 expect(toBranchSummary(branch)).toEqual({ 188 name: "master", 189 hash: "ff3a3678", 190 when: "2026-07-02T10:00:00Z", 191 isDefault: true 192 }); 193 }); 194 195 it("treats a missing is_default as not default", () => { 196 expect(toBranchSummary({ reference: { name: "topic", hash: "abc" } }).isDefault).toBe(false); 197 }); 198}); 199 200describe("toTagSummary", () => { 201 // an annotated tag's own hash is the tag object, the commit is in Target 202 it("reads the inlined reference and follows an annotated tag to its commit", () => { 203 const tag: TagEntry = { 204 name: "v1.0.0", 205 hash: "63fa1d4b", 206 message: "release", 207 tag: { 208 Tagger: { Name: "Ada", Email: "a@b.c", When: "2026-07-01T10:00:00Z" }, 209 Target: [75, 78, 254, 37] 210 } 211 }; 212 expect(toTagSummary(tag)).toEqual({ 213 name: "v1.0.0", 214 hash: "63fa1d4b", 215 commitHash: "4b4efe25", 216 when: "2026-07-01T10:00:00Z", 217 message: "release" 218 }); 219 }); 220 221 it("a lightweight tag is its own commit", () => { 222 const summary = toTagSummary({ name: "v1.0.0", hash: "eebb477b" }); 223 expect(summary.commitHash).toBe("eebb477b"); 224 }); 225}); 226 227describe("toTreeEntrySummary", () => { 228 it("carries the last commit over from snake_case", () => { 229 expect( 230 toTreeEntrySummary({ 231 name: "flake.nix", 232 mode: "0100644", 233 size: 213, 234 last_commit: { hash: "f0b11b85", message: "init", when: "2026-07-01T10:00:00Z" } 235 }) 236 ).toEqual({ 237 name: "flake.nix", 238 kind: "file", 239 size: 213, 240 lastCommitHash: "f0b11b85", 241 lastCommitWhen: "2026-07-01T10:00:00Z", 242 lastCommitMessage: "init" 243 }); 244 }); 245}); 246 247describe("resolveRepoByName", () => { 248 const owner = "did:plc:owner"; 249 250 it("asks bobbin for the owner and name", async () => { 251 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( 252 jsonResponse({ 253 uri: `at://${owner}/sh.tangled.repo/3lzg6enurmo22`, 254 value: { $type: "sh.tangled.repo", knot: "knot.test", name: "infra" } 255 }) 256 ); 257 const view = await resolveRepoByName(makeCtx(fetchMock), owner, "infra"); 258 expect(view?.uri).toBe(`at://${owner}/sh.tangled.repo/3lzg6enurmo22`); 259 const url = new URL(String(fetchMock.mock.calls[0][0])); 260 expect(url.pathname).toBe("/xrpc/sh.tangled.repo.getRepoByName"); 261 expect(url.searchParams.get("owner")).toBe(owner); 262 expect(url.searchParams.get("name")).toBe("infra"); 263 }); 264 265 it("returns null when the owner has no such repo", async () => { 266 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( 267 new Response(JSON.stringify({ error: "RecordNotFound" }), { 268 status: 404, 269 headers: { "content-type": "application/json" } 270 }) 271 ); 272 expect(await resolveRepoByName(makeCtx(fetchMock), owner, "missing")).toBeNull(); 273 }); 274 275 it("propagates anything that is not a miss", async () => { 276 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( 277 new Response(JSON.stringify({ error: "UpstreamFailed" }), { 278 status: 502, 279 headers: { "content-type": "application/json" } 280 }) 281 ); 282 await expect(resolveRepoByName(makeCtx(fetchMock), owner, "infra")).rejects.toBeInstanceOf( 283 ClientResponseError 284 ); 285 }); 286});