Skip to content
Open
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
204 changes: 204 additions & 0 deletions app/lib/methods/loadThreadMessages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { loadThreadMessages } from './loadThreadMessages';
import { type IReaction } from '../../definitions';
import database from '../database';
import { getThreadById } from '../database/services/Thread';
import { Encryption } from '../encryption';
import sdk from '../services/sdk';

jest.mock('../services/sdk', () => ({
__esModule: true,
default: { methodCallWrapper: jest.fn() }
}));

jest.mock('../database', () => ({
__esModule: true,
default: { active: {} }
}));

jest.mock('../database/services/Thread', () => ({
getThreadById: jest.fn()
}));

jest.mock('../encryption', () => ({
Encryption: { decryptMessages: jest.fn((messages: any) => Promise.resolve(messages)) }
}));

jest.mock('./helpers/log', () => ({
__esModule: true,
default: jest.fn()
}));

jest.mock('@nozbe/watermelondb/RawRecord', () => ({
sanitizedRaw: jest.fn((raw: any) => raw)
}));

jest.mock('ejson', () => ({
__esModule: true,
default: { fromJSONValue: (value: any) => value }
}));

const mockedMethodCall = sdk.methodCallWrapper as jest.MockedFunction<typeof sdk.methodCallWrapper>;
const mockedGetThreadById = getThreadById as jest.MockedFunction<typeof getThreadById>;

const TMID = 'PARENT_ID';
const RID = 'ROOM_ID';

interface IParentFixture {
_id: string;
rid: string;
msg: string;
tlm: Date;
tcount: number;
_updatedAt: Date;
reactions: Partial<IReaction>[];
}

interface IReplyFixture {
_id: string;
rid: string;
tmid: string;
msg: string;
_updatedAt: Date;
}

const buildParent = (updatedAt: Date, reactions: Partial<IReaction>[]): IParentFixture => ({
_id: TMID,
rid: RID,
msg: 'parent',
tlm: new Date(),
tcount: 1,
_updatedAt: updatedAt,
reactions
});

const buildReply = (): IReplyFixture => ({ _id: 'REPLY_ID', rid: RID, tmid: TMID, msg: 'reply', _updatedAt: new Date() });

let batched: any[] = [];
let threadsCreated: any[] = [];

const setupDatabase = (): void => {
batched = [];
threadsCreated = [];
const threadsCollection = {
schema: {},
prepareCreate: jest.fn((fn: any) => {
const record: any = {};
fn(record);
threadsCreated.push(record);
return record;
})
};
const threadMessagesCollection = {
schema: {},
query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })),
prepareCreate: jest.fn((fn: any) => {
const record: any = {};
fn(record);
return record;
})
};
(database as any).active = {
get: jest.fn((table: string) => (table === 'threads' ? threadsCollection : threadMessagesCollection)),
write: jest.fn((fn: any) => fn()),
batch: jest.fn((records: any[]) => {
batched = records;
})
};
};

describe('loadThreadMessages', () => {
beforeEach(() => {
jest.clearAllMocks();
setupDatabase();
});

it('creates the threads record from the parent returned by getThreadMessages', async () => {
const parent = buildParent(new Date('2026-01-02'), [{ emoji: ':thumbsup:', usernames: ['rocket.cat'] }]);
mockedMethodCall.mockResolvedValue([parent, buildReply()] as any);
mockedGetThreadById.mockResolvedValue(null);

await loadThreadMessages({ tmid: TMID, rid: RID });

expect(threadsCreated).toHaveLength(1);
expect(threadsCreated[0].reactions).toEqual(parent.reactions);
expect(batched).toContain(threadsCreated[0]);
});

it('updates a stale threads record so newer reactions reach the UI', async () => {
const parent = buildParent(new Date('2026-01-02'), [{ emoji: ':thumbsup:', usernames: ['rocket.cat'] }]);
mockedMethodCall.mockResolvedValue([parent, buildReply()] as any);

const updated: any = {};
const threadRecord = {
id: TMID,
_updatedAt: new Date('2026-01-01'),
prepareUpdate: jest.fn((fn: any) => {
fn(updated);
return updated;
})
};
mockedGetThreadById.mockResolvedValue(threadRecord as any);

await loadThreadMessages({ tmid: TMID, rid: RID });

expect(threadRecord.prepareUpdate).toHaveBeenCalled();
expect(updated.reactions).toEqual(parent.reactions);
expect(batched).toContain(updated);
});

it('leaves an up-to-date threads record untouched', async () => {
mockedMethodCall.mockResolvedValue([buildParent(new Date('2026-01-01'), []), buildReply()] as any);

const threadRecord = { id: TMID, _updatedAt: new Date('2026-01-01'), prepareUpdate: jest.fn() };
mockedGetThreadById.mockResolvedValue(threadRecord as any);

await loadThreadMessages({ tmid: TMID, rid: RID });

expect(threadRecord.prepareUpdate).not.toHaveBeenCalled();
expect(threadsCreated).toHaveLength(0);
});

it('does not write the parent into thread_messages', async () => {
mockedMethodCall.mockResolvedValue([buildParent(new Date('2026-01-02'), []), buildReply()] as any);
mockedGetThreadById.mockResolvedValue(null);

await loadThreadMessages({ tmid: TMID, rid: RID });

const threadMessageRecords = batched.filter(r => !threadsCreated.includes(r));
expect(threadMessageRecords).toHaveLength(1);
expect(threadMessageRecords[0]._id).toBe('REPLY_ID');
});

it('still resolves when the server returns no parent', async () => {
mockedMethodCall.mockResolvedValue([buildReply()] as any);
mockedGetThreadById.mockResolvedValue(null);

await loadThreadMessages({ tmid: TMID, rid: RID });

expect(mockedGetThreadById).not.toHaveBeenCalled();
expect(threadsCreated).toHaveLength(0);
});

it('drops messages buildMessage could not normalize before decrypting', async () => {
mockedMethodCall.mockResolvedValue([buildParent(new Date('2026-01-02'), []), null, buildReply()] as any);
Comment thread
OtavioStasiak marked this conversation as resolved.
mockedGetThreadById.mockResolvedValue(null);

await loadThreadMessages({ tmid: TMID, rid: RID });

expect(Encryption.decryptMessages).toHaveBeenCalledWith([
expect.objectContaining({ _id: TMID }),
expect.objectContaining({ _id: 'REPLY_ID' })
]);
expect(threadsCreated).toHaveLength(1);
});

it('decrypts the parent along with the replies', async () => {
const parent = buildParent(new Date('2026-01-02'), []);
mockedMethodCall.mockResolvedValue([parent, buildReply()] as any);
mockedGetThreadById.mockResolvedValue(null);

await loadThreadMessages({ tmid: TMID, rid: RID });

expect(Encryption.decryptMessages).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ _id: TMID })]));
});
});
46 changes: 41 additions & 5 deletions app/lib/methods/loadThreadMessages.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Q } from '@nozbe/watermelondb';
import { type Model, Q } from '@nozbe/watermelondb';
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import EJSON from 'ejson';

