Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions apps/backend/src/__tests__/deliveryReceipts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from 'events';

// ── Mock DB ────────────────────────────────────────────────────────────────

const mockFindFirst = vi.fn();
const mockUpdate = vi.fn();
const mockSelect = vi.fn();
const mockQuery = vi.fn();

vi.mock('../db/index.js', () => ({
db: {
query: {
conversationMembers: { findFirst: mockFindFirst, findMany: mockQuery },
messages: { findFirst: mockFindFirst },
},
select: mockSelect,
update: mockUpdate,
},
}));

vi.mock('../db/schema.js', () => ({
conversationMembers: {},
messages: {},
messageEnvelopes: {},
userDevices: {},
}));

vi.mock('../lib/redis.js', () => ({ redis: null }));

vi.mock('drizzle-orm', () => ({
and: vi.fn((...args: unknown[]) => args),
eq: vi.fn((col: unknown, val: unknown) => ({ col, val })),
isNull: vi.fn((col: unknown) => ({ col, op: 'isNull' })),
inArray: vi.fn((col: unknown, vals: unknown) => ({ col, vals })),
}));

vi.mock('../services/resumeStream.js', () => ({
publishEphemeral: vi.fn().mockResolvedValue(undefined),
}));

// ── Tests ──────────────────────────────────────────────────────────────────

describe('Delivery Receipts', () => {
let mockIo: any;
let mockSocket: any;

beforeEach(() => {
vi.clearAllMocks();

mockIo = {
to: vi.fn().mockReturnThis(),
emit: vi.fn(),
};

mockSocket = {
auth: {
userId: 'user-123',
deviceId: 'device-456',
},
emit: vi.fn(),
rooms: new Set(['conversation-789']),
};

// Mock successful membership check
mockFindFirst.mockResolvedValue({ conversationId: 'conversation-789', userId: 'user-123' });

// Mock message find
mockFindFirst.mockResolvedValueOnce({ id: 'message-abc', senderId: 'sender-999', conversationId: 'conversation-789' });

// Mock update success
mockUpdate.mockResolvedValue({ rowCount: 1 });

// Mock active devices query
mockSelect.mockResolvedValue([
{ id: 'device-456' },
{ id: 'device-457' },
]);

// Mock envelopes query
mockSelect.mockResolvedValueOnce([
{ recipientDeviceId: 'device-456', deliveredAt: new Date() },
{ recipientDeviceId: 'device-457', deliveredAt: null },
]);
});

it('should handle per-device delivery receipt', async () => {
// Import dynamically after mocks are set up
const { handleDeviceDeliveryReceipt } = await import('../services/deliveryAggregation.js');

await handleDeviceDeliveryReceipt(
mockIo,
null, // redis
'message-abc',
'device-456',
'user-123',
'conversation-789'
);

// Verify database update was called
expect(mockUpdate).toHaveBeenCalled();

// Verify room-based emission
expect(mockIo.to).toHaveBeenCalledWith('room:conversation:conversation-789');
expect(mockIo.emit).toHaveBeenCalledWith('device_delivery_receipt', expect.objectContaining({
conversationId: 'conversation-789',
messageId: 'message-abc',
recipientUserId: 'user-123',
recipientDeviceId: 'device-456',
}));
});

it('should validate isMessageFullyDeliveredToUser correctly', async () => {
const { isMessageFullyDeliveredToUser } = await import('../services/deliveryAggregation.js');

// First device delivered, second not delivered
mockSelect
.mockReset()
.mockResolvedValueOnce([{ id: 'device-456' }, { id: 'device-457' }]) // active devices
.mockResolvedValueOnce([
{ recipientDeviceId: 'device-456', deliveredAt: new Date() },
{ recipientDeviceId: 'device-457', deliveredAt: null },
]); // envelopes

const notFullyDelivered = await isMessageFullyDeliveredToUser('message-abc', 'user-123');
expect(notFullyDelivered).toBe(false);

// Both devices delivered
mockSelect
.mockReset()
.mockResolvedValueOnce([{ id: 'device-456' }, { id: 'device-457' }]) // active devices
.mockResolvedValueOnce([
{ recipientDeviceId: 'device-456', deliveredAt: new Date() },
{ recipientDeviceId: 'device-457', deliveredAt: new Date() },
]); // envelopes

const fullyDelivered = await isMessageFullyDeliveredToUser('message-abc', 'user-123');
expect(fullyDelivered).toBe(true);
});

it('should be idempotent for duplicate delivery receipts', async () => {
const { handleDeviceDeliveryReceipt } = await import('../services/deliveryAggregation.js');

// First call
await handleDeviceDeliveryReceipt(
mockIo,
null,
'message-abc',
'device-456',
'user-123',
'conversation-789'
);

const firstCallCount = mockUpdate.mock.calls.length;

// Second call with same parameters
await handleDeviceDeliveryReceipt(
mockIo,
null,
'message-abc',
'device-456',
'user-123',
'conversation-789'
);

// Should have same number of update calls (idempotent)
expect(mockUpdate.mock.calls.length).toBe(firstCallCount);
});
});
176 changes: 176 additions & 0 deletions apps/backend/src/__tests__/roomManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// ── Mock DB ────────────────────────────────────────────────────────────────

const mockFindFirst = vi.fn();
const mockFindMany = vi.fn();

