Files
MeshChatX/tests/frontend/CallPage.test.js

446 lines
18 KiB
JavaScript

import { flushPromises, mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import CallPage from "@/components/call/CallPage.vue";
describe("CallPage.vue", () => {
let axiosMock;
beforeEach(() => {
axiosMock = {
get: vi.fn().mockImplementation((url) => {
const defaultData = {
config: {},
calls: [],
call_history: [],
announces: [],
voicemails: [],
active_call: null,
discovery: [],
contacts: [],
profiles: [],
ringtones: [],
voicemail: { unread_count: 0 },
};
if (url.includes("/api/v1/config")) return Promise.resolve({ data: { config: {} } });
if (url.includes("/api/v1/telephone/history")) return Promise.resolve({ data: { call_history: [] } });
if (url.includes("/api/v1/announces")) return Promise.resolve({ data: { announces: [] } });
if (url.includes("/api/v1/telephone/status")) return Promise.resolve({ data: { active_call: null } });
if (url.includes("/api/v1/telephone/voicemail/status")) {
return Promise.resolve({
data: {
has_espeak: false,
is_recording: false,
is_greeting_recording: false,
has_greeting: false,
},
});
}
if (url.includes("/api/v1/telephone/voicemails")) {
return Promise.resolve({ data: { voicemails: [], unread_count: 0 } });
}
if (url.includes("/api/v1/telephone/ringtones/status")) {
return Promise.resolve({
data: {
has_custom_ringtone: false,
enabled: true,
filename: null,
id: null,
volume: 0.5,
},
});
}
if (url.includes("/api/v1/telephone/ringtones/") && url.includes("/audio")) {
return Promise.resolve({ data: new ArrayBuffer(0) });
}
if (url.includes("/api/v1/telephone/ringtones")) {
return Promise.resolve({ data: [] });
}
if (url.includes("/api/v1/telephone/audio-profiles"))
return Promise.resolve({ data: { audio_profiles: [], default_audio_profile_id: null } });
if (url.includes("/api/v1/telephone/contacts/export")) {
return Promise.resolve({ data: { contacts: [] } });
}
if (url.includes("/api/v1/telephone/contacts/check/")) {
return Promise.resolve({ data: { is_contact: false, contact: null } });
}
if (url.includes("/api/v1/telephone/contacts")) {
return Promise.resolve({ data: { contacts: [], total_count: 0 } });
}
if (url.includes("/api/v1/contacts")) return Promise.resolve({ data: { contacts: [] } });
return Promise.resolve({ data: defaultData });
}),
post: vi.fn().mockResolvedValue({ data: {} }),
patch: vi.fn().mockResolvedValue({ data: {} }),
delete: vi.fn().mockResolvedValue({ data: {} }),
};
window.api = axiosMock;
});
afterEach(() => {
delete window.api;
});
const mountCallPage = (routeQuery = {}) => {
return mount(CallPage, {
global: {
mocks: {
$t: (key) => key,
$route: {
query: routeQuery,
},
},
stubs: {
MaterialDesignIcon: true,
LoadingSpinner: true,
LxmfUserIcon: true,
Toggle: true,
AudioWaveformPlayer: true,
RingtoneEditor: true,
},
},
});
};
it("respects tab query parameter on mount", async () => {
const wrapper = mountCallPage({ tab: "voicemail" });
await wrapper.vm.$nextTick();
expect(wrapper.vm.activeTab).toBe("voicemail");
});
it("performs optimistic mute updates", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
// Setup active call
wrapper.vm.activeCall = {
status: 6, // ESTABLISHED
is_mic_muted: false,
is_speaker_muted: false,
};
await wrapper.vm.$nextTick();
// Toggle mic
await wrapper.vm.toggleMicrophone();
// Should be muted immediately (optimistic)
expect(wrapper.vm.activeCall.is_mic_muted).toBe(true);
expect(axiosMock.get).toHaveBeenCalledWith(expect.stringContaining("/api/v1/telephone/mute-transmit"));
});
it("renders tabs correctly", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
// The tabs are hardcoded strings: Phone, Phonebook, Voicemail, Contacts
expect(wrapper.text()).toContain("Phone");
expect(wrapper.text()).toContain("Phonebook");
expect(wrapper.text()).toContain("Voicemail");
expect(wrapper.text()).toContain("Contacts");
});
it("switches tabs when clicked", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
// Initial tab should be phone
expect(wrapper.vm.activeTab).toBe("phone");
// Click Phonebook tab
const buttons = wrapper.findAll("button");
const phonebookTab = buttons.find((b) => b.text() === "Phonebook");
if (phonebookTab) {
await phonebookTab.trigger("click");
expect(wrapper.vm.activeTab).toBe("phonebook");
} else {
throw new Error("Phonebook tab not found");
}
});
it("displays 'New Call' UI by default when no active call", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("New Call");
expect(wrapper.find('input[type="text"]').exists()).toBe(true);
});
it("attempts to place a call when 'Call' button is clicked", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
const input = wrapper.find('input[type="text"]');
await input.setValue("test-destination");
// Find Call button - it's hardcoded "Call"
const buttons = wrapper.findAll("button");
const callButton = buttons.find((b) => b.text() === "Call");
if (callButton) {
await callButton.trigger("click");
// CallPage.vue uses window.api.get(`/api/v1/telephone/call/${hashToCall}`)
expect(axiosMock.get).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/telephone/call/test-destination")
);
} else {
throw new Error("Call button not found");
}
});
// Keep in sync with tests/backend/test_lxst_telephony_profiles_contract.py
const LXST_TELEPHONY_AUDIO_PROFILES_CONTRACT = {
default_audio_profile_id: 64,
audio_profiles: [
{ id: 16, name: "Ultra Low Bandwidth" },
{ id: 32, name: "Very Low Bandwidth" },
{ id: 48, name: "Low Bandwidth" },
{ id: 64, name: "Medium Quality" },
{ id: 80, name: "High Quality" },
{ id: 96, name: "Super High Quality" },
{ id: 112, name: "Ultra Low Latency" },
{ id: 128, name: "Low Latency" },
],
};
it("getAudioProfiles maps API default and profile list (LXST contract)", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
axiosMock.get.mockResolvedValueOnce({ data: LXST_TELEPHONY_AUDIO_PROFILES_CONTRACT });
await wrapper.vm.getAudioProfiles();
expect(wrapper.vm.audioProfiles).toEqual(LXST_TELEPHONY_AUDIO_PROFILES_CONTRACT.audio_profiles);
expect(wrapper.vm.selectedAudioProfileId).toBe(64);
});
it("toggleDoNotDisturb patches config", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
wrapper.vm.config = { do_not_disturb_enabled: false };
await wrapper.vm.toggleDoNotDisturb(true);
expect(axiosMock.patch).toHaveBeenCalledWith(expect.stringContaining("/api/v1/config"), {
do_not_disturb_enabled: true,
});
});
it("toggleAllowCallsFromContactsOnly patches config", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
wrapper.vm.config = { telephone_allow_calls_from_contacts_only: false };
await wrapper.vm.toggleAllowCallsFromContactsOnly(true);
expect(axiosMock.patch).toHaveBeenCalledWith(expect.stringContaining("/api/v1/config"), {
telephone_allow_calls_from_contacts_only: true,
});
});
it("ensureWebAudio stops when web audio bridge disabled", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
wrapper.vm.config = { telephone_web_audio_enabled: false };
const stop = vi.spyOn(wrapper.vm, "stopWebAudio");
await wrapper.vm.ensureWebAudio({ enabled: true });
expect(stop).toHaveBeenCalled();
});
it("ensureWebAudio starts when bridge enabled and call active", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
wrapper.vm.config = { telephone_web_audio_enabled: true };
wrapper.vm.activeCall = { status: 6 };
const start = vi.spyOn(wrapper.vm, "startWebAudio").mockResolvedValue(undefined);
await wrapper.vm.ensureWebAudio({ enabled: true, frame_ms: 48 });
expect(start).toHaveBeenCalled();
expect(wrapper.vm.audioFrameMs).toBe(48);
});
it("ensureWebAudio stops when no active call", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
wrapper.vm.config = { telephone_web_audio_enabled: true };
wrapper.vm.activeCall = null;
const stop = vi.spyOn(wrapper.vm, "stopWebAudio");
await wrapper.vm.ensureWebAudio({ enabled: true });
expect(stop).toHaveBeenCalled();
});
it("startWebAudio disables bridge when media devices API is missing", async () => {
const wrapper = mountCallPage();
await flushPromises();
wrapper.vm.config = { telephone_web_audio_enabled: true };
const updateConfig = vi.spyOn(wrapper.vm, "updateConfig").mockResolvedValue(undefined);
const stopWebAudio = vi.spyOn(wrapper.vm, "stopWebAudio");
const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: undefined,
});
try {
await wrapper.vm.startWebAudio();
expect(wrapper.vm.config.telephone_web_audio_enabled).toBe(false);
expect(updateConfig).toHaveBeenCalledWith({ telephone_web_audio_enabled: false });
expect(stopWebAudio).toHaveBeenCalled();
} finally {
if (mediaDevicesDescriptor) {
Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor);
} else {
Reflect.deleteProperty(navigator, "mediaDevices");
}
}
});
it("requestAudioPermission returns false when media devices API is missing", async () => {
const wrapper = mountCallPage();
await flushPromises();
const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: undefined,
});
try {
await expect(wrapper.vm.requestAudioPermission()).resolves.toBe(false);
} finally {
if (mediaDevicesDescriptor) {
Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor);
} else {
Reflect.deleteProperty(navigator, "mediaDevices");
}
}
});
it("refreshAudioDevices clears stale devices when media devices API is missing", async () => {
const wrapper = mountCallPage();
await flushPromises();
wrapper.vm.audioInputDevices = [{ kind: "audioinput", deviceId: "old-in" }];
wrapper.vm.audioOutputDevices = [{ kind: "audiooutput", deviceId: "old-out" }];
const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: undefined,
});
try {
await wrapper.vm.refreshAudioDevices();
expect(wrapper.vm.audioInputDevices).toEqual([]);
expect(wrapper.vm.audioOutputDevices).toEqual([]);
} finally {
if (mediaDevicesDescriptor) {
Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor);
} else {
Reflect.deleteProperty(navigator, "mediaDevices");
}
}
});
it("ensureWebAudio tears down websocket stream when call is no longer active", async () => {
const wrapper = mountCallPage();
await flushPromises();
wrapper.vm.config = { telephone_web_audio_enabled: true };
wrapper.vm.activeCall = null;
const wsClose = vi.fn();
wrapper.vm.audioWs = {
onopen: vi.fn(),
onmessage: vi.fn(),
onerror: vi.fn(),
onclose: vi.fn(),
close: wsClose,
};
const sourceDisconnect = vi.fn();
wrapper.vm.audioSourceNode = { disconnect: sourceDisconnect };
const processorDisconnect = vi.fn();
wrapper.vm.audioProcessor = { disconnect: processorDisconnect };
const stopTrack = vi.fn();
wrapper.vm.audioStream = { getTracks: () => [{ stop: stopTrack }] };
const ctxClose = vi.fn().mockResolvedValue(undefined);
wrapper.vm.audioCtx = { state: "running", close: ctxClose };
await wrapper.vm.ensureWebAudio({ enabled: true });
expect(wsClose).toHaveBeenCalledTimes(1);
expect(sourceDisconnect).toHaveBeenCalledTimes(1);
expect(processorDisconnect).toHaveBeenCalledTimes(1);
expect(stopTrack).toHaveBeenCalledTimes(1);
expect(ctxClose).toHaveBeenCalledTimes(1);
expect(wrapper.vm.audioWs).toBeNull();
});
it("getContacts maps telephone contacts list and hydrates visuals", async () => {
const wrapper = mountCallPage();
await flushPromises();
const hydrate = vi.spyOn(wrapper.vm, "hydrateContactVisuals");
axiosMock.get.mockResolvedValueOnce({
data: {
contacts: [{ id: 1, name: "Sam", remote_identity_hash: "ab".repeat(16) }],
total_count: 2,
},
});
await wrapper.vm.getContacts();
expect(wrapper.vm.contacts[0].name).toBe("Sam");
expect(hydrate).toHaveBeenCalled();
});
it("getVoicemails maps voicemails and unread_count", async () => {
const wrapper = mountCallPage();
await flushPromises();
axiosMock.get.mockResolvedValueOnce({
data: {
voicemails: [{ id: 9, remote_identity_hash: "cd".repeat(16), is_read: 0 }],
unread_count: 1,
},
});
await wrapper.vm.getVoicemails();
expect(wrapper.vm.voicemails).toHaveLength(1);
expect(wrapper.vm.unreadVoicemailsCount).toBe(1);
});
it("getRingtones stores API array on ringtones", async () => {
const wrapper = mountCallPage();
await flushPromises();
axiosMock.get.mockResolvedValueOnce({
data: [
{
id: 1,
filename: "x.opus",
display_name: "Test",
is_primary: true,
created_at: "2024-01-01",
},
],
});
await wrapper.vm.getRingtones();
expect(wrapper.vm.ringtones).toHaveLength(1);
expect(wrapper.vm.ringtones[0].filename).toBe("x.opus");
});
it("getVoicemailStatus stores voicemail status payload", async () => {
const wrapper = mountCallPage();
await flushPromises();
axiosMock.get.mockResolvedValueOnce({
data: {
has_espeak: true,
is_recording: false,
is_greeting_recording: false,
has_greeting: true,
},
});
await wrapper.vm.getVoicemailStatus();
expect(wrapper.vm.voicemailStatus.has_greeting).toBe(true);
});
it("getRingtoneStatus stores ringtone status payload", async () => {
const wrapper = mountCallPage();
await flushPromises();
axiosMock.get.mockResolvedValueOnce({
data: {
has_custom_ringtone: true,
enabled: true,
filename: "ring.opus",
id: 3,
volume: 0.8,
},
});
await wrapper.vm.getRingtoneStatus();
expect(wrapper.vm.ringtoneStatus.id).toBe(3);
expect(wrapper.vm.ringtoneStatus.volume).toBe(0.8);
});
});