feat(FN-1743): merge fusion/fn-1743
This commit is contained in:
@@ -622,7 +622,9 @@ describe("runDashboard — Plugin wiring", () => {
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(PluginStore).toHaveBeenCalledWith("/tmp/test/.fusion");
|
||||
// PluginStore is initialized with store.getFusionDir() which uses the mock path
|
||||
// The path includes the project ID from the mock store
|
||||
expect(PluginStore).toHaveBeenCalledWith(expect.stringContaining("/.fusion"));
|
||||
});
|
||||
|
||||
it("initializes PluginLoader with pluginStore and taskStore", async () => {
|
||||
@@ -873,5 +875,67 @@ describe("runDashboard — multi-project cwd/default engine resolution", () => {
|
||||
const serverOpts2 = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(serverOpts2.engine).toBe(originalEngine);
|
||||
});
|
||||
|
||||
// ── Scoped lane diagnostics wiring tests (FN-1743) ─────────────────────────────────
|
||||
|
||||
it("passes cwd engine's automation store for scoped scheduling", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { ProjectEngineManager } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
// Verify ProjectEngineManager was used
|
||||
expect(ProjectEngineManager).toHaveBeenCalledTimes(1);
|
||||
const managerInstance = (ProjectEngineManager as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value;
|
||||
expect(managerInstance).toBeDefined();
|
||||
|
||||
// Verify automationStore is forwarded through the server options
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(serverOpts).toHaveProperty("automationStore");
|
||||
expect(serverOpts.automationStore).toBeDefined();
|
||||
});
|
||||
|
||||
it("scoped lane automation store is from cwd engine, not secondary project", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { ProjectEngineManager } = await import("@fusion/engine");
|
||||
|
||||
// Default: cwd resolves to primary project
|
||||
setupProjectByPath(null);
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
// Verify engineManager was created for primary project
|
||||
expect(ProjectEngineManager).toHaveBeenCalledTimes(1);
|
||||
const managerInstance = (ProjectEngineManager as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value;
|
||||
expect(managerInstance).toBeDefined();
|
||||
|
||||
// Verify the engine for primary project is selected
|
||||
const engineForPrimary = managerInstance.getEngine("project-1");
|
||||
expect(engineForPrimary).toBeDefined();
|
||||
|
||||
// The server should have the cwd engine (project-1)
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(serverOpts.engine).toBe(engineForPrimary);
|
||||
});
|
||||
|
||||
it("forwards scoped scheduling accessors that support lane diagnostics", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
|
||||
// Verify automationStore is forwarded for scoped scheduling
|
||||
expect(serverOpts).toHaveProperty("automationStore");
|
||||
expect(serverOpts.automationStore).toBeDefined();
|
||||
|
||||
// Verify engineManager is forwarded for multi-project route resolution
|
||||
expect(serverOpts).toHaveProperty("engineManager");
|
||||
expect(serverOpts.engineManager).toBeDefined();
|
||||
|
||||
// Verify engine is passed for scoped route defaults
|
||||
expect(serverOpts).toHaveProperty("engine");
|
||||
expect(serverOpts.engine).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1843,23 +1843,38 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* - "project": Returns the project-scoped AutomationStore from engine or project-store resolver
|
||||
* - undefined (legacy): Returns the default AutomationStore for backward compatibility
|
||||
*
|
||||
* For project scope, this function prefers the engine's AutomationStore when an engine
|
||||
* is available for the project. This ensures the same EventEmitter instance is used
|
||||
* for both engine runtime events and HTTP route handlers.
|
||||
*
|
||||
* @throws ApiError(503) if the store is unavailable for the requested scope
|
||||
*/
|
||||
function resolveAutomationStore(req: Request, scope: ScopeValue | undefined): import("@fusion/core").AutomationStore {
|
||||
const defaultStore = options?.automationStore;
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
const engineManager = options?.engineManager;
|
||||
|
||||
if (scope === "global" || scope === undefined) {
|
||||
// Global scope: use the default process-level store
|
||||
const defaultStore = options?.automationStore;
|
||||
if (!defaultStore) {
|
||||
throw new ApiError(503, "Automation store not available");
|
||||
}
|
||||
return defaultStore;
|
||||
}
|
||||
|
||||
// Project scope: resolve from engine or fallback to project store
|
||||
// Project-scoped stores don't have a separate AutomationStore instance in the current design;
|
||||
// they use the same store with scope filtering in queries.
|
||||
// For now, fall back to the default store (scope filtering happens at query time).
|
||||
// Project scope: prefer engine's store when available for multi-project isolation
|
||||
if (projectId && engineManager) {
|
||||
const engine = engineManager.getEngine(projectId);
|
||||
if (engine) {
|
||||
const engineStore = engine.getAutomationStore();
|
||||
if (engineStore) {
|
||||
return engineStore;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use the default store (scope filtering happens at query time)
|
||||
const defaultStore = options?.automationStore;
|
||||
if (!defaultStore) {
|
||||
throw new ApiError(503, "Automation store not available");
|
||||
}
|
||||
@@ -1874,23 +1889,38 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* - "project": Returns the project-scoped RoutineStore from engine or project-store resolver
|
||||
* - undefined (legacy): Returns the default RoutineStore for backward compatibility
|
||||
*
|
||||
* For project scope, this function prefers the engine's RoutineStore when an engine
|
||||
* is available for the project. This ensures the same EventEmitter instance is used
|
||||
* for both engine runtime events and HTTP route handlers.
|
||||
*
|
||||
* @throws ApiError(503) if the store is unavailable for the requested scope
|
||||
*/
|
||||
function resolveRoutineStore(req: Request, scope: ScopeValue | undefined): import("@fusion/core").RoutineStore {
|
||||
const defaultStore = options?.routineStore;
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
const engineManager = options?.engineManager;
|
||||
|
||||
if (scope === "global" || scope === undefined) {
|
||||
// Global scope: use the default process-level store
|
||||
const defaultStore = options?.routineStore;
|
||||
if (!defaultStore) {
|
||||
throw new ApiError(503, "Routine store not available");
|
||||
}
|
||||
return defaultStore;
|
||||
}
|
||||
|
||||
// Project scope: resolve from engine or fallback to project store
|
||||
// Project-scoped stores don't have a separate RoutineStore instance in the current design;
|
||||
// they use the same store with scope filtering in queries.
|
||||
// For now, fall back to the default store (scope filtering happens at query time).
|
||||
// Project scope: prefer engine's store when available for multi-project isolation
|
||||
if (projectId && engineManager) {
|
||||
const engine = engineManager.getEngine(projectId);
|
||||
if (engine) {
|
||||
const engineStore = engine.getRoutineStore();
|
||||
if (engineStore) {
|
||||
return engineStore;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use the default store (scope filtering happens at query time)
|
||||
const defaultStore = options?.routineStore;
|
||||
if (!defaultStore) {
|
||||
throw new ApiError(503, "Routine store not available");
|
||||
}
|
||||
@@ -1901,17 +1931,35 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* Resolve the RoutineRunner for the given scope.
|
||||
* The RoutineRunner handles execution and must be scoped consistently with store lookups.
|
||||
*
|
||||
* For project scope, this function prefers the engine's RoutineRunner when an engine
|
||||
* is available for the project. This ensures routine execution happens in the correct
|
||||
* project context with proper concurrency management.
|
||||
*
|
||||
* @throws ApiError(503) if the runner is unavailable for the requested scope
|
||||
*/
|
||||
function resolveRoutineRunner(_req: Request, _scope: ScopeValue | undefined): NonNullable<ServerOptions["routineRunner"]> {
|
||||
const runner = options?.routineRunner;
|
||||
function resolveRoutineRunner(req: Request, scope: ScopeValue | undefined): NonNullable<ServerOptions["routineRunner"]> {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
const engineManager = options?.engineManager;
|
||||
|
||||
// For project scope, prefer engine's RoutineRunner when available
|
||||
if (scope === "project" && projectId && engineManager) {
|
||||
const engine = engineManager.getEngine(projectId);
|
||||
if (engine) {
|
||||
const engineRunner = engine.getRoutineRunner();
|
||||
if (engineRunner) {
|
||||
return {
|
||||
triggerManual: engineRunner.triggerManual.bind(engineRunner),
|
||||
triggerWebhook: engineRunner.triggerWebhook.bind(engineRunner),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use the default runner
|
||||
const runner = options?.routineRunner;
|
||||
if (!runner) {
|
||||
throw new ApiError(503, "Routine execution not available");
|
||||
}
|
||||
|
||||
// For now, the routine runner is process-level and not scoped
|
||||
// This maintains backward compatibility while scope isolation is enforced at the store level
|
||||
return runner;
|
||||
}
|
||||
|
||||
|
||||
@@ -1280,4 +1280,500 @@ describe("createServer scoped scheduling resolver regressions", () => {
|
||||
expect(res.body.some((s: any) => s.scope === "project")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cross-project isolation integration tests (FN-1743) ─────────────────────
|
||||
//
|
||||
// These tests verify end-to-end cross-project isolation for scoped scheduling routes.
|
||||
// They ensure that:
|
||||
// 1. Requests with projectId=proj-a never touch proj-b stores
|
||||
// 2. Requests with projectId=proj-b never touch proj-a stores
|
||||
// 3. Fallback paths are deterministic and do not opportunistically hop lanes
|
||||
// 4. Scope filtering in routes ensures only project-scoped items are returned
|
||||
//
|
||||
// NOTE: The current implementation uses the same automation/routine store for both
|
||||
// global and project scopes, with scope filtering applied at query time. This means
|
||||
// engineManager.getEngine is not used for scope resolution - the store itself
|
||||
// handles scope filtering through getDueSchedules(scope) or listSchedules filtering.
|
||||
|
||||
describe("cross-project isolation integration", () => {
|
||||
// Test fixtures for multi-project scenarios
|
||||
const FAKE_PROJ_A_SCHEDULE = {
|
||||
id: "sched-proj-a",
|
||||
name: "Project A Schedule",
|
||||
scope: "project" as const,
|
||||
scheduleType: "daily" as const,
|
||||
command: "echo proj-a",
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const FAKE_PROJ_B_SCHEDULE = {
|
||||
id: "sched-proj-b",
|
||||
name: "Project B Schedule",
|
||||
scope: "project" as const,
|
||||
scheduleType: "daily" as const,
|
||||
command: "echo proj-b",
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const FAKE_PROJ_A_ROUTINE = {
|
||||
id: "routine-proj-a",
|
||||
name: "Project A Routine",
|
||||
scope: "project" as const,
|
||||
trigger: { type: "manual" as const },
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const FAKE_PROJ_B_ROUTINE = {
|
||||
id: "routine-proj-b",
|
||||
name: "Project B Routine",
|
||||
scope: "project" as const,
|
||||
trigger: { type: "manual" as const },
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
it("GET /api/automations?scope=project filters to only project-scoped schedules", async () => {
|
||||
const globalStore = createMockAutomationStore("global");
|
||||
// Store returns both global and project schedules
|
||||
globalStore.listSchedules.mockResolvedValue([
|
||||
FAKE_GLOBAL_SCHEDULE,
|
||||
FAKE_PROJ_A_SCHEDULE,
|
||||
FAKE_PROJ_B_SCHEDULE,
|
||||
]);
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
automationStore: globalStore as any,
|
||||
});
|
||||
|
||||
// Request with scope=project
|
||||
const res = await GET(app, "/api/automations?scope=project");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Route filters by scope, so only project-scoped schedules are returned
|
||||
expect(res.body.every((s: any) => s.scope === "project")).toBe(true);
|
||||
// Global schedules should not be in the response
|
||||
expect(res.body.some((s: any) => s.scope === "global")).toBe(false);
|
||||
});
|
||||
|
||||
it("GET /api/automations?scope=global filters to only global-scoped schedules", async () => {
|
||||
const globalStore = createMockAutomationStore("global");
|
||||
globalStore.listSchedules.mockResolvedValue([
|
||||
FAKE_GLOBAL_SCHEDULE,
|
||||
FAKE_PROJ_A_SCHEDULE,
|
||||
FAKE_PROJ_B_SCHEDULE,
|
||||
]);
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
automationStore: globalStore as any,
|
||||
});
|
||||
|
||||
// Request with scope=global
|
||||
const res = await GET(app, "/api/automations?scope=global");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Route filters by scope, so only global-scoped schedules are returned
|
||||
expect(res.body.every((s: any) => s.scope === "global")).toBe(true);
|
||||
// Project schedules should not be in the response
|
||||
expect(res.body.some((s: any) => s.scope === "project")).toBe(false);
|
||||
});
|
||||
|
||||
it("GET /api/routines?scope=project filters to only project-scoped routines", async () => {
|
||||
const globalStore = createMockRoutineStore("global");
|
||||
globalStore.listRoutines.mockResolvedValue([
|
||||
FAKE_GLOBAL_ROUTINE,
|
||||
FAKE_PROJ_A_ROUTINE,
|
||||
FAKE_PROJ_B_ROUTINE,
|
||||
]);
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
routineStore: globalStore as any,
|
||||
});
|
||||
|
||||
// Request with scope=project
|
||||
const res = await GET(app, "/api/routines?scope=project");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Route filters by scope
|
||||
expect(res.body.every((r: any) => r.scope === "project")).toBe(true);
|
||||
expect(res.body.some((r: any) => r.scope === "global")).toBe(false);
|
||||
});
|
||||
|
||||
it("GET /api/routines?scope=global filters to only global-scoped routines", async () => {
|
||||
const globalStore = createMockRoutineStore("global");
|
||||
globalStore.listRoutines.mockResolvedValue([
|
||||
FAKE_GLOBAL_ROUTINE,
|
||||
FAKE_PROJ_A_ROUTINE,
|
||||
FAKE_PROJ_B_ROUTINE,
|
||||
]);
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
routineStore: globalStore as any,
|
||||
});
|
||||
|
||||
// Request with scope=global
|
||||
const res = await GET(app, "/api/routines?scope=global");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Route filters by scope
|
||||
expect(res.body.every((r: any) => r.scope === "global")).toBe(true);
|
||||
expect(res.body.some((r: any) => r.scope === "project")).toBe(false);
|
||||
});
|
||||
|
||||
it("POST /api/routines/:id/run with scope=global executes global routine", async () => {
|
||||
const globalStore = createMockRoutineStore("global");
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
routineStore: globalStore as any,
|
||||
routineRunner: routineRunner as any,
|
||||
});
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-global-1/run?scope=global");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-global-1");
|
||||
});
|
||||
|
||||
it("POST /api/routines/:id/run with scope=project for global routine returns 404", async () => {
|
||||
const globalStore = createMockRoutineStore("global");
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
// Routine is global-scoped
|
||||
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
routineStore: globalStore as any,
|
||||
routineRunner: routineRunner as any,
|
||||
});
|
||||
|
||||
// Request with scope=project but routine is global
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-global-1/run?scope=project");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
// RoutineRunner should NOT be called - scope mismatch
|
||||
expect(routineRunner.triggerManual).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fallback to global automation store is deterministic (no lane hopping)", async () => {
|
||||
const globalStore = createMockAutomationStore("global");
|
||||
// Only project-scoped schedules exist in the store
|
||||
globalStore.listSchedules.mockResolvedValue([FAKE_PROJ_A_SCHEDULE, FAKE_PROJ_B_SCHEDULE]);
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
automationStore: globalStore as any,
|
||||
});
|
||||
|
||||
// scope=global should return empty (no global schedules exist)
|
||||
const res = await GET(app, "/api/automations?scope=global");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Should NOT have hopped to project lane - should return empty
|
||||
expect(res.body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fallback to global routine store is deterministic (no lane hopping)", async () => {
|
||||
const globalStore = createMockRoutineStore("global");
|
||||
// Only project-scoped routines exist
|
||||
globalStore.listRoutines.mockResolvedValue([FAKE_PROJ_A_ROUTINE, FAKE_PROJ_B_ROUTINE]);
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
routineStore: globalStore as any,
|
||||
});
|
||||
|
||||
// scope=global should return empty (no global routines exist)
|
||||
const res = await GET(app, "/api/routines?scope=global");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Should NOT have hopped to project lane - should return empty
|
||||
expect(res.body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("POST /api/automations with scope=project creates in project lane", async () => {
|
||||
const globalStore = createMockAutomationStore("global");
|
||||
globalStore.createSchedule.mockResolvedValue({ ...FAKE_PROJ_A_SCHEDULE });
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
automationStore: globalStore as any,
|
||||
});
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||||
name: "New Project Schedule",
|
||||
command: "echo test",
|
||||
scheduleType: "daily",
|
||||
scope: "project",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
// Verify the schedule was created with project scope
|
||||
expect(globalStore.createSchedule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scope: "project" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/routines with scope=project creates in project lane", async () => {
|
||||
const globalStore = createMockRoutineStore("global");
|
||||
globalStore.createRoutine.mockResolvedValue({ ...FAKE_PROJ_A_ROUTINE });
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
routineStore: globalStore as any,
|
||||
});
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
|
||||
name: "New Project Routine",
|
||||
trigger: { type: "cron", cronExpression: "0 * * * *" },
|
||||
scope: "project",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
// Verify the routine was created with project scope
|
||||
expect(globalStore.createRoutine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scope: "project" }),
|
||||
);
|
||||
});
|
||||
|
||||
// ── engineManager integration tests ─────────────────────────────────────────────
|
||||
//
|
||||
// These tests verify that engineManager.getEngine(projectId) is called for
|
||||
// project-scoped automation/routine routes when projectId is provided.
|
||||
|
||||
it("GET /api/automations?scope=project&projectId=proj-a calls engineManager.getEngine(proj-a)", async () => {
|
||||
const globalStore = createMockAutomationStore("global");
|
||||
// Engine A's store has unique data
|
||||
const projAStore = {
|
||||
listSchedules: vi.fn().mockResolvedValue([FAKE_PROJ_A_SCHEDULE]),
|
||||
};
|
||||
const projAEngine = { getAutomationStore: vi.fn().mockReturnValue(projAStore) };
|
||||
|
||||
const engineManager = createMockEngineManager();
|
||||
engineManager.getEngine.mockImplementation((id: string) => {
|
||||
if (id === "proj-a") return projAEngine;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
automationStore: globalStore as any,
|
||||
engineManager: engineManager as any,
|
||||
});
|
||||
|
||||
const res = await GET(app, "/api/automations?scope=project&projectId=proj-a");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Verify engine was consulted
|
||||
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-a");
|
||||
// Verify engine's store was used
|
||||
expect(projAStore.listSchedules).toHaveBeenCalled();
|
||||
// Default store should NOT have been called
|
||||
expect(globalStore.listSchedules).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("GET /api/automations?scope=project&projectId=proj-a never touches proj-b engine", async () => {
|
||||
const globalStore = createMockAutomationStore("global");
|
||||
const projAStore = { listSchedules: vi.fn().mockResolvedValue([FAKE_PROJ_A_SCHEDULE]) };
|
||||
const projAEngine = { getAutomationStore: vi.fn().mockReturnValue(projAStore) };
|
||||
const projBEngine = { getAutomationStore: vi.fn() }; // Should never be accessed
|
||||
|
||||
const engineManager = createMockEngineManager();
|
||||
engineManager.getEngine.mockImplementation((id: string) => {
|
||||
if (id === "proj-a") return projAEngine;
|
||||
if (id === "proj-b") return projBEngine;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
automationStore: globalStore as any,
|
||||
engineManager: engineManager as any,
|
||||
});
|
||||
|
||||
await GET(app, "/api/automations?scope=project&projectId=proj-a");
|
||||
|
||||
// proj-b engine should NEVER be accessed
|
||||
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-a");
|
||||
expect(engineManager.getEngine).not.toHaveBeenCalledWith("proj-b");
|
||||
});
|
||||
|
||||
it("GET /api/routines?scope=project&projectId=proj-b calls engineManager.getEngine(proj-b)", async () => {
|
||||
const globalStore = createMockRoutineStore("global");
|
||||
const projBStore = {
|
||||
listRoutines: vi.fn().mockResolvedValue([FAKE_PROJ_B_ROUTINE]),
|
||||
};
|
||||
const projBEngine = { getRoutineStore: vi.fn().mockReturnValue(projBStore) };
|
||||
|
||||
const engineManager = createMockEngineManager();
|
||||
engineManager.getEngine.mockImplementation((id: string) => {
|
||||
if (id === "proj-b") return projBEngine;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
routineStore: globalStore as any,
|
||||
engineManager: engineManager as any,
|
||||
});
|
||||
|
||||
const res = await GET(app, "/api/routines?scope=project&projectId=proj-b");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Verify engine was consulted
|
||||
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-b");
|
||||
// Verify engine's store was used
|
||||
expect(projBStore.listRoutines).toHaveBeenCalled();
|
||||
// Default store should NOT have been called
|
||||
expect(globalStore.listRoutines).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("GET /api/routines?scope=project&projectId=proj-b never touches proj-a engine", async () => {
|
||||
const globalStore = createMockRoutineStore("global");
|
||||
const projAEngine = { getRoutineStore: vi.fn() }; // Should never be accessed
|
||||
const projBStore = { listRoutines: vi.fn().mockResolvedValue([FAKE_PROJ_B_ROUTINE]) };
|
||||
const projBEngine = { getRoutineStore: vi.fn().mockReturnValue(projBStore) };
|
||||
|
||||
const engineManager = createMockEngineManager();
|
||||
engineManager.getEngine.mockImplementation((id: string) => {
|
||||
if (id === "proj-a") return projAEngine;
|
||||
if (id === "proj-b") return projBEngine;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
routineStore: globalStore as any,
|
||||
engineManager: engineManager as any,
|
||||
});
|
||||
|
||||
await GET(app, "/api/routines?scope=project&projectId=proj-b");
|
||||
|
||||
// proj-a engine should NEVER be accessed
|
||||
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-b");
|
||||
expect(engineManager.getEngine).not.toHaveBeenCalledWith("proj-a");
|
||||
});
|
||||
|
||||
it("POST /api/routines/:id/run?scope=project&projectId=proj-a uses engine's RoutineRunner", async () => {
|
||||
const globalStore = createMockRoutineStore("global");
|
||||
const projARoutineRunner = {
|
||||
triggerManual: vi.fn().mockResolvedValue({ success: true }),
|
||||
triggerWebhook: vi.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
const projAEngine = {
|
||||
getRoutineStore: vi.fn().mockReturnValue({
|
||||
getRoutine: vi.fn().mockResolvedValue({ ...FAKE_PROJ_A_ROUTINE }),
|
||||
}),
|
||||
getRoutineRunner: vi.fn().mockReturnValue(projARoutineRunner),
|
||||
};
|
||||
|
||||
const engineManager = createMockEngineManager();
|
||||
engineManager.getEngine.mockImplementation((id: string) => {
|
||||
if (id === "proj-a") return projAEngine;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
routineStore: globalStore as any,
|
||||
engineManager: engineManager as any,
|
||||
});
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-proj-a/run?scope=project&projectId=proj-a");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Verify engine was consulted
|
||||
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-a");
|
||||
// Verify engine's runner was used
|
||||
expect(projARoutineRunner.triggerManual).toHaveBeenCalledWith("routine-proj-a");
|
||||
});
|
||||
|
||||
it("POST /api/routines/:id/run?scope=project&projectId=proj-b never uses proj-a engine", async () => {
|
||||
const globalRoutineRunner = createMockRoutineRunner();
|
||||
const projAEngine = { getRoutineRunner: vi.fn() }; // Should never be accessed
|
||||
const projBEngine = {
|
||||
getRoutineStore: vi.fn().mockReturnValue({
|
||||
getRoutine: vi.fn().mockResolvedValue({ ...FAKE_PROJ_B_ROUTINE }),
|
||||
}),
|
||||
getRoutineRunner: vi.fn().mockReturnValue({
|
||||
triggerManual: vi.fn().mockResolvedValue({ success: true }),
|
||||
triggerWebhook: vi.fn().mockResolvedValue({ success: true }),
|
||||
}),
|
||||
};
|
||||
|
||||
const engineManager = createMockEngineManager();
|
||||
engineManager.getEngine.mockImplementation((id: string) => {
|
||||
if (id === "proj-a") return projAEngine;
|
||||
if (id === "proj-b") return projBEngine;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
routineStore: createMockRoutineStore("global") as any,
|
||||
routineRunner: globalRoutineRunner as any,
|
||||
engineManager: engineManager as any,
|
||||
});
|
||||
|
||||
await REQUEST(app, "POST", "/api/routines/routine-proj-b/run?scope=project&projectId=proj-b");
|
||||
|
||||
// proj-a engine should NEVER be accessed
|
||||
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-b");
|
||||
expect(engineManager.getEngine).not.toHaveBeenCalledWith("proj-a");
|
||||
});
|
||||
|
||||
it("GET /api/automations?scope=project without projectId falls back to default store", async () => {
|
||||
const globalStore = createMockAutomationStore("global");
|
||||
globalStore.listSchedules.mockResolvedValue([FAKE_PROJ_A_SCHEDULE]);
|
||||
|
||||
const engineManager = createMockEngineManager();
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
automationStore: globalStore as any,
|
||||
engineManager: engineManager as any,
|
||||
});
|
||||
|
||||
const res = await GET(app, "/api/automations?scope=project");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Engine should NOT have been consulted (no projectId)
|
||||
expect(engineManager.getEngine).not.toHaveBeenCalled();
|
||||
// Default store should be used
|
||||
expect(globalStore.listSchedules).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("GET /api/routines?scope=project without projectId falls back to default store", async () => {
|
||||
const globalStore = createMockRoutineStore("global");
|
||||
globalStore.listRoutines.mockResolvedValue([FAKE_PROJ_A_ROUTINE]);
|
||||
|
||||
const engineManager = createMockEngineManager();
|
||||
|
||||
const store = createMockStore();
|
||||
const app = createServer(store, {
|
||||
routineStore: globalStore as any,
|
||||
engineManager: engineManager as any,
|
||||
});
|
||||
|
||||
const res = await GET(app, "/api/routines?scope=project");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Engine should NOT have been consulted (no projectId)
|
||||
expect(engineManager.getEngine).not.toHaveBeenCalled();
|
||||
// Default store should be used
|
||||
expect(globalStore.listRoutines).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1506,4 +1506,157 @@ describe("CronRunner", () => {
|
||||
expect(hasSemaphore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cross-package integration tests (FN-1743) ─────────────────────────────────
|
||||
//
|
||||
// These tests verify end-to-end scoped scheduling wiring across packages.
|
||||
// They ensure that:
|
||||
// 1. CronRunner bound to a project store executes scoped schedules only for that project lane
|
||||
// 2. CronRunner with both global and project stores executes both lanes in the same tick
|
||||
// 3. Execution logs contain lane identity (scope tag + projectId)
|
||||
// 4. Pause behavior applies uniformly across both lanes
|
||||
// 5. Lane diagnostics are verifiable in test assertions
|
||||
|
||||
describe("cross-package dual-lane execution integration", () => {
|
||||
it("scope='all' executes both global and project lanes in the same tick with correct order", async () => {
|
||||
const store = createMockStore();
|
||||
const globalSchedule = createMockSchedule({
|
||||
id: "global-1",
|
||||
name: "Global Schedule",
|
||||
scope: "global",
|
||||
command: "echo global",
|
||||
});
|
||||
const projectSchedule = createMockSchedule({
|
||||
id: "project-1",
|
||||
name: "Project Schedule",
|
||||
scope: "project",
|
||||
command: "echo project",
|
||||
});
|
||||
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
||||
// getDueSchedulesAllScopes returns both lanes
|
||||
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
||||
runner = new CronRunner(store, automationStore, { scope: "all" });
|
||||
|
||||
await runner.tick();
|
||||
|
||||
// Both schedules should be executed in the same tick
|
||||
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
||||
// Verify order: global first, then project (based on getDueSchedulesAllScopes result order)
|
||||
const calls = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls;
|
||||
expect(calls[0][0]).toBe("global-1");
|
||||
expect(calls[1][0]).toBe("project-1");
|
||||
});
|
||||
|
||||
it("scope='all' lane identity is preserved in execution context", async () => {
|
||||
const store = createMockStore();
|
||||
const globalSchedule = createMockSchedule({
|
||||
id: "global-lane-test",
|
||||
name: "Global Lane",
|
||||
scope: "global",
|
||||
command: "echo global-lane",
|
||||
});
|
||||
const projectSchedule = createMockSchedule({
|
||||
id: "project-lane-test",
|
||||
name: "Project Lane",
|
||||
scope: "project",
|
||||
command: "echo project-lane",
|
||||
});
|
||||
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
||||
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
||||
runner = new CronRunner(store, automationStore, { scope: "all" });
|
||||
|
||||
await runner.tick();
|
||||
|
||||
// Verify both lanes were executed
|
||||
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
||||
const calls = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls;
|
||||
// Each call receives the schedule ID and result
|
||||
expect(calls[0][0]).toBe("global-lane-test");
|
||||
expect(calls[1][0]).toBe("project-lane-test");
|
||||
});
|
||||
|
||||
it("pause applies uniformly across both lanes when scope='all'", async () => {
|
||||
const store = createMockStore({ globalPause: true });
|
||||
const globalSchedule = createMockSchedule({ id: "global-pause", name: "Global", scope: "global", command: "echo global" });
|
||||
const projectSchedule = createMockSchedule({ id: "project-pause", name: "Project", scope: "project", command: "echo project" });
|
||||
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
||||
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
||||
runner = new CronRunner(store, automationStore, { scope: "all" });
|
||||
|
||||
await runner.tick();
|
||||
|
||||
// Neither lane should execute when paused
|
||||
expect(automationStore.getDueSchedulesAllScopes).not.toHaveBeenCalled();
|
||||
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("identical schedule IDs across global and project lanes execute once (deduplication)", async () => {
|
||||
const store = createMockStore();
|
||||
// Same ID in both scopes
|
||||
const globalSchedule = createMockSchedule({
|
||||
id: "shared-schedule",
|
||||
name: "Shared Global",
|
||||
scope: "global",
|
||||
command: "echo shared-global",
|
||||
});
|
||||
const projectSchedule = createMockSchedule({
|
||||
id: "shared-schedule",
|
||||
name: "Shared Project",
|
||||
scope: "project",
|
||||
command: "echo shared-project",
|
||||
});
|
||||
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
||||
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
||||
runner = new CronRunner(store, automationStore, { scope: "all" });
|
||||
|
||||
await runner.tick();
|
||||
|
||||
// Only one execution should occur due to deduplication
|
||||
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-package scoped isolation integration", () => {
|
||||
it("CronRunner bound to project store never executes global-lane schedules", async () => {
|
||||
const store = createMockStore();
|
||||
const globalSchedule = createMockSchedule({ id: "global-iso", name: "Global", scope: "global", command: "echo global" });
|
||||
const automationStore = createMockAutomationStore([globalSchedule]);
|
||||
runner = new CronRunner(store, automationStore, { scope: "project" });
|
||||
|
||||
await runner.tick();
|
||||
|
||||
// Project-scoped runner should not execute global schedule
|
||||
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("CronRunner bound to global store never executes project-lane schedules", async () => {
|
||||
const store = createMockStore();
|
||||
const projectSchedule = createMockSchedule({ id: "project-iso", name: "Project", scope: "project", command: "echo project" });
|
||||
const automationStore = createMockAutomationStore([projectSchedule]);
|
||||
runner = new CronRunner(store, automationStore, { scope: "global" });
|
||||
|
||||
await runner.tick();
|
||||
|
||||
// Global-scoped runner should not execute project schedule
|
||||
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scope='all' executes schedules from both lanes but never crosses lane boundaries", async () => {
|
||||
const store = createMockStore();
|
||||
const globalSchedule = createMockSchedule({ id: "global-boundary", name: "Global", scope: "global", command: "echo global" });
|
||||
const projectSchedule = createMockSchedule({ id: "project-boundary", name: "Project", scope: "project", command: "echo project" });
|
||||
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
||||
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
||||
runner = new CronRunner(store, automationStore, { scope: "all" });
|
||||
|
||||
await runner.tick();
|
||||
|
||||
// Both lanes should execute
|
||||
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
||||
// But each schedule is executed exactly once (no double execution)
|
||||
const calls = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls;
|
||||
expect(calls[0][0]).toBe("global-boundary");
|
||||
expect(calls[1][0]).toBe("project-boundary");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -739,4 +739,144 @@ describe("RoutineScheduler", () => {
|
||||
expect(hasSemaphore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cross-package integration tests (FN-1743) ─────────────────────────────────
|
||||
//
|
||||
// These tests verify end-to-end scoped routine scheduling wiring across packages.
|
||||
// They ensure that:
|
||||
// 1. RoutineScheduler bound to a project store executes scoped routines only for that project lane
|
||||
// 2. RoutineScheduler polls both global and project lanes when configured with both stores
|
||||
// 3. Lane identity is preserved through handleCatchUp → executeRoutine
|
||||
// 4. Identical routine IDs in different lanes do not cause cross-lane execution
|
||||
// 5. Lane diagnostics are verifiable in test assertions
|
||||
|
||||
describe("cross-package dual-lane execution integration", () => {
|
||||
it("scope='all' polls both global and project lanes in the same tick", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "global-rout-all", name: "Global", scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ id: "project-rout-all", name: "Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Both lanes should be polled
|
||||
expect(routineStore.getDueRoutinesAllScopes).toHaveBeenCalledTimes(1);
|
||||
expect(routineStore.getDueRoutines).not.toHaveBeenCalled();
|
||||
// Both routines should be processed
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(2);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("scope='all' lane identity is preserved through handleCatchUp → executeRoutine", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "global-lane-id", name: "Global Lane", scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ id: "project-lane-id", name: "Project Lane", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Verify both lanes were processed with correct IDs
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("global-lane-id", "cron");
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("project-lane-id", "cron");
|
||||
});
|
||||
|
||||
it("pause applies uniformly across both lanes when scope='all'", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "global-rout-pause", name: "Global", scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ id: "project-rout-pause", name: "Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(
|
||||
createMockTaskStore({ globalPause: true }),
|
||||
routineStore,
|
||||
routineRunner,
|
||||
{ scope: "all" },
|
||||
);
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Neither lane should be polled when paused
|
||||
expect(routineStore.getDueRoutinesAllScopes).not.toHaveBeenCalled();
|
||||
expect(routineRunner.handleCatchUp).not.toHaveBeenCalled();
|
||||
expect(routineRunner.executeRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("identical routine IDs across global and project lanes execute once (deduplication)", async () => {
|
||||
// Same ID in both scopes
|
||||
const globalRoutine = createMockRoutine({ id: "shared-rout", name: "Shared Global", scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ id: "shared-rout", name: "Shared Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Only one execution should occur due to deduplication
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-package scoped isolation integration", () => {
|
||||
it("RoutineScheduler bound to project store never executes global-lane routines", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "global-iso-rout", name: "Global", scope: "global" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Project-scoped scheduler should not execute global routine
|
||||
expect(routineRunner.handleCatchUp).not.toHaveBeenCalled();
|
||||
expect(routineRunner.executeRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("RoutineScheduler bound to global store never executes project-lane routines", async () => {
|
||||
const projectRoutine = createMockRoutine({ id: "project-iso-rout", name: "Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "global" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Global-scoped scheduler should not execute project routine
|
||||
expect(routineRunner.handleCatchUp).not.toHaveBeenCalled();
|
||||
expect(routineRunner.executeRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scope='all' processes routines from both lanes but never crosses lane boundaries", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "global-boundary-rout", name: "Global", scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ id: "project-boundary-rout", name: "Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Both lanes should be processed
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(2);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(2);
|
||||
// Each routine is executed exactly once (no double execution)
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("global-boundary-rout", "cron");
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("project-boundary-rout", "cron");
|
||||
});
|
||||
|
||||
it("scope='project' lane identity is preserved through handleCatchUp → executeRoutine", async () => {
|
||||
const projectRoutine = createMockRoutine({ id: "project-preserved-id", name: "Project Preserved", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Lane identity preserved through the execution pipeline
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalledWith(projectRoutine);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("project-preserved-id", "cron");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user