const test = require('node:test'); const assert = require('node:assert/strict'); const { readFileSync } = require('node:fs'); const { resolve } = require('node:path'); const Module = require('node:module'); const ts = require('typescript'); const file = resolve(__dirname, '../src/utils/private-messages.ts'); const loaded = new Module(file, module); loaded._compile(ts.transpileModule(readFileSync(file, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText, file); const util = loaded.exports; const contact = (account_id, peer_uid, time = '', last_text = '') => ({ account_id, peer_uid, account_name: '', peer_name: '', last_text, last_message_at: time, message_count: 0 }); const message = (id, time = '', overrides = {}) => ({ id, account_id: 'a', peer_uid: '100', direction: 'inbound', message_type: 'text', text: '', state: 'succeeded', error: '', message_at: time, created_at: '', ...overrides }); test('conversation and message API queries preserve pagination, source and encoded peers', () => { assert.equal(util.conversationQuery({}, 2, 25), 'page=2&page_size=25'); const query = new URLSearchParams(util.conversationQuery({ accountId: 'a/b' }, 1, 25)); assert.equal(query.get('account_id'), 'a/b'); const messages = new URLSearchParams(util.messagesQuery('a/b', '100+20', 3, 20)); assert.equal(messages.get('account_id'), 'a/b'); assert.equal(messages.get('peer_uid'), '100+20'); assert.equal(messages.get('page'), '3'); assert.equal(messages.get('page_size'), '20'); }); test('conversation identity includes account and peer, never peer alone', () => { assert.equal(util.conversationKey(contact('a', '100')), 'a:100'); assert.notEqual(util.conversationKey(contact('a', '100')), util.conversationKey(contact('b', '100'))); assert.notEqual(util.conversationKey(contact('a', '100')), util.conversationKey(contact('a', '200'))); }); test('contact pages merge by compound key, update summaries, sort newest first and retain older rows', () => { const old = contact('a', '100', '2026-06-01T10:00:00Z'); const updated = { ...old, last_text: '最新摘要', last_message_at: '2026-06-01T12:00:00Z' }; const previous = [old, contact('b', '100', '2026-06-01T11:00:00Z')]; const result = util.mergeConversations(previous, [updated, updated]); assert.deepEqual(result.map(util.conversationKey), ['a:100', 'b:100']); assert.equal(result[0].last_text, '最新摘要'); assert.equal(previous[0].last_text, ''); assert.equal(util.mergeConversations(result, []).length, 2); }); test('missing and invalid contact times stay behind known times, equal-time ordering is deterministic', () => { const rows = [contact('b', '100'), contact('c', '100', 'invalid'), contact('a', '100', '2026-06-01T12:00:00Z')]; assert.deepEqual(util.mergeConversations([], rows).map(util.conversationKey), ['a:100', 'b:100', 'c:100']); }); test('late conversation snapshots never roll back newer real message times or summaries', () => { const fresh = contact('b', '100', '2026-06-01T12:00:10Z', '最新收发消息'); const old = contact('b', '100', '2026-06-01T12:00:01Z', '旧分页摘要'); const other = contact('a', '100', '2026-06-01T12:00:05Z'); const result = util.mergeConversations([fresh, other], [old]); assert.deepEqual(result.map(util.conversationKey), ['b:100', 'a:100']); assert.equal(result[0].last_text, '最新收发消息'); assert.equal(result[0].last_message_at, fresh.last_message_at); const duplicates = util.mergeConversations([], [old, fresh, other]); assert.equal(duplicates[0].last_text, '最新收发消息'); assert.equal(duplicates.length, 2); const incomplete = util.mergeConversations([fresh], [contact('b', '100', '', '尚无真实时间')]); assert.equal(incomplete[0].last_message_at, fresh.last_message_at); const renamed = util.mergeConversations([fresh], [{ ...fresh, peer_name: '实时昵称' }, { ...fresh, peer_name: '过期别名' }]); assert.equal(renamed[0].peer_name, '实时昵称'); }); test('unknown conversation times stay last even before the Unix epoch, and ties use stable compound keys', () => { const rows = [contact('a', '2'), contact('z', '1', '1970-01-01T00:00:00Z'), contact('y', '1', '1969-12-31T23:59:59Z'), contact('a', '10'), contact('a0', '1'), contact('b', '1', 'invalid')]; const expected = ['z:1', 'y:1', 'a:10', 'a:2', 'a0:1', 'b:1']; assert.deepEqual(util.mergeConversations([], rows).map(util.conversationKey), expected); assert.deepEqual(util.mergeConversations([], rows.slice().reverse()).map(util.conversationKey), expected); }); test('older message pages merge without duplicates, ascending times and updated send results', () => { const latest = [message('3', '2026-06-01T12:00:03Z'), message('2', '2026-06-01T12:00:02Z', { state: 'sending' })]; const updated = message('2', '2026-06-01T12:00:02Z', { state: 'succeeded' }); const result = util.mergeMessages(latest, [updated, message('1', '2026-06-01T12:00:01Z'), updated]); assert.deepEqual(result.map((m) => m.id), ['1', '2', '3']); assert.equal(result[1].state, 'succeeded'); assert.equal(latest[1].state, 'sending'); assert.equal(util.mergeMessages(result, []).length, 3); }); test('message ordering handles real storage times and exact large numeric IDs without number rounding', () => { const rows = [message('9007199254740993', '', { created_at: '2026-06-01T12:00:00Z' }), message('9007199254740992', '', { created_at: '2026-06-01T12:00:00Z' }), message('2'), message('10')]; assert.deepEqual(util.mergeMessages([], rows).map((m) => m.id), ['2', '10', '9007199254740992', '9007199254740993']); }); test('draft updates isolate sessions and success only clears the exact submitted revision', () => { let drafts = util.updateDraft({}, 'a:100', '甲'); drafts = util.updateDraft(drafts, 'b:100', '乙'); const original = drafts; const cleared = util.clearSentDraft(drafts, 'a:100', drafts['a:100'].version); assert.equal(cleared['a:100'].text, ''); assert.equal(cleared['b:100'].text, '乙'); assert.equal(original['a:100'].text, '甲'); const newer = util.updateDraft(drafts, 'a:100', '新输入'); assert.strictEqual(util.clearSentDraft(newer, 'a:100', drafts['a:100'].version), newer); assert.strictEqual(util.clearSentDraft(drafts, 'missing', 1), drafts); }); test('retyping the same value counts as new input and cannot be erased by an old success', () => { const original = util.updateDraft({}, 'a:100', '相同文本'); const changed = util.updateDraft(util.updateDraft(original, 'a:100', '其他输入'), 'a:100', '相同文本'); assert.strictEqual(util.clearSentDraft(changed, 'a:100', original['a:100'].version), changed); }); test('direction, state and type labels preserve unknown server values without faking delivery or reads', () => { assert.equal(util.messageDirectionLabel('inbound'), '收到'); assert.equal(util.messageDirectionLabel('outbound'), '发出'); assert.equal(util.messageDirectionLabel('other'), 'other'); for (const [state, label, tone] of [['succeeded', '已发送', 'success'], ['sending', '发送中', 'processing'], ['failed', '发送失败', 'error'], ['unknown', '结果未确认', 'warning']]) { assert.equal(util.messageStateLabel(state), label); assert.equal(util.messageStateTone(state), tone); } assert.equal(util.messageStateLabel('other'), 'other'); assert.equal(util.messageStateTone('other'), 'default'); assert.equal(util.messageTypeLabel('text'), '文字'); assert.equal(util.messageTypeLabel('image'), '图片'); assert.equal(util.messageTypeLabel('unknown'), '未知类型'); assert.equal(util.messageTypeLabel('video'), 'video'); });