test(FN-1742): add scope regression tests for routines and automations

- Add scope regression tests for dashboard routine routes
- Add scope regression tests for dashboard automation routes
- Add scope regression tests for RoutineStore
- Add scope regression tests for AutomationStore
- Total 609 lines of test coverage across 3 test files
This commit is contained in:
Fusion
2026-04-15 23:33:45 -07:00
committed by gsxdsm
parent dfa5823694
commit 14a8f76b37
3 changed files with 609 additions and 0 deletions

View File

@@ -747,4 +747,196 @@ describe("AutomationStore", () => {
expect(final.runHistory).toHaveLength(10);
});
});
// ── Scope-aware scheduling ─────────────────────────────────────────
describe("scope-aware scheduling", () => {
it("createSchedule without scope defaults to 'project'", async () => {
const schedule = await store.createSchedule({
name: "Default scope",
command: "echo default",
scheduleType: "hourly",
});
expect(schedule.scope).toBe("project");
// Verify round-trip persistence
const fetched = await store.getSchedule(schedule.id);
expect(fetched.scope).toBe("project");
});
it("createSchedule with scope='global' persists correctly", async () => {
const schedule = await store.createSchedule({
name: "Global scope",
command: "echo global",
scheduleType: "hourly",
scope: "global",
});
expect(schedule.scope).toBe("global");
// Verify round-trip persistence
const fetched = await store.getSchedule(schedule.id);
expect(fetched.scope).toBe("global");
});
it("listSchedules returns both global and project scopes", async () => {
const global = await store.createSchedule({
name: "Global",
command: "echo",
scheduleType: "hourly",
scope: "global",
});
const project = await store.createSchedule({
name: "Project",
command: "echo",
scheduleType: "hourly",
scope: "project",
});
const list = await store.listSchedules();
expect(list).toHaveLength(2);
const globalFound = list.find((s) => s.id === global.id);
const projectFound = list.find((s) => s.id === project.id);
expect(globalFound?.scope).toBe("global");
expect(projectFound?.scope).toBe("project");
});
it("getDueSchedules filters by scope - global only", async () => {
const global = await store.createSchedule({
name: "Global due",
command: "echo",
scheduleType: "hourly",
scope: "global",
});
const project = await store.createSchedule({
name: "Project due",
command: "echo",
scheduleType: "hourly",
scope: "project",
});
// Set nextRunAt to the past via direct DB update
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(pastDate, global.id);
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(pastDate, project.id);
const globalDue = await store.getDueSchedules("global");
expect(globalDue.some((s) => s.id === global.id)).toBe(true);
expect(globalDue.some((s) => s.id === project.id)).toBe(false);
const projectDue = await store.getDueSchedules("project");
expect(projectDue.some((s) => s.id === project.id)).toBe(true);
expect(projectDue.some((s) => s.id === global.id)).toBe(false);
});
it("getDueSchedulesAllScopes returns schedules from both scopes", async () => {
const global = await store.createSchedule({
name: "Global due",
command: "echo",
scheduleType: "hourly",
scope: "global",
});
const project = await store.createSchedule({
name: "Project due",
command: "echo",
scheduleType: "hourly",
scope: "project",
});
// Set nextRunAt to the past via direct DB update
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(pastDate, global.id);
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(pastDate, project.id);
const allDue = await store.getDueSchedulesAllScopes();
expect(allDue.some((s) => s.id === global.id)).toBe(true);
expect(allDue.some((s) => s.id === project.id)).toBe(true);
});
it("getDueSchedules does not leak scopes - global not in project", async () => {
const global = await store.createSchedule({
name: "Global only",
command: "echo",
scheduleType: "hourly",
scope: "global",
});
// Set nextRunAt to the past
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(pastDate, global.id);
const projectDue = await store.getDueSchedules("project");
expect(projectDue.some((s) => s.id === global.id)).toBe(false);
});
it("getDueSchedules does not leak scopes - project not in global", async () => {
const project = await store.createSchedule({
name: "Project only",
command: "echo",
scheduleType: "hourly",
scope: "project",
});
// Set nextRunAt to the past
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(pastDate, project.id);
const globalDue = await store.getDueSchedules("global");
expect(globalDue.some((s) => s.id === project.id)).toBe(false);
});
it("recordRun preserves scope", async () => {
const schedule = await store.createSchedule({
name: "Scope preservation",
command: "echo",
scheduleType: "hourly",
scope: "global",
});
await store.recordRun(schedule.id, {
success: true,
output: "ok",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
});
const fetched = await store.getSchedule(schedule.id);
expect(fetched.scope).toBe("global");
});
it("updateSchedule does not change scope when not specified", async () => {
const schedule = await store.createSchedule({
name: "Original",
command: "echo",
scheduleType: "hourly",
scope: "global",
});
await store.updateSchedule(schedule.id, { name: "Updated" });
const fetched = await store.getSchedule(schedule.id);
expect(fetched.scope).toBe("global");
expect(fetched.name).toBe("Updated");
});
it("updateSchedule does not change scope when scope is specified (scope is immutable after creation)", async () => {
// Note: ScheduledTaskUpdateInput includes scope, but updateSchedule implementation
// does not handle it. Scope is effectively immutable after creation.
const schedule = await store.createSchedule({
name: "Scope immutable",
command: "echo",
scheduleType: "hourly",
scope: "project",
});
await store.updateSchedule(schedule.id, { name: "Updated", scope: "global" });
const fetched = await store.getSchedule(schedule.id);
// Scope remains unchanged because updateSchedule doesn't handle scope updates
expect(fetched.scope).toBe("project");
expect(fetched.name).toBe("Updated");
});
});
});