vi.mock('../db/index.js', () => ({
db: {
query: {
conversationMembers: { findFirst: mockFindFirst, findMany: mockFindMany },
},
},
}));

vi.mock('../db/schema.js', () => ({
conversationMembers: {},
}));

vi.mock('drizzle-orm', () => ({
and: vi.fn((...args: unknown[]) => args),
eq: vi.fn((col: unknown, val: unknown) => ({ col, val })),
}));

// ── Tests ──────────────────────────────────────────────────────────────────

describe('Room Manager', () => {
let mockSocket: any;
let mockIo: any;

beforeEach(() => {
vi.clearAllMocks();

mockSocket = {
auth: {
userId: 'user-123',
deviceId: 'device-456',
},
join: vi.fn().mockResolvedValue(undefined),
rooms: new Set(),
};

mockIo = {
fetchSockets: vi.fn().mockResolvedValue([]),
to: vi.fn().mockReturnThis(),
emit: vi.fn(),
};

// Mock successful membership check
mockFindFirst.mockResolvedValue({ conversationId: 'conversation-789', userId: 'user-123' });
mockFindMany.mockResolvedValue([
{ conversationId: 'conversation-789' },
{ conversationId: 'conversation-790' },
]);
});

it('should generate correct room names', async () => {
const { conversationRoom, userRoom } = await import('../services/roomManager.js');

expect(conversationRoom('conversation-789')).toBe('room:conversation:conversation-789');
expect(userRoom('user-123')).toBe('room:user:user-123');
});

it('should join conversation room with membership validation', async () => {
const { joinConversationRoom } = await import('../services/roomManager.js');

await joinConversationRoom(mockSocket, 'conversation-789');

// Verify membership validation
expect(mockFindFirst).toHaveBeenCalled();

// Verify room join
expect(mockSocket.join).toHaveBeenCalledWith('room:conversation:conversation-789');
});

it('should reject conversation room join without membership', async () => {
const { joinConversationRoom } = await import('../services/roomManager.js');

// Mock no membership
mockFindFirst.mockResolvedValue(null);

await expect(joinConversationRoom(mockSocket, 'conversation-789'))
.rejects.toThrow('Not a member of this conversation');

expect(mockSocket.join).not.toHaveBeenCalled();
});

it('should join user room', async () => {
const { joinUserRoom } = await import('../services/roomManager.js');

joinUserRoom(mockSocket);

expect(mockSocket.join).toHaveBeenCalledWith('room:user:user-123');
});

it('should emit typing indicators to conversation room', async () => {
const { emitTypingIndicator } = await import('../services/roomManager.js');

emitTypingIndicator(mockIo, 'conversation-789', 'user-123', 'device-456');

expect(mockIo.to).toHaveBeenCalledWith('room:conversation:conversation-789');
expect(mockIo.emit).toHaveBeenCalledWith('typing_start', {
conversationId: 'conversation-789',
userId: 'user-123',
deviceId: 'device-456',
});
});

it('should emit presence updates to conversation room', async () => {
const { emitPresenceUpdate } = await import('../services/roomManager.js');

emitPresenceUpdate(mockIo, 'conversation-789', 'user-123', true, Date.now());

expect(mockIo.to).toHaveBeenCalledWith('room:conversation:conversation-789');
expect(mockIo.emit).toHaveBeenCalledWith('presence_update', {
userId: 'user-123',
online: true,
status: 'online',
lastSeen: expect.any(Number),
});
});

it('should emit cross-device events to user room', async () => {
const { emitCrossDeviceEvent } = await import('../services/roomManager.js');

const eventData = { type: 'settings_updated', settings: { theme: 'dark' } };
emitCrossDeviceEvent(mockIo, 'user-123', 'settings_updated', eventData);

expect(mockIo.to).toHaveBeenCalledWith('room:user:user-123');
expect(mockIo.emit).toHaveBeenCalledWith('cross_device_event', {
type: 'settings_updated',
userId: 'user-123',
data: eventData,
timestamp: expect.any(String),
});
});

it('should validate conversation membership', async () => {
const { validateConversationMembership } = await import('../services/roomManager.js');

const hasMembership = await validateConversationMembership('user-123', 'conversation-789');
expect(hasMembership).toBe(true);

// Test without membership
mockFindFirst.mockResolvedValue(null);
const noMembership = await validateConversationMembership('user-123', 'conversation-789');
expect(noMembership).toBe(false);
});

it('should rebuild rooms after restart', async () => {
const { rebuildRoomsAfterRestart } = await import('../services/roomManager.js');

// Mock sockets
const mockSockets = [
{
auth: { userId: 'user-123', deviceId: 'device-456' },
join: vi.fn().mockResolvedValue(undefined),
},
{
auth: { userId: 'user-124', deviceId: 'device-457' },
join: vi.fn().mockResolvedValue(undefined),
},
];

mockIo.fetchSockets.mockResolvedValue(mockSockets);

await rebuildRoomsAfterRestart(mockIo);

// Verify each socket joined user room
expect(mockSockets[0].join).toHaveBeenCalledWith('room:user:user-123');
expect(mockSockets[1].join).toHaveBeenCalledWith('room:user:user-124');

// Verify conversation rooms were joined (mockFindMany returns conversations)
expect(mockFindMany).toHaveBeenCalledTimes(2);
});
});
Loading