mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-23 10:55:50 +00:00
798 lines
30 KiB
JavaScript
798 lines
30 KiB
JavaScript
import { mount } from "@vue/test-utils";
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
|
|
|
|
let wsHandlers = {};
|
|
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
|
|
default: {
|
|
on: vi.fn((event, handler) => {
|
|
wsHandlers[event] = wsHandlers[event] || [];
|
|
wsHandlers[event].push(handler);
|
|
}),
|
|
off: vi.fn((event, handler) => {
|
|
if (wsHandlers[event]) {
|
|
wsHandlers[event] = wsHandlers[event].filter((h) => h !== handler);
|
|
}
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
|
|
default: { formatTimeAgo: (d) => "1h ago" },
|
|
}));
|
|
|
|
const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
|
|
|
|
function mountBell(options = {}) {
|
|
return mount(NotificationBell, {
|
|
global: {
|
|
components: { MaterialDesignIcon },
|
|
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
|
|
mocks: {
|
|
$router: { push: vi.fn() },
|
|
$t: (key) => {
|
|
const map = {
|
|
"app.notifications_no_new": "No new notifications",
|
|
"app.notifications_empty_history": "No notification history",
|
|
"app.notifications_history_title": "Recent notification history",
|
|
};
|
|
return map[key] || key;
|
|
},
|
|
},
|
|
},
|
|
...options,
|
|
});
|
|
}
|
|
|
|
function simulateWsMessage(type, extra = {}) {
|
|
const payload = { type, ...extra };
|
|
if (type === "lxmf.delivery" && payload.lxmf_message === undefined) {
|
|
// Default to a user-facing inbound text message so the bell will
|
|
// reload. Tests that want to exercise the false-trigger path pass an
|
|
// explicit lxmf_message override (reaction, telemetry, empty, etc.).
|
|
payload.lxmf_message = { is_incoming: true, content: "hello", title: "", fields: {} };
|
|
}
|
|
const data = JSON.stringify(payload);
|
|
(wsHandlers["message"] || []).forEach((h) => h({ data }));
|
|
}
|
|
|
|
describe("NotificationBell UI", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
wsHandlers = {};
|
|
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
|
|
global.api.post = vi.fn().mockResolvedValue({ data: {} });
|
|
});
|
|
|
|
afterEach(() => {
|
|
wsHandlers = {};
|
|
});
|
|
|
|
it("renders bell button", () => {
|
|
const wrapper = mountBell();
|
|
const btn = wrapper.find("button.relative.rounded-full");
|
|
expect(btn.exists()).toBe(true);
|
|
});
|
|
|
|
it("shows unread badge when unreadCount > 0", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
wrapper.vm.unreadCount = 5;
|
|
await wrapper.vm.$nextTick();
|
|
expect(wrapper.text()).toContain("5");
|
|
});
|
|
|
|
it("shows 9+ when unreadCount > 9", async () => {
|
|
const wrapper = mountBell();
|
|
wrapper.vm.unreadCount = 12;
|
|
await wrapper.vm.$nextTick();
|
|
expect(wrapper.text()).toContain("9+");
|
|
});
|
|
|
|
it("opens dropdown on button click", async () => {
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
await wrapper.find("button").trigger("click");
|
|
await wrapper.vm.$nextTick();
|
|
expect(wrapper.vm.isDropdownOpen).toBe(true);
|
|
expect(document.body.textContent).toContain("Notifications");
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("shows Clear button when dropdown open and notifications exist", async () => {
|
|
global.api.get = vi.fn().mockResolvedValue({
|
|
data: {
|
|
notifications: [
|
|
{ destination_hash: "h1", display_name: "A", updated_at: new Date().toISOString(), content: "Hi" },
|
|
],
|
|
unread_count: 1,
|
|
},
|
|
});
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
await wrapper.find("button").trigger("click");
|
|
await wrapper.vm.$nextTick();
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
expect(document.body.textContent).toContain("Clear");
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("shows No new notifications when empty", async () => {
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
await wrapper.find("button").trigger("click");
|
|
await wrapper.vm.$nextTick();
|
|
await new Promise((r) => setTimeout(r, 150));
|
|
expect(document.body.textContent).toContain("No new notifications");
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("opening empty dropdown adds one notifications fetch after mount", async () => {
|
|
global.api.get = vi.fn().mockResolvedValue({
|
|
data: { notifications: [], unread_count: 0 },
|
|
});
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
await wrapper.vm.$nextTick();
|
|
const notifGetsAfterMount = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
await wrapper.find("button").trigger("click");
|
|
await new Promise((r) => setTimeout(r, 150));
|
|
const notifGetsAfterOpen = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
expect(notifGetsAfterOpen - notifGetsAfterMount).toBe(1);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("dropdown has Notifications heading when open", async () => {
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
await wrapper.find("button").trigger("click");
|
|
await wrapper.vm.$nextTick();
|
|
const h3 = document.body.querySelector("h3");
|
|
expect(h3?.textContent).toBe("Notifications");
|
|
wrapper.unmount();
|
|
});
|
|
});
|
|
|
|
describe("NotificationBell websocket reliability", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
wsHandlers = {};
|
|
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
|
|
global.api.post = vi.fn().mockResolvedValue({ data: {} });
|
|
});
|
|
|
|
afterEach(() => {
|
|
wsHandlers = {};
|
|
});
|
|
|
|
it("reloads on lxmf.delivery websocket event", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
global.api.get = vi.fn().mockResolvedValue({
|
|
data: { notifications: [{ destination_hash: "d1", display_name: "X", content: "msg" }], unread_count: 1 },
|
|
});
|
|
|
|
simulateWsMessage("lxmf.delivery");
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
expect(wrapper.vm.unreadCount).toBe(1);
|
|
});
|
|
|
|
it("reloads on telephone_missed_call websocket event", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
global.api.get = vi.fn().mockResolvedValue({
|
|
data: {
|
|
notifications: [
|
|
{
|
|
id: 1,
|
|
type: "telephone_missed_call",
|
|
destination_hash: "c1",
|
|
display_name: "Caller",
|
|
content: "Missed",
|
|
},
|
|
],
|
|
unread_count: 1,
|
|
},
|
|
});
|
|
|
|
simulateWsMessage("telephone_missed_call", { remote_identity_hash: "c1" });
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
expect(wrapper.vm.unreadCount).toBe(1);
|
|
});
|
|
|
|
it("reloads on new_voicemail websocket event", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
global.api.get = vi.fn().mockResolvedValue({
|
|
data: {
|
|
notifications: [
|
|
{
|
|
id: 2,
|
|
type: "telephone_voicemail",
|
|
destination_hash: "v1",
|
|
display_name: "VM",
|
|
content: "Voicemail",
|
|
},
|
|
],
|
|
unread_count: 1,
|
|
},
|
|
});
|
|
|
|
simulateWsMessage("new_voicemail", { remote_identity_hash: "v1" });
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
expect(wrapper.vm.unreadCount).toBe(1);
|
|
});
|
|
|
|
it("does NOT reload on unrelated websocket events", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const callsBefore = global.api.get.mock.calls.length;
|
|
|
|
simulateWsMessage("telephone_ringing");
|
|
simulateWsMessage("telephone_call_ended");
|
|
simulateWsMessage("lxmf_message_state_updated");
|
|
simulateWsMessage("lxmf.delivery", { lxmf_message: { is_incoming: false } });
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
expect(global.api.get.mock.calls.length).toBe(callsBefore);
|
|
});
|
|
|
|
it("does NOT reload on outbound lxmf.delivery (delivery confirmation path)", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const callsBefore = global.api.get.mock.calls.length;
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: { is_incoming: false, state: "delivered" },
|
|
});
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
expect(global.api.get.mock.calls.length).toBe(callsBefore);
|
|
});
|
|
|
|
it("reloads on inbound lxmf.delivery", async () => {
|
|
global.api.get = vi.fn().mockResolvedValue({
|
|
data: { notifications: [], unread_count: 0 },
|
|
});
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const callsAfterMount = global.api.get.mock.calls.length;
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: { is_incoming: true, content: "hi", title: "", fields: {} },
|
|
});
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
expect(global.api.get.mock.calls.length).toBeGreaterThan(callsAfterMount);
|
|
const notifCalls = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications");
|
|
expect(notifCalls.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("rapid sequential websocket events all trigger reloads", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const initialCalls = global.api.get.mock.calls.length;
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
simulateWsMessage("lxmf.delivery");
|
|
}
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
|
|
expect(global.api.get.mock.calls.length).toBeGreaterThan(initialCalls);
|
|
});
|
|
|
|
it("ignores malformed websocket payload without crashing", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const initialCalls = global.api.get.mock.calls.length;
|
|
(wsHandlers["message"] || []).forEach((h) => h({ data: "not-json{" }));
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expect(global.api.get.mock.calls.length).toBe(initialCalls);
|
|
});
|
|
});
|
|
|
|
describe("NotificationBell false-trigger suppression", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
wsHandlers = {};
|
|
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
|
|
global.api.post = vi.fn().mockResolvedValue({ data: {} });
|
|
});
|
|
|
|
afterEach(() => {
|
|
wsHandlers = {};
|
|
});
|
|
|
|
function expectNoNotificationsReload(callsBefore) {
|
|
const notifGets = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications");
|
|
expect(notifGets.length).toBe(callsBefore);
|
|
}
|
|
|
|
it("does NOT reload on inbound reaction (is_reaction flag)", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: {
|
|
is_incoming: true,
|
|
is_reaction: true,
|
|
content: "",
|
|
fields: { app_extensions: { reaction_to: "abc", emoji: "fire" } },
|
|
},
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expectNoNotificationsReload(before);
|
|
});
|
|
|
|
it("does NOT reload on inbound reaction signaled only via app_extensions", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: {
|
|
is_incoming: true,
|
|
content: "",
|
|
fields: { app_extensions: { reaction_to: "abc" } },
|
|
},
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expectNoNotificationsReload(before);
|
|
});
|
|
|
|
it("does NOT reload on inbound telemetry-only message", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: {
|
|
is_incoming: true,
|
|
content: "",
|
|
title: "",
|
|
fields: { telemetry: { something: 1 } },
|
|
},
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expectNoNotificationsReload(before);
|
|
});
|
|
|
|
it("does NOT reload on inbound icon-only / empty payload message", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: { is_incoming: true, content: "", title: "", fields: {} },
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expectNoNotificationsReload(before);
|
|
});
|
|
|
|
it("does NOT reload when content is whitespace only", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: { is_incoming: true, content: " \n\t ", title: "", fields: {} },
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expectNoNotificationsReload(before);
|
|
});
|
|
|
|
it("does NOT reload on lxmf.delivery without lxmf_message field", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
(wsHandlers["message"] || []).forEach((h) => h({ data: JSON.stringify({ type: "lxmf.delivery" }) }));
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expectNoNotificationsReload(before);
|
|
});
|
|
|
|
it("does NOT reload on lxmf_message_state_updated (delivery status)", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
|
|
simulateWsMessage("lxmf_message_state_updated", {
|
|
lxmf_message: { is_incoming: false, state: "delivered" },
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expectNoNotificationsReload(before);
|
|
});
|
|
|
|
it("DOES reload on real inbound text message", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: { is_incoming: true, content: "hello", title: "", fields: {} },
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
expect(after).toBeGreaterThan(before);
|
|
});
|
|
|
|
it("DOES reload on inbound title-only message", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: { is_incoming: true, content: "", title: "Subject", fields: {} },
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
expect(after).toBeGreaterThan(before);
|
|
});
|
|
|
|
it("DOES reload on inbound image attachment", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: {
|
|
is_incoming: true,
|
|
content: "",
|
|
fields: { image: { image_size: 1024, image_type: "png" } },
|
|
},
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
expect(after).toBeGreaterThan(before);
|
|
});
|
|
|
|
it("DOES reload on inbound audio attachment", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: {
|
|
is_incoming: true,
|
|
content: "",
|
|
fields: { audio: { audio_size: 4242, audio_mode: 1 } },
|
|
},
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
expect(after).toBeGreaterThan(before);
|
|
});
|
|
|
|
it("DOES reload on inbound file attachment", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: {
|
|
is_incoming: true,
|
|
content: "",
|
|
fields: { file_attachments: [{ file_name: "x.txt", file_size: 5 }] },
|
|
},
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
|
|
expect(after).toBeGreaterThan(before);
|
|
});
|
|
|
|
it("isUserFacingLxmfDelivery method directly classifies common payloads", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const fn = wrapper.vm.isUserFacingLxmfDelivery;
|
|
expect(fn(null)).toBe(false);
|
|
expect(fn(undefined)).toBe(false);
|
|
expect(fn({ is_incoming: false, content: "hello" })).toBe(false);
|
|
expect(fn({ is_incoming: true, content: "hello" })).toBe(true);
|
|
expect(fn({ is_incoming: true, content: "", title: "" })).toBe(false);
|
|
expect(fn({ is_incoming: true, content: "", title: "Subject" })).toBe(true);
|
|
expect(fn({ is_incoming: true, is_reaction: true, content: "still ignored" })).toBe(false);
|
|
expect(
|
|
fn({
|
|
is_incoming: true,
|
|
content: "",
|
|
fields: { app_extensions: { reaction_to: "x" } },
|
|
})
|
|
).toBe(false);
|
|
expect(fn({ is_incoming: true, content: "", fields: { telemetry: { x: 1 } } })).toBe(false);
|
|
expect(fn({ is_incoming: true, content: "", fields: { image: { image_size: 1 } } })).toBe(true);
|
|
expect(fn({ is_incoming: true, content: "", fields: { audio: { audio_size: 1 } } })).toBe(true);
|
|
expect(
|
|
fn({
|
|
is_incoming: true,
|
|
content: "",
|
|
fields: { file_attachments: [{ file_name: "a", file_size: 1 }] },
|
|
})
|
|
).toBe(true);
|
|
expect(fn({ is_incoming: true, content: "", fields: { file_attachments: [] } })).toBe(false);
|
|
});
|
|
|
|
it("badge stays at zero through a flood of reaction events", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
for (let i = 0; i < 25; i++) {
|
|
simulateWsMessage("lxmf.delivery", {
|
|
lxmf_message: {
|
|
is_incoming: true,
|
|
content: "",
|
|
fields: { app_extensions: { reaction_to: `m${i}` } },
|
|
},
|
|
});
|
|
}
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
expect(wrapper.vm.unreadCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("NotificationBell badge accuracy", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
wsHandlers = {};
|
|
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
|
|
global.api.post = vi.fn().mockResolvedValue({ data: {} });
|
|
});
|
|
|
|
afterEach(() => {
|
|
wsHandlers = {};
|
|
});
|
|
|
|
it("badge hidden when unread count is 0", async () => {
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
const badge = wrapper.find("span.bg-red-500");
|
|
expect(badge.exists()).toBe(false);
|
|
});
|
|
|
|
it("badge shows exact count for 1-9", async () => {
|
|
for (let n = 1; n <= 9; n++) {
|
|
const wrapper = mountBell();
|
|
wrapper.vm.unreadCount = n;
|
|
await wrapper.vm.$nextTick();
|
|
expect(wrapper.text()).toContain(String(n));
|
|
}
|
|
});
|
|
|
|
it("badge shows 9+ for counts above 9", async () => {
|
|
for (const n of [10, 50, 100, 999]) {
|
|
const wrapper = mountBell();
|
|
wrapper.vm.unreadCount = n;
|
|
await wrapper.vm.$nextTick();
|
|
expect(wrapper.text()).toContain("9+");
|
|
expect(wrapper.text()).not.toContain(String(n));
|
|
}
|
|
});
|
|
|
|
it("badge updates reactively when unreadCount changes", async () => {
|
|
const wrapper = mountBell();
|
|
wrapper.vm.unreadCount = 3;
|
|
await wrapper.vm.$nextTick();
|
|
expect(wrapper.text()).toContain("3");
|
|
|
|
wrapper.vm.unreadCount = 0;
|
|
await wrapper.vm.$nextTick();
|
|
expect(wrapper.find("span.bg-red-500").exists()).toBe(false);
|
|
|
|
wrapper.vm.unreadCount = 15;
|
|
await wrapper.vm.$nextTick();
|
|
expect(wrapper.text()).toContain("9+");
|
|
});
|
|
|
|
it("opening dropdown syncs unread count from server after mark-as-viewed", async () => {
|
|
global.api.get = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
notifications: [{ destination_hash: "d1", display_name: "A", content: "m" }],
|
|
unread_count: 3,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
notifications: [{ destination_hash: "d1", display_name: "A", content: "m" }],
|
|
unread_count: 3,
|
|
},
|
|
})
|
|
.mockResolvedValue({
|
|
data: { notifications: [], unread_count: 0 },
|
|
});
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
await wrapper.vm.$nextTick();
|
|
|
|
await wrapper.find("button").trigger("click");
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
|
|
expect(wrapper.vm.unreadCount).toBe(0);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("API failure does not cause false badge", async () => {
|
|
global.api.get = vi.fn().mockRejectedValue(new Error("Network error"));
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
expect(wrapper.vm.unreadCount).toBe(0);
|
|
expect(wrapper.vm.notifications).toEqual([]);
|
|
});
|
|
|
|
it("API returning null/empty fields does not cause false badge", async () => {
|
|
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: null, unread_count: null } });
|
|
const wrapper = mountBell();
|
|
await wrapper.vm.$nextTick();
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
expect(wrapper.vm.unreadCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("NotificationBell mark-as-viewed", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
wsHandlers = {};
|
|
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
|
|
global.api.post = vi.fn().mockResolvedValue({ data: {} });
|
|
});
|
|
|
|
afterEach(() => {
|
|
wsHandlers = {};
|
|
});
|
|
|
|
it("calls mark-as-viewed API when dropdown is opened", async () => {
|
|
global.api.get = vi.fn().mockResolvedValue({
|
|
data: {
|
|
notifications: [
|
|
{ type: "lxmf_message", destination_hash: "abc", display_name: "A", content: "x" },
|
|
{ type: "telephone_missed_call", id: 42, destination_hash: "mc", display_name: "B", content: "y" },
|
|
],
|
|
unread_count: 2,
|
|
},
|
|
});
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
await wrapper.find("button").trigger("click");
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
|
|
const postCalls = global.api.post.mock.calls;
|
|
const markCall = postCalls.find((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
|
|
expect(markCall).toBeTruthy();
|
|
expect(markCall[1].destination_hashes).toContain("abc");
|
|
expect(markCall[1].notification_ids).toContain(42);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("skips mark-as-viewed when no notifications", async () => {
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
await wrapper.find("button").trigger("click");
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
const markCalls = global.api.post.mock.calls.filter((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
|
|
expect(markCalls.length).toBe(0);
|
|
wrapper.unmount();
|
|
});
|
|
});
|
|
|
|
describe("NotificationBell history", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
wsHandlers = {};
|
|
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
|
|
global.api.post = vi.fn().mockResolvedValue({ data: {} });
|
|
});
|
|
|
|
afterEach(() => {
|
|
wsHandlers = {};
|
|
});
|
|
|
|
it("shows history control when dropdown is open", async () => {
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
await wrapper.find("button.relative.rounded-full").trigger("click");
|
|
await wrapper.vm.$nextTick();
|
|
const historyBtn = document.body.querySelector('[aria-label="Recent notification history"]');
|
|
expect(historyBtn).toBeTruthy();
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("requests unread=false when toggling history on", async () => {
|
|
global.api.get = vi.fn().mockResolvedValue({
|
|
data: {
|
|
notifications: [
|
|
{
|
|
id: 9,
|
|
type: "telephone_missed_call",
|
|
destination_hash: "ab",
|
|
display_name: "X",
|
|
content: "missed",
|
|
},
|
|
],
|
|
unread_count: 0,
|
|
},
|
|
});
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
await wrapper.vm.$nextTick();
|
|
await wrapper.find("button.relative.rounded-full").trigger("click");
|
|
await new Promise((r) => setTimeout(r, 120));
|
|
global.api.get.mockClear();
|
|
await wrapper.vm.toggleHistory();
|
|
await wrapper.vm.$nextTick();
|
|
const notifCalls = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications");
|
|
expect(notifCalls.length).toBeGreaterThan(0);
|
|
const lastParams = notifCalls[notifCalls.length - 1][1].params;
|
|
expect(lastParams.unread).toBe(false);
|
|
expect(wrapper.vm.showHistory).toBe(true);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("resets history mode when dropdown closes", async () => {
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
wrapper.vm.showHistory = true;
|
|
wrapper.vm.closeDropdown();
|
|
expect(wrapper.vm.showHistory).toBe(false);
|
|
});
|
|
|
|
it("shows empty history copy in history mode", async () => {
|
|
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
await wrapper.find("button.relative.rounded-full").trigger("click");
|
|
await new Promise((r) => setTimeout(r, 120));
|
|
await wrapper.vm.toggleHistory();
|
|
await wrapper.vm.$nextTick();
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
expect(document.body.textContent).toContain("No notification history");
|
|
wrapper.unmount();
|
|
});
|
|
});
|
|
|
|
describe("NotificationBell clear all", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
wsHandlers = {};
|
|
global.api.post = vi.fn().mockResolvedValue({ data: {} });
|
|
});
|
|
|
|
afterEach(() => {
|
|
wsHandlers = {};
|
|
});
|
|
|
|
it("clears all notifications and marks conversations as read", async () => {
|
|
let callCount = 0;
|
|
global.api.get = vi.fn().mockImplementation((url) => {
|
|
if (url === "/api/v1/notifications") {
|
|
callCount++;
|
|
if (callCount <= 2) {
|
|
return Promise.resolve({
|
|
data: {
|
|
notifications: [{ destination_hash: "x", display_name: "X", content: "m" }],
|
|
unread_count: 1,
|
|
},
|
|
});
|
|
}
|
|
return Promise.resolve({ data: { notifications: [], unread_count: 0 } });
|
|
}
|
|
if (url === "/api/v1/lxmf/conversations") {
|
|
return Promise.resolve({
|
|
data: {
|
|
conversations: [
|
|
{ destination_hash: "conv1", is_unread: true },
|
|
{ destination_hash: "conv2", is_unread: false },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
|
|
const wrapper = mountBell({ attachTo: document.body });
|
|
await wrapper.find("button").trigger("click");
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
|
|
await wrapper.vm.clearAllNotifications();
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
|
|
const readCalls = global.api.post.mock.calls.filter((c) => c[0]?.includes("/mark-as-read"));
|
|
expect(readCalls.length).toBe(1);
|
|
expect(readCalls[0][0]).toContain("conv1");
|
|
|
|
wrapper.unmount();
|
|
});
|
|
});
|