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.0 kB 190 lines
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});