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