mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-05-27 13:24:10 +00:00
Add tests for message handling and conversation updates
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user