diff --git a/app/containers/MessageErrorActions.test.tsx b/app/containers/MessageErrorActions.test.tsx new file mode 100644 index 0000000000..70801c3a33 --- /dev/null +++ b/app/containers/MessageErrorActions.test.tsx @@ -0,0 +1,117 @@ +import { createRef } from 'react'; +import { act, render } from '@testing-library/react-native'; + +import MessageErrorActions, { type IMessageErrorActions } from './MessageErrorActions'; +import database from '../lib/database'; +import log from '../lib/methods/helpers/log'; +import { type TMessageModel } from '../definitions'; +import { FakeDatabase, type FakeModel } from '../lib/database/__tests__/mockedWatermelonDB'; + +jest.mock('../lib/database', () => ({ + __esModule: true, + default: { active: null } +})); + +jest.mock('../lib/methods/helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('../lib/methods/sendMessage', () => ({ + resendMessage: jest.fn(() => Promise.resolve()) +})); + +const mockShowActionSheet = jest.fn(); +jest.mock('./ActionSheet', () => ({ + useActionSheet: () => ({ showActionSheet: mockShowActionSheet }) +})); + +const renderComponent = (tmid?: string) => { + const ref = createRef(); + render(); + return ref; +}; + +const pressDelete = (ref: React.RefObject, message: FakeModel) => { + ref.current?.showMessageErrorActions(message as unknown as TMessageModel); + const { options } = mockShowActionSheet.mock.calls[mockShowActionSheet.mock.calls.length - 1][0]; + const deleteOption = options.find((o: { icon: string }) => o.icon === 'delete'); + return deleteOption.onPress() as Promise; +}; + +let db: FakeDatabase; + +beforeEach(() => { + jest.clearAllMocks(); + db = new FakeDatabase(); + (database as unknown as { active: FakeDatabase }).active = db; +}); + +describe('MessageErrorActions handleDelete', () => { + it('deletes a failed thread message while another writer touches the same record', async () => { + const threadMessage = db.add('thread_messages', 'msg-1'); + const message = db.add('messages', 'msg-1'); + db.add('messages', 'tmid-1', { tcount: 1, tlm: new Date() }); + db.add('threads', 'tmid-1'); + + const ref = renderComponent('tmid-1'); + + let concurrentWriter: Promise = Promise.resolve(); + await act(async () => { + const deleting = pressDelete(ref, threadMessage); + // A saga writing to the very same record while the delete is in flight + concurrentWriter = db.write(async () => { + await db.batch([threadMessage.prepareUpdate(m => (m.tcount = 0))]); + }); + await Promise.all([deleting, concurrentWriter]); + }); + + await expect(concurrentWriter).resolves.not.toThrow(); + expect(log).not.toHaveBeenCalled(); + + // no prepare and no find escaped the writer lock + expect(db.prepareLog.every(entry => entry.insideWriter)).toBe(true); + expect(db.findLog.every(entry => entry.insideWriter)).toBe(true); + + // the whole tree was committed in a single batch + expect(db.batches[0]).toEqual([ + 'thread_messages#msg-1:destroyPermanently', + 'messages#msg-1:destroyPermanently', + 'messages#tmid-1:update', + 'threads#tmid-1:destroyPermanently' + ]); + // the thread header lost its thread count and the thread record is gone + expect(db.collections.messages.get('tmid-1')?.tcount).toBeNull(); + expect(db.collections.messages.get('tmid-1')?.tlm).toBeNull(); + expect(message._preparedState).toBeNull(); + }); + + it('decrements the thread count when other messages remain', async () => { + const threadMessage = db.add('thread_messages', 'msg-1'); + db.add('messages', 'tmid-1', { tcount: 3 }); + db.add('threads', 'tmid-1'); + + const ref = renderComponent('tmid-1'); + await act(async () => { + await pressDelete(ref, threadMessage); + }); + + expect(log).not.toHaveBeenCalled(); + expect(db.collections.messages.get('tmid-1')?.tcount).toBe(2); + expect(db.batches[0]).toEqual(['thread_messages#msg-1:destroyPermanently', 'messages#tmid-1:update']); + }); + + it('destroys only the message on the non-thread branch', async () => { + const message = db.add('messages', 'msg-1'); + + const ref = renderComponent(); + await act(async () => { + await pressDelete(ref, message); + }); + + expect(log).not.toHaveBeenCalled(); + expect(db.findLog).toEqual([]); + expect(db.batches).toEqual([['messages#msg-1:destroyPermanently']]); + expect(db.prepareLog).toEqual([{ record: 'messages#msg-1', op: 'destroyPermanently', insideWriter: true }]); + }); +}); diff --git a/app/containers/MessageErrorActions.tsx b/app/containers/MessageErrorActions.tsx index 3164da0d1c..d3a1596353 100644 --- a/app/containers/MessageErrorActions.tsx +++ b/app/containers/MessageErrorActions.tsx @@ -23,54 +23,55 @@ const MessageErrorActions = forwardRef( const handleDelete = async (message: TMessageModel) => { try { const db = database.active; - const deleteBatch: Model[] = []; const msgCollection = db.get('messages'); const threadCollection = db.get('threads'); - // Delete the object (it can be Message or ThreadMessage instance) - deleteBatch.push(message.prepareDestroyPermanently()); + await db.write(async () => { + const deleteBatch: Model[] = []; - // If it's a thread, we find and delete the whole tree, if necessary - if (tmid) { - try { - const msg = await msgCollection.find(message.id); - deleteBatch.push(msg.prepareDestroyPermanently()); - } catch { - // Do nothing: message not found - } + // Delete the object (it can be Message or ThreadMessage instance) + deleteBatch.push(message.prepareDestroyPermanently()); - try { - // Find the thread header and update it - const msg = await msgCollection.find(tmid); - if (msg?.tcount && msg.tcount <= 1) { - deleteBatch.push( - msg.prepareUpdate(m => { - m.tcount = null; - m.tlm = null; - }) - ); + // If it's a thread, we find and delete the whole tree, if necessary + if (tmid) { + try { + const msg = await msgCollection.find(message.id); + deleteBatch.push(msg.prepareDestroyPermanently()); + } catch { + // Do nothing: message not found + } - try { - // If the whole thread was removed, delete the thread - const thread = await threadCollection.find(tmid); - deleteBatch.push(thread.prepareDestroyPermanently()); - } catch { - // Do nothing: thread not found + try { + // Find the thread header and update it + const msg = await msgCollection.find(tmid); + if (msg?.tcount && msg.tcount <= 1) { + deleteBatch.push( + msg.prepareUpdate(m => { + m.tcount = null; + m.tlm = null; + }) + ); + + try { + // If the whole thread was removed, delete the thread + const thread = await threadCollection.find(tmid); + deleteBatch.push(thread.prepareDestroyPermanently()); + } catch { + // Do nothing: thread not found + } + } else { + deleteBatch.push( + msg.prepareUpdate(m => { + if (m.tcount) { + m.tcount -= 1; + } + }) + ); } - } else { - deleteBatch.push( - msg.prepareUpdate(m => { - if (m.tcount) { - m.tcount -= 1; - } - }) - ); + } catch { + // Do nothing: message not found } - } catch { - // Do nothing: message not found } - } - await db.write(async () => { await db.batch(deleteBatch); }); } catch (e) { diff --git a/app/lib/database/__tests__/mockedWatermelonDB.tsx b/app/lib/database/__tests__/mockedWatermelonDB.tsx new file mode 100644 index 0000000000..49152aba1b --- /dev/null +++ b/app/lib/database/__tests__/mockedWatermelonDB.tsx @@ -0,0 +1,168 @@ +// WatermelonDB stand-ins: pending changes, batch inside a writer, and the serialized writer lock. + +export const tick = () => Promise.resolve(); + +// Let every already-queued microtask/promise chain settle. +export const flush = () => new Promise(resolve => setImmediate(resolve)); + +export const deferred = () => { + let resolve: () => void = () => undefined; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +}; + +export interface IFakeRecord { + _preparedState: string | null; + prepareUpdate: (updater?: (record: any) => void) => any; + prepareDestroyPermanently: () => any; + [key: string]: any; +} + +export interface ILogEntry { + record: string; + op: string; + insideWriter: boolean; +} + +interface IFakeModelHost { + insideWriter: boolean; + prepareLog: ILogEntry[]; +} + +export class FakeModel { + id: string; + table: string; + _preparedState: string | null = null; + _db?: IFakeModelHost; + [key: string]: any; + + constructor(table: string, id: string, props: Record = {}, db?: IFakeModelHost) { + this._db = db; + this.table = table; + this.id = id; + Object.assign(this, props); + } + + get _debugName() { + return `${this.table}#${this.id}`; + } + + _log(op: string) { + this._db?.prepareLog.push({ record: this._debugName, op, insideWriter: this._db.insideWriter }); + } + + prepareDestroyPermanently() { + this._log('destroyPermanently'); + if (this._preparedState) { + throw new Error(`Cannot destroy permanently record with pending changes (${this._debugName})`); + } + this._preparedState = 'destroyPermanently'; + return this; + } + + prepareUpdate(updater: (m: any) => void = () => {}) { + this._log('update'); + if (this._preparedState) { + throw new Error(`Cannot update a record with pending changes (${this._debugName})`); + } + updater(this); + this._preparedState = 'update'; + return this; + } +} + +// A record identified by a `table#id` debug name, for tests that don't need a whole FakeDatabase. +export const makeFakeRecord = (debugName: string, fields: Record = {}): IFakeRecord => { + const [table, id] = debugName.split('#'); + return new FakeModel(table, id, fields) as unknown as IFakeRecord; +}; + +// Serialized writer lock, like WatermelonDB's. +export const createWriterLock = () => { + let queue: Promise = Promise.resolve(); + return (work: () => Promise): Promise => { + const run = queue.then(() => work()); + queue = run.catch(() => undefined); + return run; + }; +}; + +// db.batch commits prepared records, clearing their pending state (like the real writer). +export const commitPreparedRecords = (...args: any[]) => { + args.flat().forEach((item: any) => { + if (item && typeof item === 'object' && '_preparedState' in item) { + item._preparedState = null; + } + }); + return Promise.resolve(undefined); +}; + +export const createBatchMock = () => jest.fn(commitPreparedRecords); + +// Whether the mocked `log` received a WatermelonDB "pending changes" error. +export const loggedPendingChanges = (log: unknown) => + (log as jest.Mock).mock.calls.some(([error]) => /pending changes/.test(error?.message ?? '')); + +// An instrumented `database.active` that records what each writer prepared, found and batched. +export class FakeDatabase { + insideWriter = false; + prepareLog: ILogEntry[] = []; + findLog: { record: string; insideWriter: boolean }[] = []; + batches: string[][] = []; + collections: Record> = { + messages: new Map(), + threads: new Map(), + thread_messages: new Map() + }; + _queue = createWriterLock(); + + add(table: string, id: string, props: Record = {}) { + const record = new FakeModel(table, id, props, this); + if (!this.collections[table]) { + this.collections[table] = new Map(); + } + this.collections[table].set(id, record); + return record; + } + + get(table: string) { + return { + find: async (id: string) => { + this.findLog.push({ record: `${table}#${id}`, insideWriter: this.insideWriter }); + await tick(); + const record = this.collections[table]?.get(id); + if (!record) { + throw new Error(`Record ${table}#${id} not found`); + } + return record; + } + }; + } + + write(work: () => Promise): Promise { + return this._queue(async () => { + this.insideWriter = true; + try { + return await work(); + } finally { + this.insideWriter = false; + } + }); + } + + async batch(records: FakeModel[]) { + if (!this.insideWriter) { + throw new Error('Database.batch() can only be called from inside of a Writer'); + } + await tick(); + this.batches.push(records.map(r => `${r._debugName}:${r._preparedState}`)); + records.forEach(record => { + if (!record._preparedState) { + throw new Error("Cannot batch a record that doesn't have a prepared create/update/delete"); + } + record._preparedState = null; + }); + } +} diff --git a/app/lib/methods/handleMediaDownload.test.ts b/app/lib/methods/handleMediaDownload.test.ts index da47abb2f8..29fafaa4f0 100644 --- a/app/lib/methods/handleMediaDownload.test.ts +++ b/app/lib/methods/handleMediaDownload.test.ts @@ -3,29 +3,18 @@ import database from '../database'; import { getMessageById } from '../database/services/Message'; import { getThreadById } from '../database/services/Thread'; import { getThreadMessageById } from '../database/services/ThreadMessage'; +import { createBatchMock, deferred, flush, makeFakeRecord } from '../database/__tests__/mockedWatermelonDB'; -const mockDbBatch = jest.fn((...args: any[]) => { - // db.batch commits prepared records, clearing their pending state (like the real writer). - args.flat().forEach((item: any) => { - if (item && typeof item === 'object' && '_preparedState' in item) { - item._preparedState = null; - } - }); - return Promise.resolve(undefined); -}); +const mockDbBatch = createBatchMock(); jest.mock('../database', () => { - let writerQueue: Promise = Promise.resolve(); + const { createWriterLock } = require('../database/__tests__/mockedWatermelonDB'); + const write = createWriterLock(); return { __esModule: true, default: { active: { get: jest.fn(), - // Serialized writer lock, like WatermelonDB's. - write: (callback: () => Promise) => { - const run = writerQueue.then(() => callback()); - writerQueue = run.catch(() => undefined); - return run; - }, + write, batch: (...args: unknown[]) => mockDbBatch(...args) } } @@ -153,31 +142,8 @@ describe('persistMessage', () => { const downloadUrl = 'https://server.com/file-upload/abc/photo.jpg'; const uri = 'file:///local/photo.jpg'; - // Mimics a WatermelonDB Model: prepareUpdate throws while a previous prepared - // update has not been committed yet. - const makeRecord = (debugName: string) => { - const record: any = { - attachments: [{ image_url: '/file-upload/abc/photo.jpg' }], - _preparedState: null as string | null, - prepareUpdate(recordUpdater: (m: any) => void) { - if (record._preparedState) { - throw new Error(`Cannot update a record with pending changes (${debugName})`); - } - recordUpdater(record); - record._preparedState = 'update'; - return record; - } - }; - return record; - }; - - const deferred = () => { - let resolve: () => void = () => undefined; - const promise = new Promise(r => { - resolve = r; - }); - return { promise, resolve }; - }; + const makeRecord = (debugName: string) => + makeFakeRecord(debugName, { attachments: [{ image_url: '/file-upload/abc/photo.jpg' }] }); beforeEach(() => { jest.clearAllMocks(); @@ -209,7 +175,7 @@ describe('persistMessage', () => { // Give an unlocked implementation the chance to prepare now — before the concurrent // writer runs — and hold the records pending until its own batch. - await new Promise(resolve => setImmediate(resolve)); + await flush(); concurrentGate.resolve(); await expect(Promise.all([concurrentWrite, persisting])).resolves.toBeDefined(); diff --git a/app/lib/methods/sendMessage.test.ts b/app/lib/methods/sendMessage.test.ts index 0a24bffb7a..05c69ab8c7 100644 --- a/app/lib/methods/sendMessage.test.ts +++ b/app/lib/methods/sendMessage.test.ts @@ -2,6 +2,14 @@ import database from '../database'; import log from './helpers/log'; import { messagesStatus } from '../constants/messagesStatus'; import { sendMessage } from './sendMessage'; +import { + createBatchMock, + createWriterLock, + deferred, + flush, + loggedPendingChanges, + makeFakeRecord +} from '../database/__tests__/mockedWatermelonDB'; type FakeRecord = Record; @@ -12,24 +20,7 @@ interface FakeCollection { prepareCreate: (updater: (m: FakeRecord) => void) => FakeRecord; } -// Mirrors WatermelonDB's invariant: prepareUpdate on a record that already has a prepared change -// throws `Cannot update a record with pending changes` (Model/index.js). -const makeRecord = (debugName: string, fields: FakeRecord = {}): FakeRecord => { - const record: FakeRecord = { - ...fields, - __debugName: debugName, - _preparedState: null, - prepareUpdate(updater: (m: FakeRecord) => void) { - if (record._preparedState) { - throw new Error(`Cannot update a record with pending changes (${debugName})`); - } - updater(record); - record._preparedState = 'update'; - return record; - } - }; - return record; -}; +const makeRecord = (debugName: string, fields: FakeRecord = {}): FakeRecord => makeFakeRecord(debugName, fields); const makeCollection = (name: string): FakeCollection => { const collection: FakeCollection = { @@ -49,6 +40,7 @@ const makeCollection = (name: string): FakeCollection => { // sanitizedRaw is mocked to identity below, so `_raw.id` is the client-generated id. const id = record._raw?.id; if (id) { + record.id = id; collection.records.set(id, record); } return record; @@ -65,34 +57,19 @@ const mockGetCollection = (name: string): FakeCollection => { return collections[name]; }; -// db.batch commits prepared records, clearing their pending state (like the real writer). -const mockDbBatch = jest.fn((...args: any[]) => { - args.flat().forEach((item: FakeRecord) => { - if (item && typeof item === 'object' && '_preparedState' in item) { - item._preparedState = null; - } - }); - return Promise.resolve(undefined); -}); +const mockDbBatch = createBatchMock(); +const mockDbWrite = createWriterLock(); -jest.mock('../database', () => { - let writerQueue: Promise = Promise.resolve(); - return { - __esModule: true, - default: { - active: { - get: (name: string) => mockGetCollection(name), - // Serialized writer lock, like WatermelonDB's. - write: (callback: () => Promise) => { - const run = writerQueue.then(() => callback()); - writerQueue = run.catch(() => undefined); - return run; - }, - batch: (...args: unknown[]) => mockDbBatch(...args) - } +jest.mock('../database', () => ({ + __esModule: true, + default: { + active: { + get: (name: string) => mockGetCollection(name), + write: (callback: () => Promise) => mockDbWrite(callback), + batch: (...args: unknown[]) => mockDbBatch(...args) } - }; -}); + } +})); jest.mock('@nozbe/watermelondb/RawRecord', () => ({ sanitizedRaw: (raw: unknown) => raw @@ -125,19 +102,6 @@ jest.mock('./helpers/log', () => ({ const db = (database as any).active; -const deferred = () => { - let resolve: () => void = () => undefined; - const promise = new Promise(r => { - resolve = r; - }); - return { promise, resolve }; -}; - -// Let every already-queued microtask/promise chain settle. -const flush = () => new Promise(resolve => setImmediate(resolve)); - -const loggedPendingChanges = () => (log as jest.Mock).mock.calls.some(([error]) => /pending changes/.test(error?.message ?? '')); - describe('sendMessage', () => { const rid = 'GENERAL'; const user = { id: 'userId', username: 'rocket.cat', name: 'Rocket Cat' }; @@ -181,7 +145,7 @@ describe('sendMessage', () => { await expect(Promise.all([concurrentWrite, send])).resolves.toBeDefined(); - expect(loggedPendingChanges()).toBe(false); + expect(loggedPendingChanges(log)).toBe(false); const created = mockDbBatch.mock.calls .flat(2) .find((item: FakeRecord) => item?.status === messagesStatus.TEMP || item?.status === messagesStatus.SENT); @@ -243,7 +207,7 @@ describe('sendMessage', () => { await expect(Promise.all([concurrentWrite, send])).resolves.toBeDefined(); - expect(loggedPendingChanges()).toBe(false); + expect(loggedPendingChanges(log)).toBe(false); const threadMessageRecord = mockGetCollection('thread_messages').records.get(messageId) as FakeRecord; expect(messageRecord.status).toBe(messagesStatus.SENT); diff --git a/app/lib/methods/subscriptions/room.test.ts b/app/lib/methods/subscriptions/room.test.ts index 005a31242c..7156e1da29 100644 --- a/app/lib/methods/subscriptions/room.test.ts +++ b/app/lib/methods/subscriptions/room.test.ts @@ -5,6 +5,13 @@ import { getMessageById } from '../../database/services/Message'; import { getThreadById } from '../../database/services/Thread'; import database from '../../database'; import log from '../helpers/log'; +import { + commitPreparedRecords, + deferred, + flush, + loggedPendingChanges, + makeFakeRecord +} from '../../database/__tests__/mockedWatermelonDB'; const mockSubscribeRoom = jest.fn, [string]>(() => Promise.resolve([])); const mockOnStreamData = jest.fn, [string, (...args: unknown[]) => void]>(() => @@ -82,7 +89,8 @@ jest.mock('../../encryption', () => ({ const mockDbBatch = jest.fn().mockResolvedValue(undefined); const mockDbGet = jest.fn(); jest.mock('../../database', () => { - let writerQueue: Promise = Promise.resolve(); + const { createWriterLock } = require('../../database/__tests__/mockedWatermelonDB'); + const write = createWriterLock(); const mockModel = { prepareCreate: jest.fn(() => ({})), prepareUpdate: jest.fn(() => ({})), @@ -94,11 +102,7 @@ jest.mock('../../database', () => { default: { active: { get: (...args: unknown[]) => mockDbGet(...args) ?? mockModel, - write: jest.fn((callback: () => Promise) => { - const run = writerQueue.then(() => callback()); - writerQueue = run.catch(() => undefined); - return run; - }), + write: jest.fn(write), batch: (...args: unknown[]) => mockDbBatch(...args) } } @@ -141,76 +145,23 @@ describe('RoomSubscription', () => { }); describe('updateMessage concurrency', () => { - const makeRecord = (debugName: string) => ({ - _preparedState: null as string | null, - prepareUpdate(recordUpdater: (m: any) => void) { - if (this._preparedState) { - throw new Error(`Cannot update a record with pending changes (${debugName})`); - } - recordUpdater(this); - this._preparedState = 'update'; - return this; - } - }); - it('does not throw "pending changes" when two stream events for the same message id arrive concurrently', async () => { const _id = 'KXse45i7gGYE8j4Xb'; - const messageRecord = makeRecord(`messages#${_id}`); - const threadRecord = makeRecord(`threads#${_id}`); + const messageRecord = makeFakeRecord(`messages#${_id}`); + const threadRecord = makeFakeRecord(`threads#${_id}`); (getMessageById as jest.Mock).mockResolvedValue(messageRecord); (getThreadById as jest.Mock).mockResolvedValue(threadRecord); - // db.batch commits prepared records, clearing their pending state (like the real writer). - mockDbBatch.mockImplementation((...items: any[]) => { - items.forEach(item => { - if (item && typeof item === 'object' && '_preparedState' in item) { - item._preparedState = null; - } - }); - return Promise.resolve(undefined); - }); + mockDbBatch.mockImplementation(commitPreparedRecords); const message = { _id, rid, tlm: { $date: 1 } } as any; await Promise.all([sub.updateMessage({ ...message }), sub.updateMessage({ ...message })]); - const loggedPendingChanges = (log as jest.Mock).mock.calls.some(([err]) => /pending changes/.test(err?.message)); - expect(loggedPendingChanges).toBe(false); + expect(loggedPendingChanges(log)).toBe(false); }); }); describe('deleteMessage concurrency', () => { - // Mimics a WatermelonDB Model: both prepare* calls throw while a previous - // prepared change has not been committed yet. - const makeDeletableRecord = (debugName: string) => { - const record: any = { - _preparedState: null as string | null, - prepareUpdate(recordUpdater: (m: any) => void) { - if (record._preparedState) { - throw new Error(`Cannot update a record with pending changes (${debugName})`); - } - recordUpdater(record); - record._preparedState = 'update'; - return record; - }, - prepareDestroyPermanently() { - if (record._preparedState) { - throw new Error(`Cannot destroy permanently record with pending changes (${debugName})`); - } - record._preparedState = 'destroyPermanently'; - return record; - } - }; - return record; - }; - - const deferred = () => { - let resolve: () => void = () => undefined; - const promise = new Promise(r => { - resolve = r; - }); - return { promise, resolve }; - }; - let interactionTask: Promise | null = null; beforeEach(() => { @@ -220,14 +171,7 @@ describe('RoomSubscription', () => { interactionTask = task(); return { then: () => undefined, done: () => undefined, cancel: () => undefined } as any; }); - mockDbBatch.mockImplementation((...items: any[]) => { - items.flat().forEach(item => { - if (item && typeof item === 'object' && '_preparedState' in item) { - item._preparedState = null; - } - }); - return Promise.resolve(undefined); - }); + mockDbBatch.mockImplementation(commitPreparedRecords); }); afterEach(() => { @@ -236,9 +180,9 @@ describe('RoomSubscription', () => { it('does not throw "pending changes" when a concurrent writer touches a message being deleted', async () => { const _id = 'KXse45i7gGYE8j4Xb'; - const messageRecord = makeDeletableRecord(`messages#${_id}`); - const threadRecord = makeDeletableRecord(`threads#${_id}`); - const threadMessageRecord = makeDeletableRecord(`thread_messages#${_id}`); + const messageRecord = makeFakeRecord(`messages#${_id}`); + const threadRecord = makeFakeRecord(`threads#${_id}`); + const threadMessageRecord = makeFakeRecord(`thread_messages#${_id}`); const collections: Record = { messages: { find: () => Promise.resolve(messageRecord) }, threads: { find: () => Promise.resolve(threadRecord) }, @@ -266,13 +210,12 @@ describe('RoomSubscription', () => { // Give an unlocked implementation the chance to prepare now — before the concurrent // writer runs — and hold the records pending until its own batch. - await new Promise(resolve => setImmediate(resolve)); + await flush(); concurrentGate.resolve(); await expect(Promise.all([concurrentWrite, interactionTask])).resolves.toBeDefined(); - const loggedPendingChanges = (log as jest.Mock).mock.calls.some(([err]) => /pending changes/.test(err?.message)); - expect(loggedPendingChanges).toBe(false); + expect(loggedPendingChanges(log)).toBe(false); // The whole delete batch committed together and nothing was left prepared. const deleteBatch = mockDbBatch.mock.calls diff --git a/jest.config.js b/jest.config.js index 8dcdf6d5a6..defea78810 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,12 @@ module.exports = { modulePathIgnorePatterns: ['/.*worktrees/'], - testPathIgnorePatterns: ['e2e', 'node_modules', '/.*worktrees/', '/__tests__/testHelpers\\.tsx$'], + testPathIgnorePatterns: [ + 'e2e', + 'node_modules', + '/.*worktrees/', + '/__tests__/testHelpers\\.tsx$', + '/__tests__/mockedWatermelonDB\\.tsx$' + ], transformIgnorePatterns: [ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@rocket.chat/ui-kit|@rocket.chat/sdk|@rocket.chat/message-parser|tiny-events)' ],