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>
394 lines
11 KiB
TypeScript
394 lines
11 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|