diff --git a/tests/backend/test_message_handler.py b/tests/backend/test_message_handler.py index b4e2a14..01dd7d4 100644 --- a/tests/backend/test_message_handler.py +++ b/tests/backend/test_message_handler.py @@ -31,6 +31,71 @@ class TestMessageHandler(unittest.TestCase): self.assertIn("DELETE FROM lxmf_conversation_folders", second_call_args[0]) self.assertIn("dest", second_call_args[1]) + def test_get_conversations_includes_failed_count(self): + self.db.provider.fetchall.return_value = [ + { + "id": 1, + "hash": "h1", + "source_hash": "src", + "destination_hash": "dst", + "peer_hash": "peer1", + "state": "failed", + "progress": 0, + "is_incoming": 0, + "title": "", + "content": "failed msg", + "fields": "{}", + "timestamp": 1234567890, + "is_spam": 0, + "reply_to_hash": None, + "created_at": "2023-01-01", + "updated_at": "2023-01-01", + "peer_app_data": None, + "custom_display_name": None, + "contact_image": None, + "icon_name": None, + "foreground_colour": None, + "background_colour": None, + "last_read_at": None, + "folder_id": None, + "folder_name": None, + "failed_count": 3, + "is_contact": 0, + } + ] + result = self.handler.get_conversations("local") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["failed_count"], 3) + + def test_get_conversations_with_filter_failed(self): + self.db.provider.fetchall.return_value = [] + self.handler.get_conversations("local", filter_failed=True) + args, _ = self.db.provider.fetchall.call_args + self.assertIn("state = 'failed'", args[0]) + + def test_search_messages(self): + self.db.provider.fetchall.return_value = [ + {"peer_hash": "peer1", "max_ts": 1234567890} + ] + result = self.handler.search_messages("local", "test") + self.assertEqual(len(result), 1) + args, _ = self.db.provider.fetchall.call_args + self.assertIn("LIKE", args[0]) + + def test_get_conversation_messages_with_after_id(self): + self.db.provider.fetchall.return_value = [] + self.handler.get_conversation_messages("local", "dest", after_id=5) + args, _ = self.db.provider.fetchall.call_args + self.assertIn("id > ?", args[0]) + self.assertIn(5, args[1]) + + def test_get_conversation_messages_with_before_id(self): + self.db.provider.fetchall.return_value = [] + self.handler.get_conversation_messages("local", "dest", before_id=10) + args, _ = self.db.provider.fetchall.call_args + self.assertIn("id < ?", args[0]) + self.assertIn(10, args[1]) + if __name__ == "__main__": unittest.main() diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js index 290fa66..d8773f8 100644 --- a/tests/frontend/ConversationViewer.test.js +++ b/tests/frontend/ConversationViewer.test.js @@ -184,6 +184,187 @@ describe("ConversationViewer.vue", () => { ); }); + it("shows retry button in context menu for failed outbound messages", async () => { + const wrapper = mountConversationViewer(); + const failedChatItem = { + type: "lxmf_message", + is_outbound: true, + lxmf_message: { + hash: "failed-hash", + state: "failed", + content: "failed message", + destination_hash: "test-hash", + source_hash: "my-hash", + fields: {}, + }, + }; + wrapper.vm.chatItems = [failedChatItem]; + await wrapper.vm.$nextTick(); + + wrapper.vm.messageContextMenu.chatItem = failedChatItem; + wrapper.vm.messageContextMenu.show = true; + await wrapper.vm.$nextTick(); + + const menuHtml = wrapper.html(); + expect(menuHtml).toContain("Retry"); + }); + + it("does not show retry in context menu for delivered messages", async () => { + const wrapper = mountConversationViewer(); + const deliveredItem = { + type: "lxmf_message", + is_outbound: true, + lxmf_message: { + hash: "delivered-hash", + state: "delivered", + content: "delivered message", + destination_hash: "test-hash", + source_hash: "my-hash", + fields: {}, + }, + }; + wrapper.vm.chatItems = [deliveredItem]; + await wrapper.vm.$nextTick(); + + wrapper.vm.messageContextMenu.chatItem = deliveredItem; + wrapper.vm.messageContextMenu.show = true; + await wrapper.vm.$nextTick(); + + const retryButtons = wrapper.findAll("button").filter((b) => b.text().includes("Retry")); + expect(retryButtons).toHaveLength(0); + }); + + it("calls retrySendingMessage when retry context menu clicked", async () => { + const wrapper = mountConversationViewer(); + const failedChatItem = { + type: "lxmf_message", + is_outbound: true, + lxmf_message: { + hash: "retry-hash", + state: "failed", + content: "retry me", + destination_hash: "test-hash", + source_hash: "my-hash", + fields: {}, + reply_to_hash: null, + }, + }; + + axiosMock.post.mockResolvedValue({ + data: { lxmf_message: { hash: "new-hash", state: "outbound" } }, + }); + + const retrySpy = vi.spyOn(wrapper.vm, "retrySendingMessage").mockResolvedValue(undefined); + + wrapper.vm.messageContextMenu.chatItem = failedChatItem; + wrapper.vm.messageContextMenu.show = true; + await wrapper.vm.$nextTick(); + + const retryButton = wrapper.findAll("button").find((b) => b.text().includes("Retry")); + expect(retryButton).toBeDefined(); + await retryButton.trigger("click"); + + expect(retrySpy).toHaveBeenCalledWith(failedChatItem); + }); + + it("marks received messages as not outbound", async () => { + const wrapper = mountConversationViewer(); + + const incomingMessage = { + hash: "incoming-hash", + source_hash: "test-hash", + destination_hash: "my-hash", + content: "hello", + state: "delivered", + fields: {}, + }; + + wrapper.vm.onLxmfMessageReceived(incomingMessage); + + const addedItem = wrapper.vm.chatItems.find((i) => i.lxmf_message?.hash === "incoming-hash"); + expect(addedItem).toBeDefined(); + expect(addedItem.is_outbound).toBe(false); + }); + + it("generates created_at from timestamp when missing", async () => { + const wrapper = mountConversationViewer(); + + const liveMsg = { + hash: "live-hash", + source_hash: "test-hash", + destination_hash: "my-hash", + content: "hello", + state: "delivered", + timestamp: 1700000000, + fields: {}, + }; + + wrapper.vm.onLxmfMessageReceived(liveMsg); + + const addedItem = wrapper.vm.chatItems.find((i) => i.lxmf_message?.hash === "live-hash"); + expect(addedItem.lxmf_message.created_at).toBe(new Date(1700000000 * 1000).toISOString()); + }); + + it("converts unknown state to outbound for outgoing messages", async () => { + const wrapper = mountConversationViewer(); + + const outMsg = { + hash: "out-hash", + source_hash: "my-hash", + destination_hash: "test-hash", + content: "hello", + state: "unknown", + timestamp: 1700000000, + fields: {}, + }; + + wrapper.vm.onLxmfMessageCreated(outMsg); + + const addedItem = wrapper.vm.chatItems.find((i) => i.lxmf_message?.hash === "out-hash"); + expect(addedItem).toBeDefined(); + expect(addedItem.lxmf_message.state).toBe("outbound"); + expect(addedItem.is_outbound).toBe(true); + }); + + it("preserves unknown state for incoming messages", async () => { + const wrapper = mountConversationViewer(); + + const inMsg = { + hash: "in-unknown-hash", + source_hash: "test-hash", + destination_hash: "my-hash", + content: "hello", + state: "unknown", + timestamp: 1700000000, + fields: {}, + }; + + wrapper.vm.onLxmfMessageReceived(inMsg); + + const addedItem = wrapper.vm.chatItems.find((i) => i.lxmf_message?.hash === "in-unknown-hash"); + expect(addedItem.lxmf_message.state).toBe("unknown"); + }); + + it("does not overwrite existing created_at", async () => { + const wrapper = mountConversationViewer(); + + const dbMsg = { + hash: "db-hash", + source_hash: "test-hash", + destination_hash: "my-hash", + content: "hello", + state: "delivered", + timestamp: 1700000000, + created_at: "2023-11-14T22:13:20.000Z", + fields: {}, + }; + + wrapper.vm.onLxmfMessageReceived(dbMsg); + + const addedItem = wrapper.vm.chatItems.find((i) => i.lxmf_message?.hash === "db-hash"); + expect(addedItem.lxmf_message.created_at).toBe("2023-11-14T22:13:20.000Z"); + }); + it("sets reply state and includes reply_to_hash in sendMessage", async () => { const wrapper = mountConversationViewer(); const chatItem = { diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js index 3d50ae7..044de78 100644 --- a/tests/frontend/MessagesPage.test.js +++ b/tests/frontend/MessagesPage.test.js @@ -109,4 +109,208 @@ describe("MessagesPage.vue", () => { expect(composeSpy).toHaveBeenCalledWith("f".repeat(32)); }); + + it("updates existing conversation in-place without API call on lxmf_message_created", async () => { + const destHash = "a".repeat(32); + const wrapper = mountMessagesPage(); + await wrapper.vm.$nextTick(); + + wrapper.vm.conversations = [ + { + destination_hash: destHash, + display_name: "Test Peer", + latest_message_preview: "old msg", + updated_at: "2025-01-01T00:00:00Z", + }, + ]; + + axiosMock.get.mockClear(); + + await wrapper.vm.onWebsocketMessage({ + data: JSON.stringify({ + type: "lxmf_message_created", + lxmf_message: { + hash: "abc", + source_hash: "my-hash", + destination_hash: destHash, + is_incoming: false, + content: "new msg", + title: "", + timestamp: 1700000000, + }, + }), + }); + + expect(wrapper.vm.conversations[0].latest_message_preview).toBe("new msg"); + const convCalls = axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/lxmf/conversations"); + expect(convCalls).toHaveLength(0); + }); + + it("resolves display name for new conversation only", async () => { + const destHash = "d".repeat(32); + const wrapper = mountMessagesPage(); + await wrapper.vm.$nextTick(); + + wrapper.vm.conversations = []; + wrapper.vm.selectedPeer = { destination_hash: destHash, display_name: "Anonymous Peer" }; + + axiosMock.get.mockClear(); + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/lxmf/conversations") + return Promise.resolve({ + data: { + conversations: [ + { + destination_hash: destHash, + display_name: "Resolved Name", + custom_display_name: null, + }, + ], + }, + }); + return Promise.resolve({ data: {} }); + }); + + await wrapper.vm.onWebsocketMessage({ + data: JSON.stringify({ + type: "lxmf_message_created", + lxmf_message: { + hash: "new1", + source_hash: "my-hash", + destination_hash: destHash, + is_incoming: false, + content: "hello", + title: "", + timestamp: 1700000000, + }, + }), + }); + + await wrapper.vm.$nextTick(); + expect(wrapper.vm.conversations[0].display_name).toBe("Resolved Name"); + expect(wrapper.vm.selectedPeer.display_name).toBe("Resolved Name"); + + const convCalls = axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/lxmf/conversations"); + expect(convCalls).toHaveLength(1); + expect(convCalls[0][1].params.search).toBe(destHash); + expect(convCalls[0][1].params.limit).toBe(1); + }); + + it("updates failed_messages_count on state transition to failed", async () => { + const destHash = "e".repeat(32); + const wrapper = mountMessagesPage(); + await wrapper.vm.$nextTick(); + + wrapper.vm.conversations = [{ destination_hash: destHash, failed_messages_count: 0 }]; + + await wrapper.vm.onWebsocketMessage({ + data: JSON.stringify({ + type: "lxmf_message_state_updated", + lxmf_message: { + hash: "f1", + source_hash: "my-hash", + destination_hash: destHash, + is_incoming: false, + state: "failed", + }, + }), + }); + + expect(wrapper.vm.conversations[0].failed_messages_count).toBe(1); + }); + + it("does not trigger API call on state updates", async () => { + const destHash = "f".repeat(32); + const wrapper = mountMessagesPage(); + await wrapper.vm.$nextTick(); + + wrapper.vm.conversations = [{ destination_hash: destHash, failed_messages_count: 0 }]; + + axiosMock.get.mockClear(); + + for (const state of ["outbound", "sending", "sent", "delivered"]) { + await wrapper.vm.onWebsocketMessage({ + data: JSON.stringify({ + type: "lxmf_message_state_updated", + lxmf_message: { + hash: "s1", + source_hash: "my-hash", + destination_hash: destHash, + is_incoming: false, + state, + }, + }), + }); + } + + const convCalls = axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/lxmf/conversations"); + expect(convCalls).toHaveLength(0); + }); + + it("mutates existing conversation object without replacing array entry", async () => { + const destHash = "a".repeat(32); + const wrapper = mountMessagesPage(); + await wrapper.vm.$nextTick(); + + wrapper.vm.conversations = [ + { + destination_hash: destHash, + display_name: "Peer", + latest_message_preview: "old", + updated_at: "2025-01-01T00:00:00Z", + }, + ]; + + const lengthBefore = wrapper.vm.conversations.length; + + await wrapper.vm.onWebsocketMessage({ + data: JSON.stringify({ + type: "lxmf_message_created", + lxmf_message: { + hash: "abc", + source_hash: "my-hash", + destination_hash: destHash, + is_incoming: false, + content: "new", + title: "", + timestamp: 1700000000, + }, + }), + }); + + expect(wrapper.vm.conversations.length).toBe(lengthBefore); + expect(wrapper.vm.conversations[0].latest_message_preview).toBe("new"); + expect(wrapper.vm.conversations[0].display_name).toBe("Peer"); + }); + + it("uses conversation display name instead of Unknown Peer when composing", async () => { + const destHash = "c".repeat(32); + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/config") + return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } }); + if (url === "/api/v1/lxmf/conversations") + return Promise.resolve({ + data: { + conversations: [ + { + destination_hash: destHash, + display_name: "Existing Peer", + custom_display_name: null, + }, + ], + }, + }); + if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } }); + return Promise.resolve({ data: {} }); + }); + + const wrapper = mountMessagesPage(); + wrapper.vm.conversations = [ + { destination_hash: destHash, display_name: "Existing Peer", custom_display_name: null }, + ]; + await wrapper.vm.$nextTick(); + + await wrapper.vm.onComposeNewMessage(destHash); + expect(wrapper.vm.selectedPeer.display_name).toBe("Existing Peer"); + }); });