/*
Unit tests for gitmesh covering its documented public contract and regression-sensitive edge cases.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- Fixtures stay inline and minimal so each case states its own setup.
- RFC-0563: initial implementation — unit tests for gitmesh config, sync, status, verify.
*/
// @ai-invariant fixtures must not couple to internals beyond the public surface under test
import { test, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
validateConfig,
loadGitMeshConfig,
autoCreateConfigFromGit,
CONFIG_FILENAME,
} from "../gitmesh/config.ts";
import * as gitOps from "../gitmesh/git-ops.ts";
import type { GitMeshConfig } from "../gitmesh/types.ts";
// ---------------------------------------------------------------------------
// Config validation tests
// ---------------------------------------------------------------------------
function validConfig(): GitMeshConfig {
return {
remotes: [{ name: "origin", url: "git@github.com:foo/bar.git", trusted: true }],
trackedBranch: "main",
syncIntervalMs: 0,
verifySignatures: false,
};
}
test("validateConfig: accepts a valid config", () => {
expect(() => validateConfig(validConfig())).not.toThrow();
});
test("validateConfig: rejects non-object", () => {
expect(() => validateConfig(null)).toThrow("must be an object");
expect(() => validateConfig("string")).toThrow("must be an object");
});
test("validateConfig: rejects missing remotes array", () => {
const c = validConfig() as unknown as Record;
delete c.remotes;
expect(() => validateConfig(c)).toThrow("remotes must be an array");
});
test("validateConfig: rejects remote with empty name", () => {
const c = validConfig();
c.remotes[0].name = "";
expect(() => validateConfig(c)).toThrow("remote.name must be a non-empty string");
});
test("validateConfig: rejects remote with non-boolean trusted", () => {
const c = validConfig();
(c.remotes[0] as unknown as { trusted: string }).trusted = "yes";
expect(() => validateConfig(c)).toThrow("remote.trusted must be a boolean");
});
test("validateConfig: rejects empty trackedBranch", () => {
const c = validConfig();
c.trackedBranch = "";
expect(() => validateConfig(c)).toThrow("trackedBranch must be a non-empty string");
});
test("validateConfig: rejects negative syncIntervalMs", () => {
const c = validConfig();
c.syncIntervalMs = -1;
expect(() => validateConfig(c)).toThrow("syncIntervalMs must be a non-negative number");
});
test("validateConfig: rejects non-boolean verifySignatures", () => {
const c = validConfig() as unknown as Record;
c.verifySignatures = "true";
expect(() => validateConfig(c)).toThrow("verifySignatures must be a boolean");
});
// ---------------------------------------------------------------------------
// Config loading and auto-creation tests (with temp directories)
// ---------------------------------------------------------------------------
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "gitmesh-test-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
test("loadGitMeshConfig: loads and validates a valid config file", async () => {
const config = validConfig();
await writeFile(join(tempDir, CONFIG_FILENAME), JSON.stringify(config));
const loaded = await loadGitMeshConfig(tempDir);
expect(loaded.remotes).toHaveLength(1);
expect(loaded.remotes[0].name).toBe("origin");
expect(loaded.trackedBranch).toBe("main");
});
test("loadGitMeshConfig: throws when file does not exist", async () => {
await expect(loadGitMeshConfig(tempDir)).rejects.toThrow();
});
test("autoCreateConfigFromGit: creates config from .git/config remotes", async () => {
// Mock gitRemoteList
vi.spyOn(gitOps, "gitRemoteList").mockResolvedValueOnce([
{ name: "origin", url: "git@github.com:foo/bar.git" },
{ name: "peer-1", url: "git@peer-1:foo/bar.git" },
]);
const config = await autoCreateConfigFromGit(tempDir);
expect(config.remotes).toHaveLength(2);
expect(config.remotes[0].name).toBe("origin");
expect(config.remotes[0].trusted).toBe(true);
expect(config.remotes[1].name).toBe("peer-1");
expect(config.trackedBranch).toBe("main");
expect(config.syncIntervalMs).toBe(0);
expect(config.verifySignatures).toBe(false);
// Verify file was written
const loaded = await loadGitMeshConfig(tempDir);
expect(loaded.remotes).toHaveLength(2);
});
// ---------------------------------------------------------------------------
// Convergence algorithm tests
// ---------------------------------------------------------------------------
test("convergence: selects remote with highest committer timestamp", () => {
const fetchResults = [
{ remote: "origin", success: true, sha: "aaa", timestamp: 1000 },
{ remote: "peer-1", success: true, sha: "bbb", timestamp: 2000 },
{ remote: "peer-2", success: true, sha: "ccc", timestamp: 1500 },
];
const latest = fetchResults.reduce((best, current) =>
current.timestamp > best.timestamp ? current : best,
);
expect(latest.remote).toBe("peer-1");
expect(latest.sha).toBe("bbb");
});
test("convergence: with equal timestamps, first remote wins (reduce is stable)", () => {
const fetchResults = [
{ remote: "origin", success: true, sha: "aaa", timestamp: 1000 },
{ remote: "peer-1", success: true, sha: "bbb", timestamp: 1000 },
];
const latest = fetchResults.reduce((best, current) =>
current.timestamp > best.timestamp ? current : best,
);
expect(latest.remote).toBe("origin");
});
// ---------------------------------------------------------------------------
// Signature status counting tests
// ---------------------------------------------------------------------------
test("signature counting: G=signed, U=unsigned, B=invalid", () => {
const commits = [
{ sha: "aaa", signatureStatus: "G" },
{ sha: "bbb", signatureStatus: "G" },
{ sha: "ccc", signatureStatus: "U" },
{ sha: "ddd", signatureStatus: "B" },
{ sha: "eee", signatureStatus: "X" },
];
let signed = 0,
unsigned = 0,
invalid = 0;
for (const commit of commits) {
if (commit.signatureStatus === "G") signed++;
else if (commit.signatureStatus === "U" || commit.signatureStatus === "N") unsigned++;
else invalid++;
}
expect(signed).toBe(2);
expect(unsigned).toBe(1);
expect(invalid).toBe(2);
});
test("signature counting: verified is true when no unsigned and no invalid", () => {
const commits = [
{ sha: "aaa", signatureStatus: "G" },
{ sha: "bbb", signatureStatus: "G" },
];
let unsigned = 0,
invalid = 0;
for (const commit of commits) {
if (commit.signatureStatus === "U" || commit.signatureStatus === "N") unsigned++;
else if (commit.signatureStatus !== "G") invalid++;
}
const verified = unsigned === 0 && invalid === 0;
expect(verified).toBe(true);
});
test("signature counting: verified is false when there are unsigned commits", () => {
const commits = [
{ sha: "aaa", signatureStatus: "G" },
{ sha: "bbb", signatureStatus: "U" },
];
let unsigned = 0,
invalid = 0;
for (const commit of commits) {
if (commit.signatureStatus === "U" || commit.signatureStatus === "N") unsigned++;
else if (commit.signatureStatus !== "G") invalid++;
}
const verified = unsigned === 0 && invalid === 0;
expect(verified).toBe(false);
});
// ---------------------------------------------------------------------------
// Lock file tests
// ---------------------------------------------------------------------------
test("lock: acquireLock fails when lock file already exists", async () => {
await mkdir(join(tempDir, ".git"), { recursive: true });
await writeFile(join(tempDir, ".git", "gitmesh.lock"), "exists");
const { open } = await import("node:fs/promises");
try {
await open(join(tempDir, ".git", "gitmesh.lock"), "wx");
expect.fail("should have thrown");
} catch (err) {
expect((err as NodeJS.ErrnoException).code).toBe("EEXIST");
}
});