feat(#93): implement agent spawn via federation
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>
This commit is contained in:
5
apps/api/.env.test
Normal file
5
apps/api/.env.test
Normal file
@@ -0,0 +1,5 @@
|
||||
DATABASE_URL="postgresql://test:test@localhost:5432/test"
|
||||
ENCRYPTION_KEY="test-encryption-key-32-characters"
|
||||
JWT_SECRET="test-jwt-secret"
|
||||
INSTANCE_NAME="Test Instance"
|
||||
INSTANCE_URL="https://test.example.com"
|
||||
@@ -5,6 +5,7 @@ The Mosaic Stack API is a NestJS-based backend service providing REST endpoints
|
||||
## Overview
|
||||
|
||||
The API serves as the central backend for:
|
||||
|
||||
- **Task Management** - Create, update, track tasks with filtering and sorting
|
||||
- **Event Management** - Calendar events and scheduling
|
||||
- **Project Management** - Organize work into projects
|
||||
@@ -18,20 +19,20 @@ The API serves as the central backend for:
|
||||
|
||||
## Available Modules
|
||||
|
||||
| Module | Base Path | Description |
|
||||
|--------|-----------|-------------|
|
||||
| **Tasks** | `/api/tasks` | CRUD operations for tasks with filtering |
|
||||
| **Events** | `/api/events` | Calendar events and scheduling |
|
||||
| **Projects** | `/api/projects` | Project management |
|
||||
| **Knowledge** | `/api/knowledge/entries` | Wiki entries with markdown support |
|
||||
| **Knowledge Tags** | `/api/knowledge/tags` | Tag management for knowledge entries |
|
||||
| **Ideas** | `/api/ideas` | Quick capture and idea management |
|
||||
| **Domains** | `/api/domains` | Domain categorization |
|
||||
| **Personalities** | `/api/personalities` | AI personality configurations |
|
||||
| **Widgets** | `/api/widgets` | Dashboard widget data |
|
||||
| **Layouts** | `/api/layouts` | Dashboard layout configuration |
|
||||
| **Ollama** | `/api/ollama` | LLM integration (generate, chat, embed) |
|
||||
| **Users** | `/api/users/me/preferences` | User preferences |
|
||||
| Module | Base Path | Description |
|
||||
| ------------------ | --------------------------- | ---------------------------------------- |
|
||||
| **Tasks** | `/api/tasks` | CRUD operations for tasks with filtering |
|
||||
| **Events** | `/api/events` | Calendar events and scheduling |
|
||||
| **Projects** | `/api/projects` | Project management |
|
||||
| **Knowledge** | `/api/knowledge/entries` | Wiki entries with markdown support |
|
||||
| **Knowledge Tags** | `/api/knowledge/tags` | Tag management for knowledge entries |
|
||||
| **Ideas** | `/api/ideas` | Quick capture and idea management |
|
||||
| **Domains** | `/api/domains` | Domain categorization |
|
||||
| **Personalities** | `/api/personalities` | AI personality configurations |
|
||||
| **Widgets** | `/api/widgets` | Dashboard widget data |
|
||||
| **Layouts** | `/api/layouts` | Dashboard layout configuration |
|
||||
| **Ollama** | `/api/ollama` | LLM integration (generate, chat, embed) |
|
||||
| **Users** | `/api/users/me/preferences` | User preferences |
|
||||
|
||||
### Health Check
|
||||
|
||||
@@ -51,11 +52,11 @@ The API uses **BetterAuth** for authentication with the following features:
|
||||
|
||||
The API uses a layered guard system:
|
||||
|
||||
| Guard | Purpose | Applies To |
|
||||
|-------|---------|------------|
|
||||
| **AuthGuard** | Verifies user authentication via Bearer token | Most protected endpoints |
|
||||
| **WorkspaceGuard** | Validates workspace membership and sets Row-Level Security (RLS) context | Workspace-scoped resources |
|
||||
| **PermissionGuard** | Enforces role-based access control | Admin operations |
|
||||
| Guard | Purpose | Applies To |
|
||||
| ------------------- | ------------------------------------------------------------------------ | -------------------------- |
|
||||
| **AuthGuard** | Verifies user authentication via Bearer token | Most protected endpoints |
|
||||
| **WorkspaceGuard** | Validates workspace membership and sets Row-Level Security (RLS) context | Workspace-scoped resources |
|
||||
| **PermissionGuard** | Enforces role-based access control | Admin operations |
|
||||
|
||||
### Workspace Roles
|
||||
|
||||
@@ -69,15 +70,16 @@ The API uses a layered guard system:
|
||||
Used with `@RequirePermission()` decorator:
|
||||
|
||||
```typescript
|
||||
Permission.WORKSPACE_OWNER // Requires OWNER role
|
||||
Permission.WORKSPACE_ADMIN // Requires ADMIN or OWNER
|
||||
Permission.WORKSPACE_MEMBER // Requires MEMBER, ADMIN, or OWNER
|
||||
Permission.WORKSPACE_ANY // Any authenticated member including GUEST
|
||||
Permission.WORKSPACE_OWNER; // Requires OWNER role
|
||||
Permission.WORKSPACE_ADMIN; // Requires ADMIN or OWNER
|
||||
Permission.WORKSPACE_MEMBER; // Requires MEMBER, ADMIN, or OWNER
|
||||
Permission.WORKSPACE_ANY; // Any authenticated member including GUEST
|
||||
```
|
||||
|
||||
### Providing Workspace Context
|
||||
|
||||
Workspace ID can be provided via:
|
||||
|
||||
1. **Header**: `X-Workspace-Id: <workspace-id>` (highest priority)
|
||||
2. **URL Parameter**: `:workspaceId`
|
||||
3. **Request Body**: `workspaceId` field
|
||||
@@ -85,7 +87,7 @@ Workspace ID can be provided via:
|
||||
### Example: Protected Controller
|
||||
|
||||
```typescript
|
||||
@Controller('tasks')
|
||||
@Controller("tasks")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class TasksController {
|
||||
@Post()
|
||||
@@ -98,13 +100,13 @@ export class TasksController {
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PORT` | API server port | `3001` |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | Required |
|
||||
| `NODE_ENV` | Environment (`development`, `production`) | - |
|
||||
| `NEXT_PUBLIC_APP_URL` | Frontend application URL (for CORS) | `http://localhost:3000` |
|
||||
| `WEB_URL` | WebSocket CORS origin | `http://localhost:3000` |
|
||||
| Variable | Description | Default |
|
||||
| --------------------- | ----------------------------------------- | ----------------------- |
|
||||
| `PORT` | API server port | `3001` |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | Required |
|
||||
| `NODE_ENV` | Environment (`development`, `production`) | - |
|
||||
| `NEXT_PUBLIC_APP_URL` | Frontend application URL (for CORS) | `http://localhost:3000` |
|
||||
| `WEB_URL` | WebSocket CORS origin | `http://localhost:3000` |
|
||||
|
||||
## Running Locally
|
||||
|
||||
@@ -117,22 +119,26 @@ export class TasksController {
|
||||
### Setup
|
||||
|
||||
1. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Set up environment variables:**
|
||||
|
||||
```bash
|
||||
cp .env.example .env # If available
|
||||
# Edit .env with your DATABASE_URL
|
||||
```
|
||||
|
||||
3. **Generate Prisma client:**
|
||||
|
||||
```bash
|
||||
pnpm prisma:generate
|
||||
```
|
||||
|
||||
4. **Run database migrations:**
|
||||
|
||||
```bash
|
||||
pnpm prisma:migrate
|
||||
```
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ioredis": "^5.9.2",
|
||||
"jose": "^6.1.3",
|
||||
"marked": "^17.0.1",
|
||||
"marked-gfm-heading-id": "^4.1.3",
|
||||
"marked-highlight": "^2.2.3",
|
||||
|
||||
@@ -340,7 +340,8 @@ pnpm prisma migrate deploy
|
||||
\`\`\`
|
||||
|
||||
For setup instructions, see [[development-setup]].`,
|
||||
summary: "Comprehensive documentation of the Mosaic Stack database schema and Prisma conventions",
|
||||
summary:
|
||||
"Comprehensive documentation of the Mosaic Stack database schema and Prisma conventions",
|
||||
status: EntryStatus.PUBLISHED,
|
||||
visibility: Visibility.WORKSPACE,
|
||||
tags: ["architecture", "development"],
|
||||
@@ -373,7 +374,7 @@ This is a draft document. See [[architecture-overview]] for current state.`,
|
||||
|
||||
// Create entries and track them for linking
|
||||
const createdEntries = new Map<string, any>();
|
||||
|
||||
|
||||
for (const entryData of entries) {
|
||||
const entry = await tx.knowledgeEntry.create({
|
||||
data: {
|
||||
@@ -388,7 +389,7 @@ This is a draft document. See [[architecture-overview]] for current state.`,
|
||||
updatedBy: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
createdEntries.set(entryData.slug, entry);
|
||||
|
||||
// Create initial version
|
||||
@@ -406,7 +407,7 @@ This is a draft document. See [[architecture-overview]] for current state.`,
|
||||
|
||||
// Add tags
|
||||
for (const tagSlug of entryData.tags) {
|
||||
const tag = tags.find(t => t.slug === tagSlug);
|
||||
const tag = tags.find((t) => t.slug === tagSlug);
|
||||
if (tag) {
|
||||
await tx.knowledgeEntryTag.create({
|
||||
data: {
|
||||
@@ -427,7 +428,11 @@ This is a draft document. See [[architecture-overview]] for current state.`,
|
||||
{ source: "welcome", target: "database-schema", text: "database-schema" },
|
||||
{ source: "architecture-overview", target: "development-setup", text: "development-setup" },
|
||||
{ source: "architecture-overview", target: "database-schema", text: "database-schema" },
|
||||
{ source: "development-setup", target: "architecture-overview", text: "architecture-overview" },
|
||||
{
|
||||
source: "development-setup",
|
||||
target: "architecture-overview",
|
||||
text: "architecture-overview",
|
||||
},
|
||||
{ source: "development-setup", target: "database-schema", text: "database-schema" },
|
||||
{ source: "database-schema", target: "architecture-overview", text: "architecture-overview" },
|
||||
{ source: "database-schema", target: "development-setup", text: "development-setup" },
|
||||
@@ -437,7 +442,7 @@ This is a draft document. See [[architecture-overview]] for current state.`,
|
||||
for (const link of links) {
|
||||
const sourceEntry = createdEntries.get(link.source);
|
||||
const targetEntry = createdEntries.get(link.target);
|
||||
|
||||
|
||||
if (sourceEntry && targetEntry) {
|
||||
await tx.knowledgeLink.create({
|
||||
data: {
|
||||
|
||||
@@ -152,10 +152,7 @@ describe("ActivityController", () => {
|
||||
const result = await controller.findOne("activity-123", mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockActivity);
|
||||
expect(mockActivityService.findOne).toHaveBeenCalledWith(
|
||||
"activity-123",
|
||||
"workspace-123"
|
||||
);
|
||||
expect(mockActivityService.findOne).toHaveBeenCalledWith("activity-123", "workspace-123");
|
||||
});
|
||||
|
||||
it("should return null if activity not found", async () => {
|
||||
@@ -213,11 +210,7 @@ describe("ActivityController", () => {
|
||||
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
|
||||
);
|
||||
const result = await controller.getAuditTrail(EntityType.TASK, "task-123", mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockAuditTrail);
|
||||
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
|
||||
@@ -248,11 +241,7 @@ describe("ActivityController", () => {
|
||||
|
||||
mockActivityService.getAuditTrail.mockResolvedValue(eventAuditTrail);
|
||||
|
||||
const result = await controller.getAuditTrail(
|
||||
EntityType.EVENT,
|
||||
"event-123",
|
||||
mockWorkspaceId
|
||||
);
|
||||
const result = await controller.getAuditTrail(EntityType.EVENT, "event-123", mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(eventAuditTrail);
|
||||
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
|
||||
@@ -312,11 +301,7 @@ describe("ActivityController", () => {
|
||||
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
|
||||
);
|
||||
const result = await controller.getAuditTrail(EntityType.TASK, "task-123", undefined as any);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
|
||||
|
||||
@@ -25,9 +25,7 @@ describe("ActivityLoggingInterceptor", () => {
|
||||
],
|
||||
}).compile();
|
||||
|
||||
interceptor = module.get<ActivityLoggingInterceptor>(
|
||||
ActivityLoggingInterceptor
|
||||
);
|
||||
interceptor = module.get<ActivityLoggingInterceptor>(ActivityLoggingInterceptor);
|
||||
activityService = module.get<ActivityService>(ActivityService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
@@ -324,9 +322,7 @@ describe("ActivityLoggingInterceptor", () => {
|
||||
const context = createMockExecutionContext("POST", {}, {}, user);
|
||||
const next = createMockCallHandler({ id: "test-123" });
|
||||
|
||||
mockActivityService.logActivity.mockRejectedValue(
|
||||
new Error("Logging failed")
|
||||
);
|
||||
mockActivityService.logActivity.mockRejectedValue(new Error("Logging failed"));
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
@@ -727,9 +723,7 @@ describe("ActivityLoggingInterceptor", () => {
|
||||
expect(logCall.details.data.settings.apiKey).toBe("[REDACTED]");
|
||||
expect(logCall.details.data.settings.public).toBe("visible_data");
|
||||
expect(logCall.details.data.settings.auth.token).toBe("[REDACTED]");
|
||||
expect(logCall.details.data.settings.auth.refreshToken).toBe(
|
||||
"[REDACTED]"
|
||||
);
|
||||
expect(logCall.details.data.settings.auth.refreshToken).toBe("[REDACTED]");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,11 +86,7 @@ describe("AgentTasksController", () => {
|
||||
|
||||
const result = await controller.create(createDto, workspaceId, user);
|
||||
|
||||
expect(mockAgentTasksService.create).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
user.id,
|
||||
createDto
|
||||
);
|
||||
expect(mockAgentTasksService.create).toHaveBeenCalledWith(workspaceId, user.id, createDto);
|
||||
expect(result).toEqual(mockTask);
|
||||
});
|
||||
});
|
||||
@@ -183,10 +179,7 @@ describe("AgentTasksController", () => {
|
||||
|
||||
const result = await controller.findOne(id, workspaceId);
|
||||
|
||||
expect(mockAgentTasksService.findOne).toHaveBeenCalledWith(
|
||||
id,
|
||||
workspaceId
|
||||
);
|
||||
expect(mockAgentTasksService.findOne).toHaveBeenCalledWith(id, workspaceId);
|
||||
expect(result).toEqual(mockTask);
|
||||
});
|
||||
});
|
||||
@@ -220,11 +213,7 @@ describe("AgentTasksController", () => {
|
||||
|
||||
const result = await controller.update(id, updateDto, workspaceId);
|
||||
|
||||
expect(mockAgentTasksService.update).toHaveBeenCalledWith(
|
||||
id,
|
||||
workspaceId,
|
||||
updateDto
|
||||
);
|
||||
expect(mockAgentTasksService.update).toHaveBeenCalledWith(id, workspaceId, updateDto);
|
||||
expect(result).toEqual(mockTask);
|
||||
});
|
||||
});
|
||||
@@ -240,10 +229,7 @@ describe("AgentTasksController", () => {
|
||||
|
||||
const result = await controller.remove(id, workspaceId);
|
||||
|
||||
expect(mockAgentTasksService.remove).toHaveBeenCalledWith(
|
||||
id,
|
||||
workspaceId
|
||||
);
|
||||
expect(mockAgentTasksService.remove).toHaveBeenCalledWith(id, workspaceId);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,9 +242,7 @@ describe("AgentTasksService", () => {
|
||||
|
||||
mockPrismaService.agentTask.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(id, workspaceId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
await expect(service.findOne(id, workspaceId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,9 +314,7 @@ describe("AgentTasksService", () => {
|
||||
|
||||
mockPrismaService.agentTask.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update(id, workspaceId, updateDto)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.update(id, workspaceId, updateDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,9 +341,7 @@ describe("AgentTasksService", () => {
|
||||
|
||||
mockPrismaService.agentTask.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(id, workspaceId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
await expect(service.remove(id, workspaceId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -551,7 +551,8 @@ describe("DiscordService", () => {
|
||||
Authorization: "Bearer secret_token_12345",
|
||||
},
|
||||
};
|
||||
(errorWithSecrets as any).token = "MTk4NjIyNDgzNDcxOTI1MjQ4.Cl2FMQ.ZnCjm1XVW7vRze4b7Cq4se7kKWs";
|
||||
(errorWithSecrets as any).token =
|
||||
"MTk4NjIyNDgzNDcxOTI1MjQ4.Cl2FMQ.ZnCjm1XVW7vRze4b7Cq4se7kKWs";
|
||||
|
||||
// Trigger error event handler
|
||||
expect(mockErrorCallbacks.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -5,6 +5,7 @@ This directory contains shared guards and decorators for workspace-based permiss
|
||||
## Overview
|
||||
|
||||
The permission system provides:
|
||||
|
||||
- **Workspace isolation** via Row-Level Security (RLS)
|
||||
- **Role-based access control** (RBAC) using workspace member roles
|
||||
- **Declarative permission requirements** using decorators
|
||||
@@ -18,6 +19,7 @@ Located in `../auth/guards/auth.guard.ts`
|
||||
Verifies user authentication and attaches user data to the request.
|
||||
|
||||
**Sets on request:**
|
||||
|
||||
- `request.user` - Authenticated user object
|
||||
- `request.session` - User session data
|
||||
|
||||
@@ -26,23 +28,27 @@ Verifies user authentication and attaches user data to the request.
|
||||
Validates workspace access and sets up RLS context.
|
||||
|
||||
**Responsibilities:**
|
||||
|
||||
1. Extracts workspace ID from request (header, param, or body)
|
||||
2. Verifies user is a member of the workspace
|
||||
3. Sets the current user context for RLS policies
|
||||
4. Attaches workspace context to the request
|
||||
|
||||
**Sets on request:**
|
||||
|
||||
- `request.workspace.id` - Validated workspace ID
|
||||
- `request.user.workspaceId` - Workspace ID (for backward compatibility)
|
||||
|
||||
**Workspace ID Sources (in priority order):**
|
||||
|
||||
1. `X-Workspace-Id` header
|
||||
2. `:workspaceId` URL parameter
|
||||
3. `workspaceId` in request body
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@Controller('tasks')
|
||||
@Controller("tasks")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard)
|
||||
export class TasksController {
|
||||
@Get()
|
||||
@@ -57,23 +63,26 @@ export class TasksController {
|
||||
Enforces role-based access control using workspace member roles.
|
||||
|
||||
**Responsibilities:**
|
||||
|
||||
1. Reads required permission from `@RequirePermission()` decorator
|
||||
2. Fetches user's role in the workspace
|
||||
3. Checks if role satisfies the required permission
|
||||
4. Attaches role to request for convenience
|
||||
|
||||
**Sets on request:**
|
||||
|
||||
- `request.user.workspaceRole` - User's role in the workspace
|
||||
|
||||
**Must be used after AuthGuard and WorkspaceGuard.**
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@Controller('admin')
|
||||
@Controller("admin")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class AdminController {
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
@Delete('data')
|
||||
@Delete("data")
|
||||
async deleteData() {
|
||||
// Only ADMIN or OWNER can execute
|
||||
}
|
||||
@@ -88,14 +97,15 @@ Specifies the minimum permission level required for a route.
|
||||
|
||||
**Permission Levels:**
|
||||
|
||||
| Permission | Allowed Roles | Use Case |
|
||||
|------------|--------------|----------|
|
||||
| `WORKSPACE_OWNER` | OWNER | Critical operations (delete workspace, transfer ownership) |
|
||||
| `WORKSPACE_ADMIN` | OWNER, ADMIN | Administrative functions (manage members, settings) |
|
||||
| `WORKSPACE_MEMBER` | OWNER, ADMIN, MEMBER | Standard operations (create/edit content) |
|
||||
| `WORKSPACE_ANY` | All roles including GUEST | Read-only or basic access |
|
||||
| Permission | Allowed Roles | Use Case |
|
||||
| ------------------ | ------------------------- | ---------------------------------------------------------- |
|
||||
| `WORKSPACE_OWNER` | OWNER | Critical operations (delete workspace, transfer ownership) |
|
||||
| `WORKSPACE_ADMIN` | OWNER, ADMIN | Administrative functions (manage members, settings) |
|
||||
| `WORKSPACE_MEMBER` | OWNER, ADMIN, MEMBER | Standard operations (create/edit content) |
|
||||
| `WORKSPACE_ANY` | All roles including GUEST | Read-only or basic access |
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
@Post('invite')
|
||||
@@ -109,6 +119,7 @@ async inviteMember(@Body() inviteDto: InviteDto) {
|
||||
Parameter decorator to extract the validated workspace ID.
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
async getTasks(@Workspace() workspaceId: string) {
|
||||
@@ -121,6 +132,7 @@ async getTasks(@Workspace() workspaceId: string) {
|
||||
Parameter decorator to extract the full workspace context.
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
async getTasks(@WorkspaceContext() workspace: { id: string }) {
|
||||
@@ -135,6 +147,7 @@ Located in `../auth/decorators/current-user.decorator.ts`
|
||||
Extracts the authenticated user from the request.
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@Post()
|
||||
async create(@CurrentUser() user: any, @Body() dto: CreateDto) {
|
||||
@@ -153,7 +166,7 @@ import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
@Controller('resources')
|
||||
@Controller("resources")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class ResourcesController {
|
||||
@Get()
|
||||
@@ -164,17 +177,13 @@ export class ResourcesController {
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create(
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: any,
|
||||
@Body() dto: CreateDto
|
||||
) {
|
||||
async create(@Workspace() workspaceId: string, @CurrentUser() user: any, @Body() dto: CreateDto) {
|
||||
// Members and above can create
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async delete(@Param('id') id: string) {
|
||||
async delete(@Param("id") id: string) {
|
||||
// Only admins can delete
|
||||
}
|
||||
}
|
||||
@@ -185,24 +194,32 @@ export class ResourcesController {
|
||||
Different endpoints can have different permission requirements:
|
||||
|
||||
```typescript
|
||||
@Controller('projects')
|
||||
@Controller("projects")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class ProjectsController {
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async list() { /* Anyone can view */ }
|
||||
async list() {
|
||||
/* Anyone can view */
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create() { /* Members can create */ }
|
||||
async create() {
|
||||
/* Members can create */
|
||||
}
|
||||
|
||||
@Patch('settings')
|
||||
@Patch("settings")
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async updateSettings() { /* Only admins */ }
|
||||
async updateSettings() {
|
||||
/* Only admins */
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@RequirePermission(Permission.WORKSPACE_OWNER)
|
||||
async deleteProject() { /* Only owner */ }
|
||||
async deleteProject() {
|
||||
/* Only owner */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -211,17 +228,19 @@ export class ProjectsController {
|
||||
The workspace ID can be provided in multiple ways:
|
||||
|
||||
**Via Header (Recommended for SPAs):**
|
||||
|
||||
```typescript
|
||||
// Frontend
|
||||
fetch('/api/tasks', {
|
||||
fetch("/api/tasks", {
|
||||
headers: {
|
||||
'Authorization': 'Bearer <token>',
|
||||
'X-Workspace-Id': 'workspace-uuid',
|
||||
}
|
||||
})
|
||||
Authorization: "Bearer <token>",
|
||||
"X-Workspace-Id": "workspace-uuid",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Via URL Parameter:**
|
||||
|
||||
```typescript
|
||||
@Get(':workspaceId/tasks')
|
||||
async getTasks(@Param('workspaceId') workspaceId: string) {
|
||||
@@ -230,6 +249,7 @@ async getTasks(@Param('workspaceId') workspaceId: string) {
|
||||
```
|
||||
|
||||
**Via Request Body:**
|
||||
|
||||
```typescript
|
||||
@Post()
|
||||
async create(@Body() dto: { workspaceId: string; name: string }) {
|
||||
@@ -240,6 +260,7 @@ async create(@Body() dto: { workspaceId: string; name: string }) {
|
||||
## Row-Level Security (RLS)
|
||||
|
||||
When `WorkspaceGuard` is applied, it automatically:
|
||||
|
||||
1. Calls `setCurrentUser(userId)` to set the RLS context
|
||||
2. All subsequent database queries are automatically filtered by RLS policies
|
||||
3. Users can only access data in workspaces they're members of
|
||||
@@ -249,10 +270,12 @@ When `WorkspaceGuard` is applied, it automatically:
|
||||
## Testing
|
||||
|
||||
Tests are provided for both guards:
|
||||
|
||||
- `workspace.guard.spec.ts` - WorkspaceGuard tests
|
||||
- `permission.guard.spec.ts` - PermissionGuard tests
|
||||
|
||||
**Run tests:**
|
||||
|
||||
```bash
|
||||
npm test -- workspace.guard.spec
|
||||
npm test -- permission.guard.spec
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("BaseFilterDto", () => {
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.property === "sortOrder")).toBe(true);
|
||||
expect(errors.some((e) => e.property === "sortOrder")).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept comma-separated sortBy fields", async () => {
|
||||
@@ -134,7 +134,7 @@ describe("BaseFilterDto", () => {
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.property === "dateFrom")).toBe(true);
|
||||
expect(errors.some((e) => e.property === "dateFrom")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid date format for dateTo", async () => {
|
||||
@@ -144,7 +144,7 @@ describe("BaseFilterDto", () => {
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.property === "dateTo")).toBe(true);
|
||||
expect(errors.some((e) => e.property === "dateTo")).toBe(true);
|
||||
});
|
||||
|
||||
it("should trim whitespace from search query", async () => {
|
||||
@@ -165,6 +165,6 @@ describe("BaseFilterDto", () => {
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.property === "search")).toBe(true);
|
||||
expect(errors.some((e) => e.property === "search")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,10 +44,7 @@ describe("PermissionGuard", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockExecutionContext = (
|
||||
user: any,
|
||||
workspace: any
|
||||
): ExecutionContext => {
|
||||
const createMockExecutionContext = (user: any, workspace: any): ExecutionContext => {
|
||||
const mockRequest = {
|
||||
user,
|
||||
workspace,
|
||||
@@ -67,10 +64,7 @@ describe("PermissionGuard", () => {
|
||||
const workspaceId = "workspace-456";
|
||||
|
||||
it("should allow access when no permission is required", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(undefined);
|
||||
|
||||
@@ -80,10 +74,7 @@ describe("PermissionGuard", () => {
|
||||
});
|
||||
|
||||
it("should allow OWNER to access WORKSPACE_OWNER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_OWNER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
@@ -99,30 +90,19 @@ describe("PermissionGuard", () => {
|
||||
});
|
||||
|
||||
it("should deny ADMIN access to WORKSPACE_OWNER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_OWNER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should allow OWNER and ADMIN to access WORKSPACE_ADMIN permission", async () => {
|
||||
const context1 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context2 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context1 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
const context2 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ADMIN);
|
||||
|
||||
@@ -140,34 +120,20 @@ describe("PermissionGuard", () => {
|
||||
});
|
||||
|
||||
it("should deny MEMBER access to WORKSPACE_ADMIN permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ADMIN);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should allow OWNER, ADMIN, and MEMBER to access WORKSPACE_MEMBER permission", async () => {
|
||||
const context1 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context2 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context3 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context1 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
const context2 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
const context3 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
@@ -191,26 +157,18 @@ describe("PermissionGuard", () => {
|
||||
});
|
||||
|
||||
it("should deny GUEST access to WORKSPACE_MEMBER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.GUEST,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should allow any role (including GUEST) to access WORKSPACE_ANY permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ANY);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
@@ -227,9 +185,7 @@ describe("PermissionGuard", () => {
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when workspace context is missing", async () => {
|
||||
@@ -237,42 +193,28 @@ describe("PermissionGuard", () => {
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not a workspace member", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"You are not a member of this workspace"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle database errors gracefully", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(
|
||||
new Error("Database error")
|
||||
);
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(new Error("Database error"));
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,10 +58,7 @@ describe("WorkspaceGuard", () => {
|
||||
const workspaceId = "workspace-456";
|
||||
|
||||
it("should allow access when user is a workspace member (via header)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { "x-workspace-id": workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
@@ -87,11 +84,7 @@ describe("WorkspaceGuard", () => {
|
||||
});
|
||||
|
||||
it("should allow access when user is a workspace member (via URL param)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{},
|
||||
{ workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, {}, { workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
@@ -105,12 +98,7 @@ describe("WorkspaceGuard", () => {
|
||||
});
|
||||
|
||||
it("should allow access when user is a workspace member (via body)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{},
|
||||
{},
|
||||
{ workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, {}, {}, { workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
@@ -154,59 +142,38 @@ describe("WorkspaceGuard", () => {
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not authenticated", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
null,
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext(null, { "x-workspace-id": workspaceId });
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"User not authenticated"
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow("User not authenticated");
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when workspace ID is missing", async () => {
|
||||
const context = createMockExecutionContext({ id: userId });
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"Workspace ID is required"
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(BadRequestException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow("Workspace ID is required");
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not a workspace member", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { "x-workspace-id": workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"You do not have access to this workspace"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle database errors gracefully", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { "x-workspace-id": workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(
|
||||
new Error("Database connection failed")
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,18 +27,14 @@ describe("QueryBuilder", () => {
|
||||
it("should handle single field", () => {
|
||||
const result = QueryBuilder.buildSearchFilter("test", ["title"]);
|
||||
expect(result).toEqual({
|
||||
OR: [
|
||||
{ title: { contains: "test", mode: "insensitive" } },
|
||||
],
|
||||
OR: [{ title: { contains: "test", mode: "insensitive" } }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should trim search query", () => {
|
||||
const result = QueryBuilder.buildSearchFilter(" test ", ["title"]);
|
||||
expect(result).toEqual({
|
||||
OR: [
|
||||
{ title: { contains: "test", mode: "insensitive" } },
|
||||
],
|
||||
OR: [{ title: { contains: "test", mode: "insensitive" } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -56,26 +52,17 @@ describe("QueryBuilder", () => {
|
||||
|
||||
it("should build multi-field sort", () => {
|
||||
const result = QueryBuilder.buildSortOrder("priority,dueDate", SortOrder.DESC);
|
||||
expect(result).toEqual([
|
||||
{ priority: "desc" },
|
||||
{ dueDate: "desc" },
|
||||
]);
|
||||
expect(result).toEqual([{ priority: "desc" }, { dueDate: "desc" }]);
|
||||
});
|
||||
|
||||
it("should handle mixed sorting with custom order per field", () => {
|
||||
const result = QueryBuilder.buildSortOrder("priority:asc,dueDate:desc");
|
||||
expect(result).toEqual([
|
||||
{ priority: "asc" },
|
||||
{ dueDate: "desc" },
|
||||
]);
|
||||
expect(result).toEqual([{ priority: "asc" }, { dueDate: "desc" }]);
|
||||
});
|
||||
|
||||
it("should use default order when not specified per field", () => {
|
||||
const result = QueryBuilder.buildSortOrder("priority,dueDate", SortOrder.ASC);
|
||||
expect(result).toEqual([
|
||||
{ priority: "asc" },
|
||||
{ dueDate: "asc" },
|
||||
]);
|
||||
expect(result).toEqual([{ priority: "asc" }, { dueDate: "asc" }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -60,9 +60,7 @@ describe("CoordinatorIntegrationController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("PATCH /coordinator/jobs/:id/status should require authentication", async () => {
|
||||
@@ -72,9 +70,7 @@ describe("CoordinatorIntegrationController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("PATCH /coordinator/jobs/:id/progress should require authentication", async () => {
|
||||
@@ -84,9 +80,7 @@ describe("CoordinatorIntegrationController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("POST /coordinator/jobs/:id/complete should require authentication", async () => {
|
||||
@@ -96,9 +90,7 @@ describe("CoordinatorIntegrationController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("POST /coordinator/jobs/:id/fail should require authentication", async () => {
|
||||
@@ -108,9 +100,7 @@ describe("CoordinatorIntegrationController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("GET /coordinator/jobs/:id should require authentication", async () => {
|
||||
@@ -120,9 +110,7 @@ describe("CoordinatorIntegrationController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("GET /coordinator/health should require authentication", async () => {
|
||||
@@ -132,9 +120,7 @@ describe("CoordinatorIntegrationController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,9 +147,7 @@ describe("CoordinatorIntegrationController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow("Invalid API key");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,8 +83,20 @@ describe("CronService", () => {
|
||||
it("should return all schedules for a workspace", async () => {
|
||||
const workspaceId = "ws-123";
|
||||
const expectedSchedules = [
|
||||
{ id: "cron-1", workspaceId, expression: "0 9 * * *", command: "morning briefing", enabled: true },
|
||||
{ id: "cron-2", workspaceId, expression: "0 17 * * *", command: "evening summary", enabled: true },
|
||||
{
|
||||
id: "cron-1",
|
||||
workspaceId,
|
||||
expression: "0 9 * * *",
|
||||
command: "morning briefing",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "cron-2",
|
||||
workspaceId,
|
||||
expression: "0 17 * * *",
|
||||
command: "evening summary",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
mockPrisma.cronSchedule.findMany.mockResolvedValue(expectedSchedules);
|
||||
|
||||
@@ -103,18 +103,10 @@ describe("DomainsController", () => {
|
||||
|
||||
mockDomainsService.create.mockResolvedValue(mockDomain);
|
||||
|
||||
const result = await controller.create(
|
||||
createDto,
|
||||
mockWorkspaceId,
|
||||
mockUser
|
||||
);
|
||||
const result = await controller.create(createDto, mockWorkspaceId, mockUser);
|
||||
|
||||
expect(result).toEqual(mockDomain);
|
||||
expect(service.create).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
mockUserId,
|
||||
createDto
|
||||
);
|
||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, createDto);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,10 +162,7 @@ describe("DomainsController", () => {
|
||||
const result = await controller.findOne(mockDomainId, mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockDomain);
|
||||
expect(service.findOne).toHaveBeenCalledWith(
|
||||
mockDomainId,
|
||||
mockWorkspaceId
|
||||
);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockDomainId, mockWorkspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,12 +176,7 @@ describe("DomainsController", () => {
|
||||
const updatedDomain = { ...mockDomain, ...updateDto };
|
||||
mockDomainsService.update.mockResolvedValue(updatedDomain);
|
||||
|
||||
const result = await controller.update(
|
||||
mockDomainId,
|
||||
updateDto,
|
||||
mockWorkspaceId,
|
||||
mockUser
|
||||
);
|
||||
const result = await controller.update(mockDomainId, updateDto, mockWorkspaceId, mockUser);
|
||||
|
||||
expect(result).toEqual(updatedDomain);
|
||||
expect(service.update).toHaveBeenCalledWith(
|
||||
@@ -210,11 +194,7 @@ describe("DomainsController", () => {
|
||||
|
||||
await controller.remove(mockDomainId, mockWorkspaceId, mockUser);
|
||||
|
||||
expect(service.remove).toHaveBeenCalledWith(
|
||||
mockDomainId,
|
||||
mockWorkspaceId,
|
||||
mockUserId
|
||||
);
|
||||
expect(service.remove).toHaveBeenCalledWith(mockDomainId, mockWorkspaceId, mockUserId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,11 +63,7 @@ describe("EventsController", () => {
|
||||
const result = await controller.create(createDto, mockWorkspaceId, mockUser);
|
||||
|
||||
expect(result).toEqual(mockEvent);
|
||||
expect(service.create).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
mockUserId,
|
||||
createDto
|
||||
);
|
||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, createDto);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards in production)", async () => {
|
||||
@@ -153,7 +149,12 @@ describe("EventsController", () => {
|
||||
|
||||
await controller.update(mockEventId, updateDto, undefined as any, mockUser);
|
||||
|
||||
expect(mockEventsService.update).toHaveBeenCalledWith(mockEventId, undefined, mockUserId, updateDto);
|
||||
expect(mockEventsService.update).toHaveBeenCalledWith(
|
||||
mockEventId,
|
||||
undefined,
|
||||
mockUserId,
|
||||
updateDto
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,11 +164,7 @@ describe("EventsController", () => {
|
||||
|
||||
await controller.remove(mockEventId, mockWorkspaceId, mockUser);
|
||||
|
||||
expect(service.remove).toHaveBeenCalledWith(
|
||||
mockEventId,
|
||||
mockWorkspaceId,
|
||||
mockUserId
|
||||
);
|
||||
expect(service.remove).toHaveBeenCalledWith(mockEventId, mockWorkspaceId, mockUserId);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards in production)", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ModuleRef } from "@nestjs/core";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { randomUUID } from "crypto";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
@@ -26,7 +27,8 @@ export class CommandService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly federationService: FederationService,
|
||||
private readonly signatureService: SignatureService,
|
||||
private readonly httpService: HttpService
|
||||
private readonly httpService: HttpService,
|
||||
private readonly moduleRef: ModuleRef
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -158,15 +160,33 @@ export class CommandService {
|
||||
throw new Error(verificationResult.error ?? "Invalid signature");
|
||||
}
|
||||
|
||||
// Process command (placeholder - would delegate to actual command processor)
|
||||
// Process command
|
||||
let responseData: unknown;
|
||||
let success = true;
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
try {
|
||||
// TODO: Implement actual command processing
|
||||
// For now, return a placeholder response
|
||||
responseData = { message: "Command received and processed" };
|
||||
// Route agent commands to FederationAgentService
|
||||
if (commandMessage.commandType.startsWith("agent.")) {
|
||||
// Import FederationAgentService dynamically to avoid circular dependency
|
||||
const { FederationAgentService } = await import("./federation-agent.service");
|
||||
const federationAgentService = this.moduleRef.get(FederationAgentService, {
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const agentResponse = await federationAgentService.handleAgentCommand(
|
||||
commandMessage.instanceId,
|
||||
commandMessage.commandType,
|
||||
commandMessage.payload
|
||||
);
|
||||
|
||||
success = agentResponse.success;
|
||||
responseData = agentResponse.data;
|
||||
errorMessage = agentResponse.error;
|
||||
} else {
|
||||
// Other command types can be added here
|
||||
responseData = { message: "Command received and processed" };
|
||||
}
|
||||
} catch (error) {
|
||||
success = false;
|
||||
errorMessage = error instanceof Error ? error.message : "Command processing failed";
|
||||
|
||||
457
apps/api/src/federation/federation-agent.service.spec.ts
Normal file
457
apps/api/src/federation/federation-agent.service.spec.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Tests for Federation Agent Service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { FederationAgentService } from "./federation-agent.service";
|
||||
import { CommandService } from "./command.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { FederationConnectionStatus } from "@prisma/client";
|
||||
import { of, throwError } from "rxjs";
|
||||
import type {
|
||||
SpawnAgentCommandPayload,
|
||||
AgentStatusCommandPayload,
|
||||
KillAgentCommandPayload,
|
||||
SpawnAgentResponseData,
|
||||
AgentStatusResponseData,
|
||||
KillAgentResponseData,
|
||||
} from "./types/federation-agent.types";
|
||||
|
||||
describe("FederationAgentService", () => {
|
||||
let service: FederationAgentService;
|
||||
let commandService: ReturnType<typeof vi.mocked<CommandService>>;
|
||||
let prisma: ReturnType<typeof vi.mocked<PrismaService>>;
|
||||
let httpService: ReturnType<typeof vi.mocked<HttpService>>;
|
||||
let configService: ReturnType<typeof vi.mocked<ConfigService>>;
|
||||
|
||||
const mockWorkspaceId = "workspace-1";
|
||||
const mockConnectionId = "connection-1";
|
||||
const mockAgentId = "agent-123";
|
||||
const mockTaskId = "task-456";
|
||||
const mockOrchestratorUrl = "http://localhost:3001";
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockCommandService = {
|
||||
sendCommand: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPrisma = {
|
||||
federationConnection: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockHttpService = {
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "orchestrator.url") {
|
||||
return mockOrchestratorUrl;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
FederationAgentService,
|
||||
{ provide: CommandService, useValue: mockCommandService },
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: HttpService, useValue: mockHttpService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<FederationAgentService>(FederationAgentService);
|
||||
commandService = module.get(CommandService);
|
||||
prisma = module.get(PrismaService);
|
||||
httpService = module.get(HttpService);
|
||||
configService = module.get(ConfigService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("spawnAgentOnRemote", () => {
|
||||
const spawnPayload: SpawnAgentCommandPayload = {
|
||||
taskId: mockTaskId,
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "git.example.com/org/repo",
|
||||
branch: "main",
|
||||
workItems: ["item-1"],
|
||||
},
|
||||
};
|
||||
|
||||
const mockConnection = {
|
||||
id: mockConnectionId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: "remote-instance-1",
|
||||
remoteUrl: "https://remote.example.com",
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
};
|
||||
|
||||
it("should spawn agent on remote instance", async () => {
|
||||
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection as never);
|
||||
|
||||
const mockCommandResponse = {
|
||||
id: "msg-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: "COMMAND" as never,
|
||||
messageId: "msg-uuid",
|
||||
commandType: "agent.spawn",
|
||||
payload: spawnPayload as never,
|
||||
response: {
|
||||
agentId: mockAgentId,
|
||||
status: "spawning",
|
||||
spawnedAt: "2026-02-03T14:30:00Z",
|
||||
} as never,
|
||||
status: "DELIVERED" as never,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
commandService.sendCommand.mockResolvedValue(mockCommandResponse as never);
|
||||
|
||||
const result = await service.spawnAgentOnRemote(
|
||||
mockWorkspaceId,
|
||||
mockConnectionId,
|
||||
spawnPayload
|
||||
);
|
||||
|
||||
expect(prisma.federationConnection.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockConnectionId, workspaceId: mockWorkspaceId },
|
||||
});
|
||||
|
||||
expect(commandService.sendCommand).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
mockConnectionId,
|
||||
"agent.spawn",
|
||||
spawnPayload
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockCommandResponse);
|
||||
});
|
||||
|
||||
it("should throw error if connection not found", async () => {
|
||||
prisma.federationConnection.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.spawnAgentOnRemote(mockWorkspaceId, mockConnectionId, spawnPayload)
|
||||
).rejects.toThrow("Connection not found");
|
||||
|
||||
expect(commandService.sendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw error if connection not active", async () => {
|
||||
const inactiveConnection = {
|
||||
...mockConnection,
|
||||
status: FederationConnectionStatus.DISCONNECTED,
|
||||
};
|
||||
|
||||
prisma.federationConnection.findUnique.mockResolvedValue(inactiveConnection as never);
|
||||
|
||||
await expect(
|
||||
service.spawnAgentOnRemote(mockWorkspaceId, mockConnectionId, spawnPayload)
|
||||
).rejects.toThrow("Connection is not active");
|
||||
|
||||
expect(commandService.sendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentStatus", () => {
|
||||
const statusPayload: AgentStatusCommandPayload = {
|
||||
agentId: mockAgentId,
|
||||
};
|
||||
|
||||
const mockConnection = {
|
||||
id: mockConnectionId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: "remote-instance-1",
|
||||
remoteUrl: "https://remote.example.com",
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
};
|
||||
|
||||
it("should get agent status from remote instance", async () => {
|
||||
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection as never);
|
||||
|
||||
const mockCommandResponse = {
|
||||
id: "msg-2",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: "COMMAND" as never,
|
||||
messageId: "msg-uuid-2",
|
||||
commandType: "agent.status",
|
||||
payload: statusPayload as never,
|
||||
response: {
|
||||
agentId: mockAgentId,
|
||||
taskId: mockTaskId,
|
||||
status: "running",
|
||||
spawnedAt: "2026-02-03T14:30:00Z",
|
||||
startedAt: "2026-02-03T14:30:05Z",
|
||||
} as never,
|
||||
status: "DELIVERED" as never,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
commandService.sendCommand.mockResolvedValue(mockCommandResponse as never);
|
||||
|
||||
const result = await service.getAgentStatus(mockWorkspaceId, mockConnectionId, mockAgentId);
|
||||
|
||||
expect(commandService.sendCommand).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
mockConnectionId,
|
||||
"agent.status",
|
||||
statusPayload
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockCommandResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("killAgentOnRemote", () => {
|
||||
const killPayload: KillAgentCommandPayload = {
|
||||
agentId: mockAgentId,
|
||||
};
|
||||
|
||||
const mockConnection = {
|
||||
id: mockConnectionId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: "remote-instance-1",
|
||||
remoteUrl: "https://remote.example.com",
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
};
|
||||
|
||||
it("should kill agent on remote instance", async () => {
|
||||
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection as never);
|
||||
|
||||
const mockCommandResponse = {
|
||||
id: "msg-3",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: "COMMAND" as never,
|
||||
messageId: "msg-uuid-3",
|
||||
commandType: "agent.kill",
|
||||
payload: killPayload as never,
|
||||
response: {
|
||||
agentId: mockAgentId,
|
||||
status: "killed",
|
||||
killedAt: "2026-02-03T14:35:00Z",
|
||||
} as never,
|
||||
status: "DELIVERED" as never,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
commandService.sendCommand.mockResolvedValue(mockCommandResponse as never);
|
||||
|
||||
const result = await service.killAgentOnRemote(
|
||||
mockWorkspaceId,
|
||||
mockConnectionId,
|
||||
mockAgentId
|
||||
);
|
||||
|
||||
expect(commandService.sendCommand).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
mockConnectionId,
|
||||
"agent.kill",
|
||||
killPayload
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockCommandResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAgentCommand", () => {
|
||||
const mockConnection = {
|
||||
id: mockConnectionId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: "remote-instance-1",
|
||||
remoteUrl: "https://remote.example.com",
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
};
|
||||
|
||||
it("should handle agent.spawn command", async () => {
|
||||
const spawnPayload: SpawnAgentCommandPayload = {
|
||||
taskId: mockTaskId,
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "git.example.com/org/repo",
|
||||
branch: "main",
|
||||
workItems: ["item-1"],
|
||||
},
|
||||
};
|
||||
|
||||
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
|
||||
|
||||
const mockOrchestratorResponse = {
|
||||
agentId: mockAgentId,
|
||||
status: "spawning",
|
||||
};
|
||||
|
||||
httpService.post.mockReturnValue(
|
||||
of({
|
||||
data: mockOrchestratorResponse,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: {} as never,
|
||||
}) as never
|
||||
);
|
||||
|
||||
const result = await service.handleAgentCommand(
|
||||
"remote-instance-1",
|
||||
"agent.spawn",
|
||||
spawnPayload
|
||||
);
|
||||
|
||||
expect(httpService.post).toHaveBeenCalledWith(
|
||||
`${mockOrchestratorUrl}/agents/spawn`,
|
||||
expect.objectContaining({
|
||||
taskId: mockTaskId,
|
||||
agentType: "worker",
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({
|
||||
agentId: mockAgentId,
|
||||
status: "spawning",
|
||||
spawnedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle agent.status command", async () => {
|
||||
const statusPayload: AgentStatusCommandPayload = {
|
||||
agentId: mockAgentId,
|
||||
};
|
||||
|
||||
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
|
||||
|
||||
const mockOrchestratorResponse = {
|
||||
agentId: mockAgentId,
|
||||
taskId: mockTaskId,
|
||||
status: "running",
|
||||
spawnedAt: "2026-02-03T14:30:00Z",
|
||||
startedAt: "2026-02-03T14:30:05Z",
|
||||
};
|
||||
|
||||
httpService.get.mockReturnValue(
|
||||
of({
|
||||
data: mockOrchestratorResponse,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: {} as never,
|
||||
}) as never
|
||||
);
|
||||
|
||||
const result = await service.handleAgentCommand(
|
||||
"remote-instance-1",
|
||||
"agent.status",
|
||||
statusPayload
|
||||
);
|
||||
|
||||
expect(httpService.get).toHaveBeenCalledWith(
|
||||
`${mockOrchestratorUrl}/agents/${mockAgentId}/status`
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockOrchestratorResponse);
|
||||
});
|
||||
|
||||
it("should handle agent.kill command", async () => {
|
||||
const killPayload: KillAgentCommandPayload = {
|
||||
agentId: mockAgentId,
|
||||
};
|
||||
|
||||
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
|
||||
|
||||
const mockOrchestratorResponse = {
|
||||
message: `Agent ${mockAgentId} killed successfully`,
|
||||
};
|
||||
|
||||
httpService.post.mockReturnValue(
|
||||
of({
|
||||
data: mockOrchestratorResponse,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: {} as never,
|
||||
}) as never
|
||||
);
|
||||
|
||||
const result = await service.handleAgentCommand(
|
||||
"remote-instance-1",
|
||||
"agent.kill",
|
||||
killPayload
|
||||
);
|
||||
|
||||
expect(httpService.post).toHaveBeenCalledWith(
|
||||
`${mockOrchestratorUrl}/agents/${mockAgentId}/kill`,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({
|
||||
agentId: mockAgentId,
|
||||
status: "killed",
|
||||
killedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("should return error for unknown command type", async () => {
|
||||
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
|
||||
|
||||
const result = await service.handleAgentCommand("remote-instance-1", "agent.unknown", {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Unknown agent command type: agent.unknown");
|
||||
});
|
||||
|
||||
it("should throw error if connection not found", async () => {
|
||||
prisma.federationConnection.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.handleAgentCommand("remote-instance-1", "agent.spawn", {})
|
||||
).rejects.toThrow("No connection found for remote instance");
|
||||
});
|
||||
|
||||
it("should handle orchestrator errors", async () => {
|
||||
const spawnPayload: SpawnAgentCommandPayload = {
|
||||
taskId: mockTaskId,
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "git.example.com/org/repo",
|
||||
branch: "main",
|
||||
workItems: ["item-1"],
|
||||
},
|
||||
};
|
||||
|
||||
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
|
||||
|
||||
httpService.post.mockReturnValue(
|
||||
throwError(() => new Error("Orchestrator connection failed")) as never
|
||||
);
|
||||
|
||||
const result = await service.handleAgentCommand(
|
||||
"remote-instance-1",
|
||||
"agent.spawn",
|
||||
spawnPayload
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Orchestrator connection failed");
|
||||
});
|
||||
});
|
||||
});
|
||||
338
apps/api/src/federation/federation-agent.service.ts
Normal file
338
apps/api/src/federation/federation-agent.service.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Federation Agent Service
|
||||
*
|
||||
* Handles spawning and managing agents on remote federated instances.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CommandService } from "./command.service";
|
||||
import { FederationConnectionStatus } from "@prisma/client";
|
||||
import type { CommandMessageDetails } from "./types/message.types";
|
||||
import type {
|
||||
SpawnAgentCommandPayload,
|
||||
AgentStatusCommandPayload,
|
||||
KillAgentCommandPayload,
|
||||
SpawnAgentResponseData,
|
||||
AgentStatusResponseData,
|
||||
KillAgentResponseData,
|
||||
} from "./types/federation-agent.types";
|
||||
|
||||
/**
|
||||
* Agent command response structure
|
||||
*/
|
||||
export interface AgentCommandResponse {
|
||||
/** Whether the command was successful */
|
||||
success: boolean;
|
||||
/** Response data if successful */
|
||||
data?:
|
||||
| SpawnAgentResponseData
|
||||
| AgentStatusResponseData
|
||||
| KillAgentResponseData
|
||||
| Record<string, unknown>;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FederationAgentService {
|
||||
private readonly logger = new Logger(FederationAgentService.name);
|
||||
private readonly orchestratorUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly commandService: CommandService,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
this.orchestratorUrl =
|
||||
this.configService.get<string>("orchestrator.url") ?? "http://localhost:3001";
|
||||
this.logger.log(
|
||||
`FederationAgentService initialized with orchestrator URL: ${this.orchestratorUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an agent on a remote federated instance
|
||||
* @param workspaceId Workspace ID
|
||||
* @param connectionId Federation connection ID
|
||||
* @param payload Agent spawn command payload
|
||||
* @returns Command message details
|
||||
*/
|
||||
async spawnAgentOnRemote(
|
||||
workspaceId: string,
|
||||
connectionId: string,
|
||||
payload: SpawnAgentCommandPayload
|
||||
): Promise<CommandMessageDetails> {
|
||||
this.logger.log(
|
||||
`Spawning agent on remote instance via connection ${connectionId} for task ${payload.taskId}`
|
||||
);
|
||||
|
||||
// Validate connection exists and is active
|
||||
const connection = await this.prisma.federationConnection.findUnique({
|
||||
where: { id: connectionId, workspaceId },
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
throw new Error("Connection not found");
|
||||
}
|
||||
|
||||
if (connection.status !== FederationConnectionStatus.ACTIVE) {
|
||||
throw new Error("Connection is not active");
|
||||
}
|
||||
|
||||
// Send command via federation
|
||||
const result = await this.commandService.sendCommand(
|
||||
workspaceId,
|
||||
connectionId,
|
||||
"agent.spawn",
|
||||
payload as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
this.logger.log(`Agent spawn command sent successfully: ${result.messageId}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent status from remote instance
|
||||
* @param workspaceId Workspace ID
|
||||
* @param connectionId Federation connection ID
|
||||
* @param agentId Agent ID
|
||||
* @returns Command message details
|
||||
*/
|
||||
async getAgentStatus(
|
||||
workspaceId: string,
|
||||
connectionId: string,
|
||||
agentId: string
|
||||
): Promise<CommandMessageDetails> {
|
||||
this.logger.log(`Getting agent status for ${agentId} via connection ${connectionId}`);
|
||||
|
||||
// Validate connection exists and is active
|
||||
const connection = await this.prisma.federationConnection.findUnique({
|
||||
where: { id: connectionId, workspaceId },
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
throw new Error("Connection not found");
|
||||
}
|
||||
|
||||
if (connection.status !== FederationConnectionStatus.ACTIVE) {
|
||||
throw new Error("Connection is not active");
|
||||
}
|
||||
|
||||
// Send status command
|
||||
const payload: AgentStatusCommandPayload = { agentId };
|
||||
const result = await this.commandService.sendCommand(
|
||||
workspaceId,
|
||||
connectionId,
|
||||
"agent.status",
|
||||
payload as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
this.logger.log(`Agent status command sent successfully: ${result.messageId}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill an agent on remote instance
|
||||
* @param workspaceId Workspace ID
|
||||
* @param connectionId Federation connection ID
|
||||
* @param agentId Agent ID
|
||||
* @returns Command message details
|
||||
*/
|
||||
async killAgentOnRemote(
|
||||
workspaceId: string,
|
||||
connectionId: string,
|
||||
agentId: string
|
||||
): Promise<CommandMessageDetails> {
|
||||
this.logger.log(`Killing agent ${agentId} via connection ${connectionId}`);
|
||||
|
||||
// Validate connection exists and is active
|
||||
const connection = await this.prisma.federationConnection.findUnique({
|
||||
where: { id: connectionId, workspaceId },
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
throw new Error("Connection not found");
|
||||
}
|
||||
|
||||
if (connection.status !== FederationConnectionStatus.ACTIVE) {
|
||||
throw new Error("Connection is not active");
|
||||
}
|
||||
|
||||
// Send kill command
|
||||
const payload: KillAgentCommandPayload = { agentId };
|
||||
const result = await this.commandService.sendCommand(
|
||||
workspaceId,
|
||||
connectionId,
|
||||
"agent.kill",
|
||||
payload as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
this.logger.log(`Agent kill command sent successfully: ${result.messageId}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming agent command from remote instance
|
||||
* @param remoteInstanceId Remote instance ID that sent the command
|
||||
* @param commandType Command type (agent.spawn, agent.status, agent.kill)
|
||||
* @param payload Command payload
|
||||
* @returns Agent command response
|
||||
*/
|
||||
async handleAgentCommand(
|
||||
remoteInstanceId: string,
|
||||
commandType: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<AgentCommandResponse> {
|
||||
this.logger.log(`Handling agent command ${commandType} from ${remoteInstanceId}`);
|
||||
|
||||
// Verify connection exists for remote instance
|
||||
const connection = await this.prisma.federationConnection.findFirst({
|
||||
where: {
|
||||
remoteInstanceId,
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
throw new Error("No connection found for remote instance");
|
||||
}
|
||||
|
||||
// Route command to appropriate handler
|
||||
try {
|
||||
switch (commandType) {
|
||||
case "agent.spawn":
|
||||
return await this.handleSpawnCommand(payload as unknown as SpawnAgentCommandPayload);
|
||||
|
||||
case "agent.status":
|
||||
return await this.handleStatusCommand(payload as unknown as AgentStatusCommandPayload);
|
||||
|
||||
case "agent.kill":
|
||||
return await this.handleKillCommand(payload as unknown as KillAgentCommandPayload);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown agent command type: ${commandType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error handling agent command: ${String(error)}`);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle agent spawn command by calling local orchestrator
|
||||
* @param payload Spawn command payload
|
||||
* @returns Spawn response
|
||||
*/
|
||||
private async handleSpawnCommand(
|
||||
payload: SpawnAgentCommandPayload
|
||||
): Promise<AgentCommandResponse> {
|
||||
this.logger.log(`Processing spawn command for task ${payload.taskId}`);
|
||||
|
||||
try {
|
||||
const orchestratorPayload = {
|
||||
taskId: payload.taskId,
|
||||
agentType: payload.agentType,
|
||||
context: payload.context,
|
||||
options: payload.options,
|
||||
};
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post<{ agentId: string; status: string }>(
|
||||
`${this.orchestratorUrl}/agents/spawn`,
|
||||
orchestratorPayload
|
||||
)
|
||||
);
|
||||
|
||||
const spawnedAt = new Date().toISOString();
|
||||
|
||||
const responseData: SpawnAgentResponseData = {
|
||||
agentId: response.data.agentId,
|
||||
status: response.data.status as "spawning",
|
||||
spawnedAt,
|
||||
};
|
||||
|
||||
this.logger.log(`Agent spawned successfully: ${responseData.agentId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: responseData,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to spawn agent: ${String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle agent status command by calling local orchestrator
|
||||
* @param payload Status command payload
|
||||
* @returns Status response
|
||||
*/
|
||||
private async handleStatusCommand(
|
||||
payload: AgentStatusCommandPayload
|
||||
): Promise<AgentCommandResponse> {
|
||||
this.logger.log(`Processing status command for agent ${payload.agentId}`);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(`${this.orchestratorUrl}/agents/${payload.agentId}/status`)
|
||||
);
|
||||
|
||||
const responseData: AgentStatusResponseData = response.data as AgentStatusResponseData;
|
||||
|
||||
this.logger.log(`Agent status retrieved: ${responseData.status}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: responseData,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get agent status: ${String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle agent kill command by calling local orchestrator
|
||||
* @param payload Kill command payload
|
||||
* @returns Kill response
|
||||
*/
|
||||
private async handleKillCommand(payload: KillAgentCommandPayload): Promise<AgentCommandResponse> {
|
||||
this.logger.log(`Processing kill command for agent ${payload.agentId}`);
|
||||
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.httpService.post(`${this.orchestratorUrl}/agents/${payload.agentId}/kill`, {})
|
||||
);
|
||||
|
||||
const killedAt = new Date().toISOString();
|
||||
|
||||
const responseData: KillAgentResponseData = {
|
||||
agentId: payload.agentId,
|
||||
status: "killed",
|
||||
killedAt,
|
||||
};
|
||||
|
||||
this.logger.log(`Agent killed successfully: ${payload.agentId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: responseData,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to kill agent: ${String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import { Controller, Get, Post, UseGuards, Logger, Req, Body, Param, Query } fro
|
||||
import { FederationService } from "./federation.service";
|
||||
import { FederationAuditService } from "./audit.service";
|
||||
import { ConnectionService } from "./connection.service";
|
||||
import { FederationAgentService } from "./federation-agent.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||
import type { PublicInstanceIdentity } from "./types/instance.types";
|
||||
import type { ConnectionDetails } from "./types/connection.types";
|
||||
import type { CommandMessageDetails } from "./types/message.types";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import {
|
||||
InitiateConnectionDto,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
DisconnectConnectionDto,
|
||||
IncomingConnectionRequestDto,
|
||||
} from "./dto/connection.dto";
|
||||
import type { SpawnAgentCommandPayload } from "./types/federation-agent.types";
|
||||
import { FederationConnectionStatus } from "@prisma/client";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@@ -29,7 +32,8 @@ export class FederationController {
|
||||
constructor(
|
||||
private readonly federationService: FederationService,
|
||||
private readonly auditService: FederationAuditService,
|
||||
private readonly connectionService: ConnectionService
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly federationAgentService: FederationAgentService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -211,4 +215,81 @@ export class FederationController {
|
||||
connectionId: connection.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an agent on a remote federated instance
|
||||
* Requires authentication
|
||||
*/
|
||||
@Post("agents/spawn")
|
||||
@UseGuards(AuthGuard)
|
||||
async spawnAgentOnRemote(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() body: { connectionId: string; payload: SpawnAgentCommandPayload }
|
||||
): Promise<CommandMessageDetails> {
|
||||
if (!req.user?.workspaceId) {
|
||||
throw new Error("Workspace ID not found in request");
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`User ${req.user.id} spawning agent on remote instance via connection ${body.connectionId}`
|
||||
);
|
||||
|
||||
return this.federationAgentService.spawnAgentOnRemote(
|
||||
req.user.workspaceId,
|
||||
body.connectionId,
|
||||
body.payload
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent status from remote instance
|
||||
* Requires authentication
|
||||
*/
|
||||
@Get("agents/:agentId/status")
|
||||
@UseGuards(AuthGuard)
|
||||
async getAgentStatus(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("agentId") agentId: string,
|
||||
@Query("connectionId") connectionId: string
|
||||
): Promise<CommandMessageDetails> {
|
||||
if (!req.user?.workspaceId) {
|
||||
throw new Error("Workspace ID not found in request");
|
||||
}
|
||||
|
||||
if (!connectionId) {
|
||||
throw new Error("connectionId query parameter is required");
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`User ${req.user.id} getting agent ${agentId} status via connection ${connectionId}`
|
||||
);
|
||||
|
||||
return this.federationAgentService.getAgentStatus(req.user.workspaceId, connectionId, agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill an agent on remote instance
|
||||
* Requires authentication
|
||||
*/
|
||||
@Post("agents/:agentId/kill")
|
||||
@UseGuards(AuthGuard)
|
||||
async killAgentOnRemote(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("agentId") agentId: string,
|
||||
@Body() body: { connectionId: string }
|
||||
): Promise<CommandMessageDetails> {
|
||||
if (!req.user?.workspaceId) {
|
||||
throw new Error("Workspace ID not found in request");
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`User ${req.user.id} killing agent ${agentId} via connection ${body.connectionId}`
|
||||
);
|
||||
|
||||
return this.federationAgentService.killAgentOnRemote(
|
||||
req.user.workspaceId,
|
||||
body.connectionId,
|
||||
agentId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { IdentityResolutionService } from "./identity-resolution.service";
|
||||
import { QueryService } from "./query.service";
|
||||
import { CommandService } from "./command.service";
|
||||
import { EventService } from "./event.service";
|
||||
import { FederationAgentService } from "./federation-agent.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
@Module({
|
||||
@@ -55,6 +56,7 @@ import { PrismaModule } from "../prisma/prisma.module";
|
||||
QueryService,
|
||||
CommandService,
|
||||
EventService,
|
||||
FederationAgentService,
|
||||
],
|
||||
exports: [
|
||||
FederationService,
|
||||
@@ -67,6 +69,7 @@ import { PrismaModule } from "../prisma/prisma.module";
|
||||
QueryService,
|
||||
CommandService,
|
||||
EventService,
|
||||
FederationAgentService,
|
||||
],
|
||||
})
|
||||
export class FederationModule {}
|
||||
|
||||
149
apps/api/src/federation/types/federation-agent.types.ts
Normal file
149
apps/api/src/federation/types/federation-agent.types.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Federation Agent Command Types
|
||||
*
|
||||
* Types for agent spawn commands sent via federation COMMAND messages.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Agent type options for spawning
|
||||
*/
|
||||
export type FederationAgentType = "worker" | "reviewer" | "tester";
|
||||
|
||||
/**
|
||||
* Agent status returned from remote instance
|
||||
*/
|
||||
export type FederationAgentStatus = "spawning" | "running" | "completed" | "failed" | "killed";
|
||||
|
||||
/**
|
||||
* Context for agent execution
|
||||
*/
|
||||
export interface FederationAgentContext {
|
||||
/** Git repository URL or path */
|
||||
repository: string;
|
||||
/** Git branch to work on */
|
||||
branch: string;
|
||||
/** Work items for the agent to complete */
|
||||
workItems: string[];
|
||||
/** Optional skills to load */
|
||||
skills?: string[];
|
||||
/** Optional instructions */
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for spawning an agent
|
||||
*/
|
||||
export interface FederationAgentOptions {
|
||||
/** Enable Docker sandbox isolation */
|
||||
sandbox?: boolean;
|
||||
/** Timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** Maximum retry attempts */
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for agent.spawn command
|
||||
*/
|
||||
export interface SpawnAgentCommandPayload {
|
||||
/** Unique task identifier */
|
||||
taskId: string;
|
||||
/** Type of agent to spawn */
|
||||
agentType: FederationAgentType;
|
||||
/** Context for task execution */
|
||||
context: FederationAgentContext;
|
||||
/** Optional configuration */
|
||||
options?: FederationAgentOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for agent.status command
|
||||
*/
|
||||
export interface AgentStatusCommandPayload {
|
||||
/** Unique agent identifier */
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for agent.kill command
|
||||
*/
|
||||
export interface KillAgentCommandPayload {
|
||||
/** Unique agent identifier */
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response data for agent.spawn command
|
||||
*/
|
||||
export interface SpawnAgentResponseData {
|
||||
/** Unique agent identifier */
|
||||
agentId: string;
|
||||
/** Current agent status */
|
||||
status: FederationAgentStatus;
|
||||
/** Timestamp when agent was spawned */
|
||||
spawnedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response data for agent.status command
|
||||
*/
|
||||
export interface AgentStatusResponseData {
|
||||
/** Unique agent identifier */
|
||||
agentId: string;
|
||||
/** Task identifier */
|
||||
taskId: string;
|
||||
/** Current agent status */
|
||||
status: FederationAgentStatus;
|
||||
/** Timestamp when agent was spawned */
|
||||
spawnedAt: string;
|
||||
/** Timestamp when agent started (if running/completed) */
|
||||
startedAt?: string;
|
||||
/** Timestamp when agent completed (if completed/failed/killed) */
|
||||
completedAt?: string;
|
||||
/** Error message (if failed) */
|
||||
error?: string;
|
||||
/** Agent progress data */
|
||||
progress?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response data for agent.kill command
|
||||
*/
|
||||
export interface KillAgentResponseData {
|
||||
/** Unique agent identifier */
|
||||
agentId: string;
|
||||
/** Status after kill operation */
|
||||
status: FederationAgentStatus;
|
||||
/** Timestamp when agent was killed */
|
||||
killedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Details about a federated agent
|
||||
*/
|
||||
export interface FederatedAgentDetails {
|
||||
/** Agent ID */
|
||||
agentId: string;
|
||||
/** Task ID */
|
||||
taskId: string;
|
||||
/** Remote instance ID where agent is running */
|
||||
remoteInstanceId: string;
|
||||
/** Connection ID used to spawn the agent */
|
||||
connectionId: string;
|
||||
/** Agent type */
|
||||
agentType: FederationAgentType;
|
||||
/** Current status */
|
||||
status: FederationAgentStatus;
|
||||
/** Spawn timestamp */
|
||||
spawnedAt: Date;
|
||||
/** Start timestamp */
|
||||
startedAt?: Date;
|
||||
/** Completion timestamp */
|
||||
completedAt?: Date;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
/** Context used to spawn agent */
|
||||
context: FederationAgentContext;
|
||||
/** Options used to spawn agent */
|
||||
options?: FederationAgentOptions;
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export * from "./connection.types";
|
||||
export * from "./oidc.types";
|
||||
export * from "./identity-linking.types";
|
||||
export * from "./message.types";
|
||||
export * from "./federation-agent.types";
|
||||
|
||||
@@ -375,9 +375,7 @@ describe("HeraldService", () => {
|
||||
mockDiscord.sendThreadMessage.mockRejectedValue(discordError);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow(
|
||||
"Rate limit exceeded"
|
||||
);
|
||||
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow("Rate limit exceeded");
|
||||
});
|
||||
|
||||
it("should propagate errors when fetching job events fails", async () => {
|
||||
@@ -405,9 +403,7 @@ describe("HeraldService", () => {
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow(
|
||||
"Query timeout"
|
||||
);
|
||||
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow("Query timeout");
|
||||
});
|
||||
|
||||
it("should include job context in error messages", async () => {
|
||||
|
||||
@@ -146,9 +146,9 @@ describe("KnowledgeGraphController", () => {
|
||||
it("should throw error if entry not found", async () => {
|
||||
mockGraphService.getEntryGraphBySlug.mockRejectedValue(new Error("Entry not found"));
|
||||
|
||||
await expect(
|
||||
controller.getEntryGraph("workspace-1", "non-existent", {})
|
||||
).rejects.toThrow("Entry not found");
|
||||
await expect(controller.getEntryGraph("workspace-1", "non-existent", {})).rejects.toThrow(
|
||||
"Entry not found"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { KnowledgeCacheService } from './cache.service';
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { KnowledgeCacheService } from "./cache.service";
|
||||
|
||||
// Integration tests - require running Valkey instance
|
||||
// Skip in unit test runs, enable with: INTEGRATION_TESTS=true pnpm test
|
||||
describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
|
||||
describe.skipIf(!process.env.INTEGRATION_TESTS)("KnowledgeCacheService", () => {
|
||||
let service: KnowledgeCacheService;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Set environment variables for testing
|
||||
process.env.KNOWLEDGE_CACHE_ENABLED = 'true';
|
||||
process.env.KNOWLEDGE_CACHE_TTL = '300';
|
||||
process.env.VALKEY_URL = 'redis://localhost:6379';
|
||||
process.env.KNOWLEDGE_CACHE_ENABLED = "true";
|
||||
process.env.KNOWLEDGE_CACHE_TTL = "300";
|
||||
process.env.VALKEY_URL = "redis://localhost:6379";
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [KnowledgeCacheService],
|
||||
@@ -27,35 +27,35 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('Cache Enabled/Disabled', () => {
|
||||
it('should be enabled by default', () => {
|
||||
describe("Cache Enabled/Disabled", () => {
|
||||
it("should be enabled by default", () => {
|
||||
expect(service.isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be disabled when KNOWLEDGE_CACHE_ENABLED=false', async () => {
|
||||
process.env.KNOWLEDGE_CACHE_ENABLED = 'false';
|
||||
it("should be disabled when KNOWLEDGE_CACHE_ENABLED=false", async () => {
|
||||
process.env.KNOWLEDGE_CACHE_ENABLED = "false";
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [KnowledgeCacheService],
|
||||
}).compile();
|
||||
const disabledService = module.get<KnowledgeCacheService>(KnowledgeCacheService);
|
||||
|
||||
|
||||
expect(disabledService.isEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Entry Caching', () => {
|
||||
const workspaceId = 'test-workspace-id';
|
||||
const slug = 'test-entry';
|
||||
describe("Entry Caching", () => {
|
||||
const workspaceId = "test-workspace-id";
|
||||
const slug = "test-entry";
|
||||
const entryData = {
|
||||
id: 'entry-id',
|
||||
id: "entry-id",
|
||||
workspaceId,
|
||||
slug,
|
||||
title: 'Test Entry',
|
||||
content: 'Test content',
|
||||
title: "Test Entry",
|
||||
content: "Test content",
|
||||
tags: [],
|
||||
};
|
||||
|
||||
it('should return null on cache miss', async () => {
|
||||
it("should return null on cache miss", async () => {
|
||||
if (!service.isEnabled()) {
|
||||
return; // Skip if cache is disabled
|
||||
}
|
||||
@@ -65,206 +65,206 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should cache and retrieve entry data', async () => {
|
||||
it("should cache and retrieve entry data", async () => {
|
||||
if (!service.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
|
||||
// Set cache
|
||||
await service.setEntry(workspaceId, slug, entryData);
|
||||
|
||||
|
||||
// Get from cache
|
||||
const result = await service.getEntry(workspaceId, slug);
|
||||
expect(result).toEqual(entryData);
|
||||
});
|
||||
|
||||
it('should invalidate entry cache', async () => {
|
||||
it("should invalidate entry cache", async () => {
|
||||
if (!service.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
|
||||
// Set cache
|
||||
await service.setEntry(workspaceId, slug, entryData);
|
||||
|
||||
|
||||
// Verify it's cached
|
||||
let result = await service.getEntry(workspaceId, slug);
|
||||
expect(result).toEqual(entryData);
|
||||
|
||||
|
||||
// Invalidate
|
||||
await service.invalidateEntry(workspaceId, slug);
|
||||
|
||||
|
||||
// Verify it's gone
|
||||
result = await service.getEntry(workspaceId, slug);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Caching', () => {
|
||||
const workspaceId = 'test-workspace-id';
|
||||
const query = 'test search';
|
||||
const filters = { status: 'PUBLISHED', page: 1, limit: 20 };
|
||||
describe("Search Caching", () => {
|
||||
const workspaceId = "test-workspace-id";
|
||||
const query = "test search";
|
||||
const filters = { status: "PUBLISHED", page: 1, limit: 20 };
|
||||
const searchResults = {
|
||||
data: [],
|
||||
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
||||
query,
|
||||
};
|
||||
|
||||
it('should cache and retrieve search results', async () => {
|
||||
it("should cache and retrieve search results", async () => {
|
||||
if (!service.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
|
||||
// Set cache
|
||||
await service.setSearch(workspaceId, query, filters, searchResults);
|
||||
|
||||
|
||||
// Get from cache
|
||||
const result = await service.getSearch(workspaceId, query, filters);
|
||||
expect(result).toEqual(searchResults);
|
||||
});
|
||||
|
||||
it('should differentiate search results by filters', async () => {
|
||||
it("should differentiate search results by filters", async () => {
|
||||
if (!service.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
|
||||
const filters1 = { page: 1, limit: 20 };
|
||||
const filters2 = { page: 2, limit: 20 };
|
||||
|
||||
|
||||
const results1 = { ...searchResults, pagination: { ...searchResults.pagination, page: 1 } };
|
||||
const results2 = { ...searchResults, pagination: { ...searchResults.pagination, page: 2 } };
|
||||
|
||||
|
||||
await service.setSearch(workspaceId, query, filters1, results1);
|
||||
await service.setSearch(workspaceId, query, filters2, results2);
|
||||
|
||||
|
||||
const result1 = await service.getSearch(workspaceId, query, filters1);
|
||||
const result2 = await service.getSearch(workspaceId, query, filters2);
|
||||
|
||||
|
||||
expect(result1.pagination.page).toBe(1);
|
||||
expect(result2.pagination.page).toBe(2);
|
||||
});
|
||||
|
||||
it('should invalidate all search caches for workspace', async () => {
|
||||
it("should invalidate all search caches for workspace", async () => {
|
||||
if (!service.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
|
||||
// Set multiple search caches
|
||||
await service.setSearch(workspaceId, 'query1', {}, searchResults);
|
||||
await service.setSearch(workspaceId, 'query2', {}, searchResults);
|
||||
|
||||
await service.setSearch(workspaceId, "query1", {}, searchResults);
|
||||
await service.setSearch(workspaceId, "query2", {}, searchResults);
|
||||
|
||||
// Invalidate all
|
||||
await service.invalidateSearches(workspaceId);
|
||||
|
||||
|
||||
// Verify both are gone
|
||||
const result1 = await service.getSearch(workspaceId, 'query1', {});
|
||||
const result2 = await service.getSearch(workspaceId, 'query2', {});
|
||||
|
||||
const result1 = await service.getSearch(workspaceId, "query1", {});
|
||||
const result2 = await service.getSearch(workspaceId, "query2", {});
|
||||
|
||||
expect(result1).toBeNull();
|
||||
expect(result2).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Graph Caching', () => {
|
||||
const workspaceId = 'test-workspace-id';
|
||||
const entryId = 'entry-id';
|
||||
describe("Graph Caching", () => {
|
||||
const workspaceId = "test-workspace-id";
|
||||
const entryId = "entry-id";
|
||||
const maxDepth = 2;
|
||||
const graphData = {
|
||||
centerNode: { id: entryId, slug: 'test', title: 'Test', tags: [], depth: 0 },
|
||||
centerNode: { id: entryId, slug: "test", title: "Test", tags: [], depth: 0 },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
stats: { totalNodes: 1, totalEdges: 0, maxDepth },
|
||||
};
|
||||
|
||||
it('should cache and retrieve graph data', async () => {
|
||||
it("should cache and retrieve graph data", async () => {
|
||||
if (!service.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
|
||||
// Set cache
|
||||
await service.setGraph(workspaceId, entryId, maxDepth, graphData);
|
||||
|
||||
|
||||
// Get from cache
|
||||
const result = await service.getGraph(workspaceId, entryId, maxDepth);
|
||||
expect(result).toEqual(graphData);
|
||||
});
|
||||
|
||||
it('should differentiate graphs by maxDepth', async () => {
|
||||
it("should differentiate graphs by maxDepth", async () => {
|
||||
if (!service.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
|
||||
const graph1 = { ...graphData, stats: { ...graphData.stats, maxDepth: 1 } };
|
||||
const graph2 = { ...graphData, stats: { ...graphData.stats, maxDepth: 2 } };
|
||||
|
||||
|
||||
await service.setGraph(workspaceId, entryId, 1, graph1);
|
||||
await service.setGraph(workspaceId, entryId, 2, graph2);
|
||||
|
||||
|
||||
const result1 = await service.getGraph(workspaceId, entryId, 1);
|
||||
const result2 = await service.getGraph(workspaceId, entryId, 2);
|
||||
|
||||
|
||||
expect(result1.stats.maxDepth).toBe(1);
|
||||
expect(result2.stats.maxDepth).toBe(2);
|
||||
});
|
||||
|
||||
it('should invalidate all graph caches for workspace', async () => {
|
||||
it("should invalidate all graph caches for workspace", async () => {
|
||||
if (!service.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
|
||||
// Set cache
|
||||
await service.setGraph(workspaceId, entryId, maxDepth, graphData);
|
||||
|
||||
|
||||
// Invalidate
|
||||
await service.invalidateGraphs(workspaceId);
|
||||
|
||||
|
||||
// Verify it's gone
|
||||
const result = await service.getGraph(workspaceId, entryId, maxDepth);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache Statistics', () => {
|
||||
it('should track hits and misses', async () => {
|
||||
describe("Cache Statistics", () => {
|
||||
it("should track hits and misses", async () => {
|
||||
if (!service.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
const workspaceId = 'test-workspace-id';
|
||||
const slug = 'test-entry';
|
||||
const entryData = { id: '1', slug, title: 'Test' };
|
||||
|
||||
|
||||
const workspaceId = "test-workspace-id";
|
||||
const slug = "test-entry";
|
||||
const entryData = { id: "1", slug, title: "Test" };
|
||||
|
||||
// Reset stats
|
||||
service.resetStats();
|
||||
|
||||
|
||||
// Miss
|
||||
await service.getEntry(workspaceId, slug);
|
||||
let stats = service.getStats();
|
||||
expect(stats.misses).toBe(1);
|
||||
expect(stats.hits).toBe(0);
|
||||
|
||||
|
||||
// Set
|
||||
await service.setEntry(workspaceId, slug, entryData);
|
||||
stats = service.getStats();
|
||||
expect(stats.sets).toBe(1);
|
||||
|
||||
|
||||
// Hit
|
||||
await service.getEntry(workspaceId, slug);
|
||||
stats = service.getStats();
|
||||
@@ -272,21 +272,21 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
|
||||
expect(stats.hitRate).toBeCloseTo(0.5); // 1 hit, 1 miss = 50%
|
||||
});
|
||||
|
||||
it('should reset statistics', async () => {
|
||||
it("should reset statistics", async () => {
|
||||
if (!service.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
const workspaceId = 'test-workspace-id';
|
||||
const slug = 'test-entry';
|
||||
|
||||
|
||||
const workspaceId = "test-workspace-id";
|
||||
const slug = "test-entry";
|
||||
|
||||
await service.getEntry(workspaceId, slug); // miss
|
||||
|
||||
|
||||
service.resetStats();
|
||||
const stats = service.getStats();
|
||||
|
||||
|
||||
expect(stats.hits).toBe(0);
|
||||
expect(stats.misses).toBe(0);
|
||||
expect(stats.sets).toBe(0);
|
||||
@@ -295,29 +295,29 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Clear Workspace Cache', () => {
|
||||
it('should clear all caches for a workspace', async () => {
|
||||
describe("Clear Workspace Cache", () => {
|
||||
it("should clear all caches for a workspace", async () => {
|
||||
if (!service.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
const workspaceId = 'test-workspace-id';
|
||||
|
||||
|
||||
const workspaceId = "test-workspace-id";
|
||||
|
||||
// Set various caches
|
||||
await service.setEntry(workspaceId, 'entry1', { id: '1' });
|
||||
await service.setSearch(workspaceId, 'query', {}, { data: [] });
|
||||
await service.setGraph(workspaceId, 'entry-id', 1, { nodes: [] });
|
||||
|
||||
await service.setEntry(workspaceId, "entry1", { id: "1" });
|
||||
await service.setSearch(workspaceId, "query", {}, { data: [] });
|
||||
await service.setGraph(workspaceId, "entry-id", 1, { nodes: [] });
|
||||
|
||||
// Clear all
|
||||
await service.clearWorkspaceCache(workspaceId);
|
||||
|
||||
|
||||
// Verify all are gone
|
||||
const entry = await service.getEntry(workspaceId, 'entry1');
|
||||
const search = await service.getSearch(workspaceId, 'query', {});
|
||||
const graph = await service.getGraph(workspaceId, 'entry-id', 1);
|
||||
|
||||
const entry = await service.getEntry(workspaceId, "entry1");
|
||||
const search = await service.getSearch(workspaceId, "query", {});
|
||||
const graph = await service.getGraph(workspaceId, "entry-id", 1);
|
||||
|
||||
expect(entry).toBeNull();
|
||||
expect(search).toBeNull();
|
||||
expect(graph).toBeNull();
|
||||
|
||||
@@ -271,9 +271,7 @@ describe("GraphService", () => {
|
||||
});
|
||||
|
||||
it("should filter by status", async () => {
|
||||
const entries = [
|
||||
{ ...mockEntry, id: "entry-1", status: "PUBLISHED", tags: [] },
|
||||
];
|
||||
const entries = [{ ...mockEntry, id: "entry-1", status: "PUBLISHED", tags: [] }];
|
||||
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries);
|
||||
mockPrismaService.knowledgeLink.findMany.mockResolvedValue([]);
|
||||
@@ -351,9 +349,7 @@ describe("GraphService", () => {
|
||||
{ id: "entry-1", slug: "entry-1", title: "Entry 1", link_count: "5" },
|
||||
{ id: "entry-2", slug: "entry-2", title: "Entry 2", link_count: "3" },
|
||||
]);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([
|
||||
{ id: "orphan-1" },
|
||||
]);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([{ id: "orphan-1" }]);
|
||||
|
||||
const result = await service.getGraphStats("workspace-1");
|
||||
|
||||
|
||||
@@ -170,9 +170,9 @@ This is the content of the entry.`;
|
||||
path: "",
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.importEntries(workspaceId, userId, file)
|
||||
).rejects.toThrow(BadRequestException);
|
||||
await expect(service.importEntries(workspaceId, userId, file)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle import errors gracefully", async () => {
|
||||
@@ -195,9 +195,7 @@ Content`;
|
||||
path: "",
|
||||
};
|
||||
|
||||
mockKnowledgeService.create.mockRejectedValue(
|
||||
new Error("Database error")
|
||||
);
|
||||
mockKnowledgeService.create.mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const result = await service.importEntries(workspaceId, userId, file);
|
||||
|
||||
@@ -240,10 +238,7 @@ title: Empty Entry
|
||||
it("should export entries as markdown format", async () => {
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([mockEntry]);
|
||||
|
||||
const result = await service.exportEntries(
|
||||
workspaceId,
|
||||
ExportFormat.MARKDOWN
|
||||
);
|
||||
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();
|
||||
@@ -289,9 +284,9 @@ title: Empty Entry
|
||||
it("should throw error when no entries found", async () => {
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.exportEntries(workspaceId, ExportFormat.MARKDOWN)
|
||||
).rejects.toThrow(BadRequestException);
|
||||
await expect(service.exportEntries(workspaceId, ExportFormat.MARKDOWN)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,27 +88,20 @@ describe("LinkResolutionService", () => {
|
||||
describe("resolveLink", () => {
|
||||
describe("Exact title match", () => {
|
||||
it("should resolve link by exact title match", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
|
||||
mockEntries[0]
|
||||
);
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
"TypeScript Guide"
|
||||
);
|
||||
const result = await service.resolveLink(workspaceId, "TypeScript Guide");
|
||||
|
||||
expect(result).toBe("entry-1");
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
|
||||
{
|
||||
where: {
|
||||
workspaceId,
|
||||
title: "TypeScript Guide",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId,
|
||||
title: "TypeScript Guide",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should be case-sensitive for exact title match", async () => {
|
||||
@@ -116,10 +109,7 @@ describe("LinkResolutionService", () => {
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
"typescript guide"
|
||||
);
|
||||
const result = await service.resolveLink(workspaceId, "typescript guide");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -128,41 +118,29 @@ describe("LinkResolutionService", () => {
|
||||
describe("Slug match", () => {
|
||||
it("should resolve link by slug", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(
|
||||
mockEntries[0]
|
||||
);
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(mockEntries[0]);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
"typescript-guide"
|
||||
);
|
||||
const result = await service.resolveLink(workspaceId, "typescript-guide");
|
||||
|
||||
expect(result).toBe("entry-1");
|
||||
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith(
|
||||
{
|
||||
where: {
|
||||
workspaceId_slug: {
|
||||
workspaceId,
|
||||
slug: "typescript-guide",
|
||||
},
|
||||
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId_slug: {
|
||||
workspaceId,
|
||||
slug: "typescript-guide",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should prioritize exact title match over slug match", async () => {
|
||||
// If exact title matches, slug should not be checked
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
|
||||
mockEntries[0]
|
||||
);
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
"TypeScript Guide"
|
||||
);
|
||||
const result = await service.resolveLink(workspaceId, "TypeScript Guide");
|
||||
|
||||
expect(result).toBe("entry-1");
|
||||
expect(mockPrismaService.knowledgeEntry.findUnique).not.toHaveBeenCalled();
|
||||
@@ -173,14 +151,9 @@ describe("LinkResolutionService", () => {
|
||||
it("should resolve link by case-insensitive fuzzy match", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
|
||||
mockEntries[0],
|
||||
]);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([mockEntries[0]]);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
"typescript guide"
|
||||
);
|
||||
const result = await service.resolveLink(workspaceId, "typescript guide");
|
||||
|
||||
expect(result).toBe("entry-1");
|
||||
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({
|
||||
@@ -216,10 +189,7 @@ describe("LinkResolutionService", () => {
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
"Non-existent Entry"
|
||||
);
|
||||
const result = await service.resolveLink(workspaceId, "Non-existent Entry");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -266,14 +236,9 @@ describe("LinkResolutionService", () => {
|
||||
});
|
||||
|
||||
it("should trim whitespace from target before resolving", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
|
||||
mockEntries[0]
|
||||
);
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
" TypeScript Guide "
|
||||
);
|
||||
const result = await service.resolveLink(workspaceId, " TypeScript Guide ");
|
||||
|
||||
expect(result).toBe("entry-1");
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
|
||||
@@ -291,23 +256,19 @@ describe("LinkResolutionService", () => {
|
||||
it("should resolve multiple links in batch", async () => {
|
||||
// First link: "TypeScript Guide" -> exact title match
|
||||
// Second link: "react-hooks" -> slug match
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockImplementation(
|
||||
async ({ where }: any) => {
|
||||
if (where.title === "TypeScript Guide") {
|
||||
return mockEntries[0];
|
||||
}
|
||||
return null;
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockImplementation(async ({ where }: any) => {
|
||||
if (where.title === "TypeScript Guide") {
|
||||
return mockEntries[0];
|
||||
}
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockImplementation(
|
||||
async ({ where }: any) => {
|
||||
if (where.workspaceId_slug?.slug === "react-hooks") {
|
||||
return mockEntries[1];
|
||||
}
|
||||
return null;
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockImplementation(async ({ where }: any) => {
|
||||
if (where.workspaceId_slug?.slug === "react-hooks") {
|
||||
return mockEntries[1];
|
||||
}
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
||||
|
||||
@@ -344,9 +305,7 @@ describe("LinkResolutionService", () => {
|
||||
});
|
||||
|
||||
it("should deduplicate targets", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
|
||||
mockEntries[0]
|
||||
);
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
|
||||
|
||||
const result = await service.resolveLinks(workspaceId, [
|
||||
"TypeScript Guide",
|
||||
@@ -357,9 +316,7 @@ describe("LinkResolutionService", () => {
|
||||
"TypeScript Guide": "entry-1",
|
||||
});
|
||||
// Should only be called once for the deduplicated target
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledTimes(
|
||||
1
|
||||
);
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -370,10 +327,7 @@ describe("LinkResolutionService", () => {
|
||||
{ id: "entry-3", title: "React Hooks Advanced" },
|
||||
]);
|
||||
|
||||
const result = await service.getAmbiguousMatches(
|
||||
workspaceId,
|
||||
"react hooks"
|
||||
);
|
||||
const result = await service.getAmbiguousMatches(workspaceId, "react hooks");
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual([
|
||||
@@ -385,10 +339,7 @@ describe("LinkResolutionService", () => {
|
||||
it("should return empty array when no matches found", async () => {
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getAmbiguousMatches(
|
||||
workspaceId,
|
||||
"Non-existent"
|
||||
);
|
||||
const result = await service.getAmbiguousMatches(workspaceId, "Non-existent");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@@ -398,10 +349,7 @@ describe("LinkResolutionService", () => {
|
||||
{ id: "entry-1", title: "TypeScript Guide" },
|
||||
]);
|
||||
|
||||
const result = await service.getAmbiguousMatches(
|
||||
workspaceId,
|
||||
"typescript guide"
|
||||
);
|
||||
const result = await service.getAmbiguousMatches(workspaceId, "typescript guide");
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
@@ -409,8 +357,7 @@ describe("LinkResolutionService", () => {
|
||||
|
||||
describe("resolveLinksFromContent", () => {
|
||||
it("should parse and resolve wiki links from content", async () => {
|
||||
const content =
|
||||
"Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
|
||||
const content = "Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
|
||||
|
||||
// Mock resolveLink for each target
|
||||
mockPrismaService.knowledgeEntry.findFirst
|
||||
@@ -522,9 +469,7 @@ describe("LinkResolutionService", () => {
|
||||
},
|
||||
];
|
||||
|
||||
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(
|
||||
mockBacklinks
|
||||
);
|
||||
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(mockBacklinks);
|
||||
|
||||
const result = await service.getBacklinks(targetEntryId);
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
|
||||
// Initialize services
|
||||
prisma = new PrismaClient();
|
||||
const prismaService = prisma as unknown as PrismaService;
|
||||
|
||||
|
||||
// Mock cache service for testing
|
||||
cacheService = {
|
||||
getSearch: async () => null,
|
||||
@@ -37,11 +37,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
|
||||
} as unknown as KnowledgeCacheService;
|
||||
|
||||
embeddingService = new EmbeddingService(prismaService);
|
||||
searchService = new SearchService(
|
||||
prismaService,
|
||||
cacheService,
|
||||
embeddingService
|
||||
);
|
||||
searchService = new SearchService(prismaService, cacheService, embeddingService);
|
||||
|
||||
// Create test workspace and user
|
||||
const workspace = await prisma.workspace.create({
|
||||
@@ -84,10 +80,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
|
||||
const title = "Introduction to PostgreSQL";
|
||||
const content = "PostgreSQL is a powerful open-source database.";
|
||||
|
||||
const prepared = embeddingService.prepareContentForEmbedding(
|
||||
title,
|
||||
content
|
||||
);
|
||||
const prepared = embeddingService.prepareContentForEmbedding(title, content);
|
||||
|
||||
// Title should appear twice for weighting
|
||||
expect(prepared).toContain(title);
|
||||
@@ -122,10 +115,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
|
||||
it("should skip semantic search if OpenAI not configured", async () => {
|
||||
if (!embeddingService.isConfigured()) {
|
||||
await expect(
|
||||
searchService.semanticSearch(
|
||||
"database performance",
|
||||
testWorkspaceId
|
||||
)
|
||||
searchService.semanticSearch("database performance", testWorkspaceId)
|
||||
).rejects.toThrow();
|
||||
} else {
|
||||
// If configured, this is expected to work (tested below)
|
||||
@@ -156,10 +146,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
|
||||
entry.title,
|
||||
entry.content
|
||||
);
|
||||
await embeddingService.generateAndStoreEmbedding(
|
||||
created.id,
|
||||
preparedContent
|
||||
);
|
||||
await embeddingService.generateAndStoreEmbedding(created.id, preparedContent);
|
||||
}
|
||||
|
||||
// Wait a bit for embeddings to be stored
|
||||
@@ -175,9 +162,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
|
||||
expect(results.data.length).toBeGreaterThan(0);
|
||||
|
||||
// PostgreSQL entry should rank high for "relational database"
|
||||
const postgresEntry = results.data.find(
|
||||
(r) => r.slug === "postgresql-intro"
|
||||
);
|
||||
const postgresEntry = results.data.find((r) => r.slug === "postgresql-intro");
|
||||
expect(postgresEntry).toBeDefined();
|
||||
expect(postgresEntry!.rank).toBeGreaterThan(0);
|
||||
},
|
||||
@@ -187,18 +172,13 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
|
||||
it.skipIf(!process.env["OPENAI_API_KEY"])(
|
||||
"should perform hybrid search combining vector and keyword",
|
||||
async () => {
|
||||
const results = await searchService.hybridSearch(
|
||||
"indexing",
|
||||
testWorkspaceId
|
||||
);
|
||||
const results = await searchService.hybridSearch("indexing", testWorkspaceId);
|
||||
|
||||
// Should return results
|
||||
expect(results.data.length).toBeGreaterThan(0);
|
||||
|
||||
// Should find the indexing entry
|
||||
const indexingEntry = results.data.find(
|
||||
(r) => r.slug === "database-indexing"
|
||||
);
|
||||
const indexingEntry = results.data.find((r) => r.slug === "database-indexing");
|
||||
expect(indexingEntry).toBeDefined();
|
||||
},
|
||||
30000
|
||||
@@ -230,15 +210,10 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
|
||||
// Batch generate embeddings
|
||||
const entriesForEmbedding = entries.map((e) => ({
|
||||
id: e.id,
|
||||
content: embeddingService.prepareContentForEmbedding(
|
||||
e.title,
|
||||
e.content
|
||||
),
|
||||
content: embeddingService.prepareContentForEmbedding(e.title, e.content),
|
||||
}));
|
||||
|
||||
const successCount = await embeddingService.batchGenerateEmbeddings(
|
||||
entriesForEmbedding
|
||||
);
|
||||
const successCount = await embeddingService.batchGenerateEmbeddings(entriesForEmbedding);
|
||||
|
||||
expect(successCount).toBe(3);
|
||||
|
||||
|
||||
@@ -48,10 +48,7 @@ describe("TagsController", () => {
|
||||
const result = await controller.create(createDto, workspaceId);
|
||||
|
||||
expect(result).toEqual(mockTag);
|
||||
expect(mockTagsService.create).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
createDto
|
||||
);
|
||||
expect(mockTagsService.create).toHaveBeenCalledWith(workspaceId, createDto);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
|
||||
@@ -108,10 +105,7 @@ describe("TagsController", () => {
|
||||
const result = await controller.findOne("architecture", workspaceId);
|
||||
|
||||
expect(result).toEqual(mockTagWithCount);
|
||||
expect(mockTagsService.findOne).toHaveBeenCalledWith(
|
||||
"architecture",
|
||||
workspaceId
|
||||
);
|
||||
expect(mockTagsService.findOne).toHaveBeenCalledWith("architecture", workspaceId);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
|
||||
@@ -138,18 +132,10 @@ describe("TagsController", () => {
|
||||
|
||||
mockTagsService.update.mockResolvedValue(updatedTag);
|
||||
|
||||
const result = await controller.update(
|
||||
"architecture",
|
||||
updateDto,
|
||||
workspaceId
|
||||
);
|
||||
const result = await controller.update("architecture", updateDto, workspaceId);
|
||||
|
||||
expect(result).toEqual(updatedTag);
|
||||
expect(mockTagsService.update).toHaveBeenCalledWith(
|
||||
"architecture",
|
||||
workspaceId,
|
||||
updateDto
|
||||
);
|
||||
expect(mockTagsService.update).toHaveBeenCalledWith("architecture", workspaceId, updateDto);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
|
||||
@@ -171,10 +157,7 @@ describe("TagsController", () => {
|
||||
|
||||
await controller.remove("architecture", workspaceId);
|
||||
|
||||
expect(mockTagsService.remove).toHaveBeenCalledWith(
|
||||
"architecture",
|
||||
workspaceId
|
||||
);
|
||||
expect(mockTagsService.remove).toHaveBeenCalledWith("architecture", workspaceId);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
|
||||
@@ -206,10 +189,7 @@ describe("TagsController", () => {
|
||||
const result = await controller.getEntries("architecture", workspaceId);
|
||||
|
||||
expect(result).toEqual(mockEntries);
|
||||
expect(mockTagsService.getEntriesWithTag).toHaveBeenCalledWith(
|
||||
"architecture",
|
||||
workspaceId
|
||||
);
|
||||
expect(mockTagsService.getEntriesWithTag).toHaveBeenCalledWith("architecture", workspaceId);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
|
||||
|
||||
@@ -2,11 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { TagsService } from "./tags.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import {
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { NotFoundException, ConflictException, BadRequestException } from "@nestjs/common";
|
||||
import type { CreateTagDto, UpdateTagDto } from "./dto";
|
||||
|
||||
describe("TagsService", () => {
|
||||
@@ -113,9 +109,7 @@ describe("TagsService", () => {
|
||||
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(mockTag);
|
||||
|
||||
await expect(service.create(workspaceId, createDto)).rejects.toThrow(
|
||||
ConflictException
|
||||
);
|
||||
await expect(service.create(workspaceId, createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for invalid slug format", async () => {
|
||||
@@ -124,9 +118,7 @@ describe("TagsService", () => {
|
||||
slug: "Invalid_Slug!",
|
||||
};
|
||||
|
||||
await expect(service.create(workspaceId, createDto)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
await expect(service.create(workspaceId, createDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("should generate slug from name with spaces and special chars", async () => {
|
||||
@@ -135,12 +127,10 @@ describe("TagsService", () => {
|
||||
};
|
||||
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.knowledgeTag.create.mockImplementation(
|
||||
async ({ data }: any) => ({
|
||||
...mockTag,
|
||||
slug: data.slug,
|
||||
})
|
||||
);
|
||||
mockPrismaService.knowledgeTag.create.mockImplementation(async ({ data }: any) => ({
|
||||
...mockTag,
|
||||
slug: data.slug,
|
||||
}));
|
||||
|
||||
const result = await service.create(workspaceId, createDto);
|
||||
|
||||
@@ -183,9 +173,7 @@ describe("TagsService", () => {
|
||||
describe("findOne", () => {
|
||||
it("should return a tag by slug", async () => {
|
||||
const mockTagWithCount = { ...mockTag, _count: { entries: 5 } };
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(
|
||||
mockTagWithCount
|
||||
);
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(mockTagWithCount);
|
||||
|
||||
const result = await service.findOne("architecture", workspaceId);
|
||||
|
||||
@@ -208,9 +196,7 @@ describe("TagsService", () => {
|
||||
it("should throw NotFoundException if tag not found", async () => {
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.findOne("nonexistent", workspaceId)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.findOne("nonexistent", workspaceId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,9 +231,9 @@ describe("TagsService", () => {
|
||||
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update("nonexistent", workspaceId, updateDto)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.update("nonexistent", workspaceId, updateDto)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ConflictException if new slug conflicts", async () => {
|
||||
@@ -263,9 +249,9 @@ describe("TagsService", () => {
|
||||
slug: "design",
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
service.update("architecture", workspaceId, updateDto)
|
||||
).rejects.toThrow(ConflictException);
|
||||
await expect(service.update("architecture", workspaceId, updateDto)).rejects.toThrow(
|
||||
ConflictException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,9 +278,7 @@ describe("TagsService", () => {
|
||||
it("should throw NotFoundException if tag not found", async () => {
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.remove("nonexistent", workspaceId)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.remove("nonexistent", workspaceId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -398,9 +382,9 @@ describe("TagsService", () => {
|
||||
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.findOrCreateTags(workspaceId, slugs, false)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.findOrCreateTags(workspaceId, slugs, false)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,9 +17,9 @@ The `wiki-link-parser.ts` utility provides parsing of wiki-style `[[links]]` fro
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
import { parseWikiLinks } from './utils/wiki-link-parser';
|
||||
import { parseWikiLinks } from "./utils/wiki-link-parser";
|
||||
|
||||
const content = 'See [[Main Page]] and [[Getting Started|start here]].';
|
||||
const content = "See [[Main Page]] and [[Getting Started|start here]].";
|
||||
const links = parseWikiLinks(content);
|
||||
|
||||
// Result:
|
||||
@@ -44,32 +44,41 @@ const links = parseWikiLinks(content);
|
||||
### Supported Link Formats
|
||||
|
||||
#### Basic Link (by title)
|
||||
|
||||
```markdown
|
||||
[[Page Name]]
|
||||
```
|
||||
|
||||
Links to a page by its title. Display text will be "Page Name".
|
||||
|
||||
#### Link with Display Text
|
||||
|
||||
```markdown
|
||||
[[Page Name|custom display]]
|
||||
```
|
||||
|
||||
Links to "Page Name" but displays "custom display".
|
||||
|
||||
#### Link by Slug
|
||||
|
||||
```markdown
|
||||
[[page-slug-name]]
|
||||
```
|
||||
|
||||
Links to a page by its URL slug (kebab-case).
|
||||
|
||||
### Edge Cases
|
||||
|
||||
#### Nested Brackets
|
||||
|
||||
```markdown
|
||||
[[Page [with] brackets]] ✓ Parsed correctly
|
||||
[[Page [with] brackets]] ✓ Parsed correctly
|
||||
```
|
||||
|
||||
Single brackets inside link text are allowed.
|
||||
|
||||
#### Code Blocks (Not Parsed)
|
||||
|
||||
```markdown
|
||||
Use `[[WikiLink]]` syntax for linking.
|
||||
|
||||
@@ -77,36 +86,41 @@ Use `[[WikiLink]]` syntax for linking.
|
||||
const link = "[[not parsed]]";
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
Links inside inline code or fenced code blocks are ignored.
|
||||
|
||||
#### Escaped Brackets
|
||||
|
||||
```markdown
|
||||
\[[not a link]] but [[real link]] works
|
||||
```
|
||||
|
||||
Escaped brackets are not parsed as links.
|
||||
|
||||
#### Empty or Invalid Links
|
||||
|
||||
```markdown
|
||||
[[]] ✗ Empty link (ignored)
|
||||
[[ ]] ✗ Whitespace only (ignored)
|
||||
[[ Target ]] ✓ Trimmed to "Target"
|
||||
[[]] ✗ Whitespace only (ignored)
|
||||
[[Target]] ✓ Trimmed to "Target"
|
||||
```
|
||||
|
||||
### Return Type
|
||||
|
||||
```typescript
|
||||
interface WikiLink {
|
||||
raw: string; // Full matched text: "[[Page Name]]"
|
||||
target: string; // Target page: "Page Name"
|
||||
raw: string; // Full matched text: "[[Page Name]]"
|
||||
target: string; // Target page: "Page Name"
|
||||
displayText: string; // Display text: "Page Name" or custom
|
||||
start: number; // Start position in content
|
||||
end: number; // End position in content
|
||||
start: number; // Start position in content
|
||||
end: number; // End position in content
|
||||
}
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Comprehensive test suite (100% coverage) includes:
|
||||
|
||||
- Basic parsing (single, multiple, consecutive links)
|
||||
- Display text variations
|
||||
- Edge cases (brackets, escapes, empty links)
|
||||
@@ -116,6 +130,7 @@ Comprehensive test suite (100% coverage) includes:
|
||||
- Malformed input handling
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
pnpm test --filter=@mosaic/api -- wiki-link-parser.spec.ts
|
||||
```
|
||||
@@ -130,6 +145,7 @@ This parser is designed to work with the Knowledge Module's linking system:
|
||||
4. **Link Rendering**: Replace `[[links]]` with HTML anchors
|
||||
|
||||
See related issues:
|
||||
|
||||
- #59 - Wiki-link parser (this implementation)
|
||||
- Future: Link resolution and storage
|
||||
- Future: Backlink display and navigation
|
||||
@@ -151,33 +167,38 @@ The `markdown.ts` utility provides secure markdown rendering with GFM (GitHub Fl
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
import { renderMarkdown, markdownToPlainText } from './utils/markdown';
|
||||
import { renderMarkdown, markdownToPlainText } from "./utils/markdown";
|
||||
|
||||
// Render markdown to HTML (async)
|
||||
const html = await renderMarkdown('# Hello **World**');
|
||||
const html = await renderMarkdown("# Hello **World**");
|
||||
// Result: <h1 id="hello-world">Hello <strong>World</strong></h1>
|
||||
|
||||
// Extract plain text (for search indexing)
|
||||
const plainText = await markdownToPlainText('# Hello **World**');
|
||||
const plainText = await markdownToPlainText("# Hello **World**");
|
||||
// Result: "Hello World"
|
||||
```
|
||||
|
||||
### Supported Markdown Features
|
||||
|
||||
#### Basic Formatting
|
||||
|
||||
- **Bold**: `**text**` or `__text__`
|
||||
- *Italic*: `*text*` or `_text_`
|
||||
- _Italic_: `*text*` or `_text_`
|
||||
- ~~Strikethrough~~: `~~text~~`
|
||||
- `Inline code`: `` `code` ``
|
||||
|
||||
#### Headers
|
||||
|
||||
```markdown
|
||||
# H1
|
||||
|
||||
## H2
|
||||
|
||||
### H3
|
||||
```
|
||||
|
||||
#### Lists
|
||||
|
||||
```markdown
|
||||
- Unordered list
|
||||
- Nested item
|
||||
@@ -187,19 +208,22 @@ const plainText = await markdownToPlainText('# Hello **World**');
|
||||
```
|
||||
|
||||
#### Task Lists
|
||||
|
||||
```markdown
|
||||
- [ ] Unchecked task
|
||||
- [x] Completed task
|
||||
```
|
||||
|
||||
#### Tables
|
||||
|
||||
```markdown
|
||||
| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| -------- | -------- |
|
||||
| Cell 1 | Cell 2 |
|
||||
```
|
||||
|
||||
#### Code Blocks
|
||||
|
||||
````markdown
|
||||
```typescript
|
||||
const greeting: string = "Hello";
|
||||
@@ -208,12 +232,14 @@ console.log(greeting);
|
||||
````
|
||||
|
||||
#### Links and Images
|
||||
|
||||
```markdown
|
||||
[Link text](https://example.com)
|
||||

|
||||
```
|
||||
|
||||
#### Blockquotes
|
||||
|
||||
```markdown
|
||||
> This is a quote
|
||||
> Multi-line quote
|
||||
@@ -233,6 +259,7 @@ The renderer implements multiple layers of security:
|
||||
### Testing
|
||||
|
||||
Comprehensive test suite covers:
|
||||
|
||||
- Basic markdown rendering
|
||||
- GFM features (tables, task lists, strikethrough)
|
||||
- Code syntax highlighting
|
||||
@@ -240,6 +267,7 @@ Comprehensive test suite covers:
|
||||
- Edge cases (unicode, long content, nested structures)
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
pnpm test --filter=@mosaic/api -- markdown.spec.ts
|
||||
```
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
renderMarkdown,
|
||||
renderMarkdownSync,
|
||||
markdownToPlainText,
|
||||
} from "./markdown";
|
||||
import { renderMarkdown, renderMarkdownSync, markdownToPlainText } from "./markdown";
|
||||
|
||||
describe("Markdown Rendering", () => {
|
||||
describe("renderMarkdown", () => {
|
||||
@@ -77,7 +73,7 @@ describe("Markdown Rendering", () => {
|
||||
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<input');
|
||||
expect(html).toContain("<input");
|
||||
expect(html).toContain('type="checkbox"');
|
||||
expect(html).toContain('disabled="disabled"'); // Should be disabled for safety
|
||||
});
|
||||
@@ -145,16 +141,17 @@ plain text code
|
||||
const markdown = "";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain("<img");
|
||||
expect(html).toContain('src="https://example.com/image.png"');
|
||||
expect(html).toContain('alt="Alt text"');
|
||||
});
|
||||
|
||||
it("should allow data URIs for images", async () => {
|
||||
const markdown = "";
|
||||
const markdown =
|
||||
"";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain("<img");
|
||||
expect(html).toContain('src="data:image/png;base64');
|
||||
});
|
||||
});
|
||||
@@ -164,7 +161,7 @@ plain text code
|
||||
const markdown = "# My Header Title";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<h1');
|
||||
expect(html).toContain("<h1");
|
||||
expect(html).toContain('id="');
|
||||
});
|
||||
|
||||
@@ -282,7 +279,7 @@ plain text code
|
||||
});
|
||||
|
||||
it("should strip all HTML tags", async () => {
|
||||
const markdown = '[Link](https://example.com)\n\n';
|
||||
const markdown = "[Link](https://example.com)\n\n";
|
||||
const plainText = await markdownToPlainText(markdown);
|
||||
|
||||
expect(plainText).not.toContain("<a");
|
||||
|
||||
@@ -333,9 +333,7 @@ const link = "[[Not A Link]]";
|
||||
|
||||
expect(links[0].start).toBe(5);
|
||||
expect(links[0].end).toBe(23);
|
||||
expect(content.substring(links[0].start, links[0].end)).toBe(
|
||||
"[[Target|Display]]"
|
||||
);
|
||||
expect(content.substring(links[0].start, links[0].end)).toBe("[[Target|Display]]");
|
||||
});
|
||||
|
||||
it("should track positions in multiline content", () => {
|
||||
|
||||
@@ -114,9 +114,9 @@ describe("LayoutsService", () => {
|
||||
.mockResolvedValueOnce(null) // No default
|
||||
.mockResolvedValueOnce(null); // No layouts
|
||||
|
||||
await expect(
|
||||
service.findDefault(mockWorkspaceId, mockUserId)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.findDefault(mockWorkspaceId, mockUserId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,9 +139,9 @@ describe("LayoutsService", () => {
|
||||
it("should throw NotFoundException if layout not found", async () => {
|
||||
prisma.userLayout.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.findOne("invalid-id", mockWorkspaceId, mockUserId)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.findOne("invalid-id", mockWorkspaceId, mockUserId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,12 +221,7 @@ describe("LayoutsService", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const result = await service.update(
|
||||
"layout-1",
|
||||
mockWorkspaceId,
|
||||
mockUserId,
|
||||
updateDto
|
||||
);
|
||||
const result = await service.update("layout-1", mockWorkspaceId, mockUserId, updateDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(mockFindUnique).toHaveBeenCalled();
|
||||
@@ -244,9 +239,9 @@ describe("LayoutsService", () => {
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.update("invalid-id", mockWorkspaceId, mockUserId, {})
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.update("invalid-id", mockWorkspaceId, mockUserId, {})).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,9 +264,9 @@ describe("LayoutsService", () => {
|
||||
it("should throw NotFoundException if layout not found", async () => {
|
||||
prisma.userLayout.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.remove("invalid-id", mockWorkspaceId, mockUserId)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.remove("invalid-id", mockWorkspaceId, mockUserId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,11 +48,7 @@ describe("OllamaController", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(mockOllamaService.generate).toHaveBeenCalledWith(
|
||||
"Hello",
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(mockOllamaService.generate).toHaveBeenCalledWith("Hello", undefined, undefined);
|
||||
});
|
||||
|
||||
it("should generate with options and custom model", async () => {
|
||||
@@ -84,9 +80,7 @@ describe("OllamaController", () => {
|
||||
|
||||
describe("chat", () => {
|
||||
it("should complete chat conversation", async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "user", content: "Hello!" },
|
||||
];
|
||||
const messages: ChatMessage[] = [{ role: "user", content: "Hello!" }];
|
||||
|
||||
const mockResponse = {
|
||||
model: "llama3.2",
|
||||
@@ -104,11 +98,7 @@ describe("OllamaController", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(mockOllamaService.chat).toHaveBeenCalledWith(
|
||||
messages,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(mockOllamaService.chat).toHaveBeenCalledWith(messages, undefined, undefined);
|
||||
});
|
||||
|
||||
it("should chat with options and custom model", async () => {
|
||||
@@ -158,10 +148,7 @@ describe("OllamaController", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(mockOllamaService.embed).toHaveBeenCalledWith(
|
||||
"Sample text",
|
||||
undefined
|
||||
);
|
||||
expect(mockOllamaService.embed).toHaveBeenCalledWith("Sample text", undefined);
|
||||
});
|
||||
|
||||
it("should embed with custom model", async () => {
|
||||
@@ -177,10 +164,7 @@ describe("OllamaController", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(mockOllamaService.embed).toHaveBeenCalledWith(
|
||||
"Test",
|
||||
"nomic-embed-text"
|
||||
);
|
||||
expect(mockOllamaService.embed).toHaveBeenCalledWith("Test", "nomic-embed-text");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,11 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { OllamaService } from "./ollama.service";
|
||||
import { HttpException, HttpStatus } from "@nestjs/common";
|
||||
import type {
|
||||
GenerateOptionsDto,
|
||||
ChatMessage,
|
||||
ChatOptionsDto,
|
||||
} from "./dto";
|
||||
import type { GenerateOptionsDto, ChatMessage, ChatOptionsDto } from "./dto";
|
||||
|
||||
describe("OllamaService", () => {
|
||||
let service: OllamaService;
|
||||
@@ -133,9 +129,7 @@ describe("OllamaService", () => {
|
||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(service.generate("Hello")).rejects.toThrow(HttpException);
|
||||
await expect(service.generate("Hello")).rejects.toThrow(
|
||||
"Failed to connect to Ollama"
|
||||
);
|
||||
await expect(service.generate("Hello")).rejects.toThrow("Failed to connect to Ollama");
|
||||
});
|
||||
|
||||
it("should throw HttpException on non-ok response", async () => {
|
||||
@@ -163,12 +157,9 @@ describe("OllamaService", () => {
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const shortTimeoutService =
|
||||
shortTimeoutModule.get<OllamaService>(OllamaService);
|
||||
const shortTimeoutService = shortTimeoutModule.get<OllamaService>(OllamaService);
|
||||
|
||||
await expect(shortTimeoutService.generate("Hello")).rejects.toThrow(
|
||||
HttpException
|
||||
);
|
||||
await expect(shortTimeoutService.generate("Hello")).rejects.toThrow(HttpException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,9 +201,7 @@ describe("OllamaService", () => {
|
||||
});
|
||||
|
||||
it("should chat with custom options", async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "user", content: "Hello!" },
|
||||
];
|
||||
const messages: ChatMessage[] = [{ role: "user", content: "Hello!" }];
|
||||
|
||||
const options: ChatOptionsDto = {
|
||||
temperature: 0.5,
|
||||
@@ -251,9 +240,9 @@ describe("OllamaService", () => {
|
||||
it("should throw HttpException on chat error", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("Connection refused"));
|
||||
|
||||
await expect(
|
||||
service.chat([{ role: "user", content: "Hello" }])
|
||||
).rejects.toThrow(HttpException);
|
||||
await expect(service.chat([{ role: "user", content: "Hello" }])).rejects.toThrow(
|
||||
HttpException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,9 +23,7 @@ describe("PrismaService", () => {
|
||||
|
||||
describe("onModuleInit", () => {
|
||||
it("should connect to the database", async () => {
|
||||
const connectSpy = vi
|
||||
.spyOn(service, "$connect")
|
||||
.mockResolvedValue(undefined);
|
||||
const connectSpy = vi.spyOn(service, "$connect").mockResolvedValue(undefined);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
@@ -42,9 +40,7 @@ describe("PrismaService", () => {
|
||||
|
||||
describe("onModuleDestroy", () => {
|
||||
it("should disconnect from the database", async () => {
|
||||
const disconnectSpy = vi
|
||||
.spyOn(service, "$disconnect")
|
||||
.mockResolvedValue(undefined);
|
||||
const disconnectSpy = vi.spyOn(service, "$disconnect").mockResolvedValue(undefined);
|
||||
|
||||
await service.onModuleDestroy();
|
||||
|
||||
@@ -62,9 +58,7 @@ describe("PrismaService", () => {
|
||||
});
|
||||
|
||||
it("should return false when database is not accessible", async () => {
|
||||
vi
|
||||
.spyOn(service, "$queryRaw")
|
||||
.mockRejectedValue(new Error("Database error"));
|
||||
vi.spyOn(service, "$queryRaw").mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const result = await service.isHealthy();
|
||||
|
||||
@@ -100,9 +94,7 @@ describe("PrismaService", () => {
|
||||
});
|
||||
|
||||
it("should return connected false when query fails", async () => {
|
||||
vi
|
||||
.spyOn(service, "$queryRaw")
|
||||
.mockRejectedValue(new Error("Query failed"));
|
||||
vi.spyOn(service, "$queryRaw").mockRejectedValue(new Error("Query failed"));
|
||||
|
||||
const result = await service.getConnectionInfo();
|
||||
|
||||
|
||||
@@ -62,11 +62,7 @@ describe("ProjectsController", () => {
|
||||
const result = await controller.create(createDto, mockWorkspaceId, mockUser);
|
||||
|
||||
expect(result).toEqual(mockProject);
|
||||
expect(service.create).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
mockUserId,
|
||||
createDto
|
||||
);
|
||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, createDto);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
|
||||
@@ -74,7 +70,9 @@ describe("ProjectsController", () => {
|
||||
|
||||
await controller.create({ name: "Test" }, undefined as any, mockUser);
|
||||
|
||||
expect(mockProjectsService.create).toHaveBeenCalledWith(undefined, mockUserId, { name: "Test" });
|
||||
expect(mockProjectsService.create).toHaveBeenCalledWith(undefined, mockUserId, {
|
||||
name: "Test",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,7 +147,12 @@ describe("ProjectsController", () => {
|
||||
|
||||
await controller.update(mockProjectId, updateDto, undefined as any, mockUser);
|
||||
|
||||
expect(mockProjectsService.update).toHaveBeenCalledWith(mockProjectId, undefined, mockUserId, updateDto);
|
||||
expect(mockProjectsService.update).toHaveBeenCalledWith(
|
||||
mockProjectId,
|
||||
undefined,
|
||||
mockUserId,
|
||||
updateDto
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,11 +162,7 @@ describe("ProjectsController", () => {
|
||||
|
||||
await controller.remove(mockProjectId, mockWorkspaceId, mockUser);
|
||||
|
||||
expect(service.remove).toHaveBeenCalledWith(
|
||||
mockProjectId,
|
||||
mockWorkspaceId,
|
||||
mockUserId
|
||||
);
|
||||
expect(service.remove).toHaveBeenCalledWith(mockProjectId, mockWorkspaceId, mockUserId);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
|
||||
|
||||
@@ -55,9 +55,7 @@ describe("StitcherController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("POST /stitcher/dispatch should require authentication", async () => {
|
||||
@@ -67,9 +65,7 @@ describe("StitcherController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,9 +92,7 @@ describe("StitcherController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow("Invalid API key");
|
||||
});
|
||||
|
||||
@@ -111,9 +105,7 @@ describe("StitcherController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow("No API key provided");
|
||||
});
|
||||
});
|
||||
@@ -133,9 +125,7 @@ describe("StitcherController - Security", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("QueryTasksDto", () => {
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.property === "workspaceId")).toBe(true);
|
||||
expect(errors.some((e) => e.property === "workspaceId")).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept valid status filter", async () => {
|
||||
|
||||
@@ -106,18 +106,10 @@ describe("TasksController", () => {
|
||||
|
||||
mockTasksService.create.mockResolvedValue(mockTask);
|
||||
|
||||
const result = await controller.create(
|
||||
createDto,
|
||||
mockWorkspaceId,
|
||||
mockRequest.user
|
||||
);
|
||||
const result = await controller.create(createDto, mockWorkspaceId, mockRequest.user);
|
||||
|
||||
expect(result).toEqual(mockTask);
|
||||
expect(service.create).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
mockUserId,
|
||||
createDto
|
||||
);
|
||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, createDto);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -247,11 +239,7 @@ describe("TasksController", () => {
|
||||
|
||||
await controller.remove(mockTaskId, mockWorkspaceId, mockRequest.user);
|
||||
|
||||
expect(service.remove).toHaveBeenCalledWith(
|
||||
mockTaskId,
|
||||
mockWorkspaceId,
|
||||
mockUserId
|
||||
);
|
||||
expect(service.remove).toHaveBeenCalledWith(mockTaskId, mockWorkspaceId, mockUserId);
|
||||
});
|
||||
|
||||
it("should throw error if workspaceId not found", async () => {
|
||||
@@ -262,11 +250,7 @@ describe("TasksController", () => {
|
||||
|
||||
await controller.remove(mockTaskId, mockWorkspaceId, mockRequest.user);
|
||||
|
||||
expect(service.remove).toHaveBeenCalledWith(
|
||||
mockTaskId,
|
||||
mockWorkspaceId,
|
||||
mockUserId
|
||||
);
|
||||
expect(service.remove).toHaveBeenCalledWith(mockTaskId, mockWorkspaceId, mockUserId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,8 +69,8 @@ docker compose up -d valkey
|
||||
### 1. Inject the Service
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ValkeyService } from './valkey/valkey.service';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ValkeyService } from "./valkey/valkey.service";
|
||||
|
||||
@Injectable()
|
||||
export class MyService {
|
||||
@@ -82,11 +82,11 @@ export class MyService {
|
||||
|
||||
```typescript
|
||||
const task = await this.valkeyService.enqueue({
|
||||
type: 'send-email',
|
||||
type: "send-email",
|
||||
data: {
|
||||
to: 'user@example.com',
|
||||
subject: 'Welcome!',
|
||||
body: 'Hello, welcome to Mosaic Stack',
|
||||
to: "user@example.com",
|
||||
subject: "Welcome!",
|
||||
body: "Hello, welcome to Mosaic Stack",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -102,11 +102,11 @@ const task = await this.valkeyService.dequeue();
|
||||
|
||||
if (task) {
|
||||
console.log(task.status); // 'processing'
|
||||
|
||||
|
||||
try {
|
||||
// Do work...
|
||||
await sendEmail(task.data);
|
||||
|
||||
|
||||
// Mark as completed
|
||||
await this.valkeyService.updateStatus(task.id, {
|
||||
status: TaskStatus.COMPLETED,
|
||||
@@ -129,8 +129,8 @@ const status = await this.valkeyService.getStatus(taskId);
|
||||
|
||||
if (status) {
|
||||
console.log(status.status); // 'completed' | 'failed' | 'processing' | 'pending'
|
||||
console.log(status.data); // Task metadata
|
||||
console.log(status.error); // Error message if failed
|
||||
console.log(status.data); // Task metadata
|
||||
console.log(status.error); // Error message if failed
|
||||
}
|
||||
```
|
||||
|
||||
@@ -143,7 +143,7 @@ console.log(`${length} tasks in queue`);
|
||||
|
||||
// Health check
|
||||
const healthy = await this.valkeyService.healthCheck();
|
||||
console.log(`Valkey is ${healthy ? 'healthy' : 'down'}`);
|
||||
console.log(`Valkey is ${healthy ? "healthy" : "down"}`);
|
||||
|
||||
// Clear queue (use with caution!)
|
||||
await this.valkeyService.clearQueue();
|
||||
@@ -181,12 +181,12 @@ export class EmailWorker {
|
||||
private async startWorker() {
|
||||
while (true) {
|
||||
const task = await this.valkeyService.dequeue();
|
||||
|
||||
|
||||
if (task) {
|
||||
await this.processTask(task);
|
||||
} else {
|
||||
// No tasks, wait 5 seconds
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,14 +194,14 @@ export class EmailWorker {
|
||||
private async processTask(task: TaskDto) {
|
||||
try {
|
||||
switch (task.type) {
|
||||
case 'send-email':
|
||||
case "send-email":
|
||||
await this.sendEmail(task.data);
|
||||
break;
|
||||
case 'generate-report':
|
||||
case "generate-report":
|
||||
await this.generateReport(task.data);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
await this.valkeyService.updateStatus(task.id, {
|
||||
status: TaskStatus.COMPLETED,
|
||||
});
|
||||
@@ -222,10 +222,10 @@ export class EmailWorker {
|
||||
export class ScheduledTasks {
|
||||
constructor(private readonly valkeyService: ValkeyService) {}
|
||||
|
||||
@Cron('0 0 * * *') // Daily at midnight
|
||||
@Cron("0 0 * * *") // Daily at midnight
|
||||
async dailyReport() {
|
||||
await this.valkeyService.enqueue({
|
||||
type: 'daily-report',
|
||||
type: "daily-report",
|
||||
data: { date: new Date().toISOString() },
|
||||
});
|
||||
}
|
||||
@@ -241,6 +241,7 @@ pnpm test valkey.service.spec.ts
|
||||
```
|
||||
|
||||
Tests cover:
|
||||
|
||||
- ✅ Connection and initialization
|
||||
- ✅ Enqueue operations
|
||||
- ✅ Dequeue FIFO behavior
|
||||
@@ -254,9 +255,11 @@ Tests cover:
|
||||
### ValkeyService Methods
|
||||
|
||||
#### `enqueue(task: EnqueueTaskDto): Promise<TaskDto>`
|
||||
|
||||
Add a task to the queue.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `task.type` (string): Task type identifier
|
||||
- `task.data` (object): Task metadata
|
||||
|
||||
@@ -265,6 +268,7 @@ Add a task to the queue.
|
||||
---
|
||||
|
||||
#### `dequeue(): Promise<TaskDto | null>`
|
||||
|
||||
Get the next task from the queue (FIFO).
|
||||
|
||||
**Returns:** Next task with status updated to PROCESSING, or null if queue is empty
|
||||
@@ -272,9 +276,11 @@ Get the next task from the queue (FIFO).
|
||||
---
|
||||
|
||||
#### `getStatus(taskId: string): Promise<TaskDto | null>`
|
||||
|
||||
Retrieve task status and metadata.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `taskId` (string): Task UUID
|
||||
|
||||
**Returns:** Task data or null if not found
|
||||
@@ -282,9 +288,11 @@ Retrieve task status and metadata.
|
||||
---
|
||||
|
||||
#### `updateStatus(taskId: string, update: UpdateTaskStatusDto): Promise<TaskDto | null>`
|
||||
|
||||
Update task status and optionally add results or errors.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `taskId` (string): Task UUID
|
||||
- `update.status` (TaskStatus): New status
|
||||
- `update.error` (string, optional): Error message for failed tasks
|
||||
@@ -295,6 +303,7 @@ Update task status and optionally add results or errors.
|
||||
---
|
||||
|
||||
#### `getQueueLength(): Promise<number>`
|
||||
|
||||
Get the number of tasks in queue.
|
||||
|
||||
**Returns:** Queue length
|
||||
@@ -302,11 +311,13 @@ Get the number of tasks in queue.
|
||||
---
|
||||
|
||||
#### `clearQueue(): Promise<void>`
|
||||
|
||||
Remove all tasks from queue (metadata remains until TTL).
|
||||
|
||||
---
|
||||
|
||||
#### `healthCheck(): Promise<boolean>`
|
||||
|
||||
Verify Valkey connectivity.
|
||||
|
||||
**Returns:** true if connected, false otherwise
|
||||
@@ -314,6 +325,7 @@ Verify Valkey connectivity.
|
||||
## Migration Notes
|
||||
|
||||
If upgrading from BullMQ or another queue system:
|
||||
|
||||
1. Task IDs are UUIDs (not incremental)
|
||||
2. No built-in retry mechanism (implement in worker)
|
||||
3. No job priorities (strict FIFO)
|
||||
@@ -329,7 +341,7 @@ For advanced features like retries, priorities, or scheduled jobs, consider wrap
|
||||
// Check Valkey connectivity
|
||||
const healthy = await this.valkeyService.healthCheck();
|
||||
if (!healthy) {
|
||||
console.error('Valkey is not responding');
|
||||
console.error("Valkey is not responding");
|
||||
}
|
||||
```
|
||||
|
||||
@@ -349,6 +361,7 @@ docker exec -it mosaic-valkey valkey-cli DEL mosaic:task:queue
|
||||
### Debug Logging
|
||||
|
||||
The service logs all operations at `info` level. Check application logs for:
|
||||
|
||||
- Task enqueue/dequeue operations
|
||||
- Status updates
|
||||
- Connection events
|
||||
@@ -356,6 +369,7 @@ The service logs all operations at `info` level. Check application logs for:
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for consideration:
|
||||
|
||||
- [ ] Task priorities (weighted queues)
|
||||
- [ ] Retry mechanism with exponential backoff
|
||||
- [ ] Delayed/scheduled tasks
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { ValkeyService } from './valkey.service';
|
||||
import { TaskStatus } from './dto/task.dto';
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { ValkeyService } from "./valkey.service";
|
||||
import { TaskStatus } from "./dto/task.dto";
|
||||
|
||||
// Mock ioredis module
|
||||
vi.mock('ioredis', () => {
|
||||
vi.mock("ioredis", () => {
|
||||
// In-memory store for mocked Redis
|
||||
const store = new Map<string, string>();
|
||||
const lists = new Map<string, string[]>();
|
||||
@@ -13,13 +13,13 @@ vi.mock('ioredis', () => {
|
||||
class MockRedisClient {
|
||||
// Connection methods
|
||||
async ping() {
|
||||
return 'PONG';
|
||||
return "PONG";
|
||||
}
|
||||
|
||||
|
||||
async quit() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
on() {
|
||||
return this;
|
||||
}
|
||||
@@ -27,9 +27,9 @@ vi.mock('ioredis', () => {
|
||||
// String operations
|
||||
async setex(key: string, ttl: number, value: string) {
|
||||
store.set(key, value);
|
||||
return 'OK';
|
||||
return "OK";
|
||||
}
|
||||
|
||||
|
||||
async get(key: string) {
|
||||
return store.get(key) || null;
|
||||
}
|
||||
@@ -43,7 +43,7 @@ vi.mock('ioredis', () => {
|
||||
list.push(...values);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
|
||||
async lpop(key: string) {
|
||||
const list = lists.get(key);
|
||||
if (!list || list.length === 0) {
|
||||
@@ -51,15 +51,15 @@ vi.mock('ioredis', () => {
|
||||
}
|
||||
return list.shift()!;
|
||||
}
|
||||
|
||||
|
||||
async llen(key: string) {
|
||||
const list = lists.get(key);
|
||||
return list ? list.length : 0;
|
||||
}
|
||||
|
||||
|
||||
async del(...keys: string[]) {
|
||||
let deleted = 0;
|
||||
keys.forEach(key => {
|
||||
keys.forEach((key) => {
|
||||
if (store.delete(key)) deleted++;
|
||||
if (lists.delete(key)) deleted++;
|
||||
});
|
||||
@@ -78,16 +78,16 @@ vi.mock('ioredis', () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('ValkeyService', () => {
|
||||
describe("ValkeyService", () => {
|
||||
let service: ValkeyService;
|
||||
let module: TestingModule;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear environment
|
||||
process.env.VALKEY_URL = 'redis://localhost:6379';
|
||||
process.env.VALKEY_URL = "redis://localhost:6379";
|
||||
|
||||
// Clear the mock store before each test
|
||||
const Redis = await import('ioredis');
|
||||
const Redis = await import("ioredis");
|
||||
(Redis.default as any).__clearStore();
|
||||
|
||||
module = await Test.createTestingModule({
|
||||
@@ -95,7 +95,7 @@ describe('ValkeyService', () => {
|
||||
}).compile();
|
||||
|
||||
service = module.get<ValkeyService>(ValkeyService);
|
||||
|
||||
|
||||
// Initialize the service
|
||||
await service.onModuleInit();
|
||||
});
|
||||
@@ -104,41 +104,41 @@ describe('ValkeyService', () => {
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should be defined', () => {
|
||||
describe("initialization", () => {
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should connect to Valkey on module init', async () => {
|
||||
it("should connect to Valkey on module init", async () => {
|
||||
expect(service).toBeDefined();
|
||||
const healthCheck = await service.healthCheck();
|
||||
expect(healthCheck).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enqueue', () => {
|
||||
it('should enqueue a task successfully', async () => {
|
||||
describe("enqueue", () => {
|
||||
it("should enqueue a task successfully", async () => {
|
||||
const taskDto = {
|
||||
type: 'test-task',
|
||||
data: { message: 'Hello World' },
|
||||
type: "test-task",
|
||||
data: { message: "Hello World" },
|
||||
};
|
||||
|
||||
const result = await service.enqueue(taskDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.type).toBe('test-task');
|
||||
expect(result.data).toEqual({ message: 'Hello World' });
|
||||
expect(result.type).toBe("test-task");
|
||||
expect(result.data).toEqual({ message: "Hello World" });
|
||||
expect(result.status).toBe(TaskStatus.PENDING);
|
||||
expect(result.createdAt).toBeDefined();
|
||||
expect(result.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should increment queue length when enqueueing', async () => {
|
||||
it("should increment queue length when enqueueing", async () => {
|
||||
const initialLength = await service.getQueueLength();
|
||||
|
||||
|
||||
await service.enqueue({
|
||||
type: 'task-1',
|
||||
type: "task-1",
|
||||
data: {},
|
||||
});
|
||||
|
||||
@@ -147,20 +147,20 @@ describe('ValkeyService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('dequeue', () => {
|
||||
it('should return null when queue is empty', async () => {
|
||||
describe("dequeue", () => {
|
||||
it("should return null when queue is empty", async () => {
|
||||
const result = await service.dequeue();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should dequeue tasks in FIFO order', async () => {
|
||||
it("should dequeue tasks in FIFO order", async () => {
|
||||
const task1 = await service.enqueue({
|
||||
type: 'task-1',
|
||||
type: "task-1",
|
||||
data: { order: 1 },
|
||||
});
|
||||
|
||||
const task2 = await service.enqueue({
|
||||
type: 'task-2',
|
||||
type: "task-2",
|
||||
data: { order: 2 },
|
||||
});
|
||||
|
||||
@@ -173,9 +173,9 @@ describe('ValkeyService', () => {
|
||||
expect(dequeued2?.status).toBe(TaskStatus.PROCESSING);
|
||||
});
|
||||
|
||||
it('should update task status to PROCESSING when dequeued', async () => {
|
||||
it("should update task status to PROCESSING when dequeued", async () => {
|
||||
const task = await service.enqueue({
|
||||
type: 'test-task',
|
||||
type: "test-task",
|
||||
data: {},
|
||||
});
|
||||
|
||||
@@ -187,73 +187,73 @@ describe('ValkeyService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('should return null for non-existent task', async () => {
|
||||
const status = await service.getStatus('non-existent-id');
|
||||
describe("getStatus", () => {
|
||||
it("should return null for non-existent task", async () => {
|
||||
const status = await service.getStatus("non-existent-id");
|
||||
expect(status).toBeNull();
|
||||
});
|
||||
|
||||
it('should return task status for existing task', async () => {
|
||||
it("should return task status for existing task", async () => {
|
||||
const task = await service.enqueue({
|
||||
type: 'test-task',
|
||||
data: { key: 'value' },
|
||||
type: "test-task",
|
||||
data: { key: "value" },
|
||||
});
|
||||
|
||||
const status = await service.getStatus(task.id);
|
||||
expect(status).toBeDefined();
|
||||
expect(status?.id).toBe(task.id);
|
||||
expect(status?.type).toBe('test-task');
|
||||
expect(status?.data).toEqual({ key: 'value' });
|
||||
expect(status?.type).toBe("test-task");
|
||||
expect(status?.data).toEqual({ key: "value" });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('should update task status to COMPLETED', async () => {
|
||||
describe("updateStatus", () => {
|
||||
it("should update task status to COMPLETED", async () => {
|
||||
const task = await service.enqueue({
|
||||
type: 'test-task',
|
||||
type: "test-task",
|
||||
data: {},
|
||||
});
|
||||
|
||||
const updated = await service.updateStatus(task.id, {
|
||||
status: TaskStatus.COMPLETED,
|
||||
result: { output: 'success' },
|
||||
result: { output: "success" },
|
||||
});
|
||||
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated?.status).toBe(TaskStatus.COMPLETED);
|
||||
expect(updated?.completedAt).toBeDefined();
|
||||
expect(updated?.data).toEqual({ output: 'success' });
|
||||
expect(updated?.data).toEqual({ output: "success" });
|
||||
});
|
||||
|
||||
it('should update task status to FAILED with error', async () => {
|
||||
it("should update task status to FAILED with error", async () => {
|
||||
const task = await service.enqueue({
|
||||
type: 'test-task',
|
||||
type: "test-task",
|
||||
data: {},
|
||||
});
|
||||
|
||||
const updated = await service.updateStatus(task.id, {
|
||||
status: TaskStatus.FAILED,
|
||||
error: 'Task failed due to error',
|
||||
error: "Task failed due to error",
|
||||
});
|
||||
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated?.status).toBe(TaskStatus.FAILED);
|
||||
expect(updated?.error).toBe('Task failed due to error');
|
||||
expect(updated?.error).toBe("Task failed due to error");
|
||||
expect(updated?.completedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return null when updating non-existent task', async () => {
|
||||
const updated = await service.updateStatus('non-existent-id', {
|
||||
it("should return null when updating non-existent task", async () => {
|
||||
const updated = await service.updateStatus("non-existent-id", {
|
||||
status: TaskStatus.COMPLETED,
|
||||
});
|
||||
|
||||
expect(updated).toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve existing data when updating status', async () => {
|
||||
it("should preserve existing data when updating status", async () => {
|
||||
const task = await service.enqueue({
|
||||
type: 'test-task',
|
||||
data: { original: 'data' },
|
||||
type: "test-task",
|
||||
data: { original: "data" },
|
||||
});
|
||||
|
||||
await service.updateStatus(task.id, {
|
||||
@@ -261,28 +261,28 @@ describe('ValkeyService', () => {
|
||||
});
|
||||
|
||||
const status = await service.getStatus(task.id);
|
||||
expect(status?.data).toEqual({ original: 'data' });
|
||||
expect(status?.data).toEqual({ original: "data" });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueueLength', () => {
|
||||
it('should return 0 for empty queue', async () => {
|
||||
describe("getQueueLength", () => {
|
||||
it("should return 0 for empty queue", async () => {
|
||||
const length = await service.getQueueLength();
|
||||
expect(length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct queue length', async () => {
|
||||
await service.enqueue({ type: 'task-1', data: {} });
|
||||
await service.enqueue({ type: 'task-2', data: {} });
|
||||
await service.enqueue({ type: 'task-3', data: {} });
|
||||
it("should return correct queue length", async () => {
|
||||
await service.enqueue({ type: "task-1", data: {} });
|
||||
await service.enqueue({ type: "task-2", data: {} });
|
||||
await service.enqueue({ type: "task-3", data: {} });
|
||||
|
||||
const length = await service.getQueueLength();
|
||||
expect(length).toBe(3);
|
||||
});
|
||||
|
||||
it('should decrease when tasks are dequeued', async () => {
|
||||
await service.enqueue({ type: 'task-1', data: {} });
|
||||
await service.enqueue({ type: 'task-2', data: {} });
|
||||
it("should decrease when tasks are dequeued", async () => {
|
||||
await service.enqueue({ type: "task-1", data: {} });
|
||||
await service.enqueue({ type: "task-2", data: {} });
|
||||
|
||||
expect(await service.getQueueLength()).toBe(2);
|
||||
|
||||
@@ -294,10 +294,10 @@ describe('ValkeyService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearQueue', () => {
|
||||
it('should clear all tasks from queue', async () => {
|
||||
await service.enqueue({ type: 'task-1', data: {} });
|
||||
await service.enqueue({ type: 'task-2', data: {} });
|
||||
describe("clearQueue", () => {
|
||||
it("should clear all tasks from queue", async () => {
|
||||
await service.enqueue({ type: "task-1", data: {} });
|
||||
await service.enqueue({ type: "task-2", data: {} });
|
||||
|
||||
expect(await service.getQueueLength()).toBe(2);
|
||||
|
||||
@@ -306,21 +306,21 @@ describe('ValkeyService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('healthCheck', () => {
|
||||
it('should return true when Valkey is healthy', async () => {
|
||||
describe("healthCheck", () => {
|
||||
it("should return true when Valkey is healthy", async () => {
|
||||
const healthy = await service.healthCheck();
|
||||
expect(healthy).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration flow', () => {
|
||||
it('should handle complete task lifecycle', async () => {
|
||||
describe("integration flow", () => {
|
||||
it("should handle complete task lifecycle", async () => {
|
||||
// 1. Enqueue task
|
||||
const task = await service.enqueue({
|
||||
type: 'email-notification',
|
||||
type: "email-notification",
|
||||
data: {
|
||||
to: 'user@example.com',
|
||||
subject: 'Test Email',
|
||||
to: "user@example.com",
|
||||
subject: "Test Email",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -335,8 +335,8 @@ describe('ValkeyService', () => {
|
||||
const completedTask = await service.updateStatus(task.id, {
|
||||
status: TaskStatus.COMPLETED,
|
||||
result: {
|
||||
to: 'user@example.com',
|
||||
subject: 'Test Email',
|
||||
to: "user@example.com",
|
||||
subject: "Test Email",
|
||||
sentAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
@@ -350,11 +350,11 @@ describe('ValkeyService', () => {
|
||||
expect(finalStatus?.data.sentAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle multiple concurrent tasks', async () => {
|
||||
it("should handle multiple concurrent tasks", async () => {
|
||||
const tasks = await Promise.all([
|
||||
service.enqueue({ type: 'task-1', data: { id: 1 } }),
|
||||
service.enqueue({ type: 'task-2', data: { id: 2 } }),
|
||||
service.enqueue({ type: 'task-3', data: { id: 3 } }),
|
||||
service.enqueue({ type: "task-1", data: { id: 1 } }),
|
||||
service.enqueue({ type: "task-2", data: { id: 2 } }),
|
||||
service.enqueue({ type: "task-3", data: { id: 3 } }),
|
||||
]);
|
||||
|
||||
expect(await service.getQueueLength()).toBe(3);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { WebSocketGateway } from './websocket.gateway';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { WebSocketGateway } from "./websocket.gateway";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
data: {
|
||||
@@ -12,7 +12,7 @@ interface AuthenticatedSocket extends Socket {
|
||||
};
|
||||
}
|
||||
|
||||
describe('WebSocketGateway', () => {
|
||||
describe("WebSocketGateway", () => {
|
||||
let gateway: WebSocketGateway;
|
||||
let authService: AuthService;
|
||||
let prismaService: PrismaService;
|
||||
@@ -53,7 +53,7 @@ describe('WebSocketGateway', () => {
|
||||
|
||||
// Mock authenticated client
|
||||
mockClient = {
|
||||
id: 'test-socket-id',
|
||||
id: "test-socket-id",
|
||||
join: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
@@ -61,7 +61,7 @@ describe('WebSocketGateway', () => {
|
||||
data: {},
|
||||
handshake: {
|
||||
auth: {
|
||||
token: 'valid-token',
|
||||
token: "valid-token",
|
||||
},
|
||||
},
|
||||
} as unknown as AuthenticatedSocket;
|
||||
@@ -76,36 +76,36 @@ describe('WebSocketGateway', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('Authentication', () => {
|
||||
it('should validate token and populate socket.data on successful authentication', async () => {
|
||||
describe("Authentication", () => {
|
||||
it("should validate token and populate socket.data on successful authentication", async () => {
|
||||
const mockSessionData = {
|
||||
user: { id: 'user-123', email: 'test@example.com' },
|
||||
session: { id: 'session-123' },
|
||||
user: { id: "user-123", email: "test@example.com" },
|
||||
session: { id: "session-123" },
|
||||
};
|
||||
|
||||
vi.spyOn(authService, 'verifySession').mockResolvedValue(mockSessionData);
|
||||
vi.spyOn(prismaService.workspaceMember, 'findFirst').mockResolvedValue({
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-456',
|
||||
role: 'MEMBER',
|
||||
vi.spyOn(authService, "verifySession").mockResolvedValue(mockSessionData);
|
||||
vi.spyOn(prismaService.workspaceMember, "findFirst").mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
} as never);
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(authService.verifySession).toHaveBeenCalledWith('valid-token');
|
||||
expect(mockClient.data.userId).toBe('user-123');
|
||||
expect(mockClient.data.workspaceId).toBe('workspace-456');
|
||||
expect(authService.verifySession).toHaveBeenCalledWith("valid-token");
|
||||
expect(mockClient.data.userId).toBe("user-123");
|
||||
expect(mockClient.data.workspaceId).toBe("workspace-456");
|
||||
});
|
||||
|
||||
it('should disconnect client with invalid token', async () => {
|
||||
vi.spyOn(authService, 'verifySession').mockResolvedValue(null);
|
||||
it("should disconnect client with invalid token", async () => {
|
||||
vi.spyOn(authService, "verifySession").mockResolvedValue(null);
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disconnect client without token', async () => {
|
||||
it("should disconnect client without token", async () => {
|
||||
const clientNoToken = {
|
||||
...mockClient,
|
||||
handshake: { auth: {} },
|
||||
@@ -116,23 +116,23 @@ describe('WebSocketGateway', () => {
|
||||
expect(clientNoToken.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disconnect client if token verification throws error', async () => {
|
||||
vi.spyOn(authService, 'verifySession').mockRejectedValue(new Error('Invalid token'));
|
||||
it("should disconnect client if token verification throws error", async () => {
|
||||
vi.spyOn(authService, "verifySession").mockRejectedValue(new Error("Invalid token"));
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have connection timeout mechanism in place', () => {
|
||||
it("should have connection timeout mechanism in place", () => {
|
||||
// This test verifies that the gateway has a CONNECTION_TIMEOUT_MS constant
|
||||
// The actual timeout is tested indirectly through authentication failure tests
|
||||
expect((gateway as { CONNECTION_TIMEOUT_MS: number }).CONNECTION_TIMEOUT_MS).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiting', () => {
|
||||
it('should reject connections exceeding rate limit', async () => {
|
||||
describe("Rate Limiting", () => {
|
||||
it("should reject connections exceeding rate limit", async () => {
|
||||
// Mock rate limiter to return false (limit exceeded)
|
||||
const rateLimitedClient = { ...mockClient } as AuthenticatedSocket;
|
||||
|
||||
@@ -146,109 +146,109 @@ describe('WebSocketGateway', () => {
|
||||
// expect(rateLimitedClient.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow connections within rate limit', async () => {
|
||||
it("should allow connections within rate limit", async () => {
|
||||
const mockSessionData = {
|
||||
user: { id: 'user-123', email: 'test@example.com' },
|
||||
session: { id: 'session-123' },
|
||||
user: { id: "user-123", email: "test@example.com" },
|
||||
session: { id: "session-123" },
|
||||
};
|
||||
|
||||
vi.spyOn(authService, 'verifySession').mockResolvedValue(mockSessionData);
|
||||
vi.spyOn(prismaService.workspaceMember, 'findFirst').mockResolvedValue({
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-456',
|
||||
role: 'MEMBER',
|
||||
vi.spyOn(authService, "verifySession").mockResolvedValue(mockSessionData);
|
||||
vi.spyOn(prismaService.workspaceMember, "findFirst").mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
} as never);
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockClient.disconnect).not.toHaveBeenCalled();
|
||||
expect(mockClient.data.userId).toBe('user-123');
|
||||
expect(mockClient.data.userId).toBe("user-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workspace Access Validation', () => {
|
||||
it('should verify user has access to workspace', async () => {
|
||||
describe("Workspace Access Validation", () => {
|
||||
it("should verify user has access to workspace", async () => {
|
||||
const mockSessionData = {
|
||||
user: { id: 'user-123', email: 'test@example.com' },
|
||||
session: { id: 'session-123' },
|
||||
user: { id: "user-123", email: "test@example.com" },
|
||||
session: { id: "session-123" },
|
||||
};
|
||||
|
||||
vi.spyOn(authService, 'verifySession').mockResolvedValue(mockSessionData);
|
||||
vi.spyOn(prismaService.workspaceMember, 'findFirst').mockResolvedValue({
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-456',
|
||||
role: 'MEMBER',
|
||||
vi.spyOn(authService, "verifySession").mockResolvedValue(mockSessionData);
|
||||
vi.spyOn(prismaService.workspaceMember, "findFirst").mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
} as never);
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(prismaService.workspaceMember.findFirst).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-123' },
|
||||
where: { userId: "user-123" },
|
||||
select: { workspaceId: true, userId: true, role: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should disconnect client without workspace access', async () => {
|
||||
it("should disconnect client without workspace access", async () => {
|
||||
const mockSessionData = {
|
||||
user: { id: 'user-123', email: 'test@example.com' },
|
||||
session: { id: 'session-123' },
|
||||
user: { id: "user-123", email: "test@example.com" },
|
||||
session: { id: "session-123" },
|
||||
};
|
||||
|
||||
vi.spyOn(authService, 'verifySession').mockResolvedValue(mockSessionData);
|
||||
vi.spyOn(prismaService.workspaceMember, 'findFirst').mockResolvedValue(null);
|
||||
vi.spyOn(authService, "verifySession").mockResolvedValue(mockSessionData);
|
||||
vi.spyOn(prismaService.workspaceMember, "findFirst").mockResolvedValue(null);
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only allow joining workspace rooms user has access to', async () => {
|
||||
it("should only allow joining workspace rooms user has access to", async () => {
|
||||
const mockSessionData = {
|
||||
user: { id: 'user-123', email: 'test@example.com' },
|
||||
session: { id: 'session-123' },
|
||||
user: { id: "user-123", email: "test@example.com" },
|
||||
session: { id: "session-123" },
|
||||
};
|
||||
|
||||
vi.spyOn(authService, 'verifySession').mockResolvedValue(mockSessionData);
|
||||
vi.spyOn(prismaService.workspaceMember, 'findFirst').mockResolvedValue({
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-456',
|
||||
role: 'MEMBER',
|
||||
vi.spyOn(authService, "verifySession").mockResolvedValue(mockSessionData);
|
||||
vi.spyOn(prismaService.workspaceMember, "findFirst").mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
} as never);
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
// Should join the workspace room they have access to
|
||||
expect(mockClient.join).toHaveBeenCalledWith('workspace:workspace-456');
|
||||
expect(mockClient.join).toHaveBeenCalledWith("workspace:workspace-456");
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleConnection', () => {
|
||||
describe("handleConnection", () => {
|
||||
beforeEach(() => {
|
||||
const mockSessionData = {
|
||||
user: { id: 'user-123', email: 'test@example.com' },
|
||||
session: { id: 'session-123' },
|
||||
user: { id: "user-123", email: "test@example.com" },
|
||||
session: { id: "session-123" },
|
||||
};
|
||||
|
||||
vi.spyOn(authService, 'verifySession').mockResolvedValue(mockSessionData);
|
||||
vi.spyOn(prismaService.workspaceMember, 'findFirst').mockResolvedValue({
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-456',
|
||||
role: 'MEMBER',
|
||||
vi.spyOn(authService, "verifySession").mockResolvedValue(mockSessionData);
|
||||
vi.spyOn(prismaService.workspaceMember, "findFirst").mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
} as never);
|
||||
|
||||
mockClient.data = {
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-456',
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
};
|
||||
});
|
||||
|
||||
it('should join client to workspace room on connection', async () => {
|
||||
it("should join client to workspace room on connection", async () => {
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockClient.join).toHaveBeenCalledWith('workspace:workspace-456');
|
||||
expect(mockClient.join).toHaveBeenCalledWith("workspace:workspace-456");
|
||||
});
|
||||
|
||||
it('should reject connection without authentication', async () => {
|
||||
it("should reject connection without authentication", async () => {
|
||||
const unauthClient = {
|
||||
...mockClient,
|
||||
data: {},
|
||||
@@ -261,23 +261,23 @@ describe('WebSocketGateway', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDisconnect', () => {
|
||||
it('should leave workspace room on disconnect', () => {
|
||||
describe("handleDisconnect", () => {
|
||||
it("should leave workspace room on disconnect", () => {
|
||||
// Populate data as if client was authenticated
|
||||
const authenticatedClient = {
|
||||
...mockClient,
|
||||
data: {
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-456',
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
},
|
||||
} as unknown as AuthenticatedSocket;
|
||||
|
||||
gateway.handleDisconnect(authenticatedClient);
|
||||
|
||||
expect(authenticatedClient.leave).toHaveBeenCalledWith('workspace:workspace-456');
|
||||
expect(authenticatedClient.leave).toHaveBeenCalledWith("workspace:workspace-456");
|
||||
});
|
||||
|
||||
it('should not throw error when disconnecting unauthenticated client', () => {
|
||||
it("should not throw error when disconnecting unauthenticated client", () => {
|
||||
const unauthenticatedClient = {
|
||||
...mockClient,
|
||||
data: {},
|
||||
@@ -287,279 +287,279 @@ describe('WebSocketGateway', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitTaskCreated', () => {
|
||||
it('should emit task:created event to workspace room', () => {
|
||||
describe("emitTaskCreated", () => {
|
||||
it("should emit task:created event to workspace room", () => {
|
||||
const task = {
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
workspaceId: 'workspace-456',
|
||||
id: "task-1",
|
||||
title: "Test Task",
|
||||
workspaceId: "workspace-456",
|
||||
};
|
||||
|
||||
gateway.emitTaskCreated('workspace-456', task);
|
||||
gateway.emitTaskCreated("workspace-456", task);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('task:created', task);
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("task:created", task);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitTaskUpdated', () => {
|
||||
it('should emit task:updated event to workspace room', () => {
|
||||
describe("emitTaskUpdated", () => {
|
||||
it("should emit task:updated event to workspace room", () => {
|
||||
const task = {
|
||||
id: 'task-1',
|
||||
title: 'Updated Task',
|
||||
workspaceId: 'workspace-456',
|
||||
id: "task-1",
|
||||
title: "Updated Task",
|
||||
workspaceId: "workspace-456",
|
||||
};
|
||||
|
||||
gateway.emitTaskUpdated('workspace-456', task);
|
||||
gateway.emitTaskUpdated("workspace-456", task);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('task:updated', task);
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("task:updated", task);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitTaskDeleted', () => {
|
||||
it('should emit task:deleted event to workspace room', () => {
|
||||
const taskId = 'task-1';
|
||||
describe("emitTaskDeleted", () => {
|
||||
it("should emit task:deleted event to workspace room", () => {
|
||||
const taskId = "task-1";
|
||||
|
||||
gateway.emitTaskDeleted('workspace-456', taskId);
|
||||
gateway.emitTaskDeleted("workspace-456", taskId);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('task:deleted', { id: taskId });
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("task:deleted", { id: taskId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitEventCreated', () => {
|
||||
it('should emit event:created event to workspace room', () => {
|
||||
describe("emitEventCreated", () => {
|
||||
it("should emit event:created event to workspace room", () => {
|
||||
const event = {
|
||||
id: 'event-1',
|
||||
title: 'Test Event',
|
||||
workspaceId: 'workspace-456',
|
||||
id: "event-1",
|
||||
title: "Test Event",
|
||||
workspaceId: "workspace-456",
|
||||
};
|
||||
|
||||
gateway.emitEventCreated('workspace-456', event);
|
||||
gateway.emitEventCreated("workspace-456", event);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('event:created', event);
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("event:created", event);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitEventUpdated', () => {
|
||||
it('should emit event:updated event to workspace room', () => {
|
||||
describe("emitEventUpdated", () => {
|
||||
it("should emit event:updated event to workspace room", () => {
|
||||
const event = {
|
||||
id: 'event-1',
|
||||
title: 'Updated Event',
|
||||
workspaceId: 'workspace-456',
|
||||
id: "event-1",
|
||||
title: "Updated Event",
|
||||
workspaceId: "workspace-456",
|
||||
};
|
||||
|
||||
gateway.emitEventUpdated('workspace-456', event);
|
||||
gateway.emitEventUpdated("workspace-456", event);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('event:updated', event);
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("event:updated", event);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitEventDeleted', () => {
|
||||
it('should emit event:deleted event to workspace room', () => {
|
||||
const eventId = 'event-1';
|
||||
describe("emitEventDeleted", () => {
|
||||
it("should emit event:deleted event to workspace room", () => {
|
||||
const eventId = "event-1";
|
||||
|
||||
gateway.emitEventDeleted('workspace-456', eventId);
|
||||
gateway.emitEventDeleted("workspace-456", eventId);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('event:deleted', { id: eventId });
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("event:deleted", { id: eventId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitProjectUpdated', () => {
|
||||
it('should emit project:updated event to workspace room', () => {
|
||||
describe("emitProjectUpdated", () => {
|
||||
it("should emit project:updated event to workspace room", () => {
|
||||
const project = {
|
||||
id: 'project-1',
|
||||
name: 'Updated Project',
|
||||
workspaceId: 'workspace-456',
|
||||
id: "project-1",
|
||||
name: "Updated Project",
|
||||
workspaceId: "workspace-456",
|
||||
};
|
||||
|
||||
gateway.emitProjectUpdated('workspace-456', project);
|
||||
gateway.emitProjectUpdated("workspace-456", project);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('project:updated', project);
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("project:updated", project);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Job Events', () => {
|
||||
describe('emitJobCreated', () => {
|
||||
it('should emit job:created event to workspace jobs room', () => {
|
||||
describe("Job Events", () => {
|
||||
describe("emitJobCreated", () => {
|
||||
it("should emit job:created event to workspace jobs room", () => {
|
||||
const job = {
|
||||
id: 'job-1',
|
||||
workspaceId: 'workspace-456',
|
||||
type: 'code-task',
|
||||
status: 'PENDING',
|
||||
id: "job-1",
|
||||
workspaceId: "workspace-456",
|
||||
type: "code-task",
|
||||
status: "PENDING",
|
||||
};
|
||||
|
||||
gateway.emitJobCreated('workspace-456', job);
|
||||
gateway.emitJobCreated("workspace-456", job);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456:jobs');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('job:created', job);
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456:jobs");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("job:created", job);
|
||||
});
|
||||
|
||||
it('should emit job:created event to specific job room', () => {
|
||||
it("should emit job:created event to specific job room", () => {
|
||||
const job = {
|
||||
id: 'job-1',
|
||||
workspaceId: 'workspace-456',
|
||||
type: 'code-task',
|
||||
status: 'PENDING',
|
||||
id: "job-1",
|
||||
workspaceId: "workspace-456",
|
||||
type: "code-task",
|
||||
status: "PENDING",
|
||||
};
|
||||
|
||||
gateway.emitJobCreated('workspace-456', job);
|
||||
gateway.emitJobCreated("workspace-456", job);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('job:job-1');
|
||||
expect(mockServer.to).toHaveBeenCalledWith("job:job-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitJobStatusChanged', () => {
|
||||
it('should emit job:status event to workspace jobs room', () => {
|
||||
describe("emitJobStatusChanged", () => {
|
||||
it("should emit job:status event to workspace jobs room", () => {
|
||||
const data = {
|
||||
id: 'job-1',
|
||||
workspaceId: 'workspace-456',
|
||||
status: 'RUNNING',
|
||||
previousStatus: 'PENDING',
|
||||
id: "job-1",
|
||||
workspaceId: "workspace-456",
|
||||
status: "RUNNING",
|
||||
previousStatus: "PENDING",
|
||||
};
|
||||
|
||||
gateway.emitJobStatusChanged('workspace-456', 'job-1', data);
|
||||
gateway.emitJobStatusChanged("workspace-456", "job-1", data);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456:jobs');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('job:status', data);
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456:jobs");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("job:status", data);
|
||||
});
|
||||
|
||||
it('should emit job:status event to specific job room', () => {
|
||||
it("should emit job:status event to specific job room", () => {
|
||||
const data = {
|
||||
id: 'job-1',
|
||||
workspaceId: 'workspace-456',
|
||||
status: 'RUNNING',
|
||||
previousStatus: 'PENDING',
|
||||
id: "job-1",
|
||||
workspaceId: "workspace-456",
|
||||
status: "RUNNING",
|
||||
previousStatus: "PENDING",
|
||||
};
|
||||
|
||||
gateway.emitJobStatusChanged('workspace-456', 'job-1', data);
|
||||
gateway.emitJobStatusChanged("workspace-456", "job-1", data);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('job:job-1');
|
||||
expect(mockServer.to).toHaveBeenCalledWith("job:job-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitJobProgress', () => {
|
||||
it('should emit job:progress event to workspace jobs room', () => {
|
||||
describe("emitJobProgress", () => {
|
||||
it("should emit job:progress event to workspace jobs room", () => {
|
||||
const data = {
|
||||
id: 'job-1',
|
||||
workspaceId: 'workspace-456',
|
||||
id: "job-1",
|
||||
workspaceId: "workspace-456",
|
||||
progressPercent: 45,
|
||||
message: 'Processing step 2 of 4',
|
||||
message: "Processing step 2 of 4",
|
||||
};
|
||||
|
||||
gateway.emitJobProgress('workspace-456', 'job-1', data);
|
||||
gateway.emitJobProgress("workspace-456", "job-1", data);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456:jobs');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('job:progress', data);
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456:jobs");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("job:progress", data);
|
||||
});
|
||||
|
||||
it('should emit job:progress event to specific job room', () => {
|
||||
it("should emit job:progress event to specific job room", () => {
|
||||
const data = {
|
||||
id: 'job-1',
|
||||
workspaceId: 'workspace-456',
|
||||
id: "job-1",
|
||||
workspaceId: "workspace-456",
|
||||
progressPercent: 45,
|
||||
message: 'Processing step 2 of 4',
|
||||
message: "Processing step 2 of 4",
|
||||
};
|
||||
|
||||
gateway.emitJobProgress('workspace-456', 'job-1', data);
|
||||
gateway.emitJobProgress("workspace-456", "job-1", data);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('job:job-1');
|
||||
expect(mockServer.to).toHaveBeenCalledWith("job:job-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitStepStarted', () => {
|
||||
it('should emit step:started event to workspace jobs room', () => {
|
||||
describe("emitStepStarted", () => {
|
||||
it("should emit step:started event to workspace jobs room", () => {
|
||||
const data = {
|
||||
id: 'step-1',
|
||||
jobId: 'job-1',
|
||||
workspaceId: 'workspace-456',
|
||||
name: 'Build',
|
||||
id: "step-1",
|
||||
jobId: "job-1",
|
||||
workspaceId: "workspace-456",
|
||||
name: "Build",
|
||||
};
|
||||
|
||||
gateway.emitStepStarted('workspace-456', 'job-1', data);
|
||||
gateway.emitStepStarted("workspace-456", "job-1", data);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456:jobs');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('step:started', data);
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456:jobs");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("step:started", data);
|
||||
});
|
||||
|
||||
it('should emit step:started event to specific job room', () => {
|
||||
it("should emit step:started event to specific job room", () => {
|
||||
const data = {
|
||||
id: 'step-1',
|
||||
jobId: 'job-1',
|
||||
workspaceId: 'workspace-456',
|
||||
name: 'Build',
|
||||
id: "step-1",
|
||||
jobId: "job-1",
|
||||
workspaceId: "workspace-456",
|
||||
name: "Build",
|
||||
};
|
||||
|
||||
gateway.emitStepStarted('workspace-456', 'job-1', data);
|
||||
gateway.emitStepStarted("workspace-456", "job-1", data);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('job:job-1');
|
||||
expect(mockServer.to).toHaveBeenCalledWith("job:job-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitStepCompleted', () => {
|
||||
it('should emit step:completed event to workspace jobs room', () => {
|
||||
describe("emitStepCompleted", () => {
|
||||
it("should emit step:completed event to workspace jobs room", () => {
|
||||
const data = {
|
||||
id: 'step-1',
|
||||
jobId: 'job-1',
|
||||
workspaceId: 'workspace-456',
|
||||
name: 'Build',
|
||||
id: "step-1",
|
||||
jobId: "job-1",
|
||||
workspaceId: "workspace-456",
|
||||
name: "Build",
|
||||
success: true,
|
||||
};
|
||||
|
||||
gateway.emitStepCompleted('workspace-456', 'job-1', data);
|
||||
gateway.emitStepCompleted("workspace-456", "job-1", data);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456:jobs');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('step:completed', data);
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456:jobs");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("step:completed", data);
|
||||
});
|
||||
|
||||
it('should emit step:completed event to specific job room', () => {
|
||||
it("should emit step:completed event to specific job room", () => {
|
||||
const data = {
|
||||
id: 'step-1',
|
||||
jobId: 'job-1',
|
||||
workspaceId: 'workspace-456',
|
||||
name: 'Build',
|
||||
id: "step-1",
|
||||
jobId: "job-1",
|
||||
workspaceId: "workspace-456",
|
||||
name: "Build",
|
||||
success: true,
|
||||
};
|
||||
|
||||
gateway.emitStepCompleted('workspace-456', 'job-1', data);
|
||||
gateway.emitStepCompleted("workspace-456", "job-1", data);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('job:job-1');
|
||||
expect(mockServer.to).toHaveBeenCalledWith("job:job-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitStepOutput', () => {
|
||||
it('should emit step:output event to workspace jobs room', () => {
|
||||
describe("emitStepOutput", () => {
|
||||
it("should emit step:output event to workspace jobs room", () => {
|
||||
const data = {
|
||||
id: 'step-1',
|
||||
jobId: 'job-1',
|
||||
workspaceId: 'workspace-456',
|
||||
output: 'Build completed successfully',
|
||||
id: "step-1",
|
||||
jobId: "job-1",
|
||||
workspaceId: "workspace-456",
|
||||
output: "Build completed successfully",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
gateway.emitStepOutput('workspace-456', 'job-1', data);
|
||||
gateway.emitStepOutput("workspace-456", "job-1", data);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456:jobs');
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('step:output', data);
|
||||
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456:jobs");
|
||||
expect(mockServer.emit).toHaveBeenCalledWith("step:output", data);
|
||||
});
|
||||
|
||||
it('should emit step:output event to specific job room', () => {
|
||||
it("should emit step:output event to specific job room", () => {
|
||||
const data = {
|
||||
id: 'step-1',
|
||||
jobId: 'job-1',
|
||||
workspaceId: 'workspace-456',
|
||||
output: 'Build completed successfully',
|
||||
id: "step-1",
|
||||
jobId: "job-1",
|
||||
workspaceId: "workspace-456",
|
||||
output: "Build completed successfully",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
gateway.emitStepOutput('workspace-456', 'job-1', data);
|
||||
gateway.emitStepOutput("workspace-456", "job-1", data);
|
||||
|
||||
expect(mockServer.to).toHaveBeenCalledWith('job:job-1');
|
||||
expect(mockServer.to).toHaveBeenCalledWith("job:job-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user