feat(messaging): add read receipt WS broadcast and E2E tests

Add ConversationReadEvent domain event emitted from mark-read handler,
with message:read broadcast via MessagingGateway to conversation rooms.
Includes E2E Playwright test covering message exchange, read receipts,
pagination, and soft-delete flows.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 04:53:37 +07:00
parent a720825257
commit e2e748f0c7
6 changed files with 232 additions and 1 deletions

View File

@@ -9,6 +9,8 @@ describe('MarkConversationReadHandler', () => {
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
const conversation = {
id: 'conv-1',
status: 'ACTIVE' as const,
@@ -23,10 +25,12 @@ describe('MarkConversationReadHandler', () => {
findById: vi.fn().mockResolvedValue(conversation),
resetUnreadCount: vi.fn().mockResolvedValue(undefined),
};
mockEventBus = { publish: vi.fn() };
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
handler = new MarkConversationReadHandler(
mockConversationRepo as any,
mockEventBus as any,
mockLogger as any,
);
});
@@ -37,6 +41,13 @@ describe('MarkConversationReadHandler', () => {
await handler.execute(command);
expect(mockConversationRepo.resetUnreadCount).toHaveBeenCalledWith('conv-1', 'user-1');
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'conversation.read',
conversationId: 'conv-1',
userId: 'user-1',
}),
);
});
it('throws NotFoundException when conversation does not exist', async () => {

View File

@@ -1,6 +1,8 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, ForbiddenException, NotFoundException, LoggerService } from '@modules/shared';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
import { DomainException, ForbiddenException, NotFoundException, EventBusService, LoggerService } from '@modules/shared';
import { ConversationReadEvent } from '../../../domain/events/conversation-read.event';
import {
CONVERSATION_REPOSITORY,
type IConversationRepository,
@@ -12,6 +14,7 @@ export class MarkConversationReadHandler implements ICommandHandler<MarkConversa
constructor(
@Inject(CONVERSATION_REPOSITORY)
private readonly conversationRepo: IConversationRepository,
private readonly eventBus: EventBusService,
private readonly logger: LoggerService,
) {}
@@ -30,6 +33,11 @@ export class MarkConversationReadHandler implements ICommandHandler<MarkConversa
}
await this.conversationRepo.resetUnreadCount(conversationId, userId);
// Publish domain event so the gateway broadcasts read receipt
this.eventBus.publish(
new ConversationReadEvent(conversationId, conversationId, userId),
);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(

View File

@@ -0,0 +1,12 @@
import type { DomainEvent } from '@modules/shared';
export class ConversationReadEvent implements DomainEvent {
readonly eventName = 'conversation.read';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly conversationId: string,
public readonly userId: string,
) {}
}

View File

@@ -1,6 +1,7 @@
export type { ConversationEntity, ConversationParticipantEntity } from './entities/conversation.entity';
export type { MessageEntity } from './entities/message.entity';
export { MessageSentEvent } from './events/message-sent.event';
export { ConversationReadEvent } from './events/conversation-read.event';
export {
CONVERSATION_REPOSITORY,
type IConversationRepository,

View File

@@ -20,6 +20,7 @@ import { LoggerService } from '@modules/shared';
import { MarkConversationReadCommand } from '../../application/commands/mark-read/mark-read.command';
import { SendMessageCommand } from '../../application/commands/send-message/send-message.command';
import type { MessageSentEvent } from '../../domain/events/message-sent.event';
import type { ConversationReadEvent } from '../../domain/events/conversation-read.event';
import {
CONVERSATION_REPOSITORY,
type IConversationRepository,
@@ -226,6 +227,25 @@ export class MessagingGateway
}
}
@OnEvent('conversation.read', { async: true })
async handleConversationRead(event: ConversationReadEvent): Promise<void> {
try {
this.server.to(`conversation:${event.conversationId}`).emit('message:read', {
conversationId: event.conversationId,
userId: event.userId,
readAt: event.occurredAt.toISOString(),
});
} catch (error) {
this.logger.error(
`Failed to emit WS read receipt for conversation ${event.conversationId}: ${
error instanceof Error ? error.message : error
}`,
error instanceof Error ? error.stack : undefined,
'MessagingGateway',
);
}
}
/* ────────────────────────────────────────────
* Private helpers
* ──────────────────────────────────────────── */