Expand All @@ -7,7 +7,8 @@ import log from './helpers/log';
import { Encryption } from '../encryption';
import protectedFunction from './helpers/protectedFunction';
import buildMessage from './helpers/buildMessage';
import { type TThreadMessageModel } from '../../definitions';
import { type TThreadMessageModel, type TThreadModel } from '../../definitions';
import { getThreadById } from '../database/services/Thread';
import sdk from '../services/sdk';

async function load({ tmid }: { tmid: string }) {
Expand All @@ -23,14 +24,47 @@ async function load({ tmid }: { tmid: string }) {
}
}

export function loadThreadMessages({ tmid, rid }: { tmid: string; rid: string }) {
// The only refresh the threads record gets on open, so updates missed by the room stream reach the UI.
async function prepareThreadUpsert(threadParent: TThreadModel | undefined, rid: string): Promise<Model | null> {
if (!threadParent) {
return null;
}
const threadsCollection = database.active.get('threads');
const threadRecord = await getThreadById(threadParent._id);
if (!threadRecord) {
return threadsCollection.prepareCreate(
protectedFunction((t: TThreadModel) => {
t._raw = sanitizedRaw({ id: threadParent._id }, threadsCollection.schema);
Object.assign(t, threadParent);
if (t.subscription) {
t.subscription.id = rid;
}
})
);
}
if (threadRecord._updatedAt < threadParent._updatedAt) {
return threadRecord.prepareUpdate(
protectedFunction((t: TThreadModel) => {
Object.assign(t, threadParent);
})
);
}
return null;
}

export function loadThreadMessages({ tmid, rid }: { tmid: string; rid: string }): Promise<void> {
return new Promise<void>(async (resolve, reject) => {
try {
let data = await load({ tmid });
if (data && data.length) {
try {
data = data.filter((m: TThreadMessageModel) => m.tmid).map((m: TThreadMessageModel) => buildMessage(m));
data = data
.filter(Boolean)
.map((m: TThreadMessageModel) => buildMessage(m))
.filter((m: TThreadMessageModel | null): m is TThreadMessageModel => !!m);
data = await Encryption.decryptMessages(data);
const threadParent = data.find((m: TThreadMessageModel) => m._id === tmid);
data = data.filter((m: TThreadMessageModel) => m.tmid);
const db = database.active;
const threadMessagesCollection = db.get('thread_messages');
const allThreadMessagesRecords = await threadMessagesCollection.query(Q.where('rid', tmid)).fetch();
Expand Down Expand Up @@ -72,8 +106,10 @@ export function loadThreadMessages({ tmid, rid }: { tmid: string; rid: string })
);
});

const threadToUpsert = await prepareThreadUpsert(threadParent, rid);

await db.write(async () => {
await db.batch([...threadMessagesToCreate, ...threadMessagesToUpdate]);
await db.batch([threadToUpsert, ...threadMessagesToCreate, ...threadMessagesToUpdate].filter(Boolean) as Model[]);
});
} catch (e) {
log(e);
Expand Down
Loading