Add tests for ContactsPage and new ConversationViewer and NomadNetworkSidebar components

This commit is contained in:
Sudo-Ivan
2026-03-11 05:13:04 -05:00
parent f11a9b9675
commit 454fffff61
4 changed files with 505 additions and 0 deletions

View File

@@ -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");
});
});

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
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();
});
});