import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, createTask, deleteTask, getAllChats, getAllRegisteredGroups, getLastBotMessageTimestamp, getMessagesSince, getNewMessages, getTaskById, setRegisteredGroup, storeChatMetadata, storeMessage, updateTask, } from './db.js'; import { formatMessages } from './router.js'; beforeEach(() => { _initTestDatabase(); }); // Helper to store a message using the normalized NewMessage interface function store(overrides: { id: string; chat_jid: string; sender: string; sender_name: string; content: string; timestamp: string; is_from_me?: boolean; }) { storeMessage({ id: overrides.id, chat_jid: overrides.chat_jid, sender: overrides.sender, sender_name: overrides.sender_name, content: overrides.content, timestamp: overrides.timestamp, is_from_me: overrides.is_from_me ?? false, }); } // --- storeMessage (NewMessage format) --- describe('storeMessage', () => { it('stores a message and retrieves it', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'msg-1', chat_jid: 'group@g.us', sender: '123@s.whatsapp.net', sender_name: 'Alice', content: 'hello world', timestamp: '2024-01-01T00:00:01.000Z', }); const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].id).toBe('msg-1'); expect(messages[0].sender).toBe('123@s.whatsapp.net'); expect(messages[0].sender_name).toBe('Alice'); expect(messages[0].content).toBe('hello world'); }); it('filters out empty content', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'msg-2', chat_jid: 'group@g.us', sender: '111@s.whatsapp.net', sender_name: 'Dave', content: '', timestamp: '2024-01-01T00:00:04.000Z', }); const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(0); }); it('stores is_from_me flag', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'msg-3', chat_jid: 'group@g.us', sender: 'me@s.whatsapp.net', sender_name: 'Me', content: 'my message', timestamp: '2024-01-01T00:00:05.000Z', is_from_me: true, }); // Message is stored (we can retrieve it — is_from_me doesn't affect retrieval) const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); }); it('upserts on duplicate id+chat_jid', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'msg-dup', chat_jid: 'group@g.us', sender: '123@s.whatsapp.net', sender_name: 'Alice', content: 'original', timestamp: '2024-01-01T00:00:01.000Z', }); store({ id: 'msg-dup', chat_jid: 'group@g.us', sender: '123@s.whatsapp.net', sender_name: 'Alice', content: 'updated', timestamp: '2024-01-01T00:00:01.000Z', }); const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].content).toBe('updated'); }); }); // --- reply context persistence --- describe('reply context', () => { it('stores and retrieves reply_to fields', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); storeMessage({ id: 'reply-1', chat_jid: 'group@g.us', sender: '123', sender_name: 'Alice', content: 'Yes, on my way!', timestamp: '2024-01-01T00:00:01.000Z', reply_to_message_id: '42', reply_to_message_content: 'Are you coming tonight?', reply_to_sender_name: 'Bob', }); const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].reply_to_message_id).toBe('42'); expect(messages[0].reply_to_message_content).toBe('Are you coming tonight?'); expect(messages[0].reply_to_sender_name).toBe('Bob'); }); it('returns null for messages without reply context', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'no-reply', chat_jid: 'group@g.us', sender: '123', sender_name: 'Alice', content: 'Just a normal message', timestamp: '2024-01-01T00:00:01.000Z', }); const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].reply_to_message_id).toBeNull(); expect(messages[0].reply_to_message_content).toBeNull(); expect(messages[0].reply_to_sender_name).toBeNull(); }); it('retrieves reply context via getNewMessages', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); storeMessage({ id: 'reply-2', chat_jid: 'group@g.us', sender: '456', sender_name: 'Carol', content: 'Agreed', timestamp: '2024-01-01T00:00:01.000Z', reply_to_message_id: '99', reply_to_message_content: 'We should meet', reply_to_sender_name: 'Dave', }); const { messages } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].reply_to_message_id).toBe('99'); expect(messages[0].reply_to_sender_name).toBe('Dave'); }); }); // --- getMessagesSince --- describe('getMessagesSince', () => { beforeEach(() => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'm1', chat_jid: 'group@g.us', sender: 'Alice@s.whatsapp.net', sender_name: 'Alice', content: 'first', timestamp: '2024-01-01T00:00:01.000Z', }); store({ id: 'm2', chat_jid: 'group@g.us', sender: 'Bob@s.whatsapp.net', sender_name: 'Bob', content: 'second', timestamp: '2024-01-01T00:00:02.000Z', }); storeMessage({ id: 'm3', chat_jid: 'group@g.us', sender: 'Bot@s.whatsapp.net', sender_name: 'Bot', content: 'bot reply', timestamp: '2024-01-01T00:00:03.000Z', is_bot_message: true, }); store({ id: 'm4', chat_jid: 'group@g.us', sender: 'Carol@s.whatsapp.net', sender_name: 'Carol', content: 'third', timestamp: '2024-01-01T00:00:04.000Z', }); }); it('returns messages after the given timestamp', () => { const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:02.000Z', 'Andy'); // Should exclude m1, m2 (before/at timestamp), m3 (bot message) expect(msgs).toHaveLength(1); expect(msgs[0].content).toBe('third'); }); it('excludes bot messages via is_bot_message flag', () => { const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); const botMsgs = msgs.filter((m) => m.content === 'bot reply'); expect(botMsgs).toHaveLength(0); }); it('returns all non-bot messages when sinceTimestamp is empty', () => { const msgs = getMessagesSince('group@g.us', '', 'Andy'); // 3 user messages (bot message excluded) expect(msgs).toHaveLength(3); }); it('recovers cursor from last bot reply when lastAgentTimestamp is missing', () => { // beforeEach already inserts m3 (bot reply at 00:00:03) and m4 (user at 00:00:04) // Add more old history before the bot reply for (let i = 1; i <= 50; i++) { store({ id: `history-${i}`, chat_jid: 'group@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: `old message ${i}`, timestamp: `2023-06-${String(i).padStart(2, '0')}T12:00:00.000Z`, }); } // New message after the bot reply (m3 at 00:00:03) store({ id: 'new-1', chat_jid: 'group@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: 'new message after bot reply', timestamp: '2024-01-02T00:00:00.000Z', }); // Recover cursor from the last bot message (m3 from beforeEach) const recovered = getLastBotMessageTimestamp('group@g.us', 'Andy'); expect(recovered).toBe('2024-01-01T00:00:03.000Z'); // Using recovered cursor: only gets messages after the bot reply const msgs = getMessagesSince('group@g.us', recovered!, 'Andy', 10); // m4 (third, 00:00:04) + new-1 — skips all 50 old messages and m1/m2 expect(msgs).toHaveLength(2); expect(msgs[0].content).toBe('third'); expect(msgs[1].content).toBe('new message after bot reply'); }); it('caps messages to configured limit even with recovered cursor', () => { // beforeEach inserts m3 (bot at 00:00:03). Add 30 messages after it. for (let i = 1; i <= 30; i++) { store({ id: `pending-${i}`, chat_jid: 'group@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: `pending message ${i}`, timestamp: `2024-02-${String(i).padStart(2, '0')}T12:00:00.000Z`, }); } const recovered = getLastBotMessageTimestamp('group@g.us', 'Andy'); expect(recovered).toBe('2024-01-01T00:00:03.000Z'); // With limit=10, only the 10 most recent are returned const msgs = getMessagesSince('group@g.us', recovered!, 'Andy', 10); expect(msgs).toHaveLength(10); // Most recent 10: pending-21 through pending-30 expect(msgs[0].content).toBe('pending message 21'); expect(msgs[9].content).toBe('pending message 30'); }); it('returns last N messages when no bot reply and no cursor exist', () => { // Use a fresh group with no bot messages storeChatMetadata('fresh@g.us', '2024-01-01T00:00:00.000Z'); for (let i = 1; i <= 20; i++) { store({ id: `fresh-${i}`, chat_jid: 'fresh@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: `message ${i}`, timestamp: `2024-02-${String(i).padStart(2, '0')}T12:00:00.000Z`, }); } const recovered = getLastBotMessageTimestamp('fresh@g.us', 'Andy'); expect(recovered).toBeUndefined(); // No cursor → sinceTimestamp = '' but limit caps the result const msgs = getMessagesSince('fresh@g.us', '', 'Andy', 10); expect(msgs).toHaveLength(10); const prompt = formatMessages(msgs, 'Asia/Jerusalem'); const messageTagCount = (prompt.match(/ { // Simulate a message written before migration: has prefix but is_bot_message = 0 store({ id: 'm5', chat_jid: 'group@g.us', sender: 'Bot@s.whatsapp.net', sender_name: 'Bot', content: 'Andy: old bot reply', timestamp: '2024-01-01T00:00:05.000Z', }); const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:04.000Z', 'Andy'); expect(msgs).toHaveLength(0); }); }); // --- getNewMessages --- describe('getNewMessages', () => { beforeEach(() => { storeChatMetadata('group1@g.us', '2024-01-01T00:00:00.000Z'); storeChatMetadata('group2@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'a1', chat_jid: 'group1@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: 'g1 msg1', timestamp: '2024-01-01T00:00:01.000Z', }); store({ id: 'a2', chat_jid: 'group2@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: 'g2 msg1', timestamp: '2024-01-01T00:00:02.000Z', }); storeMessage({ id: 'a3', chat_jid: 'group1@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: 'bot reply', timestamp: '2024-01-01T00:00:03.000Z', is_bot_message: true, }); store({ id: 'a4', chat_jid: 'group1@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: 'g1 msg2', timestamp: '2024-01-01T00:00:04.000Z', }); }); it('returns new messages across multiple groups', () => { const { messages, newTimestamp } = getNewMessages( ['group1@g.us', 'group2@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', ); // Excludes bot message, returns 3 user messages expect(messages).toHaveLength(3); expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z'); }); it('filters by timestamp', () => { const { messages } = getNewMessages(['group1@g.us', 'group2@g.us'], '2024-01-01T00:00:02.000Z', 'Andy'); // Only g1 msg2 (after ts, not bot) expect(messages).toHaveLength(1); expect(messages[0].content).toBe('g1 msg2'); }); it('returns empty for no registered groups', () => { const { messages, newTimestamp } = getNewMessages([], '', 'Andy'); expect(messages).toHaveLength(0); expect(newTimestamp).toBe(''); }); }); // --- storeChatMetadata --- describe('storeChatMetadata', () => { it('stores chat with JID as default name', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); const chats = getAllChats(); expect(chats).toHaveLength(1); expect(chats[0].jid).toBe('group@g.us'); expect(chats[0].name).toBe('group@g.us'); }); it('stores chat with explicit name', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z', 'My Group'); const chats = getAllChats(); expect(chats[0].name).toBe('My Group'); }); it('updates name on subsequent call with name', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Updated Name'); const chats = getAllChats(); expect(chats).toHaveLength(1); expect(chats[0].name).toBe('Updated Name'); }); it('preserves newer timestamp on conflict', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:05.000Z'); storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z'); const chats = getAllChats(); expect(chats[0].last_message_time).toBe('2024-01-01T00:00:05.000Z'); }); }); // --- Task CRUD --- describe('task CRUD', () => { it('creates and retrieves a task', () => { createTask({ id: 'task-1', group_folder: 'main', chat_jid: 'group@g.us', prompt: 'do something', schedule_type: 'once', schedule_value: '2024-06-01T00:00:00.000Z', context_mode: 'isolated', next_run: '2024-06-01T00:00:00.000Z', status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); const task = getTaskById('task-1'); expect(task).toBeDefined(); expect(task!.prompt).toBe('do something'); expect(task!.status).toBe('active'); }); it('updates task status', () => { createTask({ id: 'task-2', group_folder: 'main', chat_jid: 'group@g.us', prompt: 'test', schedule_type: 'once', schedule_value: '2024-06-01T00:00:00.000Z', context_mode: 'isolated', next_run: null, status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); updateTask('task-2', { status: 'paused' }); expect(getTaskById('task-2')!.status).toBe('paused'); }); it('deletes a task and its run logs', () => { createTask({ id: 'task-3', group_folder: 'main', chat_jid: 'group@g.us', prompt: 'delete me', schedule_type: 'once', schedule_value: '2024-06-01T00:00:00.000Z', context_mode: 'isolated', next_run: null, status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); deleteTask('task-3'); expect(getTaskById('task-3')).toBeUndefined(); }); }); // --- LIMIT behavior --- describe('message query LIMIT', () => { beforeEach(() => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); for (let i = 1; i <= 10; i++) { store({ id: `lim-${i}`, chat_jid: 'group@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: `message ${i}`, timestamp: `2024-01-01T00:00:${String(i).padStart(2, '0')}.000Z`, }); } }); it('getNewMessages caps to limit and returns most recent in chronological order', () => { const { messages, newTimestamp } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', 3); expect(messages).toHaveLength(3); expect(messages[0].content).toBe('message 8'); expect(messages[2].content).toBe('message 10'); // Chronological order preserved expect(messages[1].timestamp > messages[0].timestamp).toBe(true); // newTimestamp reflects latest returned row expect(newTimestamp).toBe('2024-01-01T00:00:10.000Z'); }); it('getMessagesSince caps to limit and returns most recent in chronological order', () => { const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy', 3); expect(messages).toHaveLength(3); expect(messages[0].content).toBe('message 8'); expect(messages[2].content).toBe('message 10'); expect(messages[1].timestamp > messages[0].timestamp).toBe(true); }); it('returns all messages when count is under the limit', () => { const { messages } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', 50); expect(messages).toHaveLength(10); }); }); // --- RegisteredGroup isMain round-trip --- describe('registered group isMain', () => { it('persists isMain=true through set/get round-trip', () => { setRegisteredGroup('main@s.whatsapp.net', { name: 'Main Chat', folder: 'whatsapp_main', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', isMain: true, }); const groups = getAllRegisteredGroups(); const group = groups['main@s.whatsapp.net']; expect(group).toBeDefined(); expect(group.isMain).toBe(true); expect(group.folder).toBe('whatsapp_main'); }); it('omits isMain for non-main groups', () => { setRegisteredGroup('group@g.us', { name: 'Family Chat', folder: 'whatsapp_family-chat', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', }); const groups = getAllRegisteredGroups(); const group = groups['group@g.us']; expect(group).toBeDefined(); expect(group.isMain).toBeUndefined(); }); });