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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10112,6 +10112,89 @@ describe("Automation routes", () => {
|
||||
// Without scope, listSchedules should be called without scope argument
|
||||
expect(automationStore.listSchedules).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
// ── Additional scope regression coverage ──────────────────────
|
||||
|
||||
it("POST /automations/:id/run with matching scope returns 200", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, scope: "global" as const });
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/automations/sched-001/run?scope=global");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.schedule).toBeDefined();
|
||||
expect(res.body.result).toBeDefined();
|
||||
});
|
||||
|
||||
it("POST /automations/:id/run with scope mismatch returns 404", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, scope: "global" as const });
|
||||
const { app } = buildApp(mockStore);
|
||||
// Request with scope=project but schedule is global-scoped
|
||||
const res = await REQUEST(app, "POST", "/api/automations/sched-001/run?scope=project");
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("Schedule not found");
|
||||
});
|
||||
|
||||
it("POST /automations/:id/toggle with matching scope returns 200", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, scope: "project" as const });
|
||||
mockStore.updateSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, scope: "project" as const, enabled: false });
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/automations/sched-001/toggle?scope=project");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("POST /automations/:id/toggle with scope mismatch returns 404", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, scope: "project" as const });
|
||||
const { app } = buildApp(mockStore);
|
||||
// Request with scope=global but schedule is project-scoped
|
||||
const res = await REQUEST(app, "POST", "/api/automations/sched-001/toggle?scope=global");
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("Schedule not found");
|
||||
});
|
||||
|
||||
it("POST /automations/:id/steps/reorder with matching scope returns 200", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
const scheduleWithSteps = {
|
||||
...FAKE_SCHEDULE,
|
||||
scope: "global" as const,
|
||||
steps: [
|
||||
{ id: "step-1", type: "command" as const, name: "Step 1", command: "echo 1" },
|
||||
{ id: "step-2", type: "command" as const, name: "Step 2", command: "echo 2" },
|
||||
],
|
||||
};
|
||||
mockStore.getSchedule.mockResolvedValue(scheduleWithSteps);
|
||||
mockStore.reorderSteps = vi.fn().mockResolvedValue(scheduleWithSteps);
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/automations/sched-001/steps/reorder?scope=global", JSON.stringify({
|
||||
stepIds: ["step-2", "step-1"],
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("GET /automations returns all when scope is omitted (legacy)", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
const globalSchedule = { ...FAKE_SCHEDULE, id: "sched-001", scope: "global" as const };
|
||||
const projectSchedule = { ...FAKE_SCHEDULE, id: "sched-002", scope: "project" as const };
|
||||
mockStore.listSchedules.mockResolvedValue([globalSchedule, projectSchedule]);
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await GET(app, "/api/automations");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("GET /automations returns empty array for scope with no matches", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
// Only project-scoped schedules exist
|
||||
const projectSchedule = { ...FAKE_SCHEDULE, scope: "project" as const };
|
||||
mockStore.listSchedules.mockResolvedValue([projectSchedule]);
|
||||
const { app } = buildApp(mockStore);
|
||||
// Filter by global scope, but only project schedules exist
|
||||
const res = await GET(app, "/api/automations?scope=global");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10863,6 +10946,83 @@ describe("Routine routes", () => {
|
||||
// Without scope, listRoutines should be called without scope argument
|
||||
expect(routineStore.listRoutines).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
// ── Additional scope regression coverage ──────────────────────
|
||||
|
||||
it("POST /routines/:id/webhook is scope-independent (webhooks use routine's own scope)", async () => {
|
||||
// Webhooks should NOT filter by request scope params - they use the routine's own scope
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockResolvedValue({
|
||||
...FAKE_ROUTINE,
|
||||
scope: "project" as const,
|
||||
trigger: { type: "webhook" as const, webhookPath: "/trigger/test" },
|
||||
});
|
||||
const { app, routineRunner } = buildRoutineApp(mockStore);
|
||||
// POST to webhook WITHOUT any scope param - should work regardless of scope
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-001/webhook", JSON.stringify({}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.result).toBeDefined();
|
||||
expect(routineRunner.triggerWebhook).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POST /routines/:id/trigger with matching scope returns 200", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockResolvedValue({ ...FAKE_ROUTINE, scope: "global" as const });
|
||||
const { app, routineRunner } = buildRoutineApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-001/trigger?scope=global");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.routine).toBeDefined();
|
||||
expect(res.body.result).toBeDefined();
|
||||
expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-001");
|
||||
});
|
||||
|
||||
it("POST /routines/:id/trigger with scope mismatch returns 404", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockResolvedValue({ ...FAKE_ROUTINE, scope: "global" as const });
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
// Request with scope=project but routine is global-scoped
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-001/trigger?scope=project");
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("Routine not found");
|
||||
});
|
||||
|
||||
it("GET /routines/:id/runs with matching scope returns 200", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockResolvedValue({
|
||||
...FAKE_ROUTINE,
|
||||
scope: "project" as const,
|
||||
runHistory: [
|
||||
{ routineId: "routine-001", startedAt: "2026-03-30T00:00:00.000Z", completedAt: "2026-03-30T00:01:00.000Z", success: true, output: "Test" },
|
||||
],
|
||||
});
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await GET(app, "/api/routines/routine-001/runs?scope=project");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("GET /routines returns all when scope is omitted (legacy)", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
const globalRoutine = { ...FAKE_ROUTINE, id: "routine-001", scope: "global" as const };
|
||||
const projectRoutine = { ...FAKE_ROUTINE, id: "routine-002", scope: "project" as const };
|
||||
mockStore.listRoutines.mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await GET(app, "/api/routines");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("GET /routines returns empty array for scope with no matches", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
// Only global-scoped routines exist
|
||||
const globalRoutine = { ...FAKE_ROUTINE, scope: "global" as const };
|
||||
mockStore.listRoutines.mockResolvedValue([globalRoutine]);
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
// Filter by project scope, but only global routines exist
|
||||
const res = await GET(app, "/api/routines?scope=project");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user