feat(#90): implement EVENT subscriptions for federation
Implement event pub/sub messaging for federation to enable real-time event streaming between federated instances. Features: - Event subscription management (subscribe/unsubscribe) - Event publishing to subscribed instances - Event acknowledgment protocol - Server-side event filtering based on subscriptions - Full signature verification and connection validation Implementation: - FederationEventSubscription model for storing subscriptions - EventService with complete event lifecycle management - EventController with authenticated and public endpoints - EventMessage, EventAck, and SubscriptionDetails types - Comprehensive DTOs for all event operations API Endpoints: - POST /api/v1/federation/events/subscribe - POST /api/v1/federation/events/unsubscribe - POST /api/v1/federation/events/publish - GET /api/v1/federation/events/subscriptions - GET /api/v1/federation/events/messages - POST /api/v1/federation/incoming/event (public) - POST /api/v1/federation/incoming/event/ack (public) Testing: - 18 unit tests for EventService (89.09% coverage) - 11 unit tests for EventController (83.87% coverage) - All 29 tests passing - Follows TDD red-green-refactor cycle Technical Notes: - Reuses existing FederationMessage model with eventType field - Follows patterns from QueryService and CommandService - Uses existing signature and connection infrastructure - Supports hierarchical event type naming (e.g., "task.created") Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
393
apps/api/src/federation/event.controller.spec.ts
Normal file
393
apps/api/src/federation/event.controller.spec.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* EventController Tests
|
||||
*
|
||||
* Tests for event subscription and publishing endpoints.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { EventController } from "./event.controller";
|
||||
import { EventService } from "./event.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { FederationMessageType, FederationMessageStatus } from "@prisma/client";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { EventMessage, EventAck } from "./types/message.types";
|
||||
|
||||
describe("EventController", () => {
|
||||
let controller: EventController;
|
||||
let eventService: EventService;
|
||||
|
||||
const mockEventService = {
|
||||
subscribeToEventType: vi.fn(),
|
||||
unsubscribeFromEventType: vi.fn(),
|
||||
publishEvent: vi.fn(),
|
||||
getEventSubscriptions: vi.fn(),
|
||||
getEventMessages: vi.fn(),
|
||||
getEventMessage: vi.fn(),
|
||||
handleIncomingEvent: vi.fn(),
|
||||
processEventAck: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockUserId = "user-123";
|
||||
const mockConnectionId = "connection-123";
|
||||
const mockEventType = "task.created";
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [EventController],
|
||||
providers: [
|
||||
{
|
||||
provide: EventService,
|
||||
useValue: mockEventService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<EventController>(EventController);
|
||||
eventService = module.get<EventService>(EventService);
|
||||
});
|
||||
|
||||
describe("subscribeToEvent", () => {
|
||||
it("should subscribe to an event type", async () => {
|
||||
const req = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const dto = {
|
||||
connectionId: mockConnectionId,
|
||||
eventType: mockEventType,
|
||||
metadata: { key: "value" },
|
||||
};
|
||||
|
||||
const mockSubscription = {
|
||||
id: "sub-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
eventType: mockEventType,
|
||||
metadata: { key: "value" },
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockEventService.subscribeToEventType.mockResolvedValue(mockSubscription);
|
||||
|
||||
const result = await controller.subscribeToEvent(req, dto);
|
||||
|
||||
expect(result).toEqual(mockSubscription);
|
||||
expect(mockEventService.subscribeToEventType).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
mockConnectionId,
|
||||
mockEventType,
|
||||
{ key: "value" }
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error if workspace not found", async () => {
|
||||
const req = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const dto = {
|
||||
connectionId: mockConnectionId,
|
||||
eventType: mockEventType,
|
||||
};
|
||||
|
||||
await expect(controller.subscribeToEvent(req, dto)).rejects.toThrow(
|
||||
"Workspace ID not found in request"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsubscribeFromEvent", () => {
|
||||
it("should unsubscribe from an event type", async () => {
|
||||
const req = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const dto = {
|
||||
connectionId: mockConnectionId,
|
||||
eventType: mockEventType,
|
||||
};
|
||||
|
||||
mockEventService.unsubscribeFromEventType.mockResolvedValue(undefined);
|
||||
|
||||
await controller.unsubscribeFromEvent(req, dto);
|
||||
|
||||
expect(mockEventService.unsubscribeFromEventType).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
mockConnectionId,
|
||||
mockEventType
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishEvent", () => {
|
||||
it("should publish an event", async () => {
|
||||
const req = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const dto = {
|
||||
eventType: mockEventType,
|
||||
payload: { data: "test" },
|
||||
};
|
||||
|
||||
const mockMessages = [
|
||||
{
|
||||
id: "msg-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
messageId: "msg-id-123",
|
||||
eventType: mockEventType,
|
||||
payload: { data: "test" },
|
||||
status: FederationMessageStatus.DELIVERED,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
mockEventService.publishEvent.mockResolvedValue(mockMessages);
|
||||
|
||||
const result = await controller.publishEvent(req, dto);
|
||||
|
||||
expect(result).toEqual(mockMessages);
|
||||
expect(mockEventService.publishEvent).toHaveBeenCalledWith(mockWorkspaceId, mockEventType, {
|
||||
data: "test",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSubscriptions", () => {
|
||||
it("should return all subscriptions for workspace", async () => {
|
||||
const req = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const mockSubscriptions = [
|
||||
{
|
||||
id: "sub-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
eventType: "task.created",
|
||||
metadata: {},
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
mockEventService.getEventSubscriptions.mockResolvedValue(mockSubscriptions);
|
||||
|
||||
const result = await controller.getSubscriptions(req);
|
||||
|
||||
expect(result).toEqual(mockSubscriptions);
|
||||
expect(mockEventService.getEventSubscriptions).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter by connectionId when provided", async () => {
|
||||
const req = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const mockSubscriptions = [
|
||||
{
|
||||
id: "sub-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
eventType: "task.created",
|
||||
metadata: {},
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
mockEventService.getEventSubscriptions.mockResolvedValue(mockSubscriptions);
|
||||
|
||||
const result = await controller.getSubscriptions(req, mockConnectionId);
|
||||
|
||||
expect(result).toEqual(mockSubscriptions);
|
||||
expect(mockEventService.getEventSubscriptions).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
mockConnectionId
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventMessages", () => {
|
||||
it("should return all event messages for workspace", async () => {
|
||||
const req = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const mockMessages = [
|
||||
{
|
||||
id: "msg-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
messageId: "msg-id-1",
|
||||
eventType: "task.created",
|
||||
payload: { data: "test1" },
|
||||
status: FederationMessageStatus.DELIVERED,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
mockEventService.getEventMessages.mockResolvedValue(mockMessages);
|
||||
|
||||
const result = await controller.getEventMessages(req);
|
||||
|
||||
expect(result).toEqual(mockMessages);
|
||||
expect(mockEventService.getEventMessages).toHaveBeenCalledWith(mockWorkspaceId, undefined);
|
||||
});
|
||||
|
||||
it("should filter by status when provided", async () => {
|
||||
const req = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const mockMessages = [
|
||||
{
|
||||
id: "msg-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
messageId: "msg-id-1",
|
||||
eventType: "task.created",
|
||||
payload: { data: "test1" },
|
||||
status: FederationMessageStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
mockEventService.getEventMessages.mockResolvedValue(mockMessages);
|
||||
|
||||
const result = await controller.getEventMessages(req, FederationMessageStatus.PENDING);
|
||||
|
||||
expect(result).toEqual(mockMessages);
|
||||
expect(mockEventService.getEventMessages).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
FederationMessageStatus.PENDING
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventMessage", () => {
|
||||
it("should return a single event message", async () => {
|
||||
const req = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const messageId = "msg-123";
|
||||
|
||||
const mockMessage = {
|
||||
id: messageId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
messageId: "msg-id-123",
|
||||
eventType: "task.created",
|
||||
payload: { data: "test" },
|
||||
status: FederationMessageStatus.DELIVERED,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockEventService.getEventMessage.mockResolvedValue(mockMessage);
|
||||
|
||||
const result = await controller.getEventMessage(req, messageId);
|
||||
|
||||
expect(result).toEqual(mockMessage);
|
||||
expect(mockEventService.getEventMessage).toHaveBeenCalledWith(mockWorkspaceId, messageId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleIncomingEvent", () => {
|
||||
it("should handle incoming event and return acknowledgment", async () => {
|
||||
const eventMessage: EventMessage = {
|
||||
messageId: "msg-123",
|
||||
instanceId: "remote-instance-123",
|
||||
eventType: "task.created",
|
||||
payload: { data: "test" },
|
||||
timestamp: Date.now(),
|
||||
signature: "signature-123",
|
||||
};
|
||||
|
||||
const mockAck: EventAck = {
|
||||
messageId: "ack-123",
|
||||
correlationId: eventMessage.messageId,
|
||||
instanceId: "local-instance-123",
|
||||
received: true,
|
||||
timestamp: Date.now(),
|
||||
signature: "ack-signature-123",
|
||||
};
|
||||
|
||||
mockEventService.handleIncomingEvent.mockResolvedValue(mockAck);
|
||||
|
||||
const result = await controller.handleIncomingEvent(eventMessage);
|
||||
|
||||
expect(result).toEqual(mockAck);
|
||||
expect(mockEventService.handleIncomingEvent).toHaveBeenCalledWith(eventMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleIncomingEventAck", () => {
|
||||
it("should process event acknowledgment", async () => {
|
||||
const ack: EventAck = {
|
||||
messageId: "ack-123",
|
||||
correlationId: "msg-123",
|
||||
instanceId: "remote-instance-123",
|
||||
received: true,
|
||||
timestamp: Date.now(),
|
||||
signature: "ack-signature-123",
|
||||
};
|
||||
|
||||
mockEventService.processEventAck.mockResolvedValue(undefined);
|
||||
|
||||
const result = await controller.handleIncomingEventAck(ack);
|
||||
|
||||
expect(result).toEqual({ status: "acknowledged" });
|
||||
expect(mockEventService.processEventAck).toHaveBeenCalledWith(ack);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user