mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-09-28 07:57:21 +01:00
Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
376 lines
11 KiB
TypeScript
376 lines
11 KiB
TypeScript
// Copyright 2026 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
import assert from 'node:assert/strict';
|
|
import { cwd } from 'node:process';
|
|
|
|
import { ReadStatus } from '../../../messages/MessageReadStatus.std.ts';
|
|
import { SeenStatus } from '../../../MessageSeenStatus.std.ts';
|
|
import type { WritableDB } from '../../../sql/Interface.std.ts';
|
|
import { setupTests } from '../../../sql/Server.node.ts';
|
|
import type { UnreadReminderFilter } from '../../../sql/server/unreadReminders.std.ts';
|
|
import {
|
|
getUnremindedUnreadMessageTimeRanges,
|
|
getUnremindedUnreadMessageTimeRangesQuery,
|
|
getUnreadReminderCountQuery,
|
|
getUnreadReminderSendersQuery,
|
|
getUnreadReminderSummaryData,
|
|
} from '../../../sql/server/unreadReminders.std.ts';
|
|
import { createDB, explain, insertData } from '../helpers.node.ts';
|
|
import { generateAci } from '../../../test-helpers/serviceIdUtils.std.ts';
|
|
import { DAY } from '../../../util/durations/constants.std.ts';
|
|
|
|
const OUR_ACI = generateAci();
|
|
const ALICE_ACI = generateAci();
|
|
const BOB_ACI = generateAci();
|
|
|
|
const yesterday = Date.now() - DAY;
|
|
|
|
let messageCounter = 0;
|
|
|
|
type MessageOverrides = {
|
|
conversationId: string;
|
|
receivedAtMs?: number | null;
|
|
readStatus?: ReadStatus;
|
|
sourceServiceId?: string;
|
|
mentionsMe?: 0 | 1;
|
|
quoteAuthorAci?: string;
|
|
storyId?: string | null;
|
|
};
|
|
|
|
function composeMessage(overrides: MessageOverrides) {
|
|
messageCounter += 1;
|
|
const {
|
|
conversationId,
|
|
receivedAtMs = yesterday,
|
|
readStatus = ReadStatus.Unread,
|
|
sourceServiceId = ALICE_ACI,
|
|
mentionsMe = 0,
|
|
quoteAuthorAci,
|
|
storyId = null,
|
|
} = overrides;
|
|
|
|
const json: Record<string, unknown> = { id: `m${messageCounter}` };
|
|
if (quoteAuthorAci != null) {
|
|
json.quote = { authorAci: quoteAuthorAci };
|
|
}
|
|
|
|
return {
|
|
id: `m${messageCounter}`,
|
|
conversationId,
|
|
type: 'incoming',
|
|
readStatus,
|
|
seenStatus: SeenStatus.Unseen,
|
|
received_at: messageCounter,
|
|
received_at_ms: receivedAtMs,
|
|
sent_at: receivedAtMs ?? yesterday,
|
|
sourceServiceId,
|
|
mentionsMe,
|
|
storyId,
|
|
json: JSON.stringify(json),
|
|
};
|
|
}
|
|
|
|
describe('sql/server/unreadReminders', () => {
|
|
let db: WritableDB;
|
|
beforeEach(() => {
|
|
messageCounter = 0;
|
|
db = createDB();
|
|
setupTests(db, { userDataPath: cwd() });
|
|
insertData(db, 'conversations', [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }]);
|
|
});
|
|
afterEach(() => {
|
|
db.close();
|
|
});
|
|
|
|
describe('getUnremindedUnreadMessageTimeRanges', () => {
|
|
it('returns the oldest and newest unread arrival for a conversation', () => {
|
|
insertData(db, 'messages', [
|
|
composeMessage({ conversationId: 'c1', receivedAtMs: yesterday }),
|
|
composeMessage({
|
|
conversationId: 'c1',
|
|
receivedAtMs: yesterday + 5_000,
|
|
}),
|
|
composeMessage({
|
|
conversationId: 'c1',
|
|
receivedAtMs: yesterday + 1_000,
|
|
}),
|
|
]);
|
|
|
|
assert.deepEqual(
|
|
getUnremindedUnreadMessageTimeRanges(db, [
|
|
{
|
|
conversationId: 'c1',
|
|
lastRemindedAt: 0,
|
|
includeStoryReplies: true,
|
|
},
|
|
]),
|
|
[
|
|
{
|
|
conversationId: 'c1',
|
|
oldestUnremindedReceivedAtMs: yesterday,
|
|
newestUnreadReceivedAtMs: yesterday + 5_000,
|
|
},
|
|
]
|
|
);
|
|
});
|
|
|
|
it('excludes activity before lastRemindedAt', () => {
|
|
insertData(db, 'messages', [
|
|
composeMessage({ conversationId: 'c1', receivedAtMs: yesterday }),
|
|
composeMessage({
|
|
conversationId: 'c1',
|
|
receivedAtMs: yesterday + 10_000,
|
|
}),
|
|
]);
|
|
|
|
const result = getUnremindedUnreadMessageTimeRanges(db, [
|
|
{
|
|
conversationId: 'c1',
|
|
lastRemindedAt: yesterday + 5_000,
|
|
includeStoryReplies: true,
|
|
},
|
|
]);
|
|
|
|
assert.deepEqual(result, [
|
|
{
|
|
conversationId: 'c1',
|
|
oldestUnremindedReceivedAtMs: yesterday + 10_000,
|
|
newestUnreadReceivedAtMs: yesterday + 10_000,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('omits a conversation whose unread all predates the cutoff', () => {
|
|
insertData(db, 'messages', [
|
|
composeMessage({ conversationId: 'c1', receivedAtMs: yesterday }),
|
|
]);
|
|
|
|
assert.deepEqual(
|
|
getUnremindedUnreadMessageTimeRanges(db, [
|
|
{
|
|
conversationId: 'c1',
|
|
lastRemindedAt: yesterday + 1,
|
|
includeStoryReplies: true,
|
|
},
|
|
]),
|
|
[]
|
|
);
|
|
});
|
|
|
|
it('ignores read messages', () => {
|
|
insertData(db, 'messages', [
|
|
composeMessage({
|
|
conversationId: 'c1',
|
|
receivedAtMs: yesterday,
|
|
readStatus: ReadStatus.Read,
|
|
}),
|
|
]);
|
|
|
|
assert.deepEqual(
|
|
getUnremindedUnreadMessageTimeRanges(db, [
|
|
{
|
|
conversationId: 'c1',
|
|
lastRemindedAt: 0,
|
|
includeStoryReplies: true,
|
|
},
|
|
]),
|
|
[]
|
|
);
|
|
});
|
|
|
|
it('respects includeStoryReplies', () => {
|
|
insertData(db, 'messages', [
|
|
composeMessage({
|
|
conversationId: 'c1',
|
|
receivedAtMs: yesterday,
|
|
storyId: 'story1',
|
|
}),
|
|
]);
|
|
|
|
assert.deepEqual(
|
|
getUnremindedUnreadMessageTimeRanges(db, [
|
|
{
|
|
conversationId: 'c1',
|
|
lastRemindedAt: 0,
|
|
includeStoryReplies: false,
|
|
},
|
|
]),
|
|
[]
|
|
);
|
|
assert.equal(
|
|
getUnremindedUnreadMessageTimeRanges(db, [
|
|
{
|
|
conversationId: 'c1',
|
|
lastRemindedAt: 0,
|
|
includeStoryReplies: true,
|
|
},
|
|
]).length,
|
|
1
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getUnreadReminderSummaryData', () => {
|
|
const options = { includeStoryReplies: true, ourAci: OUR_ACI };
|
|
|
|
it('counts unread messages and their senders, most recent first', () => {
|
|
insertData(db, 'messages', [
|
|
composeMessage({
|
|
conversationId: 'c1',
|
|
receivedAtMs: yesterday,
|
|
sourceServiceId: ALICE_ACI,
|
|
}),
|
|
composeMessage({
|
|
conversationId: 'c1',
|
|
receivedAtMs: yesterday + 5_000,
|
|
sourceServiceId: BOB_ACI,
|
|
}),
|
|
composeMessage({
|
|
conversationId: 'c1',
|
|
receivedAtMs: yesterday + 1_000,
|
|
sourceServiceId: ALICE_ACI,
|
|
}),
|
|
]);
|
|
|
|
const data = getUnreadReminderSummaryData(db, 'c1', options);
|
|
assert.equal(data.unreadMessageCount, 3);
|
|
assert.deepEqual(
|
|
data.senders.map(({ sourceServiceId }) => sourceServiceId),
|
|
[BOB_ACI, ALICE_ACI]
|
|
);
|
|
});
|
|
|
|
it('counts mentions of us and who made them', () => {
|
|
insertData(db, 'messages', [
|
|
composeMessage({ conversationId: 'c1', mentionsMe: 1 }),
|
|
composeMessage({ conversationId: 'c1', mentionsMe: 0 }),
|
|
]);
|
|
|
|
const data = getUnreadReminderSummaryData(db, 'c1', options);
|
|
assert.equal(data.mentionCount, 1);
|
|
assert.deepEqual(
|
|
data.mentioners.map(({ sourceServiceId }) => sourceServiceId),
|
|
[ALICE_ACI]
|
|
);
|
|
});
|
|
|
|
it('counts only replies quoting our own ACI', () => {
|
|
insertData(db, 'messages', [
|
|
composeMessage({ conversationId: 'c1', quoteAuthorAci: OUR_ACI }),
|
|
composeMessage({ conversationId: 'c1', quoteAuthorAci: BOB_ACI }),
|
|
composeMessage({ conversationId: 'c1' }),
|
|
]);
|
|
|
|
const data = getUnreadReminderSummaryData(db, 'c1', options);
|
|
assert.equal(data.replyCount, 1);
|
|
assert.deepEqual(
|
|
data.repliers.map(({ sourceServiceId }) => sourceServiceId),
|
|
[ALICE_ACI]
|
|
);
|
|
});
|
|
|
|
it('does not count a reply that has already been read', () => {
|
|
insertData(db, 'messages', [
|
|
composeMessage({
|
|
conversationId: 'c1',
|
|
quoteAuthorAci: OUR_ACI,
|
|
readStatus: ReadStatus.Read,
|
|
}),
|
|
]);
|
|
|
|
assert.equal(
|
|
getUnreadReminderSummaryData(db, 'c1', options).replyCount,
|
|
0
|
|
);
|
|
});
|
|
|
|
it('is scoped to a single conversation', () => {
|
|
insertData(db, 'messages', [
|
|
composeMessage({ conversationId: 'c1' }),
|
|
composeMessage({ conversationId: 'c2' }),
|
|
composeMessage({ conversationId: 'c2' }),
|
|
]);
|
|
|
|
assert.equal(
|
|
getUnreadReminderSummaryData(db, 'c1', options).unreadMessageCount,
|
|
1
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('query plans', () => {
|
|
it('getUnremindedUnreadMessageTimeRangesQuery uses messages_unread_no_story_id index', () => {
|
|
const details = explain(
|
|
db,
|
|
getUnremindedUnreadMessageTimeRangesQuery([
|
|
{
|
|
conversationId: 'c1',
|
|
lastRemindedAt: 0,
|
|
includeStoryReplies: true,
|
|
},
|
|
{
|
|
conversationId: 'c2',
|
|
lastRemindedAt: yesterday,
|
|
includeStoryReplies: false,
|
|
},
|
|
])
|
|
);
|
|
|
|
assert.ok(
|
|
details.includes(
|
|
'SEARCH messages USING INDEX messages_unread_no_story_id ' +
|
|
'(conversationId=? AND readStatus=? AND isStory=?)'
|
|
),
|
|
details
|
|
);
|
|
assert.ok(!details.includes('SCAN messages'), details);
|
|
});
|
|
|
|
describe('summaryData', () => {
|
|
for (const includeStoryReplies of [true, false]) {
|
|
for (const filter of ['all', 'mentions', 'replies']) {
|
|
// oxlint-disable-next-line no-loop-func
|
|
it(`getUnreadReminderSendersQuery: ${filter}, ${includeStoryReplies}`, () => {
|
|
const details = explain(
|
|
db,
|
|
getUnreadReminderSendersQuery(
|
|
'c1',
|
|
filter as UnreadReminderFilter,
|
|
{
|
|
includeStoryReplies,
|
|
ourAci: OUR_ACI,
|
|
}
|
|
)
|
|
);
|
|
assert.ok(
|
|
details.includes(`SEARCH messages USING INDEX messages_unread`),
|
|
`${details} uses messages_unread* index`
|
|
);
|
|
assert.ok(!details.includes('SCAN'), `${details} does not scan`);
|
|
});
|
|
// oxlint-disable-next-line no-loop-func
|
|
it(`getUnreadReminderCountQuery: ${filter}, ${includeStoryReplies}`, () => {
|
|
const details = explain(
|
|
db,
|
|
getUnreadReminderCountQuery(
|
|
'c1',
|
|
filter as UnreadReminderFilter,
|
|
{
|
|
includeStoryReplies,
|
|
ourAci: OUR_ACI,
|
|
}
|
|
)
|
|
);
|
|
|
|
assert.ok(
|
|
details.includes(`SEARCH messages USING INDEX messages_unread`),
|
|
`${details} uses messages_unread* index`
|
|
);
|
|
assert.ok(!details.includes('SCAN'), `${details} does not scan`);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|