Implements FED-010: Agent Spawn via Federation feature that enables spawning and managing Claude agents on remote federated Mosaic Stack instances via COMMAND message type. Features: - Federation agent command types (spawn, status, kill) - FederationAgentService for handling agent operations - Integration with orchestrator's agent spawner/lifecycle services - API endpoints for spawning, querying status, and killing agents - Full command routing through federation COMMAND infrastructure - Comprehensive test coverage (12/12 tests passing) Architecture: - Hub → Spoke: Spawn agents on remote instances - Command flow: FederationController → FederationAgentService → CommandService → Remote Orchestrator - Response handling: Remote orchestrator returns agent status/results - Security: Connection validation, signature verification Files created: - apps/api/src/federation/types/federation-agent.types.ts - apps/api/src/federation/federation-agent.service.ts - apps/api/src/federation/federation-agent.service.spec.ts Files modified: - apps/api/src/federation/command.service.ts (agent command routing) - apps/api/src/federation/federation.controller.ts (agent endpoints) - apps/api/src/federation/federation.module.ts (service registration) - apps/orchestrator/src/api/agents/agents.controller.ts (status endpoint) - apps/orchestrator/src/api/agents/agents.module.ts (lifecycle integration) Testing: - 12/12 tests passing for FederationAgentService - All command service tests passing - TypeScript compilation successful - Linting passed Refs #93 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
315 lines
8.9 KiB
TypeScript
315 lines
8.9 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { ActivityController } from "./activity.controller";
|
|
import { ActivityService } from "./activity.service";
|
|
import { ActivityAction, EntityType } from "@prisma/client";
|
|
import type { QueryActivityLogDto } from "./dto";
|
|
|
|
describe("ActivityController", () => {
|
|
let controller: ActivityController;
|
|
let service: ActivityService;
|
|
|
|
const mockActivityService = {
|
|
findAll: vi.fn(),
|
|
findOne: vi.fn(),
|
|
getAuditTrail: vi.fn(),
|
|
};
|
|
|
|
const mockWorkspaceId = "workspace-123";
|
|
|
|
beforeEach(() => {
|
|
service = mockActivityService as any;
|
|
controller = new ActivityController(service);
|
|
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("findAll", () => {
|
|
const mockPaginatedResult = {
|
|
data: [
|
|
{
|
|
id: "activity-1",
|
|
workspaceId: "workspace-123",
|
|
userId: "user-123",
|
|
action: ActivityAction.CREATED,
|
|
entityType: EntityType.TASK,
|
|
entityId: "task-123",
|
|
details: {},
|
|
createdAt: new Date("2024-01-01"),
|
|
user: {
|
|
id: "user-123",
|
|
name: "Test User",
|
|
email: "test@example.com",
|
|
},
|
|
},
|
|
],
|
|
meta: {
|
|
total: 1,
|
|
page: 1,
|
|
limit: 50,
|
|
totalPages: 1,
|
|
},
|
|
};
|
|
|
|
it("should return paginated activity logs using authenticated user's workspaceId", async () => {
|
|
const query: QueryActivityLogDto = {
|
|
workspaceId: "workspace-123",
|
|
page: 1,
|
|
limit: 50,
|
|
};
|
|
|
|
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
|
|
|
|
const result = await controller.findAll(query, mockWorkspaceId);
|
|
|
|
expect(result).toEqual(mockPaginatedResult);
|
|
expect(mockActivityService.findAll).toHaveBeenCalledWith({
|
|
...query,
|
|
workspaceId: "workspace-123",
|
|
});
|
|
});
|
|
|
|
it("should handle query with filters", async () => {
|
|
const query: QueryActivityLogDto = {
|
|
workspaceId: "workspace-123",
|
|
userId: "user-123",
|
|
action: ActivityAction.CREATED,
|
|
entityType: EntityType.TASK,
|
|
page: 1,
|
|
limit: 10,
|
|
};
|
|
|
|
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
|
|
|
|
await controller.findAll(query, mockWorkspaceId);
|
|
|
|
expect(mockActivityService.findAll).toHaveBeenCalledWith({
|
|
...query,
|
|
workspaceId: "workspace-123",
|
|
});
|
|
});
|
|
|
|
it("should handle query with date range", async () => {
|
|
const startDate = new Date("2024-01-01");
|
|
const endDate = new Date("2024-01-31");
|
|
|
|
const query: QueryActivityLogDto = {
|
|
workspaceId: "workspace-123",
|
|
startDate,
|
|
endDate,
|
|
page: 1,
|
|
limit: 50,
|
|
};
|
|
|
|
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
|
|
|
|
await controller.findAll(query, mockWorkspaceId);
|
|
|
|
expect(mockActivityService.findAll).toHaveBeenCalledWith({
|
|
...query,
|
|
workspaceId: "workspace-123",
|
|
});
|
|
});
|
|
|
|
it("should use user's workspaceId even if query provides different one", async () => {
|
|
const query: QueryActivityLogDto = {
|
|
workspaceId: "different-workspace",
|
|
page: 1,
|
|
limit: 50,
|
|
};
|
|
|
|
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
|
|
|
|
await controller.findAll(query, mockWorkspaceId);
|
|
|
|
// Should use authenticated user's workspaceId, not query's
|
|
expect(mockActivityService.findAll).toHaveBeenCalledWith({
|
|
...query,
|
|
workspaceId: "workspace-123",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("findOne", () => {
|
|
const mockActivity = {
|
|
id: "activity-123",
|
|
workspaceId: "workspace-123",
|
|
userId: "user-123",
|
|
action: ActivityAction.CREATED,
|
|
entityType: EntityType.TASK,
|
|
entityId: "task-123",
|
|
details: {},
|
|
createdAt: new Date(),
|
|
user: {
|
|
id: "user-123",
|
|
name: "Test User",
|
|
email: "test@example.com",
|
|
},
|
|
};
|
|
|
|
it("should return a single activity log using authenticated user's workspaceId", async () => {
|
|
mockActivityService.findOne.mockResolvedValue(mockActivity);
|
|
|
|
const result = await controller.findOne("activity-123", mockWorkspaceId);
|
|
|
|
expect(result).toEqual(mockActivity);
|
|
expect(mockActivityService.findOne).toHaveBeenCalledWith("activity-123", "workspace-123");
|
|
});
|
|
|
|
it("should return null if activity not found", async () => {
|
|
mockActivityService.findOne.mockResolvedValue(null);
|
|
|
|
const result = await controller.findOne("nonexistent", mockWorkspaceId);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("should return null if workspaceId is missing (service handles gracefully)", async () => {
|
|
mockActivityService.findOne.mockResolvedValue(null);
|
|
|
|
const result = await controller.findOne("activity-123", undefined as any);
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockActivityService.findOne).toHaveBeenCalledWith("activity-123", undefined);
|
|
});
|
|
});
|
|
|
|
describe("getAuditTrail", () => {
|
|
const mockAuditTrail = [
|
|
{
|
|
id: "activity-1",
|
|
workspaceId: "workspace-123",
|
|
userId: "user-123",
|
|
action: ActivityAction.CREATED,
|
|
entityType: EntityType.TASK,
|
|
entityId: "task-123",
|
|
details: { title: "New Task" },
|
|
createdAt: new Date("2024-01-01"),
|
|
user: {
|
|
id: "user-123",
|
|
name: "Test User",
|
|
email: "test@example.com",
|
|
},
|
|
},
|
|
{
|
|
id: "activity-2",
|
|
workspaceId: "workspace-123",
|
|
userId: "user-456",
|
|
action: ActivityAction.UPDATED,
|
|
entityType: EntityType.TASK,
|
|
entityId: "task-123",
|
|
details: { title: "Updated Task" },
|
|
createdAt: new Date("2024-01-02"),
|
|
user: {
|
|
id: "user-456",
|
|
name: "Another User",
|
|
email: "another@example.com",
|
|
},
|
|
},
|
|
];
|
|
|
|
it("should return audit trail for a task using authenticated user's workspaceId", async () => {
|
|
mockActivityService.getAuditTrail.mockResolvedValue(mockAuditTrail);
|
|
|
|
const result = await controller.getAuditTrail(EntityType.TASK, "task-123", mockWorkspaceId);
|
|
|
|
expect(result).toEqual(mockAuditTrail);
|
|
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
|
|
"workspace-123",
|
|
EntityType.TASK,
|
|
"task-123"
|
|
);
|
|
});
|
|
|
|
it("should return audit trail for an event", async () => {
|
|
const eventAuditTrail = [
|
|
{
|
|
id: "activity-3",
|
|
workspaceId: "workspace-123",
|
|
userId: "user-123",
|
|
action: ActivityAction.CREATED,
|
|
entityType: EntityType.EVENT,
|
|
entityId: "event-123",
|
|
details: {},
|
|
createdAt: new Date(),
|
|
user: {
|
|
id: "user-123",
|
|
name: "Test User",
|
|
email: "test@example.com",
|
|
},
|
|
},
|
|
];
|
|
|
|
mockActivityService.getAuditTrail.mockResolvedValue(eventAuditTrail);
|
|
|
|
const result = await controller.getAuditTrail(EntityType.EVENT, "event-123", mockWorkspaceId);
|
|
|
|
expect(result).toEqual(eventAuditTrail);
|
|
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
|
|
"workspace-123",
|
|
EntityType.EVENT,
|
|
"event-123"
|
|
);
|
|
});
|
|
|
|
it("should return audit trail for a project", async () => {
|
|
const projectAuditTrail = [
|
|
{
|
|
id: "activity-4",
|
|
workspaceId: "workspace-123",
|
|
userId: "user-123",
|
|
action: ActivityAction.CREATED,
|
|
entityType: EntityType.PROJECT,
|
|
entityId: "project-123",
|
|
details: {},
|
|
createdAt: new Date(),
|
|
user: {
|
|
id: "user-123",
|
|
name: "Test User",
|
|
email: "test@example.com",
|
|
},
|
|
},
|
|
];
|
|
|
|
mockActivityService.getAuditTrail.mockResolvedValue(projectAuditTrail);
|
|
|
|
const result = await controller.getAuditTrail(
|
|
EntityType.PROJECT,
|
|
"project-123",
|
|
mockWorkspaceId
|
|
);
|
|
|
|
expect(result).toEqual(projectAuditTrail);
|
|
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
|
|
"workspace-123",
|
|
EntityType.PROJECT,
|
|
"project-123"
|
|
);
|
|
});
|
|
|
|
it("should return empty array if no audit trail found", async () => {
|
|
mockActivityService.getAuditTrail.mockResolvedValue([]);
|
|
|
|
const result = await controller.getAuditTrail(
|
|
EntityType.WORKSPACE,
|
|
"workspace-999",
|
|
mockWorkspaceId
|
|
);
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should return empty array if workspaceId is missing (service handles gracefully)", async () => {
|
|
mockActivityService.getAuditTrail.mockResolvedValue([]);
|
|
|
|
const result = await controller.getAuditTrail(EntityType.TASK, "task-123", undefined as any);
|
|
|
|
expect(result).toEqual([]);
|
|
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
|
|
undefined,
|
|
EntityType.TASK,
|
|
"task-123"
|
|
);
|
|
});
|
|
});
|
|
});
|