Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875import { describe, expect, it, vi, beforeEach } from "vitest";import type { Did } from "@atcute/lexicons/syntax";import type { OAuthUserAgent } from "@atcute/oauth-browser-client";import type { GitHubAccount, GitHubRepo } from "$lib/github";import type { ProfileRecord } from "$lib/api/records";import type { NewMigrationJob } from "$lib/api/migrator";import { canonicalRepoName, fetchGitHubAccount, findRepoCollisions, importGitHubProfile, importFromUrl, importGitHubRepos, invalidateGitHubAccount, validateGitHubRepoUrl} from "$lib/api/githubImport";
const mockCreateOptimisticRepoRecord = vi.fn();vi.mock("$lib/api/repoCreate", async (importOriginal) => { const actual = await importOriginal<typeof import("$lib/api/repoCreate")>(); return { ...actual, createOptimisticRepoRecord: (...args: unknown[]) => mockCreateOptimisticRepoRecord(...args) };});
const mockCreateMigrationTask = vi.fn();vi.mock("$lib/api/migrator", async (importOriginal) => { const actual = await importOriginal<typeof import("$lib/api/migrator")>(); return { ...actual, createMigrationTask: (...args: unknown[]) => mockCreateMigrationTask(...args) };});
const mockUploadProfileAvatar = vi.fn();vi.mock("$lib/api/profile", () => ({ uploadProfileAvatar: (...args: unknown[]) => mockUploadProfileAvatar(...args)}));
const mockPutRecord = vi.fn();const mockCreateRecord = vi.fn();const mockReadRecord = vi.fn();vi.mock("$lib/api/write", () => ({ createRecord: (...args: unknown[]) => mockCreateRecord(...args), putRecord: (...args: unknown[]) => mockPutRecord(...args), readRecord: (...args: unknown[]) => mockReadRecord(...args)}));
const mockCreatePubKey = vi.fn();vi.mock("$lib/api/settings", async (importOriginal) => { const actual = await importOriginal<typeof import("$lib/api/settings")>(); return { ...actual, createPubKey: (...args: unknown[]) => mockCreatePubKey(...args) };});
const mockListEmails = vi.fn();const mockAddEmail = vi.fn();vi.mock("$lib/api/emails", () => ({ listEmails: (...args: unknown[]) => mockListEmails(...args), addEmail: (...args: unknown[]) => mockAddEmail(...args)}));
const mockRpcCall = vi.fn();vi.mock("$lib/auth/agent", async (importOriginal) => { const actual = await importOriginal<typeof import("$lib/auth/agent")>(); return { ...actual, createClient: () => ({ call: (...args: unknown[]) => mockRpcCall(...args) }) };});
vi.mock("$lib/api/deliberi", () => ({ createDeliberiClient: (opts: unknown) => opts}));
const testAgent = { sub: "did:plc:alice" as Did} as unknown as OAuthUserAgent;
const baseRepo: GitHubRepo = { id: 12345, name: "sample-repo", fullName: "octocat/sample-repo", description: "A great sample project", cloneUrl: "https://github.com/octocat/sample-repo.git", htmlUrl: "https://github.com/octocat/sample-repo", defaultBranch: "main"};
const GITHUB_ORIGIN = "https://github.com";
const validate = ( url: string, fullName: string, kind: "htmlUrl" | "cloneUrl", origin: string = GITHUB_ORIGIN) => validateGitHubRepoUrl(url, fullName, kind, origin);
describe("validateGitHubRepoUrl", () => { it("accepts valid canonical HTTPS github.com URLs matching fullName", () => { expect( validate("https://github.com/octocat/sample-repo", "octocat/sample-repo", "htmlUrl") ).toBe("https://github.com/octocat/sample-repo");
expect( validate( "https://github.com/octocat/sample-repo.git", "octocat/sample-repo", "cloneUrl" ) ).toBe("https://github.com/octocat/sample-repo.git");
expect( validate("https://github.com/octocat/sample-repo", "octocat/sample-repo", "cloneUrl") ).toBe("https://github.com/octocat/sample-repo"); });
it("rejects URLs whose path does not match fullName", () => { expect(() => validate( "https://github.com/someone-else/sample-repo", "octocat/sample-repo", "htmlUrl" ) ).toThrow(/pathname must match repository/); });
it("rejects non-HTTPS, credentials, nondefault ports, query, and hash", () => { expect(() => validate("http://github.com/octocat/sample-repo", "octocat/sample-repo", "htmlUrl") ).toThrow(/protocol must be https/);
expect(() => validate( "https://user:pass@github.com/octocat/sample-repo", "octocat/sample-repo", "htmlUrl" ) ).toThrow(/credentials are not allowed/);
expect(() => validate( "https://github.com:8443/octocat/sample-repo", "octocat/sample-repo", "htmlUrl" ) ).toThrow(/non-default port is not allowed/);
expect(() => validate( "https://github.com/octocat/sample-repo?ref=main", "octocat/sample-repo", "htmlUrl" ) ).toThrow(/query parameters are not allowed/);
expect(() => validate( "https://github.com/octocat/sample-repo#readme", "octocat/sample-repo", "htmlUrl" ) ).toThrow(/hash fragments are not allowed/); });
it("checks the host against the account's configured origin", () => { const stub = "https://github.tngl.boltless.dev"; expect( validate(`${stub}/octocat/sample-repo.git`, "octocat/sample-repo", "cloneUrl", stub) ).toBe(`${stub}/octocat/sample-repo.git`);
expect(() => validate( "https://github.com/octocat/sample-repo.git", "octocat/sample-repo", "cloneUrl", stub ) ).toThrow(/host must be github\.tngl\.boltless\.dev/);
expect(() => validate( `${stub}/octocat/sample-repo.git`, "octocat/sample-repo", "cloneUrl", GITHUB_ORIGIN ) ).toThrow(/host must be github\.com/); });
it("refuses an origin that is not https, rather than quietly allowing it", () => { expect(() => validate( "http://github.com/octocat/sample-repo.git", "octocat/sample-repo", "cloneUrl", "http://github.com" ) ).toThrow(/protocol must be https/);
expect(() => validate( "https://github.com/octocat/sample-repo.git", "octocat/sample-repo", "cloneUrl", "not a url" ) ).toThrow(/is not a URL/); });
it("rejects malformed fullName grammar", () => { expect(() => validate("https://github.com/octocat/sample-repo", "single-name", "htmlUrl") ).toThrow(/Invalid GitHub fullName/); });});
describe("canonicalRepoName & single normalization", () => { it("matches validateRepoName semantics and lowercases", () => { expect(canonicalRepoName("Demo-Repo.git")).toBe("demo-repo"); expect(canonicalRepoName("foo.git.git")).toBe("foo.git"); expect(canonicalRepoName("plain-name")).toBe("plain-name"); });});
describe("findRepoCollisions", () => { it("detects collisions across batch and existing repositories with string or object candidates", () => { const candidates = ["awesome-app", { name: "Awesome-App.git" }, "other-tool"]; const existing = ["Other-Tool.git", "prior-repo"]; const collisions = findRepoCollisions(candidates, existing);
expect(collisions.get("awesome-app")?.hasCollision).toBe(true); expect(collisions.get("awesome-app")?.candidates).toEqual([ "awesome-app", { name: "Awesome-App.git" } ]); expect(collisions.get("awesome-app")?.conflictsWithExisting).toBe(false);
expect(collisions.get("other-tool")?.hasCollision).toBe(true); expect(collisions.get("other-tool")?.conflictsWithExisting).toBe(true); });});
describe("importGitHubRepos", () => { beforeEach(() => { vi.clearAllMocks(); mockCreateMigrationTask.mockImplementation( async ( _agent: unknown, _host: unknown, _requestId: unknown, jobs: NewMigrationJob[] ) => ({ id: "task-1", ownerDid: "did:plc:alice", createdAt: "2026-01-01T00:00:00Z", jobs: jobs.map((job, index) => ({ id: String(index + 1), name: job.name, knotDid: job.knotDid, sourceUrl: job.sourceUrl, private: false, status: "queued", attempts: 0 })) }) ); });
it("hands the clone url to the migrator, with the knot to create the repository on", async () => { const task = await importGitHubRepos( testAgent, "https://migrator.test", "https://github.com", [ { ownerDid: "did:plc:alice" as Did, ownerHandle: "alice.tangled.org", knot: "knot.test", repo: baseRepo } ] );
expect(task.id).toBe("task-1"); expect(mockCreateMigrationTask).toHaveBeenCalledTimes(1); const [, host, requestId, jobs] = mockCreateMigrationTask.mock.calls[0]; expect(host).toBe("https://migrator.test"); expect(typeof requestId).toBe("string"); expect(jobs).toEqual([ { name: "sample-repo", knotDid: "did:web:knot.test", sourceUrl: "https://github.com/octocat/sample-repo.git" } ]); });
it("creates optimistic records ahead of migration task when bobbinUrl is supplied", async () => { await importGitHubRepos( testAgent, "https://migrator.test", "https://github.com", [ { ownerDid: "did:plc:alice" as Did, ownerHandle: "alice.tangled.org", knot: "knot.test", repo: baseRepo } ], { bobbinUrl: "https://bobbin.test" } );
expect(mockCreateOptimisticRepoRecord).toHaveBeenCalledTimes(1); expect(mockCreateOptimisticRepoRecord).toHaveBeenCalledWith( testAgent, "https://bobbin.test", expect.objectContaining({ ownerDid: "did:plc:alice", ownerHandle: "alice.tangled.org", name: "sample-repo", knot: "knot.test", source: { kind: "import", url: "https://github.com/octocat/sample-repo.git" } }) ); expect(mockCreateMigrationTask).toHaveBeenCalledTimes(1); });
it("batches multiple repositories, marking private jobs, preserving knot DIDs, and normalising names", async () => { const task = await importGitHubRepos( testAgent, "https://migrator.test", "https://github.com", [ { ownerDid: "did:plc:alice" as Did, ownerHandle: "alice.tangled.org", knot: "knot.test", repo: { ...baseRepo, private: true } }, { ownerDid: "did:plc:alice" as Did, ownerHandle: "alice.tangled.org", knot: "did:web:custom-knot.test", name: "second-repo.git.git", repo: { ...baseRepo, id: 99, name: "second-repo", fullName: "octocat/second-repo", cloneUrl: "https://github.com/octocat/second-repo.git" } } ] );
expect(task.id).toBe("task-1"); expect(mockCreateMigrationTask).toHaveBeenCalledTimes(1); const [, , , jobs] = mockCreateMigrationTask.mock.calls[0]; expect(jobs).toHaveLength(2); expect(jobs[0]).toMatchObject({ name: "sample-repo", knotDid: "did:web:knot.test", private: true }); expect(jobs[1]).toMatchObject({ name: "second-repo.git", knotDid: "did:web:custom-knot.test" }); expect(jobs[1].private).toBeUndefined(); });
it("rejects a clone url that does not belong to the named repository", async () => { const mismatchedRepo: GitHubRepo = { ...baseRepo, cloneUrl: "https://github.com/imposter/sample-repo.git" };
await expect( importGitHubRepos(testAgent, "https://migrator.test", "https://github.com", [ { ownerDid: "did:plc:alice" as Did, ownerHandle: "alice.tangled.org", knot: "knot.test", repo: mismatchedRepo } ]) ).rejects.toThrow(/pathname must match repository/);
expect(mockCreateMigrationTask).not.toHaveBeenCalled(); });});
describe("importGitHubProfile", () => { const sampleAccount: GitHubAccount = { publicOrigin: "https://github.com", login: "octocat", avatarUrl: "https://avatars.githubusercontent.com/u/583231", email: "octocat@github.com", bio: "builds things", blog: "https://octocat.example.com", keys: [ { id: 1, title: "Laptop", key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG123 user@laptop" }, { id: 2, title: "Desktop", key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC456 user@desktop" } ], repos: [] };
beforeEach(() => { vi.clearAllMocks(); mockReadRecord.mockReset(); mockReadRecord.mockResolvedValue(null); mockCreatePubKey.mockReset(); mockCreatePubKey.mockImplementation(async (agent, name, key) => ({ uri: `at://${agent.sub}/sh.tangled.publicKey/${name}`, cid: "bafycreated", rkey: `rkey-${name}` })); });
it("preserves existing PDS profile fields and passes swapRecord CID to putRecord", async () => { const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { "content-type": "image/png" } }) );
const existingProfile: ProfileRecord = { $type: "sh.tangled.actor.profile", bluesky: true, description: "Existing Tangled Bio", location: "San Francisco", links: ["https://example.com"] };
mockReadRecord.mockResolvedValue({ value: existingProfile, written: { uri: "at://did:plc:alice/sh.tangled.actor.profile/self", cid: "bafk-existing-cid" } }); mockRpcCall.mockResolvedValue({ ok: true, data: { records: [] } });
mockUploadProfileAvatar.mockResolvedValue({ $type: "blob", ref: { $link: "new-blob-cid" }, mimeType: "image/png", size: 3 });
const result = await importGitHubProfile( testAgent, { deliberiUrl: "https://deliberi.test", fetch: fetchMock }, sampleAccount, { avatar: true, keys: false, email: false } );
expect(result.errors).toEqual([]); expect(result.imported).toContain("avatar"); expect(fetchMock).toHaveBeenCalledWith("/_internal/github/avatar"); expect(mockUploadProfileAvatar).toHaveBeenCalled(); expect(mockPutRecord).toHaveBeenCalledWith( testAgent, "sh.tangled.actor.profile", "self", { $type: "sh.tangled.actor.profile", bluesky: true, description: "Existing Tangled Bio", location: "San Francisco", links: ["https://example.com"], avatar: { $type: "blob", ref: { $link: "new-blob-cid" }, mimeType: "image/png", size: 3 } }, "bafk-existing-cid" ); });
it("regression: a failed profile read never writes or blanks the profile", async () => { const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { "content-type": "image/png" } }) );
mockUploadProfileAvatar.mockResolvedValue({ $type: "blob", ref: { $link: "blob-cid" }, mimeType: "image/png", size: 3 });
// network failure must not be treated as a missing profile mockReadRecord.mockRejectedValue(new Error("Connection reset by peer"));
const result = await importGitHubProfile( testAgent, { deliberiUrl: "https://deliberi.test", fetch: fetchMock }, sampleAccount, { avatar: true, keys: false, email: false } );
expect(mockPutRecord).not.toHaveBeenCalled(); expect(mockCreateRecord).not.toHaveBeenCalled(); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toContain("Connection reset by peer"); expect(result.imported).not.toContain("avatar"); });
it("creates an absent profile without overwriting a concurrent creation", async () => { const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { "content-type": "image/png" } }) );
mockUploadProfileAvatar.mockResolvedValue({ $type: "blob", ref: { $link: "fresh-blob-cid" }, mimeType: "image/png", size: 3 });
mockRpcCall.mockResolvedValue({ ok: false, status: 404, headers: new Headers(), data: { error: "RecordNotFound" } });
const result = await importGitHubProfile( testAgent, { deliberiUrl: "https://deliberi.test", fetch: fetchMock }, sampleAccount, { avatar: true, keys: false, email: false } );
expect(result.errors).toEqual([]); expect(result.imported).toContain("avatar"); expect(mockPutRecord).not.toHaveBeenCalled(); expect(mockCreateRecord).toHaveBeenCalledWith( testAgent, "sh.tangled.actor.profile", { $type: "sh.tangled.actor.profile", bluesky: false, avatar: { $type: "blob", ref: { $link: "fresh-blob-cid" }, mimeType: "image/png", size: 3 } }, "self" ); });
it("reads all pages of PDS public keys and dedups raw SSH algorithm+base64, avoiding retries and duplicates", async () => { const pdsPage1 = { records: [ { value: { key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG123 old-comment" } } ], cursor: "cursor-2" }; const pdsPage2 = { records: [], cursor: undefined };
mockRpcCall.mockImplementation(async (_schema, options) => { if (options?.params?.collection === "sh.tangled.publicKey") { if (options?.params?.cursor === "cursor-2") { return { ok: true, data: pdsPage2 }; } return { ok: true, data: pdsPage1 }; } return { ok: true, data: { records: [] } }; });
const accountWithDupes: GitHubAccount = { ...sampleAccount, keys: [ ...sampleAccount.keys, { id: 3, title: "Desktop Duplicate", key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC456 different@comment" }, { id: 4, title: "Broken Key", key: "malformed" } ] };
const result = await importGitHubProfile( testAgent, { deliberiUrl: "https://deliberi.test" }, accountWithDupes, { avatar: false, keys: true, email: false } );
expect(mockCreatePubKey).toHaveBeenCalledTimes(1); expect(mockCreatePubKey).toHaveBeenCalledWith( testAgent, "Desktop", "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC456 user@desktop" ); expect(result.imported).toContain("key:2"); expect(result.keys).toEqual([ { rkey: "rkey-Desktop", name: "Desktop", key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC456 user@desktop" } ]); expect(result.errors.some((e) => e.includes("Invalid SSH key format"))).toBe(true); });
it("handles per-key errors independently without aborting remaining keys", async () => { mockRpcCall.mockResolvedValue({ ok: true, data: { records: [] } }); mockCreatePubKey .mockRejectedValueOnce(new Error("Network timeout writing record")) .mockResolvedValueOnce({ uri: "at://uri", cid: "cid", rkey: "rkey" });
const result = await importGitHubProfile( testAgent, { deliberiUrl: "https://deliberi.test" }, sampleAccount, { avatar: false, keys: true, email: false } );
expect(mockCreatePubKey).toHaveBeenCalledTimes(2); expect(result.imported).toEqual(["key:2"]); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toContain("Network timeout writing record"); });
it.each([ ["absent", [{ address: "existing@tangled.org", verified: true, primary: true }], true], [ "already registered", [{ address: "octocat@github.com", verified: true, primary: false }], false ] ])("imports email when %s in deliberi", async (_case, existingEmails, shouldAdd) => { mockListEmails.mockResolvedValue({ emails: existingEmails });
const result = await importGitHubProfile( testAgent, { deliberiUrl: "https://deliberi.test" }, sampleAccount, { avatar: false, keys: false, email: true } );
expect(mockListEmails).toHaveBeenCalledTimes(1); if (shouldAdd) { expect(mockAddEmail).toHaveBeenCalledWith(expect.anything(), "octocat@github.com"); expect(result.imported.some((msg) => msg.includes("verification required"))).toBe(true); } else { expect(mockAddEmail).not.toHaveBeenCalled(); } expect(result.errors).toEqual([]); });
it("executes best-effort independent operations when multiple options are selected", async () => { const fetchMock = vi .fn<typeof globalThis.fetch>() .mockResolvedValue(new Response("Internal error", { status: 500 }));
mockRpcCall.mockResolvedValue({ ok: true, data: { records: [] } }); mockCreatePubKey.mockResolvedValue({ uri: "at://uri", cid: "cid", rkey: "rkey" }); mockListEmails.mockResolvedValue({ emails: [] });
const result = await importGitHubProfile( testAgent, { deliberiUrl: "https://deliberi.test", fetch: fetchMock }, sampleAccount, { avatar: true, keys: true, email: true } );
expect(result.errors).toHaveLength(1); expect(result.errors[0]).toContain("failed to fetch avatar"); expect(result.imported).toEqual([ "key:1", "key:2", "email:octocat@github.com (verification required)" ]); });});
describe("importFromUrl", () => { beforeEach(() => { vi.clearAllMocks(); mockCreateMigrationTask.mockImplementation( async ( _agent: unknown, _host: unknown, _requestId: unknown, jobs: NewMigrationJob[] ) => ({ id: "task-url", ownerDid: "did:plc:alice", createdAt: "2026-01-01T00:00:00Z", jobs: jobs.map((job, index) => ({ id: String(index + 1), name: job.name, knotDid: job.knotDid, sourceUrl: job.sourceUrl, private: false, status: "queued", attempts: 0 })) }) ); });
it("hands any https git host to the migrator, with the description", async () => { const started = await importFromUrl(testAgent, "https://migrator.test", { sourceUrl: "https://git.example.com/team/project.git", name: "project", description: "a small tool", knot: "knot.test" });
expect(started).toEqual({ taskId: "task-url", name: "project", jobId: "1" }); const [, host, requestId, jobs] = mockCreateMigrationTask.mock.calls[0]; expect(host).toBe("https://migrator.test"); expect(typeof requestId).toBe("string"); expect(jobs).toEqual([ { name: "project", knotDid: "did:web:knot.test", sourceUrl: "https://git.example.com/team/project.git", description: "a small tool" } ]); });
it.each([ ["non-https scheme", "http://git.example.com/team/project.git", /https/], ["custom port", "https://git.example.com:8443/team/project.git", /custom port/] ])("refuses an invalid source URL with %s", async (_case, sourceUrl, pattern) => { await expect( importFromUrl(testAgent, "https://migrator.test", { sourceUrl, name: "project", knot: "knot.test" }) ).rejects.toThrow(pattern); expect(mockCreateMigrationTask).not.toHaveBeenCalled(); });
it("accepts an explicit 443 port", async () => { await importFromUrl(testAgent, "https://migrator.test", { sourceUrl: "https://git.example.com:443/team/project.git", name: "project", knot: "knot.test" }); const [, , , jobs] = mockCreateMigrationTask.mock.calls[0]; expect(jobs[0].sourceUrl).toBe("https://git.example.com/team/project.git"); });
it("creates an optimistic record ahead of migration task when bobbinUrl and owner are supplied", async () => { await importFromUrl( testAgent, "https://migrator.test", { sourceUrl: "https://git.example.com/team/project.git", name: "project", description: "a small tool", knot: "knot.test", ownerDid: "did:plc:alice" as Did, ownerHandle: "alice.tangled.org" }, { bobbinUrl: "https://bobbin.test" } ); expect(mockCreateOptimisticRepoRecord).toHaveBeenCalledWith( testAgent, "https://bobbin.test", expect.objectContaining({ name: "project", knot: "knot.test", source: { kind: "import", url: "https://git.example.com/team/project.git" } }) ); expect(mockCreateMigrationTask).toHaveBeenCalledTimes(1); });});
describe("fetchGitHubAccount", () => { const accountOf = (login: string): GitHubAccount => ({ publicOrigin: "https://github.com", bio: null, blog: null, login, avatarUrl: "", email: null, keys: [], repos: [] }); const fetcherOf = () => { const fetcher = vi.fn<typeof globalThis.fetch>(); return fetcher; };
it("serves repeated loads from the cache and refetches after invalidation", async () => { const fetcher = fetcherOf(); fetcher.mockImplementation(async () => Response.json(accountOf("octocat")));
expect((await fetchGitHubAccount(fetcher))?.login).toBe("octocat"); expect((await fetchGitHubAccount(fetcher))?.login).toBe("octocat"); expect(fetcher).toHaveBeenCalledTimes(1);
invalidateGitHubAccount(fetcher); await fetchGitHubAccount(fetcher); expect(fetcher).toHaveBeenCalledTimes(2); });
it("caches per fetcher, so test doubles never share entries", async () => { const first = fetcherOf(); const second = fetcherOf(); first.mockResolvedValue(Response.json(accountOf("octocat"))); second.mockResolvedValue(Response.json(accountOf("monalisa")));
await fetchGitHubAccount(first); expect((await fetchGitHubAccount(second))?.login).toBe("monalisa"); expect(first).toHaveBeenCalledTimes(1); });
it.each([ ["401 signed-out", 401, async (res: Promise<unknown>) => expect(await res).toBeNull()], [ "500 failure", 500, async (res: Promise<unknown>) => expect(res).rejects.toThrow(/Try connecting again/) ] ])("does not cache %s responses", async (_name, status, assertFirst) => { const fetcher = fetcherOf(); fetcher.mockResolvedValue(new Response("err", { status }));
await assertFirst(fetchGitHubAccount(fetcher)); fetcher.mockResolvedValue(Response.json(accountOf("octocat"))); expect((await fetchGitHubAccount(fetcher))?.login).toBe("octocat"); expect(fetcher).toHaveBeenCalledTimes(2); });});