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>
293 lines
7.8 KiB
TypeScript
293 lines
7.8 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { BadRequestException } from "@nestjs/common";
|
|
import { ImportExportService } from "./import-export.service";
|
|
import { KnowledgeService } from "../knowledge.service";
|
|
import { PrismaService } from "../../prisma/prisma.service";
|
|
import { ExportFormat } from "../dto";
|
|
import { EntryStatus, Visibility } from "@prisma/client";
|
|
|
|
describe("ImportExportService", () => {
|
|
let service: ImportExportService;
|
|
let knowledgeService: KnowledgeService;
|
|
let prisma: PrismaService;
|
|
|
|
const workspaceId = "workspace-123";
|
|
const userId = "user-123";
|
|
|
|
const mockEntry = {
|
|
id: "entry-123",
|
|
workspaceId,
|
|
slug: "test-entry",
|
|
title: "Test Entry",
|
|
content: "Test content",
|
|
summary: "Test summary",
|
|
status: EntryStatus.PUBLISHED,
|
|
visibility: Visibility.WORKSPACE,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
tags: [
|
|
{
|
|
tag: {
|
|
id: "tag-1",
|
|
name: "TypeScript",
|
|
slug: "typescript",
|
|
color: "#3178c6",
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const mockKnowledgeService = {
|
|
create: vi.fn(),
|
|
findAll: vi.fn(),
|
|
};
|
|
|
|
const mockPrismaService = {
|
|
knowledgeEntry: {
|
|
findMany: vi.fn(),
|
|
},
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
ImportExportService,
|
|
{
|
|
provide: KnowledgeService,
|
|
useValue: mockKnowledgeService,
|
|
},
|
|
{
|
|
provide: PrismaService,
|
|
useValue: mockPrismaService,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<ImportExportService>(ImportExportService);
|
|
knowledgeService = module.get<KnowledgeService>(KnowledgeService);
|
|
prisma = module.get<PrismaService>(PrismaService);
|
|
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("importEntries", () => {
|
|
it("should import a single markdown file successfully", async () => {
|
|
const markdown = `---
|
|
title: Test Entry
|
|
status: PUBLISHED
|
|
tags:
|
|
- TypeScript
|
|
- Testing
|
|
---
|
|
|
|
This is the content of the entry.`;
|
|
|
|
const file: Express.Multer.File = {
|
|
fieldname: "file",
|
|
originalname: "test.md",
|
|
encoding: "utf-8",
|
|
mimetype: "text/markdown",
|
|
size: markdown.length,
|
|
buffer: Buffer.from(markdown),
|
|
stream: null as any,
|
|
destination: "",
|
|
filename: "",
|
|
path: "",
|
|
};
|
|
|
|
mockKnowledgeService.create.mockResolvedValue({
|
|
id: "entry-123",
|
|
slug: "test-entry",
|
|
title: "Test Entry",
|
|
});
|
|
|
|
const result = await service.importEntries(workspaceId, userId, file);
|
|
|
|
expect(result.totalFiles).toBe(1);
|
|
expect(result.imported).toBe(1);
|
|
expect(result.failed).toBe(0);
|
|
expect(result.results[0].success).toBe(true);
|
|
expect(result.results[0].title).toBe("Test Entry");
|
|
expect(mockKnowledgeService.create).toHaveBeenCalledWith(
|
|
workspaceId,
|
|
userId,
|
|
expect.objectContaining({
|
|
title: "Test Entry",
|
|
content: "This is the content of the entry.",
|
|
status: EntryStatus.PUBLISHED,
|
|
tags: ["TypeScript", "Testing"],
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should use filename as title if frontmatter title is missing", async () => {
|
|
const markdown = `This is content without frontmatter.`;
|
|
|
|
const file: Express.Multer.File = {
|
|
fieldname: "file",
|
|
originalname: "my-entry.md",
|
|
encoding: "utf-8",
|
|
mimetype: "text/markdown",
|
|
size: markdown.length,
|
|
buffer: Buffer.from(markdown),
|
|
stream: null as any,
|
|
destination: "",
|
|
filename: "",
|
|
path: "",
|
|
};
|
|
|
|
mockKnowledgeService.create.mockResolvedValue({
|
|
id: "entry-123",
|
|
slug: "my-entry",
|
|
title: "my-entry",
|
|
});
|
|
|
|
const result = await service.importEntries(workspaceId, userId, file);
|
|
|
|
expect(result.imported).toBe(1);
|
|
expect(mockKnowledgeService.create).toHaveBeenCalledWith(
|
|
workspaceId,
|
|
userId,
|
|
expect.objectContaining({
|
|
title: "my-entry",
|
|
content: "This is content without frontmatter.",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should reject invalid file types", async () => {
|
|
const file: Express.Multer.File = {
|
|
fieldname: "file",
|
|
originalname: "test.txt",
|
|
encoding: "utf-8",
|
|
mimetype: "text/plain",
|
|
size: 100,
|
|
buffer: Buffer.from("test"),
|
|
stream: null as any,
|
|
destination: "",
|
|
filename: "",
|
|
path: "",
|
|
};
|
|
|
|
await expect(service.importEntries(workspaceId, userId, file)).rejects.toThrow(
|
|
BadRequestException
|
|
);
|
|
});
|
|
|
|
it("should handle import errors gracefully", async () => {
|
|
const markdown = `---
|
|
title: Test Entry
|
|
---
|
|
|
|
Content`;
|
|
|
|
const file: Express.Multer.File = {
|
|
fieldname: "file",
|
|
originalname: "test.md",
|
|
encoding: "utf-8",
|
|
mimetype: "text/markdown",
|
|
size: markdown.length,
|
|
buffer: Buffer.from(markdown),
|
|
stream: null as any,
|
|
destination: "",
|
|
filename: "",
|
|
path: "",
|
|
};
|
|
|
|
mockKnowledgeService.create.mockRejectedValue(new Error("Database error"));
|
|
|
|
const result = await service.importEntries(workspaceId, userId, file);
|
|
|
|
expect(result.totalFiles).toBe(1);
|
|
expect(result.imported).toBe(0);
|
|
expect(result.failed).toBe(1);
|
|
expect(result.results[0].success).toBe(false);
|
|
expect(result.results[0].error).toBe("Database error");
|
|
});
|
|
|
|
it("should reject empty markdown content", async () => {
|
|
const markdown = `---
|
|
title: Empty Entry
|
|
---
|
|
|
|
`;
|
|
|
|
const file: Express.Multer.File = {
|
|
fieldname: "file",
|
|
originalname: "empty.md",
|
|
encoding: "utf-8",
|
|
mimetype: "text/markdown",
|
|
size: markdown.length,
|
|
buffer: Buffer.from(markdown),
|
|
stream: null as any,
|
|
destination: "",
|
|
filename: "",
|
|
path: "",
|
|
};
|
|
|
|
const result = await service.importEntries(workspaceId, userId, file);
|
|
|
|
expect(result.imported).toBe(0);
|
|
expect(result.failed).toBe(1);
|
|
expect(result.results[0].error).toBe("Empty content");
|
|
});
|
|
});
|
|
|
|
describe("exportEntries", () => {
|
|
it("should export entries as markdown format", async () => {
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([mockEntry]);
|
|
|
|
const result = await service.exportEntries(workspaceId, ExportFormat.MARKDOWN);
|
|
|
|
expect(result.filename).toMatch(/knowledge-export-\d{4}-\d{2}-\d{2}\.zip/);
|
|
expect(result.stream).toBeDefined();
|
|
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({
|
|
where: { workspaceId },
|
|
include: {
|
|
tags: {
|
|
include: {
|
|
tag: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
title: "asc",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should export only specified entries", async () => {
|
|
const entryIds = ["entry-123", "entry-456"];
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([mockEntry]);
|
|
|
|
await service.exportEntries(workspaceId, ExportFormat.JSON, entryIds);
|
|
|
|
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
workspaceId,
|
|
id: { in: entryIds },
|
|
},
|
|
include: {
|
|
tags: {
|
|
include: {
|
|
tag: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
title: "asc",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should throw error when no entries found", async () => {
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
|
|
|
await expect(service.exportEntries(workspaceId, ExportFormat.MARKDOWN)).rejects.toThrow(
|
|
BadRequestException
|
|
);
|
|
});
|
|
});
|
|
});
|