Skip to content

Commit 032ffb5

Browse files
authored
test(services): cover buildAutomationState's branch logic directly (#7545)
buildAutomationState is the single shared function computing the derived automation-state view (mode / permissionReadiness / actingActionClasses / pendingActionCount) for the REST route, the MCP tool, and the CLI, yet it had no dedicated test -- only incidental happy-path coverage through one CLI test and one REST integration test, neither of which drives the branches that make the extraction worthwhile. Add test/unit/automation-state.test.ts exercising, with only the IO edges stubbed so the real fold logic runs: an installed repo with a mix of acting and non-acting classes (live/ready/configured), a missing repo and a missing installationId (installation resolves to null, permissionReadiness re-consents without throwing), an installation row absent despite an id, the global env pause short-circuiting the DB freeze read, the DB global-freeze branch, and the repo-level dry-run/pause precedence -- plus the configured contract on both sides and a direct automationStateSummary formatting check. Closes #7536 Co-authored-by: bitfathers94 <237535319+bitfathers94@users.noreply.github.com>
1 parent 1a019ab commit 032ffb5

1 file changed

Lines changed: 184 additions & 0 deletions

File tree

test/unit/automation-state.test.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
// buildAutomationState folds three DB/settings reads into the derived automation-state view (#6742). We stub
4+
// only those IO edges (getRepository / getInstallation / countPendingAgentActions / isGlobalAgentFrozen /
5+
// resolveRepositorySettings) so the REAL fold logic -- resolveAgentActionMode, resolveAgentPermissionReadiness,
6+
// the acting-class filter, and the missing-repo/installation branches -- is exercised directly, not through the
7+
// REST/MCP/CLI happy-path tests that are its only incidental coverage today.
8+
vi.mock("../../src/db/repositories", () => ({
9+
getRepository: vi.fn(),
10+
getInstallation: vi.fn(),
11+
countPendingAgentActions: vi.fn(),
12+
isGlobalAgentFrozen: vi.fn(),
13+
}));
14+
15+
vi.mock("../../src/settings/repository-settings", () => ({
16+
resolveRepositorySettings: vi.fn(),
17+
}));
18+
19+
import { countPendingAgentActions, getInstallation, getRepository, isGlobalAgentFrozen } from "../../src/db/repositories";
20+
import { automationStateSummary, buildAutomationState, type AutomationState } from "../../src/services/automation-state";
21+
import { resolveRepositorySettings } from "../../src/settings/repository-settings";
22+
import type { InstallationRecord, RepositoryRecord, RepositorySettings } from "../../src/types";
23+
24+
const REPO = "acme/widgets";
25+
26+
function makeSettings(overrides: Partial<RepositorySettings> = {}): RepositorySettings {
27+
// buildAutomationState reads only these four fields; the rest of the (large) settings row is irrelevant here.
28+
return { repoFullName: REPO, autonomy: {}, autoMaintain: { requireApprovals: 1, mergeMethod: "squash" }, agentPaused: false, agentDryRun: false, ...overrides } as unknown as RepositorySettings;
29+
}
30+
31+
function makeRepo(overrides: Partial<RepositoryRecord> = {}): RepositoryRecord {
32+
return { fullName: REPO, owner: "acme", name: "widgets", isInstalled: true, isRegistered: true, isPrivate: false, ...overrides };
33+
}
34+
35+
function makeInstallation(permissions: Record<string, string>): InstallationRecord {
36+
return { id: 42, accountLogin: "acme", accountId: 7, targetType: "Organization", permissions, events: [] };
37+
}
38+
39+
function envWith(pausedFlag = ""): Env {
40+
return { AGENT_ACTIONS_PAUSED: pausedFlag } as unknown as Env;
41+
}
42+
43+
const mockGetRepository = vi.mocked(getRepository);
44+
const mockGetInstallation = vi.mocked(getInstallation);
45+
const mockCountPending = vi.mocked(countPendingAgentActions);
46+
const mockFrozen = vi.mocked(isGlobalAgentFrozen);
47+
const mockSettings = vi.mocked(resolveRepositorySettings);
48+
49+
beforeEach(() => {
50+
vi.clearAllMocks();
51+
// Sensible defaults each test overrides only what it exercises.
52+
mockGetRepository.mockResolvedValue(makeRepo({ installationId: 42 }));
53+
mockGetInstallation.mockResolvedValue(makeInstallation({ pull_requests: "write", contents: "write" }));
54+
mockCountPending.mockResolvedValue(0);
55+
mockFrozen.mockResolvedValue(false);
56+
mockSettings.mockResolvedValue(makeSettings());
57+
});
58+
59+
describe("buildAutomationState", () => {
60+
it("derives a live, ready, configured view for an installed repo with a mix of acting and non-acting classes", async () => {
61+
// review/merge act (auto), close is deny-by-default observe -> only the acting classes survive the filter.
62+
mockSettings.mockResolvedValue(makeSettings({ autonomy: { review: "auto", merge: "auto", close: "observe" } }));
63+
mockCountPending.mockResolvedValue(3);
64+
65+
const state = await buildAutomationState(envWith(), REPO);
66+
67+
expect(state).toMatchObject({
68+
repoFullName: REPO,
69+
configured: true,
70+
mode: "live",
71+
permissionReadiness: "ready",
72+
agentPaused: false,
73+
agentDryRun: false,
74+
pendingActionCount: 3,
75+
});
76+
expect(state.actingActionClasses).toEqual(["review", "merge"]);
77+
expect(state.actingActionClasses).not.toContain("close");
78+
expect(mockGetInstallation).toHaveBeenCalledWith(expect.anything(), 42);
79+
});
80+
81+
it("locks the configured = actingActionClasses.length > 0 contract to false when no class acts", async () => {
82+
mockSettings.mockResolvedValue(makeSettings({ autonomy: {} }));
83+
84+
const state = await buildAutomationState(envWith(), REPO);
85+
86+
expect(state.actingActionClasses).toEqual([]);
87+
expect(state.configured).toBe(false);
88+
// No acting class needs a write scope, so readiness collapses to not_required regardless of installation.
89+
expect(state.permissionReadiness).toBe("not_required");
90+
});
91+
92+
it("treats a missing repo (getRepository null) as no installation without throwing", async () => {
93+
mockGetRepository.mockResolvedValue(null);
94+
mockSettings.mockResolvedValue(makeSettings({ autonomy: { review: "auto" } }));
95+
96+
const state = await buildAutomationState(envWith(), REPO);
97+
98+
expect(mockGetInstallation).not.toHaveBeenCalled();
99+
// review acts -> pull_requests:write required, but with no installation permissions readiness must re-consent.
100+
expect(state.permissionReadiness).toBe("reconsent_required");
101+
expect(state.configured).toBe(true);
102+
});
103+
104+
it("skips the installation lookup when the repo row carries no installationId", async () => {
105+
mockGetRepository.mockResolvedValue(makeRepo({ installationId: null }));
106+
mockSettings.mockResolvedValue(makeSettings({ autonomy: { merge: "auto" } }));
107+
108+
const state = await buildAutomationState(envWith(), REPO);
109+
110+
expect(mockGetInstallation).not.toHaveBeenCalled();
111+
// merge acts -> contents:write required, none granted -> re-consent.
112+
expect(state.permissionReadiness).toBe("reconsent_required");
113+
});
114+
115+
it("derives null installation permissions when the installation row is missing despite an id", async () => {
116+
mockGetInstallation.mockResolvedValue(null);
117+
mockSettings.mockResolvedValue(makeSettings({ autonomy: { review: "auto" } }));
118+
119+
const state = await buildAutomationState(envWith(), REPO);
120+
121+
expect(mockGetInstallation).toHaveBeenCalledWith(expect.anything(), 42);
122+
expect(state.permissionReadiness).toBe("reconsent_required");
123+
});
124+
125+
it("resolves paused from the global env pause without consulting the DB freeze flag", async () => {
126+
// Repo-level flags are both false; the global env kill-switch alone must force paused, and short-circuit the
127+
// `||` so isGlobalAgentFrozen is never read.
128+
mockSettings.mockResolvedValue(makeSettings({ agentPaused: false, agentDryRun: false }));
129+
130+
const state = await buildAutomationState(envWith("true"), REPO);
131+
132+
expect(state.mode).toBe("paused");
133+
expect(mockFrozen).not.toHaveBeenCalled();
134+
});
135+
136+
it("resolves paused from the DB global freeze when the env pause is off", async () => {
137+
mockFrozen.mockResolvedValue(true);
138+
mockSettings.mockResolvedValue(makeSettings({ agentPaused: false, agentDryRun: false }));
139+
140+
const state = await buildAutomationState(envWith(""), REPO);
141+
142+
expect(mockFrozen).toHaveBeenCalledTimes(1);
143+
expect(state.mode).toBe("paused");
144+
});
145+
146+
it("resolves dry_run from the repo-level dry-run flag when nothing global is engaged", async () => {
147+
mockSettings.mockResolvedValue(makeSettings({ agentPaused: false, agentDryRun: true }));
148+
149+
const state = await buildAutomationState(envWith(), REPO);
150+
151+
expect(state.mode).toBe("dry_run");
152+
expect(state.agentDryRun).toBe(true);
153+
expect(state.agentPaused).toBe(false);
154+
});
155+
156+
it("resolves paused from the repo-level pause flag over dry-run", async () => {
157+
mockSettings.mockResolvedValue(makeSettings({ agentPaused: true, agentDryRun: true }));
158+
159+
const state = await buildAutomationState(envWith(), REPO);
160+
161+
expect(state.mode).toBe("paused");
162+
expect(state.agentPaused).toBe(true);
163+
expect(state.agentDryRun).toBe(true);
164+
});
165+
});
166+
167+
describe("automationStateSummary", () => {
168+
it("formats the one-line human summary from a representative state", () => {
169+
const state: AutomationState = {
170+
repoFullName: "acme/widgets",
171+
configured: true,
172+
autonomy: { review: "auto", merge: "auto" },
173+
autoMaintain: { requireApprovals: 1, mergeMethod: "squash" },
174+
agentPaused: false,
175+
agentDryRun: false,
176+
mode: "live",
177+
permissionReadiness: "ready",
178+
actingActionClasses: ["review", "merge"],
179+
pendingActionCount: 2,
180+
};
181+
182+
expect(automationStateSummary(state)).toBe("Agent automation for acme/widgets: mode=live, 2 acting class(es), 2 pending approval(s).");
183+
});
184+
});

0 commit comments

Comments
 (0)