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:
825
apps/api/src/federation/event.service.spec.ts
Normal file
825
apps/api/src/federation/event.service.spec.ts
Normal file
@@ -0,0 +1,825 @@
|
||||
/**
|
||||
* EventService Tests
|
||||
*
|
||||
* Tests for federated event message handling.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { EventService } from "./event.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { FederationService } from "./federation.service";
|
||||
import { SignatureService } from "./signature.service";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { of, throwError } from "rxjs";
|
||||
import {
|
||||
FederationConnectionStatus,
|
||||
FederationMessageType,
|
||||
FederationMessageStatus,
|
||||
} from "@prisma/client";
|
||||
import type { EventMessage, EventAck } from "./types/message.types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
describe("EventService", () => {
|
||||
let service: EventService;
|
||||
let prisma: PrismaService;
|
||||
let federationService: FederationService;
|
||||
let signatureService: SignatureService;
|
||||
let httpService: HttpService;
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockConnectionId = "connection-123";
|
||||
const mockInstanceId = "instance-123";
|
||||
const mockRemoteInstanceId = "remote-instance-123";
|
||||
const mockMessageId = "message-123";
|
||||
const mockEventType = "task.created";
|
||||
|
||||
const mockPrisma = {
|
||||
federationConnection: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
federationEventSubscription: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
federationMessage: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockFederationService = {
|
||||
getInstanceIdentity: vi.fn(),
|
||||
};
|
||||
|
||||
const mockSignatureService = {
|
||||
signMessage: vi.fn(),
|
||||
verifyMessage: vi.fn(),
|
||||
validateTimestamp: vi.fn(),
|
||||
};
|
||||
|
||||
const mockHttpService = {
|
||||
post: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EventService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrisma,
|
||||
},
|
||||
{
|
||||
provide: FederationService,
|
||||
useValue: mockFederationService,
|
||||
},
|
||||
{
|
||||
provide: SignatureService,
|
||||
useValue: mockSignatureService,
|
||||
},
|
||||
{
|
||||
provide: HttpService,
|
||||
useValue: mockHttpService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EventService>(EventService);
|
||||
prisma = module.get(PrismaService);
|
||||
federationService = module.get(FederationService);
|
||||
signatureService = module.get(SignatureService);
|
||||
httpService = module.get(HttpService);
|
||||
});
|
||||
|
||||
describe("subscribeToEventType", () => {
|
||||
it("should create a new subscription", async () => {
|
||||
const mockConnection = {
|
||||
id: mockConnectionId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: mockRemoteInstanceId,
|
||||
remoteUrl: "https://remote.example.com",
|
||||
remotePublicKey: "public-key",
|
||||
remoteCapabilities: {},
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
connectedAt: new Date(),
|
||||
disconnectedAt: null,
|
||||
};
|
||||
|
||||
const mockSubscription = {
|
||||
id: "subscription-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
eventType: mockEventType,
|
||||
metadata: {},
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
|
||||
prisma.federationEventSubscription.create.mockResolvedValue(mockSubscription);
|
||||
|
||||
const result = await service.subscribeToEventType(
|
||||
mockWorkspaceId,
|
||||
mockConnectionId,
|
||||
mockEventType
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: mockSubscription.id,
|
||||
workspaceId: mockSubscription.workspaceId,
|
||||
connectionId: mockSubscription.connectionId,
|
||||
eventType: mockSubscription.eventType,
|
||||
metadata: mockSubscription.metadata,
|
||||
isActive: mockSubscription.isActive,
|
||||
createdAt: mockSubscription.createdAt,
|
||||
updatedAt: mockSubscription.updatedAt,
|
||||
});
|
||||
|
||||
expect(prisma.federationConnection.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockConnectionId, workspaceId: mockWorkspaceId },
|
||||
});
|
||||
|
||||
expect(prisma.federationEventSubscription.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
eventType: mockEventType,
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error if connection not found", async () => {
|
||||
prisma.federationConnection.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.subscribeToEventType(mockWorkspaceId, mockConnectionId, mockEventType)
|
||||
).rejects.toThrow("Connection not found");
|
||||
});
|
||||
|
||||
it("should throw error if connection not active", async () => {
|
||||
const mockConnection = {
|
||||
id: mockConnectionId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: mockRemoteInstanceId,
|
||||
remoteUrl: "https://remote.example.com",
|
||||
remotePublicKey: "public-key",
|
||||
remoteCapabilities: {},
|
||||
status: FederationConnectionStatus.SUSPENDED,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
connectedAt: new Date(),
|
||||
disconnectedAt: null,
|
||||
};
|
||||
|
||||
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
|
||||
|
||||
await expect(
|
||||
service.subscribeToEventType(mockWorkspaceId, mockConnectionId, mockEventType)
|
||||
).rejects.toThrow("Connection is not active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsubscribeFromEventType", () => {
|
||||
it("should delete an existing subscription", async () => {
|
||||
const mockSubscription = {
|
||||
id: "subscription-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
eventType: mockEventType,
|
||||
metadata: {},
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
prisma.federationEventSubscription.findFirst.mockResolvedValue(mockSubscription);
|
||||
prisma.federationEventSubscription.delete.mockResolvedValue(mockSubscription);
|
||||
|
||||
await service.unsubscribeFromEventType(mockWorkspaceId, mockConnectionId, mockEventType);
|
||||
|
||||
expect(prisma.federationEventSubscription.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
eventType: mockEventType,
|
||||
},
|
||||
});
|
||||
|
||||
expect(prisma.federationEventSubscription.delete).toHaveBeenCalledWith({
|
||||
where: { id: mockSubscription.id },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error if subscription not found", async () => {
|
||||
prisma.federationEventSubscription.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.unsubscribeFromEventType(mockWorkspaceId, mockConnectionId, mockEventType)
|
||||
).rejects.toThrow("Subscription not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishEvent", () => {
|
||||
it("should publish event to subscribed connections", async () => {
|
||||
const mockConnection = {
|
||||
id: mockConnectionId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: mockRemoteInstanceId,
|
||||
remoteUrl: "https://remote.example.com",
|
||||
remotePublicKey: "public-key",
|
||||
remoteCapabilities: {},
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
connectedAt: new Date(),
|
||||
disconnectedAt: null,
|
||||
};
|
||||
|
||||
const mockSubscription = {
|
||||
id: "subscription-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
eventType: mockEventType,
|
||||
metadata: {},
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
connection: mockConnection,
|
||||
};
|
||||
|
||||
const mockIdentity = {
|
||||
id: "id-123",
|
||||
instanceId: mockInstanceId,
|
||||
name: "Local Instance",
|
||||
url: "https://local.example.com",
|
||||
publicKey: "public-key",
|
||||
privateKey: "private-key",
|
||||
capabilities: {},
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockMessage = {
|
||||
id: "message-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
messageId: expect.any(String),
|
||||
correlationId: null,
|
||||
query: null,
|
||||
commandType: null,
|
||||
eventType: mockEventType,
|
||||
payload: { data: "test" },
|
||||
response: null,
|
||||
status: FederationMessageStatus.PENDING,
|
||||
error: null,
|
||||
signature: "signature-123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deliveredAt: null,
|
||||
};
|
||||
|
||||
prisma.federationEventSubscription.findMany.mockResolvedValue([mockSubscription]);
|
||||
federationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
|
||||
signatureService.signMessage.mockResolvedValue("signature-123");
|
||||
prisma.federationMessage.create.mockResolvedValue(mockMessage);
|
||||
httpService.post.mockReturnValue(
|
||||
of({ data: {}, status: 200, statusText: "OK", headers: {}, config: {} as never })
|
||||
);
|
||||
|
||||
const result = await service.publishEvent(mockWorkspaceId, mockEventType, { data: "test" });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: mockMessage.id,
|
||||
workspaceId: mockMessage.workspaceId,
|
||||
connectionId: mockMessage.connectionId,
|
||||
messageType: mockMessage.messageType,
|
||||
eventType: mockMessage.eventType,
|
||||
status: mockMessage.status,
|
||||
});
|
||||
|
||||
expect(prisma.federationEventSubscription.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
eventType: mockEventType,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
connection: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(httpService.post).toHaveBeenCalledWith(
|
||||
`${mockConnection.remoteUrl}/api/v1/federation/incoming/event`,
|
||||
expect.objectContaining({
|
||||
instanceId: mockInstanceId,
|
||||
eventType: mockEventType,
|
||||
payload: { data: "test" },
|
||||
signature: "signature-123",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty array if no active subscriptions", async () => {
|
||||
prisma.federationEventSubscription.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.publishEvent(mockWorkspaceId, mockEventType, { data: "test" });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle failed delivery", async () => {
|
||||
const mockConnection = {
|
||||
id: mockConnectionId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: mockRemoteInstanceId,
|
||||
remoteUrl: "https://remote.example.com",
|
||||
remotePublicKey: "public-key",
|
||||
remoteCapabilities: {},
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
connectedAt: new Date(),
|
||||
disconnectedAt: null,
|
||||
};
|
||||
|
||||
const mockSubscription = {
|
||||
id: "subscription-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
eventType: mockEventType,
|
||||
metadata: {},
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
connection: mockConnection,
|
||||
};
|
||||
|
||||
const mockIdentity = {
|
||||
id: "id-123",
|
||||
instanceId: mockInstanceId,
|
||||
name: "Local Instance",
|
||||
url: "https://local.example.com",
|
||||
publicKey: "public-key",
|
||||
privateKey: "private-key",
|
||||
capabilities: {},
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockMessage = {
|
||||
id: "message-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
messageId: expect.any(String),
|
||||
correlationId: null,
|
||||
query: null,
|
||||
commandType: null,
|
||||
eventType: mockEventType,
|
||||
payload: { data: "test" },
|
||||
response: null,
|
||||
status: FederationMessageStatus.PENDING,
|
||||
error: null,
|
||||
signature: "signature-123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deliveredAt: null,
|
||||
};
|
||||
|
||||
prisma.federationEventSubscription.findMany.mockResolvedValue([mockSubscription]);
|
||||
federationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
|
||||
signatureService.signMessage.mockResolvedValue("signature-123");
|
||||
prisma.federationMessage.create.mockResolvedValue(mockMessage);
|
||||
httpService.post.mockReturnValue(throwError(() => new Error("Network error")));
|
||||
prisma.federationMessage.update.mockResolvedValue({
|
||||
...mockMessage,
|
||||
status: FederationMessageStatus.FAILED,
|
||||
error: "Network error",
|
||||
});
|
||||
|
||||
const result = await service.publishEvent(mockWorkspaceId, mockEventType, { data: "test" });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(prisma.federationMessage.update).toHaveBeenCalledWith({
|
||||
where: { id: mockMessage.id },
|
||||
data: {
|
||||
status: FederationMessageStatus.FAILED,
|
||||
error: "Network error",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleIncomingEvent", () => {
|
||||
it("should handle incoming event and return acknowledgment", async () => {
|
||||
const eventMessage: EventMessage = {
|
||||
messageId: mockMessageId,
|
||||
instanceId: mockRemoteInstanceId,
|
||||
eventType: mockEventType,
|
||||
payload: { data: "test" },
|
||||
timestamp: Date.now(),
|
||||
signature: "signature-123",
|
||||
};
|
||||
|
||||
const mockConnection = {
|
||||
id: mockConnectionId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: mockRemoteInstanceId,
|
||||
remoteUrl: "https://remote.example.com",
|
||||
remotePublicKey: "public-key",
|
||||
remoteCapabilities: {},
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
connectedAt: new Date(),
|
||||
disconnectedAt: null,
|
||||
};
|
||||
|
||||
const mockIdentity = {
|
||||
id: "id-123",
|
||||
instanceId: mockInstanceId,
|
||||
name: "Local Instance",
|
||||
url: "https://local.example.com",
|
||||
publicKey: "public-key",
|
||||
privateKey: "private-key",
|
||||
capabilities: {},
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
signatureService.validateTimestamp.mockReturnValue(true);
|
||||
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
|
||||
signatureService.verifyMessage.mockResolvedValue({ valid: true, error: null });
|
||||
federationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
|
||||
signatureService.signMessage.mockResolvedValue("ack-signature-123");
|
||||
|
||||
const result = await service.handleIncomingEvent(eventMessage);
|
||||
|
||||
expect(result).toEqual({
|
||||
messageId: expect.any(String),
|
||||
correlationId: mockMessageId,
|
||||
instanceId: mockInstanceId,
|
||||
received: true,
|
||||
timestamp: expect.any(Number),
|
||||
signature: "ack-signature-123",
|
||||
});
|
||||
|
||||
expect(signatureService.validateTimestamp).toHaveBeenCalledWith(eventMessage.timestamp);
|
||||
expect(prisma.federationConnection.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
remoteInstanceId: mockRemoteInstanceId,
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
expect(signatureService.verifyMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
messageId: eventMessage.messageId,
|
||||
instanceId: eventMessage.instanceId,
|
||||
eventType: eventMessage.eventType,
|
||||
payload: eventMessage.payload,
|
||||
timestamp: eventMessage.timestamp,
|
||||
},
|
||||
eventMessage.signature,
|
||||
eventMessage.instanceId
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for invalid timestamp", async () => {
|
||||
const eventMessage: EventMessage = {
|
||||
messageId: mockMessageId,
|
||||
instanceId: mockRemoteInstanceId,
|
||||
eventType: mockEventType,
|
||||
payload: { data: "test" },
|
||||
timestamp: Date.now(),
|
||||
signature: "signature-123",
|
||||
};
|
||||
|
||||
signatureService.validateTimestamp.mockReturnValue(false);
|
||||
|
||||
await expect(service.handleIncomingEvent(eventMessage)).rejects.toThrow(
|
||||
"Event timestamp is outside acceptable range"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error if no active connection found", async () => {
|
||||
const eventMessage: EventMessage = {
|
||||
messageId: mockMessageId,
|
||||
instanceId: mockRemoteInstanceId,
|
||||
eventType: mockEventType,
|
||||
payload: { data: "test" },
|
||||
timestamp: Date.now(),
|
||||
signature: "signature-123",
|
||||
};
|
||||
|
||||
signatureService.validateTimestamp.mockReturnValue(true);
|
||||
prisma.federationConnection.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.handleIncomingEvent(eventMessage)).rejects.toThrow(
|
||||
"No connection found for remote instance"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for invalid signature", async () => {
|
||||
const eventMessage: EventMessage = {
|
||||
messageId: mockMessageId,
|
||||
instanceId: mockRemoteInstanceId,
|
||||
eventType: mockEventType,
|
||||
payload: { data: "test" },
|
||||
timestamp: Date.now(),
|
||||
signature: "signature-123",
|
||||
};
|
||||
|
||||
const mockConnection = {
|
||||
id: mockConnectionId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: mockRemoteInstanceId,
|
||||
remoteUrl: "https://remote.example.com",
|
||||
remotePublicKey: "public-key",
|
||||
remoteCapabilities: {},
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
connectedAt: new Date(),
|
||||
disconnectedAt: null,
|
||||
};
|
||||
|
||||
signatureService.validateTimestamp.mockReturnValue(true);
|
||||
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
|
||||
signatureService.verifyMessage.mockResolvedValue({
|
||||
valid: false,
|
||||
error: "Invalid signature",
|
||||
});
|
||||
|
||||
await expect(service.handleIncomingEvent(eventMessage)).rejects.toThrow("Invalid signature");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processEventAck", () => {
|
||||
it("should process event acknowledgment", async () => {
|
||||
const ack: EventAck = {
|
||||
messageId: "ack-123",
|
||||
correlationId: mockMessageId,
|
||||
instanceId: mockRemoteInstanceId,
|
||||
received: true,
|
||||
timestamp: Date.now(),
|
||||
signature: "ack-signature-123",
|
||||
};
|
||||
|
||||
const mockMessage = {
|
||||
id: "message-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
messageId: mockMessageId,
|
||||
correlationId: null,
|
||||
query: null,
|
||||
commandType: null,
|
||||
eventType: mockEventType,
|
||||
payload: { data: "test" },
|
||||
response: null,
|
||||
status: FederationMessageStatus.PENDING,
|
||||
error: null,
|
||||
signature: "signature-123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deliveredAt: null,
|
||||
};
|
||||
|
||||
signatureService.validateTimestamp.mockReturnValue(true);
|
||||
prisma.federationMessage.findFirst.mockResolvedValue(mockMessage);
|
||||
signatureService.verifyMessage.mockResolvedValue({ valid: true, error: null });
|
||||
prisma.federationMessage.update.mockResolvedValue({
|
||||
...mockMessage,
|
||||
status: FederationMessageStatus.DELIVERED,
|
||||
deliveredAt: new Date(),
|
||||
});
|
||||
|
||||
await service.processEventAck(ack);
|
||||
|
||||
expect(signatureService.validateTimestamp).toHaveBeenCalledWith(ack.timestamp);
|
||||
expect(prisma.federationMessage.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
messageId: ack.correlationId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
},
|
||||
});
|
||||
expect(prisma.federationMessage.update).toHaveBeenCalledWith({
|
||||
where: { id: mockMessage.id },
|
||||
data: {
|
||||
status: FederationMessageStatus.DELIVERED,
|
||||
deliveredAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error if original event not found", async () => {
|
||||
const ack: EventAck = {
|
||||
messageId: "ack-123",
|
||||
correlationId: mockMessageId,
|
||||
instanceId: mockRemoteInstanceId,
|
||||
received: true,
|
||||
timestamp: Date.now(),
|
||||
signature: "ack-signature-123",
|
||||
};
|
||||
|
||||
signatureService.validateTimestamp.mockReturnValue(true);
|
||||
prisma.federationMessage.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.processEventAck(ack)).rejects.toThrow(
|
||||
"Original event message not found"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventSubscriptions", () => {
|
||||
it("should return all subscriptions for workspace", async () => {
|
||||
const mockSubscriptions = [
|
||||
{
|
||||
id: "sub-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
eventType: "task.created",
|
||||
metadata: {},
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "sub-2",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
eventType: "task.updated",
|
||||
metadata: {},
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
prisma.federationEventSubscription.findMany.mockResolvedValue(mockSubscriptions);
|
||||
|
||||
const result = await service.getEventSubscriptions(mockWorkspaceId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(prisma.federationEventSubscription.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by connectionId when provided", async () => {
|
||||
const mockSubscriptions = [
|
||||
{
|
||||
id: "sub-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
eventType: "task.created",
|
||||
metadata: {},
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
prisma.federationEventSubscription.findMany.mockResolvedValue(mockSubscriptions);
|
||||
|
||||
const result = await service.getEventSubscriptions(mockWorkspaceId, mockConnectionId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(prisma.federationEventSubscription.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventMessages", () => {
|
||||
it("should return all event messages for workspace", async () => {
|
||||
const mockMessages = [
|
||||
{
|
||||
id: "msg-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
messageId: "msg-id-1",
|
||||
correlationId: null,
|
||||
query: null,
|
||||
commandType: null,
|
||||
eventType: "task.created",
|
||||
payload: { data: "test1" },
|
||||
response: null,
|
||||
status: FederationMessageStatus.DELIVERED,
|
||||
error: null,
|
||||
signature: "sig-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deliveredAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "msg-2",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
messageId: "msg-id-2",
|
||||
correlationId: null,
|
||||
query: null,
|
||||
commandType: null,
|
||||
eventType: "task.updated",
|
||||
payload: { data: "test2" },
|
||||
response: null,
|
||||
status: FederationMessageStatus.PENDING,
|
||||
error: null,
|
||||
signature: "sig-2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deliveredAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
prisma.federationMessage.findMany.mockResolvedValue(mockMessages);
|
||||
|
||||
const result = await service.getEventMessages(mockWorkspaceId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(prisma.federationMessage.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by status when provided", async () => {
|
||||
const mockMessages = [
|
||||
{
|
||||
id: "msg-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: mockConnectionId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
messageId: "msg-id-1",
|
||||
correlationId: null,
|
||||
query: null,
|
||||
commandType: null,
|
||||
eventType: "task.created",
|
||||
payload: { data: "test1" },
|
||||
response: null,
|
||||
status: FederationMessageStatus.PENDING,
|
||||
error: null,
|
||||
signature: "sig-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deliveredAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
prisma.federationMessage.findMany.mockResolvedValue(mockMessages);
|
||||
|
||||
const result = await service.getEventMessages(
|
||||
mockWorkspaceId,
|
||||
FederationMessageStatus.PENDING
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(prisma.federationMessage.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
messageType: FederationMessageType.EVENT,
|
||||
status: FederationMessageStatus.PENDING,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user