From 220c23e6f8058a2b77b566f2ad838c05cd1c6ca3 Mon Sep 17 00:00:00 2001 From: dave Date: Mon, 29 Jun 2026 11:29:11 +0100 Subject: [PATCH 1/3] feat(backend): enforce sibling-device envelope coverage on send and edit (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user sends or edits a message from device A, the server now verifies that every active sibling device (B, C, …) owned by that user is covered by an envelope in the payload. If any sibling device is absent the server emits device_set_mismatch and aborts before persisting anything. - Add fetchSiblingDeviceIds() helper querying userDevices filtered by userId, ne(id, senderDeviceId), and isNull(revokedAt) - Apply the sibling check in both send_message and edit_message handlers, after the idempotency gate so duplicate replays are never re-validated - Add selfSync.test.ts with 10 unit tests covering send and edit paths - Fix messageEdit.test.ts drizzle-orm mock to export ne and isNull --- .../backend/src/__tests__/messageEdit.test.ts | 2 + apps/backend/src/__tests__/selfSync.test.ts | 384 ++++++++++++++++++ apps/backend/src/socket/messaging.ts | 55 ++- 3 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/__tests__/selfSync.test.ts diff --git a/apps/backend/src/__tests__/messageEdit.test.ts b/apps/backend/src/__tests__/messageEdit.test.ts index 9c2251d..2bf05fe 100644 --- a/apps/backend/src/__tests__/messageEdit.test.ts +++ b/apps/backend/src/__tests__/messageEdit.test.ts @@ -45,6 +45,8 @@ vi.mock('../lib/conversationCache.js', () => ({ vi.mock('drizzle-orm', () => ({ and: vi.fn((...args: unknown[]) => args), eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), + ne: vi.fn((col: unknown, val: unknown) => ({ col, val, op: 'ne' })), + isNull: vi.fn((col: unknown) => ({ col, op: 'isNull' })), lt: vi.fn(), desc: vi.fn(), sql: vi.fn(), diff --git a/apps/backend/src/__tests__/selfSync.test.ts b/apps/backend/src/__tests__/selfSync.test.ts new file mode 100644 index 0000000..fac9b7d --- /dev/null +++ b/apps/backend/src/__tests__/selfSync.test.ts @@ -0,0 +1,384 @@ +/** + * Multi-device self-sync — issue #188 + * + * When a user sends (or edits) a message, the server must verify that every + * active sibling device the sender owns is covered by an envelope in the + * payload. If any sibling is absent the server emits `device_set_mismatch` + * and aborts, so no sibling device is ever silently left out. + * + * Acceptance criteria exercised here: + * 1. Sibling devices receive their own envelopes (fan-out path still works) + * 2. Server rejects a payload missing sibling envelopes (device_set_mismatch) + * 3. A freshly linked device causes subsequent messages to fail until its + * envelope is included (verified by changing the sibling list between calls) + * 4. Revoked sibling devices are NOT required in envelopes + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// ── mocks ───────────────────────────────────────────────────────────────────── + +const mockMemberFindFirst = vi.fn(); +const mockMessageFindFirst = vi.fn(); +const mockUserDevicesFindMany = vi.fn(); +const mockMemberFindMany = vi.fn(); + +const mockReturning = vi.fn(); +const mockValues = vi.fn(() => ({ + returning: mockReturning, + then: (resolve: (v: unknown) => void) => resolve(undefined), +})); +const mockInsert = vi.fn(() => ({ values: mockValues })); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + conversationMembers: { + findFirst: mockMemberFindFirst, + findMany: mockMemberFindMany, + }, + messages: { findFirst: mockMessageFindFirst }, + userDevices: { findMany: mockUserDevicesFindMany }, + }, + insert: mockInsert, + update: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock('../db/schema.js', () => ({ + conversations: {}, + conversationMembers: {}, + messages: {}, + messageEnvelopes: {}, + userDevices: {}, +})); + +vi.mock('../lib/conversationCache.js', () => ({ + invalidateConversationCaches: vi.fn().mockResolvedValue(undefined), +})); + +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 })), + ne: vi.fn((col: unknown, val: unknown) => ({ col, val, op: 'ne' })), + isNull: vi.fn((col: unknown) => ({ col, op: 'isNull' })), + lt: vi.fn(), + desc: vi.fn(), + sql: vi.fn(), + inArray: vi.fn((col: unknown, vals: unknown) => ({ col, vals })), +})); + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function makeSocket(userId: string, deviceId = 'device-sender') { + const emitter = new EventEmitter(); + const emitted: { event: string; data: unknown }[] = []; + return Object.assign(emitter, { + auth: { userId, deviceId }, + emit: vi.fn((event: string, data: unknown) => { + emitted.push({ event, data }); + }), + join: vi.fn(), + emitted, + }); +} + +function makeIo() { + const roomEmitted: { event: string; data: unknown }[] = []; + const emitFn = vi.fn((event: string, data: unknown) => { + roomEmitted.push({ event, data }); + }); + return { + to: vi.fn(() => ({ emit: emitFn, volatile: { emit: emitFn } })), + roomEmitted, + }; +} + +async function getHandlers(socket: EventEmitter, io: unknown) { + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + return { + sendMessage: socket.listeners('send_message')[0] as (p: unknown) => Promise, + editMessage: socket.listeners('edit_message')[0] as (p: unknown) => Promise, + }; +} + +// ── shared constants ────────────────────────────────────────────────────────── + +const USER_ID = 'user-alice'; +const SENDER_DEVICE = 'device-sender'; +const SIBLING_B = 'device-sibling-b'; +const SIBLING_C = 'device-sibling-c'; +const CONV_ID = 'conv-1'; + +const MEMBERSHIP = { id: 'm1', userId: USER_ID, conversationId: CONV_ID }; +const BASE_MESSAGE = { + id: 'orig-msg', + conversationId: CONV_ID, + senderId: USER_ID, + senderDeviceId: SENDER_DEVICE, + contentType: 'text/plain', + editsMessageId: null, + ciphertext: 'abc', + sequenceNumber: 1, +}; + +beforeEach(() => { + // mockReset() clears both call history AND any unconsumed mockResolvedValueOnce queue + // entries. Use it for all query mocks to prevent stale queue values from bleeding + // between tests. (vi.clearAllMocks() only clears call history, not the queue.) + mockMemberFindFirst.mockReset().mockResolvedValue(MEMBERSHIP); + mockMessageFindFirst.mockReset().mockResolvedValue(undefined); + mockMemberFindMany.mockReset().mockResolvedValue([]); + mockUserDevicesFindMany.mockReset().mockResolvedValue([]); + mockReturning.mockReset().mockResolvedValue([{ ...BASE_MESSAGE, id: 'new-msg', sequenceNumber: 2 }]); + + // Only clear call history for structural vi.fn(impl) mocks — mockReset would + // wipe their implementations and break the insert().values().returning() chain. + mockInsert.mockClear(); + mockValues.mockClear(); +}); + +// ── send_message ────────────────────────────────────────────────────────────── + +describe('send_message — sibling device enforcement (#188)', () => { + it('accepts a message when the sender has no sibling devices', async () => { + mockUserDevicesFindMany.mockResolvedValue([]); + + const socket = makeSocket(USER_ID, SENDER_DEVICE); + const io = makeIo(); + const { sendMessage } = await getHandlers(socket, io); + + await sendMessage({ conversationId: CONV_ID, messageId: 'msg-1', ciphertext: 'hello' }); + + expect(mockInsert).toHaveBeenCalled(); + expect(socket.emitted.some((e) => e.event === 'error')).toBe(false); + expect(io.roomEmitted.some((e) => e.event === 'new_message')).toBe(true); + }); + + it('accepts a message that includes envelopes for all active siblings', async () => { + mockUserDevicesFindMany + // fetchSiblingDeviceIds call → returns sibling B + .mockResolvedValueOnce([{ id: SIBLING_B }]) + // envelope fan-out validation call → returns device info for both recipients + .mockResolvedValueOnce([ + { id: SIBLING_B, userId: USER_ID }, + { id: 'device-bob', userId: 'user-bob' }, + ]); + + const socket = makeSocket(USER_ID, SENDER_DEVICE); + const io = makeIo(); + const { sendMessage } = await getHandlers(socket, io); + + await sendMessage({ + conversationId: CONV_ID, + messageId: 'msg-2', + ciphertext: 'group-cipher', + envelopes: [ + { recipientDeviceId: SIBLING_B, ciphertext: 'for-sibling-b' }, + { recipientDeviceId: 'device-bob', ciphertext: 'for-bob' }, + ], + }); + + expect(mockInsert).toHaveBeenCalled(); + expect(socket.emitted.some((e) => e.event === 'error')).toBe(false); + expect(io.roomEmitted.some((e) => e.event === 'new_message')).toBe(true); + }); + + it('rejects with device_set_mismatch when a sibling device is omitted', async () => { + mockUserDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }, { id: SIBLING_C }]); + + const socket = makeSocket(USER_ID, SENDER_DEVICE); + const io = makeIo(); + const { sendMessage } = await getHandlers(socket, io); + + // Only include sibling-B; sibling-C is absent. + await sendMessage({ + conversationId: CONV_ID, + messageId: 'msg-3', + ciphertext: 'partial', + envelopes: [{ recipientDeviceId: SIBLING_B, ciphertext: 'for-b' }], + }); + + const errors = socket.emitted.filter((e) => e.event === 'error'); + expect(errors).toHaveLength(1); + expect((errors[0]!.data as { event: string }).event).toBe('device_set_mismatch'); + expect((errors[0]!.data as { missingDeviceIds: string[] }).missingDeviceIds).toContain( + SIBLING_C, + ); + expect(mockInsert).not.toHaveBeenCalled(); + expect(io.roomEmitted.some((e) => e.event === 'new_message')).toBe(false); + }); + + it('rejects with device_set_mismatch when envelopes are absent but siblings exist', async () => { + mockUserDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }]); + + const socket = makeSocket(USER_ID, SENDER_DEVICE); + const io = makeIo(); + const { sendMessage } = await getHandlers(socket, io); + + // No envelopes field at all — only ciphertext provided. + await sendMessage({ conversationId: CONV_ID, messageId: 'msg-4', ciphertext: 'plain' }); + + const errors = socket.emitted.filter((e) => e.event === 'error'); + expect(errors).toHaveLength(1); + expect((errors[0]!.data as { event: string }).event).toBe('device_set_mismatch'); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('does not require envelopes for revoked sibling devices', async () => { + // fetchSiblingDeviceIds only returns non-revoked → empty because the + // revokedAt filter excludes the revoked device at the DB level. + mockUserDevicesFindMany.mockResolvedValueOnce([]); // no active siblings + + const socket = makeSocket(USER_ID, SENDER_DEVICE); + const io = makeIo(); + const { sendMessage } = await getHandlers(socket, io); + + await sendMessage({ conversationId: CONV_ID, messageId: 'msg-5', ciphertext: 'ok' }); + + expect(socket.emitted.some((e) => e.event === 'error')).toBe(false); + expect(mockInsert).toHaveBeenCalled(); + }); + + it('still passes through the idempotency ack without a device set check', async () => { + // Idempotency fires BEFORE the sibling check — a duplicate messageId returns + // ack immediately, no re-validation of envelopes needed. + mockMessageFindFirst.mockResolvedValue({ sequenceNumber: 7 }); + + // Even with a sibling that would normally require an envelope… + mockUserDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }]); + + const socket = makeSocket(USER_ID, SENDER_DEVICE); + const io = makeIo(); + const { sendMessage } = await getHandlers(socket, io); + + await sendMessage({ conversationId: CONV_ID, messageId: 'dup-msg', ciphertext: 'x' }); + + expect(socket.emit).toHaveBeenCalledWith('message_ack', { + messageId: 'dup-msg', + sequenceNumber: 7, + }); + // Sibling check never runs — insert should not be called. + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('freshly linked sibling causes subsequent sends to fail without its envelope', async () => { + // First send: no siblings → succeeds. + mockUserDevicesFindMany.mockResolvedValueOnce([]); + const socket = makeSocket(USER_ID, SENDER_DEVICE); + const io = makeIo(); + const { sendMessage } = await getHandlers(socket, io); + + await sendMessage({ conversationId: CONV_ID, messageId: 'msg-first', ciphertext: 'ok' }); + expect(socket.emitted.some((e) => e.event === 'error')).toBe(false); + + vi.clearAllMocks(); + mockMemberFindFirst.mockResolvedValue(MEMBERSHIP); + mockMessageFindFirst.mockResolvedValue(undefined); + mockMemberFindMany.mockResolvedValue([]); + mockReturning.mockResolvedValue([{ ...BASE_MESSAGE, id: 'msg-second', sequenceNumber: 3 }]); + + // Second send: sibling-B just linked → now appears in DB query → must be included. + mockUserDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }]); + + await sendMessage({ conversationId: CONV_ID, messageId: 'msg-second', ciphertext: 'ok' }); + + const errors = socket.emitted.filter((e) => e.event === 'error'); + expect(errors[0]!).toBeDefined(); + expect((errors[0]!.data as { event: string }).event).toBe('device_set_mismatch'); + expect(mockInsert).not.toHaveBeenCalled(); + }); +}); + +// ── edit_message ────────────────────────────────────────────────────────────── + +describe('edit_message — sibling device enforcement (#188)', () => { + const ORIGINAL = { + id: 'orig-msg', + senderId: USER_ID, + conversationId: CONV_ID, + editsMessageId: null, + contentType: 'text/plain', + }; + + // Each edit test sets up its own mockMessageFindFirst chain so the + // outer beforeEach reset cannot interfere with the queued values. + + it('accepts an edit that includes envelopes for all active siblings', async () => { + mockMessageFindFirst + .mockResolvedValueOnce(ORIGINAL) // original message lookup + .mockResolvedValueOnce(undefined); // idempotency check + mockUserDevicesFindMany + .mockResolvedValueOnce([{ id: SIBLING_B }]) // fetchSiblingDeviceIds + .mockResolvedValueOnce([{ id: SIBLING_B, userId: USER_ID }]); // envelope fanout + + const socket = makeSocket(USER_ID, SENDER_DEVICE); + const io = makeIo(); + const { editMessage } = await getHandlers(socket, io); + + await editMessage({ + originalMessageId: 'orig-msg', + messageId: 'edit-1', + ciphertext: 'updated', + envelopes: [{ recipientDeviceId: SIBLING_B, ciphertext: 'for-sibling-b' }], + }); + + expect(mockInsert).toHaveBeenCalled(); + expect(socket.emitted.some((e) => e.event === 'error')).toBe(false); + const events = io.roomEmitted.map((e) => e.event); + expect(events).toContain('new_message'); + expect(events).toContain('message_edited'); + }); + + it('rejects an edit with device_set_mismatch when a sibling is missing', async () => { + mockMessageFindFirst + .mockResolvedValueOnce(ORIGINAL) + .mockResolvedValueOnce(undefined); + mockUserDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }, { id: SIBLING_C }]); + + const socket = makeSocket(USER_ID, SENDER_DEVICE); + const io = makeIo(); + const { editMessage } = await getHandlers(socket, io); + + await editMessage({ + originalMessageId: 'orig-msg', + messageId: 'edit-2', + ciphertext: 'updated', + envelopes: [{ recipientDeviceId: SIBLING_B, ciphertext: 'only-b' }], + }); + + const errors = socket.emitted.filter((e) => e.event === 'error'); + expect(errors).toHaveLength(1); + expect((errors[0]!.data as { event: string }).event).toBe('device_set_mismatch'); + expect((errors[0]!.data as { missingDeviceIds: string[] }).missingDeviceIds).toContain( + SIBLING_C, + ); + expect(mockInsert).not.toHaveBeenCalled(); + expect(io.roomEmitted.some((e) => e.event === 'message_edited')).toBe(false); + }); + + it('accepts an edit when the sender has no sibling devices', async () => { + mockMessageFindFirst + .mockResolvedValueOnce(ORIGINAL) + .mockResolvedValueOnce(undefined); + mockUserDevicesFindMany.mockResolvedValueOnce([]); // no siblings + + const socket = makeSocket(USER_ID, SENDER_DEVICE); + const io = makeIo(); + const { editMessage } = await getHandlers(socket, io); + + await editMessage({ + originalMessageId: 'orig-msg', + messageId: 'edit-3', + ciphertext: 'solo-edit', + }); + + expect(mockInsert).toHaveBeenCalled(); + expect(socket.emitted.some((e) => e.event === 'error')).toBe(false); + }); +}); diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 89351e4..e589304 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -1,5 +1,5 @@ import type { Server } from 'socket.io'; -import { and, eq, lt, desc, sql, inArray } from 'drizzle-orm'; +import { and, eq, lt, desc, sql, inArray, isNull, ne } from 'drizzle-orm'; import { db } from '../db/index.js'; import { conversations, @@ -16,6 +16,27 @@ import { publishEphemeral, readMissedEvents } from '../services/resumeStream.js' const PAGE_SIZE = 30; +/** + * Returns the UUIDs of all active (non-revoked) user_devices that belong to + * `userId` but are NOT the sending device (`senderDeviceId`). These are the + * "sibling" devices that must each receive their own envelope so they can + * decrypt the message locally. Issue #188. + */ +async function fetchSiblingDeviceIds( + userId: string, + senderDeviceId: string, +): Promise { + const siblings = await db.query.userDevices.findMany({ + where: and( + eq(userDevices.userId, userId), + ne(userDevices.id, senderDeviceId), + isNull(userDevices.revokedAt), + ), + columns: { id: true }, + }); + return siblings.map((d) => d.id); +} + export function registerMessagingHandlers(io: Server, socket: AuthSocket): void { const userId = socket.auth!.userId; @@ -92,6 +113,23 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void return; } + // Enforce full sibling-device coverage (#188). + // Every active device the sender owns (other than the sending device) + // must appear in the envelopes array so each can decrypt the message. + const siblingIds = await fetchSiblingDeviceIds(userId, deviceId); + if (siblingIds.length > 0) { + const providedIds = new Set(envelopes?.map((e) => e.recipientDeviceId) ?? []); + const missing = siblingIds.filter((id) => !providedIds.has(id)); + if (missing.length > 0) { + socket.emit('error', { + event: 'device_set_mismatch', + message: `Missing envelopes for ${missing.length} sibling device(s)`, + missingDeviceIds: missing, + }); + return; + } + } + const [message] = await db .insert(messages) .values({ @@ -209,6 +247,21 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void return; } + // Enforce full sibling-device coverage (#188). + const siblingIds = await fetchSiblingDeviceIds(userId, deviceId); + if (siblingIds.length > 0) { + const providedIds = new Set(envelopes?.map((e) => e.recipientDeviceId) ?? []); + const missing = siblingIds.filter((id) => !providedIds.has(id)); + if (missing.length > 0) { + socket.emit('error', { + event: 'device_set_mismatch', + message: `Missing envelopes for ${missing.length} sibling device(s)`, + missingDeviceIds: missing, + }); + return; + } + } + const [message] = await db .insert(messages) .values({ From 38005a2c31866416f9ca4b9efa8dd2185ff7e04b Mon Sep 17 00:00:00 2001 From: dave Date: Mon, 29 Jun 2026 12:03:52 +0100 Subject: [PATCH 2/3] style: apply prettier formatting to selfSync.test.ts and messaging.ts --- apps/backend/src/__tests__/selfSync.test.ts | 12 +++++------- apps/backend/src/socket/messaging.ts | 5 +---- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/__tests__/selfSync.test.ts b/apps/backend/src/__tests__/selfSync.test.ts index fac9b7d..4945354 100644 --- a/apps/backend/src/__tests__/selfSync.test.ts +++ b/apps/backend/src/__tests__/selfSync.test.ts @@ -135,7 +135,9 @@ beforeEach(() => { mockMessageFindFirst.mockReset().mockResolvedValue(undefined); mockMemberFindMany.mockReset().mockResolvedValue([]); mockUserDevicesFindMany.mockReset().mockResolvedValue([]); - mockReturning.mockReset().mockResolvedValue([{ ...BASE_MESSAGE, id: 'new-msg', sequenceNumber: 2 }]); + mockReturning + .mockReset() + .mockResolvedValue([{ ...BASE_MESSAGE, id: 'new-msg', sequenceNumber: 2 }]); // Only clear call history for structural vi.fn(impl) mocks — mockReset would // wipe their implementations and break the insert().values().returning() chain. @@ -336,9 +338,7 @@ describe('edit_message — sibling device enforcement (#188)', () => { }); it('rejects an edit with device_set_mismatch when a sibling is missing', async () => { - mockMessageFindFirst - .mockResolvedValueOnce(ORIGINAL) - .mockResolvedValueOnce(undefined); + mockMessageFindFirst.mockResolvedValueOnce(ORIGINAL).mockResolvedValueOnce(undefined); mockUserDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }, { id: SIBLING_C }]); const socket = makeSocket(USER_ID, SENDER_DEVICE); @@ -363,9 +363,7 @@ describe('edit_message — sibling device enforcement (#188)', () => { }); it('accepts an edit when the sender has no sibling devices', async () => { - mockMessageFindFirst - .mockResolvedValueOnce(ORIGINAL) - .mockResolvedValueOnce(undefined); + mockMessageFindFirst.mockResolvedValueOnce(ORIGINAL).mockResolvedValueOnce(undefined); mockUserDevicesFindMany.mockResolvedValueOnce([]); // no siblings const socket = makeSocket(USER_ID, SENDER_DEVICE); diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index e589304..d52f182 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -22,10 +22,7 @@ const PAGE_SIZE = 30; * "sibling" devices that must each receive their own envelope so they can * decrypt the message locally. Issue #188. */ -async function fetchSiblingDeviceIds( - userId: string, - senderDeviceId: string, -): Promise { +async function fetchSiblingDeviceIds(userId: string, senderDeviceId: string): Promise { const siblings = await db.query.userDevices.findMany({ where: and( eq(userDevices.userId, userId), From b6620fe7816312df73813a967438466087ce7af6 Mon Sep 17 00:00:00 2001 From: dave Date: Mon, 29 Jun 2026 23:17:35 +0100 Subject: [PATCH 3/3] fix(tests): replace deliveryPipeline factory mock with db.select stub in selfSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vi.mock factory for deliveryPipeline.js was not intercepting in CI. Switch to mocking db.select directly so deliverMessage runs its real logic: non-empty members query + empty activeDevices query → io.to().emit('new_message'). --- apps/backend/src/__tests__/selfSync.test.ts | 29 ++++++++++++--------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/__tests__/selfSync.test.ts b/apps/backend/src/__tests__/selfSync.test.ts index 973ed89..29d4f48 100644 --- a/apps/backend/src/__tests__/selfSync.test.ts +++ b/apps/backend/src/__tests__/selfSync.test.ts @@ -31,6 +31,13 @@ const mockValues = vi.fn(() => ({ })); const mockInsert = vi.fn(() => ({ values: mockValues })); +// db.select chain used by deliveryPipeline.ts inside deliverMessage. +// First call: members query → non-empty so deliverMessage doesn't early-return. +// Second call: activeDevices query → empty so deliverMessage emits new_message. +const mockSelectWhere = vi.fn(); +const mockSelectFrom = vi.fn(() => ({ where: mockSelectWhere })); +const mockSelect = vi.fn(() => ({ from: mockSelectFrom })); + vi.mock('../db/index.js', () => ({ db: { query: { @@ -44,6 +51,7 @@ vi.mock('../db/index.js', () => ({ insert: mockInsert, update: vi.fn(), delete: vi.fn(), + select: mockSelect, }, })); @@ -66,18 +74,6 @@ vi.mock('../services/pushNotification.js', () => ({ FILE_CONTENT_TYPES: new Set(), })); -vi.mock('../services/deliveryPipeline.js', () => ({ - deliverMessage: vi.fn( - async ( - io: { to: (r: string) => { emit: (e: string, d: unknown) => void } }, - message: unknown, - conversationId: string, - ) => { - io.to(conversationId).emit('new_message', message); - }, - ), -})); - vi.mock('../services/deviceDelivery.js', () => ({ publishToDevice: vi.fn().mockResolvedValue(undefined), })); @@ -164,6 +160,15 @@ beforeEach(() => { // wipe their implementations and break the insert().values().returning() chain. mockInsert.mockClear(); mockValues.mockClear(); + mockSelect.mockClear(); + mockSelectFrom.mockClear(); + // deliverMessage (deliveryPipeline.ts) calls db.select twice: + // 1st: members query → must be non-empty so it doesn't early-return + // 2nd: activeDevices query → empty triggers io.to().emit('new_message') + mockSelectWhere + .mockReset() + .mockResolvedValueOnce([{ userId: USER_ID }]) + .mockResolvedValue([]); }); // ── send_message ──────────────────────────────────────────────────────────────