From 5c33a5547b374292e5436b7c092edbcef91a4d01 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Thu, 16 Jul 2026 20:50:01 +0530 Subject: [PATCH] test: cover bulk org mirror destination routing (#343) + clarify mixed-strategy log - Add behavioral tests exercising mirrorGitHubOrgToGitea end-to-end down to the migrate HTTP payload: org-level override, per-repo override, mixed strategy uid, starred-repo mode, and preserve/single-org/flat-user no-override regression paths. All four bug-scenario tests fail on main and pass with PR #344 applied. - Fix the top-level log that claimed 'flat-user strategy' when the mixed strategy falls into the same branch. --- src/lib/gitea-org-mirror-destination.test.ts | 346 +++++++++++++++++++ src/lib/gitea.ts | 12 +- 2 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 src/lib/gitea-org-mirror-destination.test.ts diff --git a/src/lib/gitea-org-mirror-destination.test.ts b/src/lib/gitea-org-mirror-destination.test.ts new file mode 100644 index 0000000..2679cef --- /dev/null +++ b/src/lib/gitea-org-mirror-destination.test.ts @@ -0,0 +1,346 @@ +/** + * Behavioral tests for issue #343 / PR #344: + * bulk "Mirror Organization" must honor the canonical destination + * precedence (starred mode > repo override > org override > strategy). + * + * NOTE: run standalone (`bun test src/lib/gitea-org-mirror-destination.test.ts`). + * These tests replace @/lib/db, @/lib/gitea-enhanced, @/lib/http-client, + * @/lib/helpers, and @/lib/utils/mirror-source-match with module mocks; + * bun's mock.module is process-wide, so running this file in the full + * suite could pollute other test files (see the comment in + * gitea-mirror-failure-recovery.test.ts). + */ +import { describe, test, expect, mock, beforeEach } from "bun:test"; + +// --------------------------------------------------------------------------- +// Shared mutable state the module mocks read from / write to +// --------------------------------------------------------------------------- + +/** Rows returned by the FIRST select on the repositories table (the orgRepos query). */ +let orgRepoRows: any[] = []; +/** Row returned for getOrganizationConfig (select from organizations .limit(1)). */ +let orgConfigRows: any[] = []; +/** Counts selects against the repositories table. */ +let repoSelectCount = 0; +/** Every httpPost call: { url, payload }. */ +let httpPostCalls: Array<{ url: string; payload: any }> = []; +/** Every org get-or-create: orgName -> deterministic id. */ +let orgCreateCalls: string[] = []; + +const ORG_IDS: Record = {}; +let nextOrgId = 100; +function orgIdFor(name: string): number { + if (!(name in ORG_IDS)) ORG_IDS[name] = nextOrgId++; + return ORG_IDS[name]; +} + +// --------------------------------------------------------------------------- +// Module mocks (must be registered before importing ./gitea) +// --------------------------------------------------------------------------- + +const repositoriesTable = { __table: "repositories" } as any; +const organizationsTable = { __table: "organizations" } as any; + +function promiseWithLimit(rows: any[], limitRows?: any[]) { + const p: any = Promise.resolve(rows); + p.limit = () => Promise.resolve(limitRows ?? rows); + return p; +} + +mock.module("@/lib/db", () => { + const mockDb = { + select: (_fields?: any) => ({ + from: (table: any) => ({ + where: (_cond: any) => { + if (table === repositoriesTable) { + repoSelectCount++; + // First repositories select in mirrorGitHubOrgToGitea is the + // orgRepos query; everything after (idempotency checks, name + // claims) must see no rows. + const rows = repoSelectCount === 1 ? orgRepoRows : []; + return promiseWithLimit(rows, []); + } + if (table === organizationsTable) { + return promiseWithLimit(orgConfigRows, orgConfigRows); + } + return promiseWithLimit([], []); + }, + }), + }), + update: (_table: any) => ({ + set: (_data: any) => ({ where: (_cond: any) => Promise.resolve() }), + }), + insert: (_table: any) => ({ values: (_data: any) => Promise.resolve() }), + delete: (_table: any) => ({ where: (_cond: any) => Promise.resolve() }), + }; + return { + db: mockDb, + repositories: repositoriesTable, + organizations: organizationsTable, + configs: {}, + mirrorJobs: {}, + users: {}, + events: {}, + sessions: {}, + accounts: {}, + }; +}); + +mock.module("@/lib/helpers", () => ({ + createMirrorJob: mock(async () => "job-id"), +})); + +const actualHttp = await import("./http-client"); +const actualEnhanced = await import("./gitea-enhanced"); +const actualConfigEncryption = await import("./utils/config-encryption"); + +mock.module("@/lib/http-client", () => ({ + ...actualHttp, + httpGet: mock(async (url: string) => { + throw new actualHttp.HttpError(`GET ${url} -> 404`, 404, "not found"); + }), + httpPost: mock(async (url: string, payload: any) => { + httpPostCalls.push({ url, payload }); + return { data: { id: 1, ...payload }, status: 201, statusText: "Created", headers: new Headers() }; + }), + httpPut: mock(async () => ({ data: {}, status: 200, statusText: "OK", headers: new Headers() })), + httpPatch: mock(async () => ({ data: {}, status: 200, statusText: "OK", headers: new Headers() })), + httpDelete: mock(async () => ({ data: {}, status: 204, statusText: "No Content", headers: new Headers() })), +})); + +mock.module("@/lib/gitea-enhanced", () => ({ + ...actualEnhanced, + getOrCreateGiteaOrgEnhanced: mock(async ({ orgName }: any) => { + orgCreateCalls.push(orgName); + return orgIdFor(orgName); + }), + getGiteaRepoInfo: mock(async () => null), + handleExistingNonMirrorRepo: mock(async () => {}), +})); + +// NOTE: @/lib/utils/mirror-source-match is deliberately NOT mocked. Its real +// implementation resolves to "no existing mirror / name available" naturally +// under the db/fetch/gitea-enhanced mocks above, and module-mocking it +// poisons mirror-source-match.test.ts (bun mock.module is process-wide). + +mock.module("@/lib/utils/config-encryption", () => ({ + ...actualConfigEncryption, + decryptConfigTokens: (config: any) => config, +})); + +// isRepoPresentInGitea uses global fetch directly. +globalThis.fetch = mock(async () => + new Response("not found", { status: 404 }) +) as any; + +const { mirrorGitHubOrgToGitea } = await import("./gitea"); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeConfig(overrides: any = {}): any { + return { + id: "config-1", + userId: "user-1", + githubConfig: { + token: "gh-token", + owner: "me", + ...(overrides.githubConfig || {}), + }, + giteaConfig: { + url: "https://gitea.test", + token: "gitea-token", + defaultOwner: "meuser", + addTopics: false, + ...(overrides.giteaConfig || {}), + }, + }; +} + +function makeOrg(overrides: any = {}): any { + return { + id: "org-db-1", + userId: "user-1", + name: "A", + membershipRole: "member", + isIncluded: true, + status: "imported", + repositoryCount: 1, + createdAt: new Date(), + updatedAt: new Date(), + destinationOrg: null, + ...overrides, + }; +} + +function makeRepo(overrides: any = {}): any { + return { + id: "repo-1", + userId: "user-1", + configId: "config-1", + name: "r1", + fullName: "A/r1", + owner: "A", + organization: "A", + url: "https://github.com/A/r1", + cloneUrl: "https://github.com/A/r1.git", + isPrivate: false, + isForked: false, + forkedFrom: null, + hasIssues: false, + isStarred: false, + isArchived: false, + size: 0, + hasLFS: false, + hasSubmodules: false, + defaultBranch: "main", + visibility: "public", + status: "imported", + destinationOrg: null, + lastMirrored: null, + errorMessage: null, + mirroredLocation: "", + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +const fakeOctokit = {} as any; + +function migrateCalls() { + return httpPostCalls.filter((c) => c.url.includes("/repos/migrate")); +} + +beforeEach(() => { + orgRepoRows = []; + orgConfigRows = []; + repoSelectCount = 0; + httpPostCalls = []; + orgCreateCalls = []; +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("mirrorGitHubOrgToGitea destination routing (#343)", () => { + + test("Scenario 1: preserve strategy honors organization destinationOrg override", async () => { + const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } }); + const organization = makeOrg({ destinationOrg: "B" }); + orgRepoRows = [makeRepo()]; + orgConfigRows = [organization]; + + await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config }); + + // The override org must be created; the GitHub-named org must NOT be. + expect(orgCreateCalls).toContain("B"); + expect(orgCreateCalls).not.toContain("A"); + + const migrates = migrateCalls(); + expect(migrates.length).toBe(1); + expect(migrates[0].payload.uid).toBe(orgIdFor("B")); + expect(migrates[0].payload.repo_name).toBe("r1"); + }); + + test("Scenario 2: mixed strategy sends org repos to the GitHub-named org with a defined uid", async () => { + const config = makeConfig({ githubConfig: { mirrorStrategy: "mixed" } }); + const organization = makeOrg(); + orgRepoRows = [makeRepo()]; + orgConfigRows = [organization]; + + await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config }); + + const migrates = migrateCalls(); + expect(migrates.length).toBe(1); + // The main-branch bug: uid was undefined -> dropped by JSON.stringify -> + // Gitea defaulted the owner to the authenticated user. + expect(migrates[0].payload.uid).toBeDefined(); + expect(migrates[0].payload.uid).toBe(orgIdFor("A")); + expect(orgCreateCalls).toContain("A"); + }); + + test("Scenario 3: starred repo in a bulk org mirror follows starred-repo mode", async () => { + const config = makeConfig({ githubConfig: { mirrorStrategy: "mixed" } }); + const organization = makeOrg(); + orgRepoRows = [ + makeRepo({ id: "repo-2", name: "tools", fullName: "A/tools", isStarred: true }), + ]; + orgConfigRows = [organization]; + + await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config }); + + const migrates = migrateCalls(); + expect(migrates.length).toBe(1); + expect(orgCreateCalls).toContain("starred"); + expect(migrates[0].payload.uid).toBe(orgIdFor("starred")); + }); + + test("per-repo destinationOrg override beats org override and strategy", async () => { + const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } }); + const organization = makeOrg({ destinationOrg: "B" }); + orgRepoRows = [makeRepo({ destinationOrg: "C" })]; + orgConfigRows = [organization]; + + await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config }); + + const migrates = migrateCalls(); + expect(migrates.length).toBe(1); + expect(migrates[0].payload.uid).toBe(orgIdFor("C")); + expect(orgCreateCalls).toContain("C"); + }); + + test("regression: preserve strategy without overrides keeps GitHub org name and creates the org once", async () => { + const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } }); + const organization = makeOrg(); + orgRepoRows = [ + makeRepo(), + makeRepo({ id: "repo-3", name: "r2", fullName: "A/r2", cloneUrl: "https://github.com/A/r2.git" }), + ]; + orgConfigRows = [organization]; + + await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config }); + + const migrates = migrateCalls(); + expect(migrates.length).toBe(2); + for (const call of migrates) { + expect(call.payload.uid).toBe(orgIdFor("A")); + } + // Pre-created once at the top; the per-repo loop must reuse it. + expect(orgCreateCalls.filter((n) => n === "A").length).toBe(1); + }); + + test("regression: single-org strategy without overrides routes to the configured org", async () => { + const config = makeConfig({ + githubConfig: { mirrorStrategy: "single-org" }, + giteaConfig: { organization: "hub" }, + }); + const organization = makeOrg(); + orgRepoRows = [makeRepo()]; + orgConfigRows = [organization]; + + await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config }); + + const migrates = migrateCalls(); + expect(migrates.length).toBe(1); + expect(migrates[0].payload.uid).toBe(orgIdFor("hub")); + expect(orgCreateCalls.filter((n) => n === "hub").length).toBe(1); + }); + + test("regression: flat-user strategy without overrides mirrors to the user account (repo_owner, no org)", async () => { + const config = makeConfig({ githubConfig: { mirrorStrategy: "flat-user" } }); + const organization = makeOrg(); + orgRepoRows = [makeRepo()]; + orgConfigRows = [organization]; + + await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config }); + + const migrates = migrateCalls(); + expect(migrates.length).toBe(1); + expect(migrates[0].payload.repo_owner).toBe("meuser"); + expect(migrates[0].payload.uid).toBeUndefined(); + expect(orgCreateCalls.length).toBe(0); + }); +}); diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index 7517a89..4c37ea6 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -2026,9 +2026,15 @@ export async function mirrorGitHubOrgToGitea({ config, }); } else { - // For flat-user strategy, we shouldn't create organizations at all - // Skip organization creation and let individual repos be handled by getGiteaRepoOwner - console.log(`Using flat-user strategy: repos will be placed under user account`); + // flat-user: no organizations should be created at all. + // mixed: org repos resolve per-repo via getGiteaRepoOwnerAsync in the + // loop below (creating their target orgs on demand), so there is + // nothing to pre-create here either. + console.log( + mirrorStrategy === "mixed" + ? `Using mixed strategy: repos will be resolved per-repo (orgs created on demand)` + : `Using flat-user strategy: repos will be placed under user account` + ); targetOrgName = config.giteaConfig?.defaultOwner || ""; }