feat(FN-1275): add Routines tab to ScheduledTasksModal
- Add RoutineCard component with status display and run history - Add RoutineEditor form with trigger, agent, policy, and model fields - Integrate Routines tab into ScheduledTasksModal alongside Scheduled Tasks - Add CSS styles for routine components - Add comprehensive tests for RoutineCard, RoutineEditor, and ScheduledTasksModal
This commit is contained in:
279
packages/dashboard/app/components/__tests__/RoutineCard.test.tsx
Normal file
279
packages/dashboard/app/components/__tests__/RoutineCard.test.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { RoutineCard } from "../RoutineCard";
|
||||
import type { Routine, RoutineExecutionResult, RoutineTriggerType } from "@fusion/core";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
Plus: () => <span data-testid="icon-plus">+</span>,
|
||||
Play: () => <span data-testid="icon-play">▶</span>,
|
||||
Pause: () => <span data-testid="icon-pause">⏸</span>,
|
||||
Pencil: () => <span data-testid="icon-pencil">✎</span>,
|
||||
Trash2: () => <span data-testid="icon-trash">🗑</span>,
|
||||
Clock: () => <span data-testid="icon-clock">🕐</span>,
|
||||
CheckCircle: () => <span data-testid="icon-check">✓</span>,
|
||||
XCircle: () => <span data-testid="icon-x">✗</span>,
|
||||
ChevronDown: () => <span data-testid="icon-down">▼</span>,
|
||||
ChevronUp: () => <span data-testid="icon-up">▲</span>,
|
||||
Calendar: () => <span data-testid="icon-calendar">📅</span>,
|
||||
Webhook: () => <span data-testid="icon-webhook">🔗</span>,
|
||||
Code: () => <span data-testid="icon-code">💻</span>,
|
||||
Zap: () => <span data-testid="icon-zap">⚡</span>,
|
||||
}));
|
||||
|
||||
// Mock @fusion/core (no runtime values needed)
|
||||
vi.mock("@fusion/core", () => ({}));
|
||||
|
||||
function makeRoutine(overrides: Partial<Routine> = {}): Routine {
|
||||
return {
|
||||
id: "routine-001",
|
||||
name: "Test Routine",
|
||||
description: "A test routine",
|
||||
trigger: { type: "cron", cronExpression: "0 * * * *" },
|
||||
executionPolicy: "parallel",
|
||||
catchUpPolicy: "skip",
|
||||
enabled: true,
|
||||
runCount: 3,
|
||||
runHistory: [],
|
||||
createdAt: "2026-04-07T00:00:00.000Z",
|
||||
updatedAt: "2026-04-07T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRunResult(overrides: Partial<RoutineExecutionResult> = {}): RoutineExecutionResult {
|
||||
return {
|
||||
routineId: "routine-001",
|
||||
success: true,
|
||||
output: "Done",
|
||||
startedAt: "2026-04-08T00:00:00.000Z",
|
||||
completedAt: "2026-04-08T00:01:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RoutineCard", () => {
|
||||
const onEdit = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const onRun = vi.fn();
|
||||
const onToggle = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("renders routine name and trigger type badge (cron)", () => {
|
||||
render(<RoutineCard routine={makeRoutine()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("Test Routine")).toBeDefined();
|
||||
expect(screen.getByText("Cron")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders cron expression for cron triggers", () => {
|
||||
render(<RoutineCard routine={makeRoutine()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("0 * * * *")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders Webhook label for webhook triggers (no cron expression shown)", () => {
|
||||
const routine = makeRoutine({ trigger: { type: "webhook", webhookPath: "/trigger/test", secret: "secret123" } });
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("Webhook")).toBeDefined();
|
||||
expect(screen.queryByText("0 * * * *")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders API label for api triggers", () => {
|
||||
const routine = makeRoutine({ trigger: { type: "api", endpoint: "/api/test" } });
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("API")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders Manual label for manual triggers", () => {
|
||||
const routine = makeRoutine({ trigger: { type: "manual" } });
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("Manual")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders execution policy badge (parallel)", () => {
|
||||
render(<RoutineCard routine={makeRoutine({ executionPolicy: "parallel" })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("Concurrent")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders execution policy badge (queue)", () => {
|
||||
render(<RoutineCard routine={makeRoutine({ executionPolicy: "queue" })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("Queued")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders catch-up policy badge (skip)", () => {
|
||||
render(<RoutineCard routine={makeRoutine({ catchUpPolicy: "skip" })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("Skip missed")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders catch-up policy badge (run_one)", () => {
|
||||
render(<RoutineCard routine={makeRoutine({ catchUpPolicy: "run_one" })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("Catch up (latest)")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders next run time", () => {
|
||||
const futureDate = new Date(Date.now() + 3600000).toISOString();
|
||||
render(<RoutineCard routine={makeRoutine({ nextRunAt: futureDate })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText(/Next:/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders last run time", () => {
|
||||
render(<RoutineCard routine={makeRoutine({ lastRunAt: "2026-04-08T00:00:00.000Z" })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText(/Last:/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders run count", () => {
|
||||
render(<RoutineCard routine={makeRoutine({ runCount: 5 })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("Runs:")).toBeDefined();
|
||||
expect(screen.getByText("5")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows disabled styling when routine.enabled === false", () => {
|
||||
const { container } = render(<RoutineCard routine={makeRoutine({ enabled: false })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(container.querySelector(".routine-card.disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows last run result badge when lastRunResult is present (success)", () => {
|
||||
const routine = makeRoutine({ lastRunResult: makeRunResult({ success: true }) });
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("Success")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows last run result badge when lastRunResult is present (failure)", () => {
|
||||
const routine = makeRoutine({ lastRunResult: makeRunResult({ success: false, error: "Failed" }) });
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("Failed")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows description when present", () => {
|
||||
render(<RoutineCard routine={makeRoutine({ description: "My description" })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("My description")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides description when not present", () => {
|
||||
render(<RoutineCard routine={makeRoutine({ description: undefined })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.queryByText("A test routine")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("Actions", () => {
|
||||
it("clicking run button calls onRun with the routine", () => {
|
||||
const routine = makeRoutine();
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByLabelText("Run Test Routine now"));
|
||||
expect(onRun).toHaveBeenCalledWith(routine);
|
||||
});
|
||||
|
||||
it("disables run button when running prop is true", () => {
|
||||
render(<RoutineCard routine={makeRoutine()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} running={true} />);
|
||||
expect(screen.getByLabelText("Running…")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("clicking toggle button calls onToggle with the routine", () => {
|
||||
const routine = makeRoutine({ enabled: true });
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByLabelText("Disable Test Routine"));
|
||||
expect(onToggle).toHaveBeenCalledWith(routine);
|
||||
});
|
||||
|
||||
it("clicking edit button calls onEdit with the routine", () => {
|
||||
const routine = makeRoutine();
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByLabelText("Edit Test Routine"));
|
||||
expect(onEdit).toHaveBeenCalledWith(routine);
|
||||
});
|
||||
|
||||
it("clicking delete button shows confirm dialog and calls onDelete on confirm", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByLabelText("Delete My Routine"));
|
||||
await waitFor(() => {
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(onDelete).toHaveBeenCalledWith(routine);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("clicking delete button does not call onDelete when confirm is cancelled", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByLabelText("Delete My Routine"));
|
||||
await waitFor(() => {
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(onDelete).not.toHaveBeenCalled();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Run History ───────────────────────────────────────────────────────
|
||||
|
||||
describe("Run history", () => {
|
||||
it("shows run history toggle when runHistory.length > 0", () => {
|
||||
const routine = makeRoutine({
|
||||
runHistory: [
|
||||
makeRunResult({ startedAt: "2026-04-08T00:00:00.000Z", completedAt: "2026-04-08T00:01:00.000Z" }),
|
||||
],
|
||||
});
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.getByText("Run History (1)")).toBeDefined();
|
||||
});
|
||||
|
||||
it("expands/collapses run history on toggle click", async () => {
|
||||
const routine = makeRoutine({
|
||||
runHistory: [
|
||||
makeRunResult({ startedAt: "2026-04-08T00:00:00.000Z", completedAt: "2026-04-08T00:01:00.000Z" }),
|
||||
],
|
||||
});
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
|
||||
// Initially collapsed - no run detail
|
||||
expect(screen.queryByText("Done")).toBeNull();
|
||||
|
||||
// Click to expand history list
|
||||
fireEvent.click(screen.getByText("Run History (1)"));
|
||||
|
||||
// History list is visible but items are collapsed - click item header to expand
|
||||
const itemHeader = screen.getByRole("button", { name: /Run #1: succeeded/i });
|
||||
fireEvent.click(itemHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Done")).toBeDefined();
|
||||
});
|
||||
|
||||
// Click to collapse item
|
||||
fireEvent.click(itemHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Done")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows max 10 history items with overflow indicator", () => {
|
||||
const runHistory = Array.from({ length: 15 }, (_, i) =>
|
||||
makeRunResult({ startedAt: `2026-04-0${i + 1}T00:00:00.000Z`, completedAt: `2026-04-0${i + 1}T00:01:00.000Z` })
|
||||
);
|
||||
const routine = makeRoutine({ runHistory });
|
||||
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(screen.getByText("Run History (15)"));
|
||||
|
||||
// Should show only 10 items
|
||||
expect(screen.getByText("…and 5 more")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show history section when runHistory is empty", () => {
|
||||
render(<RoutineCard routine={makeRoutine({ runHistory: [] })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
|
||||
expect(screen.queryByText("Run History")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { RoutineEditor } from "../RoutineEditor";
|
||||
import type { Routine, RoutineTriggerType } from "@fusion/core";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
Calendar: () => <span data-testid="icon-calendar">📅</span>,
|
||||
Webhook: () => <span data-testid="icon-webhook">🔗</span>,
|
||||
Code: () => <span data-testid="icon-code">💻</span>,
|
||||
Zap: () => <span data-testid="icon-zap">⚡</span>,
|
||||
}));
|
||||
|
||||
// Mock @fusion/core
|
||||
vi.mock("@fusion/core", () => ({}));
|
||||
|
||||
function makeRoutine(overrides: Partial<Routine> = {}): Routine {
|
||||
return {
|
||||
id: "routine-001",
|
||||
name: "Test Routine",
|
||||
description: "A test routine",
|
||||
trigger: { type: "cron", cronExpression: "0 * * * *" },
|
||||
executionPolicy: "parallel",
|
||||
catchUpPolicy: "skip",
|
||||
enabled: true,
|
||||
runCount: 0,
|
||||
runHistory: [],
|
||||
createdAt: "2026-04-07T00:00:00.000Z",
|
||||
updatedAt: "2026-04-07T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RoutineEditor", () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const onCancel = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Create mode (no routine prop) ─────────────────────────────────────
|
||||
|
||||
describe("Create mode (no routine prop)", () => {
|
||||
it("renders form with empty fields", () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("");
|
||||
expect(screen.getByLabelText("Description (optional)")).toHaveValue("");
|
||||
});
|
||||
|
||||
it('shows "New Routine" heading', () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByText("New Routine")).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows "Create Routine" submit button', () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByText("Create Routine")).toBeDefined();
|
||||
});
|
||||
|
||||
it("defaults triggerType to 'cron'", () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByText("Cron")).toBeDefined();
|
||||
// Cron expression input should be visible
|
||||
expect(screen.getByLabelText("Cron Expression")).toHaveValue("0 * * * *");
|
||||
});
|
||||
|
||||
it("defaults executionPolicy to 'queue'", () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByLabelText("Execution Policy")).toHaveValue("queue");
|
||||
});
|
||||
|
||||
it("defaults catchUpPolicy to 'run_one'", () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByLabelText("Catch-up Policy")).toHaveValue("run_one");
|
||||
});
|
||||
|
||||
it("defaults enabled to true", () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByLabelText("Enabled")).toBeChecked();
|
||||
});
|
||||
|
||||
it("shows cron expression input when triggerType is 'cron'", () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByLabelText("Cron Expression")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows webhook inputs when triggerType is changed to 'webhook'", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText("Webhook"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Webhook Path")).toBeDefined();
|
||||
expect(screen.getByLabelText("Webhook Secret (optional)")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows API info when triggerType is changed to 'api'", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText("API"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("API Endpoint")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Manual trigger info when triggerType is changed to 'manual'", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText("Manual"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/triggered manually/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides cron expression input when triggerType is not 'cron'", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText("Webhook"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Cron Expression")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit mode (with routine prop) ─────────────────────────────────────
|
||||
|
||||
describe("Edit mode (with routine prop)", () => {
|
||||
it("pre-fills all fields from the routine prop", () => {
|
||||
const routine = makeRoutine({
|
||||
name: "My Routine",
|
||||
description: "My description",
|
||||
trigger: { type: "cron", cronExpression: "0 9 * * *" },
|
||||
executionPolicy: "reject",
|
||||
catchUpPolicy: "run",
|
||||
enabled: false,
|
||||
});
|
||||
render(<RoutineEditor routine={routine} onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("My Routine");
|
||||
expect(screen.getByLabelText("Description (optional)")).toHaveValue("My description");
|
||||
expect(screen.getByLabelText("Cron Expression")).toHaveValue("0 9 * * *");
|
||||
expect(screen.getByLabelText("Execution Policy")).toHaveValue("reject");
|
||||
expect(screen.getByLabelText("Catch-up Policy")).toHaveValue("run");
|
||||
expect(screen.getByLabelText("Enabled")).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('shows "Edit Routine" heading', () => {
|
||||
render(<RoutineEditor routine={makeRoutine()} onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByText("Edit Routine")).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows "Save Changes" submit button', () => {
|
||||
render(<RoutineEditor routine={makeRoutine()} onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByText("Save Changes")).toBeDefined();
|
||||
});
|
||||
|
||||
it("pre-fills webhook trigger fields", () => {
|
||||
const routine = makeRoutine({
|
||||
trigger: { type: "webhook", webhookPath: "/trigger/test", secret: "secret123" },
|
||||
});
|
||||
render(<RoutineEditor routine={routine} onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByText("Webhook")).toBeDefined();
|
||||
expect(screen.getByLabelText("Webhook Path")).toHaveValue("/trigger/test");
|
||||
expect(screen.getByLabelText("Webhook Secret (optional)")).toHaveValue("secret123");
|
||||
});
|
||||
|
||||
it("pre-fills api trigger fields", () => {
|
||||
const routine = makeRoutine({
|
||||
trigger: { type: "api", endpoint: "/api/test" },
|
||||
});
|
||||
render(<RoutineEditor routine={routine} onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByText("API")).toBeDefined();
|
||||
expect(screen.getByLabelText("API Endpoint")).toHaveValue("/api/test");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────────
|
||||
|
||||
describe("Validation", () => {
|
||||
it("shows error when name is empty on submit", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Name is required")).toBeDefined();
|
||||
});
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error when triggerType is 'cron' and cronExpression is empty", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Cron expression is required")).toBeDefined();
|
||||
});
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error when triggerType is 'cron' and cronExpression is invalid", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "invalid" } });
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Invalid cron format/)).toBeDefined();
|
||||
});
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error when triggerType is 'webhook' and webhookPath is empty", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText("Webhook"));
|
||||
fireEvent.change(screen.getByLabelText("Webhook Path"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Webhook path is required")).toBeDefined();
|
||||
});
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error when triggerType is 'api' and endpoint is empty", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText("API"));
|
||||
fireEvent.change(screen.getByLabelText("API Endpoint"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("API endpoint is required")).toBeDefined();
|
||||
});
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Submission ───────────────────────────────────────────────────────
|
||||
|
||||
describe("Submission", () => {
|
||||
it("calls onSubmit with correct RoutineCreateInput shape on valid create", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "New Routine",
|
||||
trigger: { type: "cron", cronExpression: "0 * * * *" },
|
||||
executionPolicy: "queue",
|
||||
catchUpPolicy: "run_one",
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onSubmit with correct shape on valid edit", async () => {
|
||||
const routine = makeRoutine({ name: "Old Name" });
|
||||
render(<RoutineEditor routine={routine} onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Updated Name" } });
|
||||
fireEvent.click(screen.getByText("Save Changes"));
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Updated Name",
|
||||
trigger: { type: "cron", cronExpression: "0 * * * *" },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("disables submit button and shows 'Saving…' during submission", async () => {
|
||||
const slowSubmit = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
render(<RoutineEditor onSubmit={slowSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
|
||||
|
||||
// Use role and name to find the submit button specifically
|
||||
const submitButton = screen.getByRole("button", { name: "Create Routine" });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Saving…")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("re-enables submit button after submission completes", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
// Button should be re-enabled
|
||||
expect(screen.getByText("Create Routine")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("builds correct webhook trigger on submit", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Webhook Routine" } });
|
||||
fireEvent.click(screen.getByText("Webhook"));
|
||||
fireEvent.change(screen.getByLabelText("Webhook Path"), { target: { value: "/trigger/my-hook" } });
|
||||
fireEvent.change(screen.getByLabelText("Webhook Secret (optional)"), { target: { value: "my-secret" } });
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trigger: {
|
||||
type: "webhook",
|
||||
webhookPath: "/trigger/my-hook",
|
||||
secret: "my-secret",
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("builds correct api trigger on submit", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "API Routine" } });
|
||||
fireEvent.click(screen.getByText("API"));
|
||||
fireEvent.change(screen.getByLabelText("API Endpoint"), { target: { value: "/api/my-routine" } });
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trigger: {
|
||||
type: "api",
|
||||
endpoint: "/api/my-routine",
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("builds correct manual trigger on submit", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Manual Routine" } });
|
||||
fireEvent.click(screen.getByText("Manual"));
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trigger: { type: "manual" },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cancel ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("Cancel", () => {
|
||||
it("calls onCancel when cancel button is clicked", () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call onSubmit when cancel is clicked", () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ScheduledTasksModal } from "../ScheduledTasksModal";
|
||||
import type { ScheduledTask, AutomationRunResult } from "@fusion/core";
|
||||
import type { ScheduledTask, AutomationRunResult, Routine } from "@fusion/core";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
@@ -15,6 +15,10 @@ vi.mock("lucide-react", () => ({
|
||||
XCircle: () => <span data-testid="icon-x">✗</span>,
|
||||
ChevronDown: () => <span data-testid="icon-down">▼</span>,
|
||||
ChevronUp: () => <span data-testid="icon-up">▲</span>,
|
||||
Calendar: () => <span data-testid="icon-calendar">📅</span>,
|
||||
Webhook: () => <span data-testid="icon-webhook">🔗</span>,
|
||||
Code: () => <span data-testid="icon-code">💻</span>,
|
||||
Zap: () => <span data-testid="icon-zap">⚡</span>,
|
||||
}));
|
||||
|
||||
// Mock @fusion/core (no runtime values needed — ScheduleForm inlines presets)
|
||||
@@ -27,6 +31,11 @@ const mockUpdateAutomation = vi.fn();
|
||||
const mockDeleteAutomation = vi.fn();
|
||||
const mockRunAutomation = vi.fn();
|
||||
const mockToggleAutomation = vi.fn();
|
||||
const mockFetchRoutines = vi.fn();
|
||||
const mockCreateRoutine = vi.fn();
|
||||
const mockUpdateRoutine = vi.fn();
|
||||
const mockDeleteRoutine = vi.fn();
|
||||
const mockRunRoutine = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAutomations: (...args: any[]) => mockFetchAutomations(...args),
|
||||
@@ -35,6 +44,11 @@ vi.mock("../../api", () => ({
|
||||
deleteAutomation: (...args: any[]) => mockDeleteAutomation(...args),
|
||||
runAutomation: (...args: any[]) => mockRunAutomation(...args),
|
||||
toggleAutomation: (...args: any[]) => mockToggleAutomation(...args),
|
||||
fetchRoutines: (...args: any[]) => mockFetchRoutines(...args),
|
||||
createRoutine: (...args: any[]) => mockCreateRoutine(...args),
|
||||
updateRoutine: (...args: any[]) => mockUpdateRoutine(...args),
|
||||
deleteRoutine: (...args: any[]) => mockDeleteRoutine(...args),
|
||||
runRoutine: (...args: any[]) => mockRunRoutine(...args),
|
||||
}));
|
||||
|
||||
function makeSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
||||
@@ -54,6 +68,23 @@ function makeSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
||||
};
|
||||
}
|
||||
|
||||
function makeRoutine(overrides: Partial<Routine> = {}): Routine {
|
||||
return {
|
||||
id: "routine-001",
|
||||
name: "Test Routine",
|
||||
description: "A test routine",
|
||||
trigger: { type: "cron", cronExpression: "0 * * * *" },
|
||||
executionPolicy: "queue",
|
||||
catchUpPolicy: "run_one",
|
||||
enabled: true,
|
||||
runCount: 0,
|
||||
runHistory: [],
|
||||
createdAt: "2026-04-07T00:00:00.000Z",
|
||||
updatedAt: "2026-04-07T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ScheduledTasksModal", () => {
|
||||
const onClose = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
@@ -61,6 +92,7 @@ describe("ScheduledTasksModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchAutomations.mockResolvedValue([]);
|
||||
mockFetchRoutines.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("renders modal with title", async () => {
|
||||
@@ -292,4 +324,305 @@ describe("ScheduledTasksModal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Routine Tab Tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe("Tab navigation", () => {
|
||||
it("shows both Schedules and Routines tabs", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Schedules")).toBeDefined();
|
||||
expect(screen.getByText("Routines")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to Schedules tab", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking Routines tab switches to routines view", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Schedules")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No routines yet")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking Schedules tab switches back to schedules view", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
// Switch to Routines
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No routines yet")).toBeDefined();
|
||||
});
|
||||
// Switch back to Schedules
|
||||
fireEvent.click(screen.getByText("Schedules"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("switching tabs resets sub-views", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Schedule")).toBeDefined();
|
||||
});
|
||||
|
||||
// Open create form
|
||||
fireEvent.click(screen.getByText("New Schedule"));
|
||||
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
|
||||
|
||||
// Switch to Routines tab
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No routines yet")).toBeDefined();
|
||||
});
|
||||
|
||||
// Switch back to Schedules - should be in list view, not create
|
||||
fireEvent.click(screen.getByText("Schedules"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("New Schedule", { selector: "h4" })).toBeNull();
|
||||
expect(screen.getByText("New Schedule")).toBeDefined(); // The button
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routines list", () => {
|
||||
it("shows empty state when no routines exist", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No routines yet")).toBeDefined();
|
||||
expect(screen.getByText("Create your first routine")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows routine cards when routines exist", async () => {
|
||||
mockFetchRoutines.mockResolvedValue([makeRoutine({ name: "My Routine" })]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "New Routine" button when routines exist', async () => {
|
||||
mockFetchRoutines.mockResolvedValue([makeRoutine()]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Routine")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routines create flow", () => {
|
||||
it('shows RoutineEditor when clicking "New Routine"', async () => {
|
||||
mockFetchRoutines.mockResolvedValue([makeRoutine()]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("New Routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows RoutineEditor from empty state CTA", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create your first routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create your first routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to list on Escape from create form", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create your first routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create your first routine"));
|
||||
expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
// Should not close the modal, just go back to list
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates routine and returns to list on success", async () => {
|
||||
const created = makeRoutine({ name: "New Routine" });
|
||||
mockFetchRoutines
|
||||
.mockResolvedValueOnce([]) // initial load
|
||||
.mockResolvedValueOnce([created]); // after create
|
||||
mockCreateRoutine.mockResolvedValue(created);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create your first routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create your first routine"));
|
||||
|
||||
// Fill form
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Routine created", "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routines edit flow", () => {
|
||||
it("shows RoutineEditor with pre-filled data when editing", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Edit My Routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Edit Routine", { selector: "h4" })).toBeDefined();
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("My Routine");
|
||||
});
|
||||
});
|
||||
|
||||
it("updates routine and returns to list on success", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
const updated = { ...routine, name: "Updated Routine" };
|
||||
mockFetchRoutines
|
||||
.mockResolvedValueOnce([routine]) // initial load
|
||||
.mockResolvedValueOnce([updated]); // after update
|
||||
mockUpdateRoutine.mockResolvedValue(updated);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Edit My Routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("My Routine");
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Updated Routine" } });
|
||||
fireEvent.click(screen.getByText("Save Changes"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Routine updated", "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routines run", () => {
|
||||
it("calls runRoutine and shows success toast", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
const result = {
|
||||
routineId: routine.id,
|
||||
success: true,
|
||||
output: "Done",
|
||||
startedAt: "2026-04-08T00:00:00.000Z",
|
||||
completedAt: "2026-04-08T00:01:00.000Z",
|
||||
};
|
||||
mockRunRoutine.mockResolvedValue({ result });
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Run My Routine now"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRunRoutine).toHaveBeenCalledWith("routine-001");
|
||||
expect(addToast).toHaveBeenCalledWith('"My Routine" completed successfully', "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when run fails", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
const result = {
|
||||
routineId: routine.id,
|
||||
success: false,
|
||||
error: "Failed",
|
||||
startedAt: "2026-04-08T00:00:00.000Z",
|
||||
completedAt: "2026-04-08T00:01:00.000Z",
|
||||
};
|
||||
mockRunRoutine.mockResolvedValue({ result });
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Run My Routine now"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routines delete", () => {
|
||||
it("calls deleteRoutine after confirm dialog", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
mockDeleteRoutine.mockResolvedValue(undefined);
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Delete My Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteRoutine).toHaveBeenCalledWith("routine-001");
|
||||
expect(addToast).toHaveBeenCalledWith('Deleted "My Routine"', "success");
|
||||
});
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routines toggle", () => {
|
||||
it("calls updateRoutine with flipped enabled state", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine", enabled: true });
|
||||
const updated = { ...routine, enabled: false };
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
mockUpdateRoutine.mockResolvedValue(updated);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Disable My Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRoutine).toHaveBeenCalledWith("routine-001", { enabled: false });
|
||||
expect(addToast).toHaveBeenCalledWith('"My Routine" disabled', "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user