mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
313 lines
10 KiB
JavaScript
313 lines
10 KiB
JavaScript
import { mount, flushPromises } from "@vue/test-utils";
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
let wsHandlers = {};
|
|
let emitterHandlers = {};
|
|
|
|
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/GlobalEmitter", () => ({
|
|
default: {
|
|
on: vi.fn((event, handler) => {
|
|
emitterHandlers[event] = emitterHandlers[event] || [];
|
|
emitterHandlers[event].push(handler);
|
|
}),
|
|
off: vi.fn((event, handler) => {
|
|
if (emitterHandlers[event]) {
|
|
emitterHandlers[event] = emitterHandlers[event].filter((h) => h !== handler);
|
|
}
|
|
}),
|
|
emit: vi.fn((event, payload) => {
|
|
(emitterHandlers[event] || []).forEach((h) => h(payload));
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
|
|
default: { formatTimeAgo: () => "1h ago" },
|
|
}));
|
|
|
|
import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
|
|
import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
|
|
import ConversationViewer from "../../meshchatx/src/frontend/components/messages/ConversationViewer.vue";
|
|
|
|
const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
|
|
|
|
const PEER_HASH = "bb".repeat(16);
|
|
|
|
function simulateWsDelivery() {
|
|
const data = JSON.stringify({
|
|
type: "lxmf.delivery",
|
|
lxmf_message: { is_incoming: true, content: "hello", title: "", fields: {} },
|
|
});
|
|
(wsHandlers["message"] || []).forEach((h) => h({ data }));
|
|
}
|
|
|
|
function mountBell() {
|
|
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;
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function mountViewer() {
|
|
return mount(ConversationViewer, {
|
|
props: {
|
|
selectedPeer: { destination_hash: PEER_HASH, display_name: "Peer" },
|
|
myLxmfAddressHash: "aa".repeat(16),
|
|
conversations: [{ destination_hash: PEER_HASH, display_name: "Peer", is_unread: true }],
|
|
},
|
|
global: {
|
|
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
|
|
mocks: {
|
|
$t: (key) => key,
|
|
$route: { meta: {} },
|
|
$router: { push: vi.fn() },
|
|
},
|
|
stubs: {
|
|
MaterialDesignIcon: true,
|
|
AddImageButton: true,
|
|
AddAudioButton: true,
|
|
SendMessageButton: true,
|
|
ConversationDropDownMenu: true,
|
|
PaperMessageModal: true,
|
|
AudioWaveformPlayer: true,
|
|
LxmfUserIcon: true,
|
|
ConversationPeerHeader: true,
|
|
ConversationMessageEntry: true,
|
|
ConversationMessageListVirtual: true,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function createNotificationsApiMock() {
|
|
let conversationRead = false;
|
|
const get = vi.fn().mockImplementation((_url, config) => {
|
|
const unreadOnly = config?.params?.unread === true;
|
|
if (!conversationRead) {
|
|
const item = {
|
|
type: "lxmf_message",
|
|
destination_hash: PEER_HASH,
|
|
display_name: "Peer",
|
|
latest_message_preview: "hello",
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
return Promise.resolve({
|
|
data: {
|
|
notifications: unreadOnly ? [item] : [item],
|
|
unread_count: 1,
|
|
},
|
|
});
|
|
}
|
|
return Promise.resolve({
|
|
data: {
|
|
notifications: [],
|
|
unread_count: 0,
|
|
},
|
|
});
|
|
});
|
|
const post = vi.fn().mockImplementation((url) => {
|
|
if (String(url).includes("/mark-as-read")) {
|
|
conversationRead = true;
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
return {
|
|
get,
|
|
post,
|
|
markRead: () => {
|
|
conversationRead = true;
|
|
},
|
|
isRead: () => conversationRead,
|
|
};
|
|
}
|
|
|
|
describe("NotificationBell conversation read sync", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
wsHandlers = {};
|
|
emitterHandlers = {};
|
|
window.URL.createObjectURL = vi.fn(() => "blob:mock");
|
|
vi.stubGlobal(
|
|
"FileReader",
|
|
vi.fn(function () {
|
|
return { readAsDataURL: vi.fn() };
|
|
})
|
|
);
|
|
vi.stubGlobal("localStorage", {
|
|
getItem: vi.fn(),
|
|
setItem: vi.fn(),
|
|
removeItem: vi.fn(),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
wsHandlers = {};
|
|
emitterHandlers = {};
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("reproduces stale badge: delivery raises count, read in Messages clears without opening bell", async () => {
|
|
const api = createNotificationsApiMock();
|
|
window.api = { get: api.get, post: api.post };
|
|
|
|
const bell = mountBell();
|
|
await flushPromises();
|
|
expect(bell.vm.unreadCount).toBe(1);
|
|
expect(bell.find("span.bg-red-500").exists()).toBe(true);
|
|
|
|
simulateWsDelivery();
|
|
await flushPromises();
|
|
expect(bell.vm.unreadCount).toBe(1);
|
|
|
|
const viewer = mountViewer();
|
|
await flushPromises();
|
|
const conversation = { destination_hash: PEER_HASH, is_unread: true };
|
|
await viewer.vm.markConversationAsRead(conversation);
|
|
await flushPromises();
|
|
|
|
expect(api.post).toHaveBeenCalledWith(`/api/v1/lxmf/conversations/${PEER_HASH}/mark-as-read`);
|
|
expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
|
|
expect(bell.vm.unreadCount).toBe(0);
|
|
expect(bell.find("span.bg-red-500").exists()).toBe(false);
|
|
|
|
bell.unmount();
|
|
viewer.unmount();
|
|
});
|
|
|
|
it("stale badge clears on bell click when server already has zero unread (empty dropdown)", async () => {
|
|
const api = createNotificationsApiMock();
|
|
api.markRead();
|
|
window.api = { get: api.get, post: api.post };
|
|
|
|
const bell = mountBell();
|
|
bell.vm.unreadCount = 3;
|
|
await bell.vm.$nextTick();
|
|
expect(bell.find("span.bg-red-500").text()).toBe("3");
|
|
|
|
await bell.find("button").trigger("click");
|
|
await flushPromises();
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
|
|
expect(bell.vm.unreadCount).toBe(0);
|
|
expect(bell.vm.notifications).toEqual([]);
|
|
expect(document.body.textContent).toContain("No new notifications");
|
|
|
|
const markCalls = api.post.mock.calls.filter((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
|
|
expect(markCalls).toHaveLength(0);
|
|
|
|
bell.unmount();
|
|
});
|
|
|
|
it("background poll refreshes badge while dropdown stays closed", async () => {
|
|
vi.useFakeTimers();
|
|
const api = createNotificationsApiMock();
|
|
window.api = { get: api.get, post: api.post };
|
|
|
|
const bell = mountBell();
|
|
await flushPromises();
|
|
expect(bell.vm.unreadCount).toBe(1);
|
|
|
|
api.markRead();
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
await flushPromises();
|
|
|
|
expect(bell.vm.unreadCount).toBe(0);
|
|
expect(bell.vm.isDropdownOpen).toBe(false);
|
|
|
|
vi.useRealTimers();
|
|
bell.unmount();
|
|
});
|
|
|
|
it("does not emit notifications-changed when mark-as-read API fails", async () => {
|
|
const api = createNotificationsApiMock();
|
|
api.post.mockImplementation((url) => {
|
|
if (String(url).includes("/mark-as-read")) {
|
|
return Promise.reject(new Error("network"));
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
window.api = { get: api.get, post: api.post };
|
|
|
|
const viewer = mountViewer();
|
|
await flushPromises();
|
|
GlobalEmitter.emit.mockClear();
|
|
|
|
const conversation = { destination_hash: PEER_HASH, is_unread: true };
|
|
await viewer.vm.markConversationAsRead(conversation);
|
|
await flushPromises();
|
|
|
|
expect(GlobalEmitter.emit).not.toHaveBeenCalledWith("notifications-changed");
|
|
expect(conversation.is_unread).toBe(true);
|
|
|
|
viewer.unmount();
|
|
});
|
|
|
|
it("badge count tracks API unread_count after websocket delivery", async () => {
|
|
let unread = 0;
|
|
window.api = {
|
|
get: vi.fn().mockImplementation(() =>
|
|
Promise.resolve({
|
|
data: {
|
|
notifications:
|
|
unread > 0
|
|
? [
|
|
{
|
|
type: "lxmf_message",
|
|
destination_hash: PEER_HASH,
|
|
display_name: "Peer",
|
|
latest_message_preview: "ping",
|
|
},
|
|
]
|
|
: [],
|
|
unread_count: unread,
|
|
},
|
|
})
|
|
),
|
|
post: vi.fn().mockResolvedValue({ data: {} }),
|
|
};
|
|
|
|
const bell = mountBell();
|
|
await flushPromises();
|
|
expect(bell.vm.unreadCount).toBe(0);
|
|
|
|
unread = 2;
|
|
simulateWsDelivery();
|
|
await flushPromises();
|
|
expect(bell.vm.unreadCount).toBe(2);
|
|
expect(bell.find("span.bg-red-500").text()).toBe("2");
|
|
|
|
unread = 0;
|
|
GlobalEmitter.emit("notifications-changed");
|
|
await flushPromises();
|
|
expect(bell.vm.unreadCount).toBe(0);
|
|
|
|
bell.unmount();
|
|
});
|
|
});
|