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:
Jason Woltje
2026-02-03 13:45:00 -06:00
parent 9501aa3867
commit ca4f5ec011
10 changed files with 2395 additions and 5 deletions

View 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);
});
});
});