From 454fffff6149879abbdcff9d190e99ef822cfd02 Mon Sep 17 00:00:00 2001 From: Sudo-Ivan Date: Wed, 11 Mar 2026 05:13:04 -0500 Subject: [PATCH] Add tests for ContactsPage and new ConversationViewer and NomadNetworkSidebar components --- tests/frontend/ContactsPage.test.js | 46 ++++ .../ConversationViewerButtons.test.js | 217 ++++++++++++++++ tests/frontend/NetworkVisualiser.test.js | 8 + tests/frontend/NomadNetworkSidebar.test.js | 234 ++++++++++++++++++ 4 files changed, 505 insertions(+) create mode 100644 tests/frontend/ConversationViewerButtons.test.js create mode 100644 tests/frontend/NomadNetworkSidebar.test.js diff --git a/tests/frontend/ContactsPage.test.js b/tests/frontend/ContactsPage.test.js index 3104e09..acf1576 100644 --- a/tests/frontend/ContactsPage.test.js +++ b/tests/frontend/ContactsPage.test.js @@ -41,6 +41,9 @@ describe("ContactsPage.vue", () => { }); } if (url === "/api/v1/telephone/contacts") { + return Promise.resolve({ data: { contacts: [], total_count: 0 } }); + } + if (url === "/api/v1/telephone/contacts/export") { return Promise.resolve({ data: { contacts: [] } }); } if (url.startsWith("/api/v1/telephone/contacts/check/")) { @@ -99,4 +102,47 @@ describe("ContactsPage.vue", () => { ); expect(axiosMock.post).not.toHaveBeenCalled(); }); + + it("exports contacts via GET /api/v1/telephone/contacts/export", async () => { + const wrapper = mountPage(); + await wrapper.vm.$nextTick(); + + await wrapper.vm.exportContacts(); + + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/telephone/contacts/export"); + }); + + it("imports contacts via POST /api/v1/telephone/contacts/import", async () => { + const wrapper = mountPage(); + await wrapper.vm.$nextTick(); + axiosMock.post.mockResolvedValue({ data: { added: 2, skipped: 0 } }); + + await wrapper.vm.importContacts([ + { name: "A", remote_identity_hash: "a".repeat(32) }, + { name: "B", remote_identity_hash: "b".repeat(32) }, + ]); + + expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/telephone/contacts/import", { + contacts: [ + { name: "A", remote_identity_hash: "a".repeat(32) }, + { name: "B", remote_identity_hash: "b".repeat(32) }, + ], + }); + }); + + it("mounts within 500ms", () => { + const start = performance.now(); + const wrapper = mountPage(); + const elapsed = performance.now() - start; + expect(wrapper.find("h1").exists()).toBe(true); + expect(elapsed).toBeLessThan(500); + }); + + it("export and import buttons are present", async () => { + const wrapper = mountPage(); + await wrapper.vm.$nextTick(); + const html = wrapper.html(); + expect(html).toContain("contacts.export_contacts"); + expect(html).toContain("contacts.import_contacts"); + }); }); diff --git a/tests/frontend/ConversationViewerButtons.test.js b/tests/frontend/ConversationViewerButtons.test.js new file mode 100644 index 0000000..cce0c0f --- /dev/null +++ b/tests/frontend/ConversationViewerButtons.test.js @@ -0,0 +1,217 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import ConversationViewer from "@/components/messages/ConversationViewer.vue"; +import WebSocketConnection from "@/js/WebSocketConnection"; +import DialogUtils from "@/js/DialogUtils"; +import GlobalEmitter from "@/js/GlobalEmitter"; +import GlobalState from "@/js/GlobalState"; + +const RENDER_THRESHOLD_MS = 500; + +vi.mock("@/js/DialogUtils", () => ({ + default: { + confirm: vi.fn(() => Promise.resolve(true)), + alert: vi.fn(), + }, +})); + +describe("ConversationViewer.vue button interactions", () => { + let axiosMock; + + beforeEach(() => { + WebSocketConnection.connect(); + vi.clearAllMocks(); + axiosMock = { + get: vi.fn().mockImplementation((url) => { + if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); + if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); + if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); + return Promise.resolve({ data: {} }); + }), + post: vi.fn().mockResolvedValue({ data: {} }), + delete: vi.fn().mockResolvedValue({ data: {} }), + }; + window.axios = axiosMock; + + GlobalState.blockedDestinations = []; + GlobalState.config = { banished_effect_enabled: false }; + + vi.stubGlobal("localStorage", { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }); + window.URL.createObjectURL = vi.fn(() => "mock-url"); + vi.stubGlobal("FileReader", vi.fn(() => ({ + readAsDataURL: vi.fn(function () { + this.result = "data:image/png;base64,mock"; + this.onload?.({ target: { result: this.result } }); + }), + }))); + }); + + afterEach(() => { + delete window.axios; + vi.unstubAllGlobals(); + WebSocketConnection.destroy(); + }); + + const mountViewer = (overrides = {}) => + mount(ConversationViewer, { + props: { + selectedPeer: { destination_hash: "a".repeat(32), display_name: "Test Peer" }, + myLxmfAddressHash: "b".repeat(32), + conversations: [], + ...overrides, + }, + global: { + mocks: { $t: (key) => key }, + stubs: { + MaterialDesignIcon: true, + AddImageButton: true, + AddAudioButton: true, + SendMessageButton: true, + ConversationDropDownMenu: true, + PaperMessageModal: true, + AudioWaveformPlayer: true, + LxmfUserIcon: true, + }, + }, + }); + + it("mounts within render threshold", () => { + const start = performance.now(); + const wrapper = mountViewer(); + const elapsed = performance.now() - start; + expect(wrapper.find(".flex").exists()).toBe(true); + expect(elapsed).toBeLessThan(RENDER_THRESHOLD_MS); + }); + + it("banish button calls API when confirmed", async () => { + const emitSpy = vi.spyOn(GlobalEmitter, "emit"); + const wrapper = mountViewer(); + await wrapper.vm.$nextTick(); + + await wrapper.vm.onBanishHeaderClick(); + + expect(DialogUtils.confirm).toHaveBeenCalled(); + expect(axiosMock.post).toHaveBeenCalledWith( + "/api/v1/blocked-destinations", + expect.objectContaining({ destination_hash: "a".repeat(32) }) + ); + expect(emitSpy).toHaveBeenCalledWith("block-status-changed"); + emitSpy.mockRestore(); + }); + + it("banish button hidden when peer is blocked", async () => { + GlobalState.blockedDestinations = [{ destination_hash: "a".repeat(32) }]; + const wrapper = mountViewer(); + await wrapper.vm.$nextTick(); + wrapper.vm.checkIfSelectedPeerBlocked(); + await wrapper.vm.$nextTick(); + + const banishBtn = wrapper.findAll("button").find((b) => b.attributes("title")?.includes("banish")); + expect(banishBtn).toBeUndefined(); + }); + + it("telemetry history button opens modal", async () => { + const wrapper = mountViewer(); + await wrapper.vm.$nextTick(); + + const telemetryBtn = wrapper.findAll("button").find((b) => b.attributes("title") === "View Telemetry History"); + expect(telemetryBtn).toBeDefined(); + + await telemetryBtn.trigger("click"); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isTelemetryHistoryModalOpen).toBe(true); + }); + + it("close button emits close", async () => { + const wrapper = mountViewer(); + await wrapper.vm.$nextTick(); + + const closeBtn = wrapper.findAll("button").find((b) => b.attributes("title") === "Close"); + expect(closeBtn).toBeDefined(); + + await closeBtn.trigger("click"); + + expect(wrapper.emitted("close")).toHaveLength(1); + }); + + it("onMessageContextMenu opens menu and Reply works", async () => { + const wrapper = mountViewer(); + const chatItem = { + type: "lxmf_message", + is_outbound: false, + lxmf_message: { + hash: "msg-1", + content: "Hello", + state: "delivered", + fields: {}, + }, + }; + wrapper.vm.chatItems = [chatItem]; + await wrapper.vm.$nextTick(); + + const replySpy = vi.spyOn(wrapper.vm, "replyToMessage"); + + wrapper.vm.onMessageContextMenu({ clientX: 100, clientY: 100 }, chatItem); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.messageContextMenu.show).toBe(true); + + const menuEl = Array.from(document.body.querySelectorAll(".fixed")).find( + (el) => el.textContent?.includes("Reply") && el.textContent?.includes("Delete") + ); + expect(menuEl).toBeTruthy(); + + const replyBtn = menuEl?.querySelector("button"); + expect(replyBtn?.textContent).toContain("Reply"); + + replyBtn?.click(); + await wrapper.vm.$nextTick(); + + expect(replySpy).toHaveBeenCalledWith(chatItem); + }); + + it("message context menu Delete calls deleteChatItem", async () => { + const wrapper = mountViewer(); + const chatItem = { + type: "lxmf_message", + is_outbound: false, + lxmf_message: { hash: "msg-del", content: "Hi", state: "delivered", fields: {} }, + }; + wrapper.vm.chatItems = [chatItem]; + await wrapper.vm.$nextTick(); + + const deleteSpy = vi.spyOn(wrapper.vm, "deleteChatItem"); + + wrapper.vm.messageContextMenu.chatItem = chatItem; + wrapper.vm.messageContextMenu.show = true; + await wrapper.vm.$nextTick(); + + const menuEl = Array.from(document.body.querySelectorAll(".fixed")).find( + (el) => el.textContent?.includes("Reply") && el.textContent?.includes("Delete") + ); + const deleteBtn = menuEl ? Array.from(menuEl.querySelectorAll("button")).find((b) => b.textContent.includes("Delete")) : null; + expect(deleteBtn).toBeTruthy(); + + deleteBtn?.click(); + await wrapper.vm.$nextTick(); + + expect(deleteSpy).toHaveBeenCalledWith(chatItem); + }); + + it("call button exists and onStartCall is callable", async () => { + const wrapper = mountViewer(); + expect(typeof wrapper.vm.onStartCall).toBe("function"); + await wrapper.vm.onStartCall(); + }); + + it("share contact button exists and openShareContactModal is callable", async () => { + const wrapper = mountViewer(); + expect(typeof wrapper.vm.openShareContactModal).toBe("function"); + wrapper.vm.openShareContactModal(); + }); +}); diff --git a/tests/frontend/NetworkVisualiser.test.js b/tests/frontend/NetworkVisualiser.test.js index 3c492a3..0e16aac 100644 --- a/tests/frontend/NetworkVisualiser.test.js +++ b/tests/frontend/NetworkVisualiser.test.js @@ -109,6 +109,14 @@ describe("NetworkVisualiser.vue", () => { } return Promise.resolve({ data: {} }); }), + post: vi.fn().mockImplementation((url) => { + if (url.includes("/api/v1/path-table")) { + return Promise.resolve({ + data: { path_table: [{ hash: "node1", interface: "eth0", hops: 1 }], total_count: 1 }, + }); + } + return Promise.resolve({ data: {} }); + }), }; window.axios = axiosMock; diff --git a/tests/frontend/NomadNetworkSidebar.test.js b/tests/frontend/NomadNetworkSidebar.test.js new file mode 100644 index 0000000..9e374ad --- /dev/null +++ b/tests/frontend/NomadNetworkSidebar.test.js @@ -0,0 +1,234 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import NomadNetworkSidebar from "@/components/nomadnetwork/NomadNetworkSidebar.vue"; +import DialogUtils from "@/js/DialogUtils"; +import GlobalState from "@/js/GlobalState"; +import GlobalEmitter from "@/js/GlobalEmitter"; + +vi.mock("@/js/DialogUtils", () => ({ + default: { + confirm: vi.fn(() => Promise.resolve(true)), + alert: vi.fn(), + prompt: vi.fn((msg, def) => Promise.resolve(def || "renamed")), + }, +})); + +describe("NomadNetworkSidebar.vue", () => { + let axiosMock; + + const defaultFavourite = { + destination_hash: "a".repeat(32), + display_name: "Test Favourite", + }; + const defaultNode = { + destination_hash: "b".repeat(32), + identity_hash: "c".repeat(32), + display_name: "Test Node", + updated_at: new Date().toISOString(), + }; + + beforeEach(() => { + axiosMock = { + get: vi.fn().mockResolvedValue({ data: {} }), + post: vi.fn().mockResolvedValue({ data: {} }), + delete: vi.fn().mockResolvedValue({ data: {} }), + }; + window.axios = axiosMock; + + GlobalState.blockedDestinations = []; + GlobalState.config = { banished_effect_enabled: false }; + + vi.stubGlobal("localStorage", { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }); + }); + + afterEach(() => { + delete window.axios; + vi.unstubAllGlobals(); + }); + + const mountSidebar = (overrides = {}) => + mount(NomadNetworkSidebar, { + props: { + nodes: overrides.nodes ?? { [defaultNode.destination_hash]: defaultNode }, + favourites: overrides.favourites ?? [defaultFavourite], + selectedDestinationHash: overrides.selectedDestinationHash ?? "", + nodesSearchTerm: overrides.nodesSearchTerm ?? "", + totalNodesCount: overrides.totalNodesCount ?? 1, + isLoadingMoreNodes: overrides.isLoadingMoreNodes ?? false, + hasMoreNodes: overrides.hasMoreNodes ?? false, + }, + global: { + mocks: { $t: (key) => key }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + }, + }, + }); + + it("renders favourites tab with favourite cards", async () => { + const wrapper = mountSidebar(); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("nomadnet.favourites"); + expect(wrapper.text()).toContain("Test Favourite"); + }); + + it("3-dots on favourite card opens context menu", async () => { + const wrapper = mountSidebar(); + await wrapper.vm.$nextTick(); + + const favouriteCard = wrapper.find(".favourite-card"); + expect(favouriteCard.exists()).toBe(true); + const dotsBtn = favouriteCard.findComponent({ name: "IconButton" }); + expect(dotsBtn.exists()).toBe(true); + + await dotsBtn.trigger("click"); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.favouriteContextMenu.show).toBe(true); + expect(wrapper.vm.favouriteContextMenu.targetHash).toBe(defaultFavourite.destination_hash); + }); + + it("favourite context menu has rename, banish, remove options", async () => { + const wrapper = mountSidebar(); + wrapper.vm.favouriteContextMenu = { + show: true, + targetHash: defaultFavourite.destination_hash, + targetSectionId: "default", + x: 100, + y: 100, + }; + wrapper.vm.sectionContextMenu.show = false; + await wrapper.vm.$nextTick(); + + const menuEls = document.body.querySelectorAll(".fixed"); + const menuEl = Array.from(menuEls).find((el) => el.textContent.includes("nomadnet.rename")); + expect(menuEl).toBeTruthy(); + expect(menuEl.textContent).toContain("nomadnet.block_node"); + expect(menuEl.textContent).toContain("nomadnet.remove"); + }); + + it("rename from favourite context menu emits rename-favourite", async () => { + const wrapper = mountSidebar(); + wrapper.vm.favouriteContextMenu = { + show: true, + targetHash: defaultFavourite.destination_hash, + targetSectionId: "default", + }; + await wrapper.vm.renameFavouriteFromContext(); + + expect(wrapper.emitted("rename-favourite")).toBeDefined(); + expect(wrapper.emitted("rename-favourite")).toHaveLength(1); + }); + + it("remove from favourite context menu emits remove-favourite", async () => { + const wrapper = mountSidebar(); + wrapper.vm.favouriteContextMenu = { + show: true, + targetHash: defaultFavourite.destination_hash, + targetSectionId: "default", + }; + await wrapper.vm.removeFavouriteFromContext(); + + expect(wrapper.emitted("remove-favourite")).toBeDefined(); + expect(wrapper.emitted("remove-favourite")).toHaveLength(1); + }); + + it("banish from favourite context menu calls API and emits block-status-changed", async () => { + const emitSpy = vi.spyOn(GlobalEmitter, "emit"); + const wrapper = mountSidebar(); + wrapper.vm.favouriteContextMenu = { + show: true, + targetHash: defaultFavourite.destination_hash, + targetSectionId: "default", + }; + wrapper.vm.sectionContextMenu.show = false; + await wrapper.vm.$nextTick(); + + const menuEls = document.body.querySelectorAll(".fixed"); + const menuEl = Array.from(menuEls).find((el) => el.textContent.includes("nomadnet.rename")); + expect(menuEl).toBeTruthy(); + const banishBtn = Array.from(menuEl.querySelectorAll("button")).find((b) => + b.textContent.includes("nomadnet.block_node") + ); + expect(banishBtn).toBeTruthy(); + await banishBtn.click(); + await wrapper.vm.$nextTick(); + + expect(DialogUtils.confirm).toHaveBeenCalled(); + expect(axiosMock.post).toHaveBeenCalledWith( + "/api/v1/blocked-destinations", + expect.objectContaining({ destination_hash: defaultFavourite.destination_hash }) + ); + expect(emitSpy).toHaveBeenCalledWith("block-status-changed"); + emitSpy.mockRestore(); + }); + + it("announces tab shows announce cards with 3-dots dropdown", async () => { + const wrapper = mountSidebar(); + const announceTab = wrapper.findAll("button").find((b) => b.text().includes("nomadnet.announces")); + await announceTab.trigger("click"); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("Test Node"); + const dropMenus = wrapper.findAllComponents({ name: "DropDownMenu" }); + expect(dropMenus.length).toBeGreaterThan(0); + }); + + it("right-click on announce card opens context menu", async () => { + const wrapper = mountSidebar(); + const announceTab = wrapper.findAll("button").find((b) => b.text().includes("nomadnet.announces")); + await announceTab.trigger("click"); + await wrapper.vm.$nextTick(); + + const announceCard = wrapper.find(".announce-card"); + expect(announceCard.exists()).toBe(true); + + await announceCard.trigger("contextmenu"); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.announceContextMenu.show).toBe(true); + expect(wrapper.vm.announceContextMenu.node).toEqual(defaultNode); + }); + + it("add favourite from announce context menu emits add-favourite", async () => { + const wrapper = mountSidebar({ favourites: [] }); + const announceTab = wrapper.findAll("button").find((b) => b.text().includes("nomadnet.announces")); + await announceTab.trigger("click"); + await wrapper.vm.$nextTick(); + + wrapper.vm.announceContextMenu = { show: true, node: defaultNode }; + await wrapper.vm.$nextTick(); + + wrapper.vm.addFavouriteFromContext(); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted("add-favourite")).toHaveLength(1); + expect(wrapper.emitted("add-favourite")[0][0]).toEqual(defaultNode); + }); + + it("block from announce context menu calls API", async () => { + const emitSpy = vi.spyOn(GlobalEmitter, "emit"); + const wrapper = mountSidebar(); + wrapper.vm.announceContextMenu = { show: true, node: defaultNode }; + await wrapper.vm.$nextTick(); + + await wrapper.vm.blockAnnounceFromContext(); + await wrapper.vm.$nextTick(); + + expect(DialogUtils.confirm).toHaveBeenCalled(); + expect(axiosMock.post).toHaveBeenCalledWith( + "/api/v1/blocked-destinations", + expect.objectContaining({ destination_hash: defaultNode.identity_hash }) + ); + expect(emitSpy).toHaveBeenCalledWith("block-status-changed"); + emitSpy.mockRestore(); + }); +});