View File

@@ -597,4 +597,261 @@ describe("RoutineStore", () => {
expect(final.runHistory).toHaveLength(10);
});
});
// ── Scope-aware routines ─────────────────────────────────────────
describe("scope-aware routines", () => {
it("createRoutine without scope defaults to 'project'", async () => {
const routine = await store.createRoutine({
name: "Default scope",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
});
expect(routine.scope).toBe("project");
// Verify round-trip persistence
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("project");
});
it("createRoutine with scope='global' persists correctly", async () => {
const routine = await store.createRoutine({
name: "Global scope",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
expect(routine.scope).toBe("global");
// Verify round-trip persistence
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
});
it("listRoutines returns both global and project scopes", async () => {
const global = await store.createRoutine({
name: "Global",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
const project = await store.createRoutine({
name: "Project",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
});
const list = await store.listRoutines();
expect(list).toHaveLength(2);
const globalFound = list.find((r) => r.id === global.id);
const projectFound = list.find((r) => r.id === project.id);
expect(globalFound?.scope).toBe("global");
expect(projectFound?.scope).toBe("project");
});
it("getDueRoutines filters by scope - global only", async () => {
const global = await store.createRoutine({
name: "Global due",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
const project = await store.createRoutine({
name: "Project due",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
});
// Set nextRunAt to the past via direct DB update
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE routines SET nextRunAt = ? WHERE id = ?").run(pastDate, global.id);
store["db"].prepare("UPDATE routines SET nextRunAt = ? WHERE id = ?").run(pastDate, project.id);
const globalDue = await store.getDueRoutines("global");
expect(globalDue.some((r) => r.id === global.id)).toBe(true);
expect(globalDue.some((r) => r.id === project.id)).toBe(false);
const projectDue = await store.getDueRoutines("project");
expect(projectDue.some((r) => r.id === project.id)).toBe(true);
expect(projectDue.some((r) => r.id === global.id)).toBe(false);
});
it("getDueRoutinesAllScopes returns routines from both scopes", async () => {
const global = await store.createRoutine({
name: "Global due",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
const project = await store.createRoutine({
name: "Project due",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
});
// Set nextRunAt to the past via direct DB update
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE routines SET nextRunAt = ? WHERE id = ?").run(pastDate, global.id);
store["db"].prepare("UPDATE routines SET nextRunAt = ? WHERE id = ?").run(pastDate, project.id);
const allDue = await store.getDueRoutinesAllScopes();
expect(allDue.some((r) => r.id === global.id)).toBe(true);
expect(allDue.some((r) => r.id === project.id)).toBe(true);
});
it("getDueRoutines does not leak scopes - global not in project", async () => {
const global = await store.createRoutine({
name: "Global only",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
// Set nextRunAt to the past
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE routines SET nextRunAt = ? WHERE id = ?").run(pastDate, global.id);
const projectDue = await store.getDueRoutines("project");
expect(projectDue.some((r) => r.id === global.id)).toBe(false);
});
it("getDueRoutines does not leak scopes - project not in global", async () => {
const project = await store.createRoutine({
name: "Project only",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
});
// Set nextRunAt to the past
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE routines SET nextRunAt = ? WHERE id = ?").run(pastDate, project.id);
const globalDue = await store.getDueRoutines("global");
expect(globalDue.some((r) => r.id === project.id)).toBe(false);
});
it("recordRun preserves scope", async () => {
const routine = await store.createRoutine({
name: "Scope preservation",
agentId: "test-agent",
trigger: { type: "manual" },
scope: "global",
});
await store.recordRun(routine.id, {
routineId: routine.id,
success: true,
output: "ok",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
});
it("trigger type variants with scope persist correctly - cron", async () => {
const routine = await store.createRoutine({
name: "Cron with global",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
expect(fetched.trigger.type).toBe("cron");
});
it("trigger type variants with scope persist correctly - webhook", async () => {
const routine = await store.createRoutine({
name: "Webhook with global",
agentId: "test-agent",
trigger: { type: "webhook", webhookPath: "/trigger/test" },
scope: "global",
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
expect(fetched.trigger.type).toBe("webhook");
});
it("trigger type variants with scope persist correctly - api", async () => {
const routine = await store.createRoutine({
name: "API with global",
agentId: "test-agent",
trigger: { type: "api", endpoint: "/api/test" },
scope: "global",
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
expect(fetched.trigger.type).toBe("api");
});
it("trigger type variants with scope persist correctly - manual", async () => {
const routine = await store.createRoutine({
name: "Manual with global",
agentId: "test-agent",
trigger: { type: "manual" },
scope: "global",
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
expect(fetched.trigger.type).toBe("manual");
});
it("startRoutineExecution preserves scope", async () => {
const routine = await store.createRoutine({
name: "Start scope test",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
await store.startRoutineExecution(routine.id, {
triggeredAt: new Date().toISOString(),
invocationSource: "test",
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
});
it("completeRoutineExecution preserves scope", async () => {
const routine = await store.createRoutine({
name: "Complete scope test",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
await store.completeRoutineExecution(routine.id, {
completedAt: new Date().toISOString(),
success: true,
resultJson: { output: "ok" },
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
});
it("cancelRoutineExecution preserves scope", async () => {
const routine = await store.createRoutine({
name: "Cancel scope test",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
await store.cancelRoutineExecution(routine.id);
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
});
});
});