Files
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

1251 lines
47 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { MessageStore } from '../src/message-store';
import { MessageStoreMigrator, prepareMessageStoreDatabase } from '../src/message-store-migration';
import { hashMessageIdInt32, GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT } from '../src/message-id';
import fs from 'fs';
import path from 'path';
import { DatabaseSync } from 'node:sqlite';
describe('MessageStore', () => {
const testDbPath = path.join('data', 'test', 'messages-test.db');
let store: MessageStore;
beforeEach(async () => {
// Clean up any existing test database
try {
fs.unlinkSync(testDbPath);
} catch {
// Ignore if file doesn't exist
}
store = new MessageStore(testDbPath);
await store.initialization;
});
afterEach(async () => {
await store.close();
// Clean up test database
try {
fs.unlinkSync(testDbPath);
} catch {
// Ignore cleanup errors
}
});
describe('resolveReplySequence', () => {
it('resolves group message reply sequence', async () => {
const groupId = 123456;
const sequence = 100;
const messageId = hashMessageIdInt32(sequence, groupId, GROUP_MESSAGE_EVENT);
// Store a group message
await store.storeMeta(messageId, {
isGroup: true,
targetId: groupId,
sequence,
sequenceAuthoritative: true,
eventName: GROUP_MESSAGE_EVENT,
clientSequence: 0,
random: 0,
timestamp: Date.now(),
});
// Resolve the reply sequence
const resolved = await store.resolveReplySequence(true, groupId, messageId);
expect(resolved).toBe(sequence);
});
it('resolves private message reply sequence without session_id matching', async () => {
// Simulate receiving a private message from user 111111
const senderUin = 111111;
const sequence = 200;
const messageId = hashMessageIdInt32(sequence, senderUin, PRIVATE_MESSAGE_EVENT);
// Store the received message (session_id is sender's UIN)
await store.storeMeta(messageId, {
isGroup: false,
targetId: senderUin,
sequence,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: 0,
random: 0,
timestamp: Date.now(),
});
// When replying to this message, we send to the sender (who becomes the recipient)
// The key fix: resolveReplySequence should work even when sessionId doesn't match
const resolved = await store.resolveReplySequence(false, senderUin, messageId);
expect(resolved).toBe(sequence);
// More importantly: it should also work when we pass a different sessionId
// (which was the bug - we were passing recipient UIN instead of sender UIN)
const differentUin = 999999;
const resolvedWithDifferentSession = await store.resolveReplySequence(
false,
differentUin,
messageId,
);
expect(resolvedWithDifferentSession).toBe(sequence);
});
it('uses the sender-local sequence for private replies, not the NT history sequence', async () => {
const messageId = -123456789;
await store.storeMeta(messageId, {
isGroup: false,
targetId: 111111,
sequence: 63214,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: 32743,
privateDirection: 'incoming',
random: 88,
timestamp: 1700000000,
});
expect(await store.resolveReplySequence(false, 111111, messageId)).toBe(32743);
});
it('returns null for non-existent message', async () => {
const resolved = await store.resolveReplySequence(true, 123456, 999999);
expect(resolved).toBeNull();
});
it('returns null for invalid messageId', async () => {
const resolved = await store.resolveReplySequence(true, 123456, 0);
expect(resolved).toBeNull();
});
it('returns null for invalid sessionId', async () => {
const messageId = hashMessageIdInt32(100, 123456, GROUP_MESSAGE_EVENT);
const resolved = await store.resolveReplySequence(true, 0, messageId);
expect(resolved).toBeNull();
});
it('distinguishes between group and private messages', async () => {
const sessionId = 123456;
const sequence = 300;
// Store a group message
const groupMessageId = hashMessageIdInt32(sequence, sessionId, GROUP_MESSAGE_EVENT);
await store.storeMeta(groupMessageId, {
isGroup: true,
targetId: sessionId,
sequence,
sequenceAuthoritative: true,
eventName: GROUP_MESSAGE_EVENT,
clientSequence: 0,
random: 0,
timestamp: Date.now(),
});
// Store a private message with same sequence but different hash
const privateMessageId = hashMessageIdInt32(sequence, sessionId, PRIVATE_MESSAGE_EVENT);
await store.storeMeta(privateMessageId, {
isGroup: false,
targetId: sessionId,
sequence,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: 0,
random: 0,
timestamp: Date.now(),
});
// Should resolve correctly based on isGroup flag
const groupResolved = await store.resolveReplySequence(true, sessionId, groupMessageId);
expect(groupResolved).toBe(sequence);
const privateResolved = await store.resolveReplySequence(
false,
sessionId,
privateMessageId,
);
expect(privateResolved).toBe(sequence);
// Should not cross-resolve
const wrongGroupResolve = await store.resolveReplySequence(
true,
sessionId,
privateMessageId,
);
expect(wrongGroupResolve).toBeNull();
const wrongPrivateResolve = await store.resolveReplySequence(
false,
sessionId,
groupMessageId,
);
expect(wrongPrivateResolve).toBeNull();
});
});
describe('storeEvent and findEvent', () => {
it('stores and retrieves event data', async () => {
const messageId = 12345;
const event = {
post_type: 'message',
message_type: 'group',
message_id: messageId,
group_id: 123456,
message: 'test message',
time: Date.now(),
};
await store.storeEvent(messageId, true, 123456, 100, GROUP_MESSAGE_EVENT, event);
const retrieved = await store.findEvent(messageId);
expect(retrieved).toEqual(event);
});
it('returns null for non-existent event', async () => {
const retrieved = await store.findEvent(999999);
expect(retrieved).toBeNull();
});
});
describe('storeMeta and findMeta', () => {
it('stores and retrieves message meta', async () => {
const messageId = 54321;
const meta = {
isGroup: true,
targetId: 123456,
sequence: 100,
sequenceAuthoritative: true,
eventName: GROUP_MESSAGE_EVENT,
clientSequence: 1,
random: 12345,
timestamp: Date.now(),
};
await store.storeMeta(messageId, meta);
const retrieved = await store.findMeta(messageId);
expect(retrieved).toEqual(meta);
});
it('returns null for non-existent meta', async () => {
const retrieved = await store.findMeta(999999);
expect(retrieved).toBeNull();
});
it('rolls back the full metadata batch when a later write fails', async () => {
const firstMessageId = 54323;
const rejectedMessageId = 54324;
const database = new DatabaseSync(testDbPath);
database.exec(`
CREATE TRIGGER reject_second_message_meta
BEFORE INSERT ON messages
WHEN NEW.message_hash = ${rejectedMessageId}
BEGIN
SELECT RAISE(ABORT, 'second message meta rejected');
END
`);
database.close();
await expect(
async () =>
await store.storeMetas([
{
messageId: firstMessageId,
meta: {
isGroup: true,
targetId: 123456,
sequence: 101,
sequenceAuthoritative: true,
eventName: GROUP_MESSAGE_EVENT,
clientSequence: 0,
random: 1001,
timestamp: 1700000001,
},
},
{
messageId: rejectedMessageId,
meta: {
isGroup: true,
targetId: 123456,
sequence: 102,
sequenceAuthoritative: true,
eventName: GROUP_MESSAGE_EVENT,
clientSequence: 0,
random: 1002,
timestamp: 1700000002,
},
},
]),
).rejects.toThrow(/second message meta rejected/);
expect(await store.findMeta(firstMessageId)).toBeNull();
expect(await store.findMeta(rejectedMessageId)).toBeNull();
});
it('preserves the write error when SQLite already rolled back the transaction', async () => {
const rejectedMessageId = 54325;
const database = new DatabaseSync(testDbPath);
database.exec(`
CREATE TRIGGER force_metadata_transaction_rollback
BEFORE INSERT ON messages
WHEN NEW.message_hash = ${rejectedMessageId}
BEGIN
SELECT RAISE(ROLLBACK, 'metadata transaction forced rollback');
END
`);
database.close();
await expect(
async () =>
await store.storeMetas([
{
messageId: rejectedMessageId,
meta: {
isGroup: true,
targetId: 123456,
sequence: 103,
sequenceAuthoritative: true,
eventName: GROUP_MESSAGE_EVENT,
clientSequence: 0,
random: 1003,
timestamp: 1700000003,
},
},
]),
).rejects.toThrow(/metadata transaction forced rollback.*rollback also failed/);
expect(await store.findMeta(rejectedMessageId)).toBeNull();
});
it('does not promote a non-authoritative sequence when event data arrives later', async () => {
const messageId = 54322;
await store.storeMeta(messageId, {
isGroup: false,
targetId: 123456,
sequence: 0,
sequenceAuthoritative: false,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: 88,
random: 12,
timestamp: 1700000000,
});
await store.storeEvent(messageId, false, 123456, 88, PRIVATE_MESSAGE_EVENT, {
time: 1700000000,
post_type: 'message',
message_type: 'private',
message_id: messageId,
message_seq: 88,
});
expect(await store.findMeta(messageId)).toMatchObject({
sequenceAuthoritative: false,
clientSequence: 88,
});
});
it('rolls back private history metadata when storing the event body fails', async () => {
const messageId = -987654321;
const peerId = 555;
const database = new DatabaseSync(testDbPath);
database.exec(`
CREATE TRIGGER reject_history_event_body
BEFORE UPDATE OF data ON messages
WHEN NEW.message_hash = ${messageId} AND NEW.data IS NOT NULL
BEGIN
SELECT RAISE(ABORT, 'history event body rejected');
END
`);
database.close();
await expect(
async () =>
await store.storeHistoryEvent(
messageId,
false,
peerId,
850,
PRIVATE_MESSAGE_EVENT,
{
time: 1_700_000_000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
user_id: peerId,
message_id: messageId,
message_seq: 50,
},
{
sequenceAuthoritative: true,
meta: {
isGroup: false,
targetId: peerId,
sequence: 850,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: 50,
privateDirection: 'incoming',
random: 42,
timestamp: 1_700_000_000,
},
},
),
).rejects.toThrow(/history event body rejected/);
expect(await store.findMeta(messageId)).toBeNull();
expect(await store.findEvent(messageId)).toBeNull();
expect(await store.findLatestAuthoritativeSequence(false, peerId)).toBeNull();
});
});
describe('listSessionEvents', () => {
async function storeGroupSequence(groupId: number): Promise<void> {
for (let sequence = 1; sequence <= 5; sequence++) {
await store.storeEvent(sequence, true, groupId, sequence, GROUP_MESSAGE_EVENT, {
post_type: 'message',
message_type: 'group',
group_id: groupId,
message_id: sequence,
message_seq: sequence,
});
}
}
it('returns the anchor and newer messages in chronological order', async () => {
const groupId = 123456;
await storeGroupSequence(groupId);
const messages = await store.listSessionEvents(true, groupId, 3, 3, false);
expect(messages.map((message) => message.message_seq)).toEqual([3, 4, 5]);
});
it('keeps the anchor and older messages as the default', async () => {
const groupId = 123456;
await storeGroupSequence(groupId);
const messages = await store.listSessionEvents(true, groupId, 3, 3);
expect(messages.map((message) => message.message_seq)).toEqual([1, 2, 3]);
});
it('excludes unconfirmed sequences from replies and history anchors (#254)', async () => {
const groupId = 123456;
await store.storeEvent(1, true, groupId, 9748, GROUP_MESSAGE_EVENT, {
post_type: 'message',
message_type: 'group',
group_id: groupId,
group_name: 'Test group',
message_id: 1,
message_seq: 9748,
});
await store.storeEvent(
2,
true,
groupId,
1799283572,
GROUP_MESSAGE_EVENT,
{
post_type: 'message_sent',
message_type: 'group',
group_id: groupId,
message_id: 2,
message_seq: 1799283572,
message: [{ type: 'file', data: { file: 'test.bin' } }],
},
{ sequenceAuthoritative: false },
);
expect(await store.findLatestAuthoritativeSequence(true, groupId)).toBe(9748);
expect(
(await store.listSessionEvents(true, groupId)).map(
(message) => message.message_seq,
),
).toEqual([9748]);
expect(await store.resolveReplySequence(true, groupId, 2)).toBeNull();
expect(await store.findMeta(2)).toMatchObject({
sequence: 1799283572,
sequenceAuthoritative: false,
});
});
it('migrates legacy synthetic group-file and reply-backfill sequences (#254)', async () => {
const groupId = 123456;
await store.close();
const legacy = new DatabaseSync(testDbPath);
legacy.exec(`
DROP TABLE messages;
CREATE TABLE messages (
message_hash INTEGER PRIMARY KEY,
is_group INTEGER NOT NULL,
session_id INTEGER NOT NULL,
sequence INTEGER NOT NULL,
event_name TEXT NOT NULL,
client_sequence INTEGER NOT NULL DEFAULT 0,
random INTEGER NOT NULL DEFAULT 0,
timestamp INTEGER NOT NULL DEFAULT 0,
data TEXT
)
`);
const insert = legacy.prepare(`
INSERT INTO messages
(message_hash, is_group, session_id, sequence, event_name, client_sequence, random, timestamp, data)
VALUES (?, 1, ?, ?, ?, 0, ?, ?, ?)
`);
const insertPrivate = legacy.prepare(`
INSERT INTO messages
(message_hash, is_group, session_id, sequence, event_name, client_sequence, random, timestamp, data)
VALUES (?, 0, ?, ?, ?, 0, ?, ?, ?)
`);
insert.run(
1,
groupId,
9748,
GROUP_MESSAGE_EVENT,
0,
1700000000,
JSON.stringify({
time: 1700000000,
post_type: 'message',
message_type: 'group',
group_id: groupId,
message_id: 1,
message_seq: 9748,
user_id: 222,
message: [{ type: 'text', data: { text: 'real' } }],
}),
);
insert.run(
2,
groupId,
1799283572,
GROUP_MESSAGE_EVENT,
1799283572,
1700000100,
JSON.stringify({
time: 1700000100,
post_type: 'message_sent',
message_type: 'group',
group_id: groupId,
message_id: 2,
message_seq: 1799283572,
user_id: 10001,
message: [{ type: 'file', data: { file: 'test.bin' } }],
}),
);
insert.run(
3,
groupId,
1813530543,
GROUP_MESSAGE_EVENT,
0,
1700000200,
JSON.stringify({
time: 1700000200,
post_type: 'message',
message_type: 'group',
group_id: groupId,
message_id: 3,
message_seq: 1813530543,
user_id: 0,
message: [{ type: 'text', data: { text: '[引用消息]' } }],
}),
);
insert.run(
4,
groupId,
9732,
GROUP_MESSAGE_EVENT,
0,
0,
JSON.stringify({
time: 0,
post_type: 'message',
message_type: 'group',
group_id: groupId,
message_id: 4,
message_seq: 9732,
user_id: 0,
message: [{ type: 'text', data: { text: '[已删除]' } }],
}),
);
insert.run(
5,
groupId,
1813530544,
GROUP_MESSAGE_EVENT,
0,
1700000300,
JSON.stringify({
time: 1700000300,
post_type: 'message',
message_type: 'group',
group_id: groupId,
message_id: 5,
message_seq: 1813530544,
user_id: 800,
message: [{ type: 'text', data: { text: 'quoted inline content' } }],
sender: {
user_id: 800,
nickname: '',
card: '',
role: 'member',
sex: 'unknown',
age: 0,
},
anonymous: null,
}),
);
insertPrivate.run(
6,
555,
800,
PRIVATE_MESSAGE_EVENT,
1234,
1700000400,
JSON.stringify({
time: 1700000400,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: 6,
message_seq: 800,
user_id: 555,
message: [{ type: 'text', data: { text: 'real private message' } }],
sender: { user_id: 555, nickname: 'Alice', sex: 'unknown', age: 0 },
}),
);
insertPrivate.run(
7,
555,
1813530545,
PRIVATE_MESSAGE_EVENT,
0,
1700000500,
JSON.stringify({
time: 1700000500,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: 7,
message_seq: 1813530545,
user_id: 555,
message: [{ type: 'text', data: { text: 'quoted private content' } }],
sender: { user_id: 555, nickname: '', sex: 'unknown', age: 0 },
}),
);
legacy
.prepare(
`
INSERT INTO messages
(message_hash, is_group, session_id, sequence, event_name, client_sequence, random, timestamp, data)
VALUES (9, 0, 555, 850, ?, 4455, 99, 1700000550, NULL)
`,
)
.run(PRIVATE_MESSAGE_EVENT);
legacy.close();
store = new MessageStore(testDbPath);
await store.initialization;
const migrator = new MessageStoreMigrator(testDbPath);
await migrator.initialization;
expect(await migrator.getStatus()).toMatchObject({
phase: 'migrating',
processed: 0,
total: 8,
});
expect(await store.findMeta(2)).toMatchObject({ sequenceAuthoritative: true });
expect(await migrator.runBatch(3)).toMatchObject({
phase: 'migrating',
processed: 3,
total: 8,
});
await store.storeMeta(-100, {
isGroup: true,
targetId: groupId,
sequence: 9700,
sequenceAuthoritative: true,
eventName: GROUP_MESSAGE_EVENT,
clientSequence: 0,
random: 0,
timestamp: 1700000600,
});
await migrator.close();
const resumedMigrator = new MessageStoreMigrator(testDbPath);
await resumedMigrator.initialization;
expect(await resumedMigrator.getStatus()).toMatchObject({
phase: 'migrating',
processed: 3,
total: 8,
});
expect(await resumedMigrator.runBatch(3)).toMatchObject({
phase: 'migrating',
processed: 6,
total: 8,
});
expect(await resumedMigrator.runBatch(3)).toMatchObject({
phase: 'complete',
processed: 8,
total: 8,
});
await resumedMigrator.close();
expect(await store.findLatestAuthoritativeSequence(true, groupId)).toBe(9748);
expect(
(await store.listSessionEvents(true, groupId)).map(
(message) => message.message_seq,
),
).toEqual([9748]);
expect(await store.findMeta(2)).toMatchObject({ sequenceAuthoritative: false });
expect(await store.findMeta(3)).toMatchObject({ sequenceAuthoritative: false });
expect(await store.findMeta(4)).toMatchObject({ sequenceAuthoritative: false });
expect(await store.findMeta(5)).toMatchObject({ sequenceAuthoritative: false });
// Pre-fix observed/fetched private rows contain sender-local field-5
// sequences and may not become anchors. A send receipt with a retained
// client sequence is already authoritative and remains usable.
expect(await store.findLatestAuthoritativeSequence(false, 555)).toBe(850);
expect(await store.findMeta(6)).toMatchObject({ sequenceAuthoritative: false });
expect(await store.findMeta(7)).toMatchObject({ sequenceAuthoritative: false });
expect(await store.findMeta(9)).toMatchObject({ sequenceAuthoritative: true });
expect(await store.findMeta(-100)).toMatchObject({
sequence: 9700,
sequenceAuthoritative: true,
});
await store.storeMeta(8, {
isGroup: false,
targetId: 555,
sequence: 900,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: 1240,
random: 88,
timestamp: 1700000600,
});
expect(await store.findLatestAuthoritativeSequence(false, 555)).toBe(900);
});
it('resumes classification when an older schema upgrade stopped between steps', async () => {
await store.close();
const legacy = new DatabaseSync(testDbPath);
legacy.exec(`
DROP TABLE messages;
CREATE TABLE messages (
message_hash INTEGER PRIMARY KEY,
is_group INTEGER NOT NULL,
session_id INTEGER NOT NULL,
sequence INTEGER NOT NULL,
sequence_authoritative INTEGER NOT NULL DEFAULT 1,
event_name TEXT NOT NULL,
client_sequence INTEGER NOT NULL DEFAULT 0,
private_direction INTEGER NOT NULL DEFAULT -1,
random INTEGER NOT NULL DEFAULT 0,
timestamp INTEGER NOT NULL DEFAULT 0,
data TEXT
);
CREATE TABLE message_store_migrations (
name TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
);
INSERT INTO messages
(message_hash, is_group, session_id, sequence, sequence_authoritative,
event_name, client_sequence, private_direction, random, timestamp, data)
VALUES
(1, 0, 555, 800, 1, '${PRIVATE_MESSAGE_EVENT}', 0, 0, 0, 1700000000,
'{"post_type":"message","message_type":"private","sub_type":"friend"}')
`);
legacy.close();
store = new MessageStore(testDbPath);
await store.initialization;
const migrator = new MessageStoreMigrator(testDbPath);
await migrator.initialization;
expect(await migrator.getStatus()).toEqual({
phase: 'migrating',
processed: 0,
total: 1,
});
expect(await migrator.runBatch(10)).toEqual({
phase: 'complete',
processed: 1,
total: 1,
});
await migrator.close();
expect(await store.findMeta(1)).toMatchObject({ sequenceAuthoritative: false });
});
it('creates the normal indexes when background preparation creates the database', async () => {
await store.close();
fs.unlinkSync(testDbPath);
await prepareMessageStoreDatabase(testDbPath);
const database = new DatabaseSync(testDbPath);
const indexes = database
.prepare(
`
SELECT name FROM sqlite_schema
WHERE type = 'index' AND tbl_name = 'messages'
`,
)
.all() as Array<{ name: string }>;
database.close();
expect(indexes.map(({ name }) => name).sort()).toEqual([
'idx_messages_private_client_direction',
'idx_messages_private_client_seq',
'idx_messages_session_authoritative_seq',
'idx_messages_session_seq',
]);
store = new MessageStore(testDbPath);
await store.initialization;
});
});
describe('private recall invalidation', () => {
it('removes the recalled cached event by peer and client sequence', async () => {
const messageId = -123456789;
const peerId = 555;
await store.storeMeta(messageId, {
isGroup: false,
targetId: peerId,
sequence: 63214,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: 32743,
random: 88,
timestamp: 1700000600,
});
await store.storeEvent(messageId, false, peerId, 63214, PRIVATE_MESSAGE_EVENT, {
time: 1700000600,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: messageId,
message_seq: 63214,
user_id: peerId,
message: [{ type: 'text', data: { text: '77' } }],
});
const selfMessageId = -22334455;
await store.storeMeta(selfMessageId, {
isGroup: false,
targetId: peerId,
sequence: 73214,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: 32743,
random: 99,
timestamp: 1700000601,
});
await store.storeEvent(selfMessageId, false, peerId, 73214, PRIVATE_MESSAGE_EVENT, {
time: 1700000601,
post_type: 'message_sent',
message_type: 'private',
sub_type: 'friend',
message_id: selfMessageId,
message_seq: 32743,
user_id: 10001,
target_id: peerId,
message: [{ type: 'text', data: { text: 'self' } }],
});
expect(await store.recordPrivateRecall(peerId, 32743, false, 1700000700)).toBe(
messageId,
);
expect(await store.findEvent(messageId)).toBeNull();
expect(await store.findMeta(messageId)).toBeNull();
expect(await store.findEvent(selfMessageId)).not.toBeNull();
expect(await store.recordPrivateRecall(peerId, 32743, false, 1700000700)).toBeNull();
expect(await store.recordPrivateRecall(peerId, 32743, true, 1700000700)).toBe(
selfMessageId,
);
});
it('prevents a recalled message from being stored after a meta-only race', async () => {
const messageId = -123456789;
const peerId = 555;
await store.storeMeta(messageId, {
isGroup: false,
targetId: peerId,
sequence: 63214,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: 32743,
privateDirection: 'incoming',
random: 88,
timestamp: 1700000600,
});
expect(await store.recordPrivateRecall(peerId, 32743, false, 1700000700)).toBe(
messageId,
);
await store.storeEvent(messageId, false, peerId, 63214, PRIVATE_MESSAGE_EVENT, {
time: 1700000600,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: messageId,
message_seq: 32743,
user_id: peerId,
});
expect(await store.findEvent(messageId)).toBeNull();
expect(await store.findMeta(messageId)).toBeNull();
});
it('prevents an outgoing self-sent event from reviving after its meta is recalled', async () => {
const messageId = -22334455;
const peerId = 555;
await store.storeMeta(messageId, {
isGroup: false,
targetId: peerId,
sequence: 73214,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: 32743,
privateDirection: 'outgoing',
random: 99,
timestamp: 1700000600,
});
expect(await store.recordPrivateRecall(peerId, 32743, true, 1700000700)).toBe(
messageId,
);
await store.storeEvent(messageId, false, peerId, 73214, PRIVATE_MESSAGE_EVENT, {
time: 1700000600,
post_type: 'message_sent',
message_type: 'private',
sub_type: 'friend',
message_id: messageId,
message_seq: 32743,
user_id: 10001,
target_id: peerId,
});
expect(await store.findEvent(messageId)).toBeNull();
expect(await store.findMeta(messageId)).toBeNull();
});
it('does not delete a later message that reused the recalled client sequence', async () => {
const peerId = 555;
const clientSequence = 32743;
const oldMessageId = -123456789;
const futureMessageId = -22334455;
const storePrivate = async (
messageId: number,
ntSequence: number,
timestamp: number,
) => {
await store.storeMeta(messageId, {
isGroup: false,
targetId: peerId,
sequence: ntSequence,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence,
privateDirection: 'incoming',
random: ntSequence,
timestamp,
});
await store.storeEvent(
messageId,
false,
peerId,
ntSequence,
PRIVATE_MESSAGE_EVENT,
{
time: timestamp,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: messageId,
message_seq: clientSequence,
user_id: peerId,
},
);
};
await storePrivate(oldMessageId, 63214, 600);
await storePrivate(futureMessageId, 73214, 800);
expect(await store.findPrivateMessageId(peerId, clientSequence, false, 700)).toBe(
oldMessageId,
);
expect(await store.recordPrivateRecall(peerId, clientSequence, false, 700)).toBe(
oldMessageId,
);
expect(await store.findEvent(oldMessageId)).toBeNull();
expect(await store.findEvent(futureMessageId)).not.toBeNull();
expect(await store.findPrivateMessageId(peerId, clientSequence, false)).toBe(
futureMessageId,
);
});
it('keeps the opposite direction when sender-local sequences collide', async () => {
const peerId = 555;
await store.recordPrivateRecall(peerId, 32743, false, 1700000700);
await store.storeEvent(-22334455, false, peerId, 73214, PRIVATE_MESSAGE_EVENT, {
time: 1700000601,
post_type: 'message_sent',
message_type: 'private',
sub_type: 'friend',
message_id: -22334455,
message_seq: 32743,
user_id: 10001,
target_id: peerId,
});
expect(await store.findEvent(-22334455)).not.toBeNull();
});
it('rejects invalid recall identifiers instead of treating them as cache misses', async () => {
await expect(
async () => await store.recordPrivateRecall(0, 32743, false, 1700000700),
).rejects.toThrow('invalid private recall peer');
await expect(
async () => await store.recordPrivateRecall(555, 0, false, 1700000700),
).rejects.toThrow('invalid private recall client sequence');
});
});
describe('resolvePrivateReplyMessageId', () => {
const peerId = 2705892349;
const sendId = -12721351;
const sentAt = 1_788_102_428;
async function storeOutgoing(
clientSequence: number,
direction: 'incoming' | 'outgoing' = 'outgoing',
) {
await store.storeMeta(sendId, {
isGroup: false,
targetId: peerId,
sequence: 682,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence,
privateDirection: direction,
random: 1,
timestamp: sentAt,
});
}
it('returns the send id when the quote sequence matches the stored client sequence', async () => {
await storeOutgoing(27_892);
expect(await store.resolvePrivateReplyMessageId(peerId, 27_892, true, sentAt)).toBe(
sendId,
);
});
it('returns the send id by timestamp when the quote sequence is not the local client sequence', async () => {
await storeOutgoing(201_616_628);
expect(await store.resolvePrivateReplyMessageId(peerId, 27_892, true, sentAt)).toBe(
sendId,
);
});
it('does not use timestamp fallback for incoming quotes', async () => {
await storeOutgoing(201_616_628, 'incoming');
expect(
await store.resolvePrivateReplyMessageId(peerId, 27_892, false, sentAt),
).toBeNull();
});
it('returns the send id when the quote sequence matches the stored conversation sequence', async () => {
await storeOutgoing(201_616_628);
expect(await store.resolvePrivateReplyMessageId(peerId, 682, true, sentAt + 2)).toBe(
sendId,
);
});
it('returns the send id when the quote time drifts a few seconds from the send receipt', async () => {
await storeOutgoing(201_616_628);
expect(await store.resolvePrivateReplyMessageId(peerId, 27_892, true, sentAt + 2)).toBe(
sendId,
);
});
it('does not guess by time when two outgoing messages share the quote window', async () => {
await storeOutgoing(201_616_628);
await store.storeMeta(sendId + 1, {
isGroup: false,
targetId: peerId,
sequence: 683,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: 201_616_629,
privateDirection: 'outgoing',
random: 2,
timestamp: sentAt + 1,
});
expect(
await store.resolvePrivateReplyMessageId(peerId, 27_892, true, sentAt + 2),
).toBeNull();
});
it('does not choose the latest same-second message without unique quoted content', async () => {
for (const id of [101, 102]) {
await store.storeMeta(id, {
isGroup: false,
targetId: peerId,
sequence: id,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: id + 1000,
privateDirection: 'outgoing',
random: id,
timestamp: sentAt,
});
await store.storeEvent(id, false, peerId, id, PRIVATE_MESSAGE_EVENT, {
time: sentAt,
post_type: 'message_sent',
message_type: 'private',
sub_type: 'friend',
message: [{ type: 'text', data: { text: 'same text' } }],
});
}
expect(await store.resolvePrivateReplyMessageId(peerId, 999, true, sentAt)).toBeNull();
expect(
await store.resolvePrivateReplyMessageId(peerId, 999, true, sentAt, [
{ type: 'text', text: 'same text' },
]),
).toBeNull();
expect(
await store.resolvePrivateReplyMessageId(peerId, 999, true, sentAt, [
{ type: 'text', text: 'same' },
]),
).toBeNull();
expect(
await store.resolvePrivateReplyMessageId(peerId, 1101, true, sentAt, [
{ type: 'text', text: 'same text' },
]),
).toBe(101);
});
it.each([
['truncated text', 'short', { type: 'text', data: { text: 'short but longer' } }],
['media label', '[语音]', { type: 'record', data: { file: 'voice.amr' } }],
])(
'does not confuse plain text with a competing %s preview',
async (_name, text, other) => {
for (const [id, message] of [
[101, [{ type: 'text', data: { text } }]],
[102, [other]],
] as const) {
await store.storeMeta(id, {
isGroup: false,
targetId: peerId,
sequence: id,
sequenceAuthoritative: true,
eventName: PRIVATE_MESSAGE_EVENT,
clientSequence: id + 1000,
privateDirection: 'outgoing',
random: id,
timestamp: sentAt,
});
await store.storeEvent(id, false, peerId, id, PRIVATE_MESSAGE_EVENT, {
time: sentAt,
post_type: 'message_sent',
message_type: 'private',
sub_type: 'friend',
message: JSON.parse(JSON.stringify(message)),
});
}
expect(
await store.resolvePrivateReplyMessageId(peerId, 999, true, sentAt, [
{ type: 'text', text },
]),
).toBeNull();
},
);
});
it('lets a real group event replace an older non-authoritative placeholder', async () => {
const groupId = 123456;
await store.storeEvent(
123,
true,
groupId,
99,
GROUP_MESSAGE_EVENT,
{
post_type: 'message',
message_type: 'group',
group_id: groupId,
message_id: 123,
message_seq: 99,
},
{ sequenceAuthoritative: false },
);
await store.storeEvent(123, true, groupId, 100, GROUP_MESSAGE_EVENT, {
post_type: 'message',
message_type: 'group',
group_id: groupId,
message_id: 123,
message_seq: 100,
});
expect(await store.findMeta(123)).toMatchObject({
sequence: 100,
sequenceAuthoritative: true,
});
});
describe('listReadSessions', () => {
it('uses current groups and only genuine incoming friend sessions', async () => {
const event = (postType: string, subType: string) => ({
time: 1700000000,
post_type: postType,
message_type: 'private',
sub_type: subType,
});
await store.storeEvent(
1001,
false,
40001,
1,
PRIVATE_MESSAGE_EVENT,
event('message', 'friend'),
);
await store.storeEvent(
1002,
false,
40001,
2,
PRIVATE_MESSAGE_EVENT,
event('message', 'friend'),
);
await store.storeEvent(
1003,
false,
40002,
3,
PRIVATE_MESSAGE_EVENT,
event('message', 'group'),
);
await store.storeEvent(
1004,
false,
40003,
4,
PRIVATE_MESSAGE_EVENT,
event('message_sent', 'friend'),
);
expect(await store.listReadSessions([30002, 30001, 30002])).toEqual({
groupIds: [30002, 30001],
privateUserIds: [40001],
});
});
it('fails on a corrupt current group target', async () => {
await expect(async () => await store.listReadSessions([0])).rejects.toThrow(
'invalid group id 0',
);
});
});
});