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