Owner asked for total minutes, call time and missed calls in one place, and the money left on voip.ms. Reports now totals answered calls, talk time and outside minutes (in from arrival, out from the answer: roughly what the provider bills), charts outside minutes per day, and shows each voip.ms trunk's balance (getBalance; verify with a real account).
194 lines
7.4 KiB
TypeScript
194 lines
7.4 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||
// M9-T1: the voip.ms API client and what uses it (06 §5.1, §7), against a fake voip.ms.
|
||
import { createServer, type Server } from 'node:http';
|
||
import type { AddressInfo } from 'node:net';
|
||
import type { LightMyRequestResponse } from 'fastify';
|
||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||
import { getDids, voipmsApi, VoipmsError } from '../src/integrations/voipms/client.ts';
|
||
import { hasTestDb } from './db.ts';
|
||
import { ADMIN, H, createTestApp } from './app.ts';
|
||
|
||
/** A fake rest.php: answers per method; remembers the queries it got. */
|
||
function fakeVoipms() {
|
||
const seen: URLSearchParams[] = [];
|
||
const answers: Record<string, unknown> = {
|
||
getServersInfo: {
|
||
status: 'success',
|
||
servers: [
|
||
{ server_name: 'Seattle 1', server_hostname: 'seattle1.voip.ms', server_ip: '1.2.3.4' },
|
||
{ server_name: 'Chicago 1', server_hostname: 'chicago1.voip.ms', server_ip: '1.2.3.5' },
|
||
],
|
||
},
|
||
getBalance: {
|
||
status: 'success',
|
||
balance: { current_balance: '12.3456', spent_total: '7.89', calls_total: '42' },
|
||
},
|
||
getDIDsInfo: {
|
||
status: 'success',
|
||
dids: [
|
||
{ did: '3125551234', description: 'Main line', sms_enabled: '1' },
|
||
{ did: '3125555678', description: '', sms_enabled: '0' },
|
||
{ did: 'junk' },
|
||
],
|
||
},
|
||
};
|
||
const server: Server = createServer((req, res) => {
|
||
const q = new URL(req.url!, 'http://x').searchParams;
|
||
seen.push(q);
|
||
const ok = q.get('api_username') === 'me@example.com' && q.get('api_password') === 'api-pw';
|
||
res.setHeader('content-type', 'application/json');
|
||
res.end(
|
||
JSON.stringify(
|
||
ok
|
||
? (answers[q.get('method')!] ?? { status: 'invalid_method' })
|
||
: { status: 'invalid_credentials' },
|
||
),
|
||
);
|
||
});
|
||
return { server, seen, answers };
|
||
}
|
||
|
||
describe('voipmsApi', () => {
|
||
const fake = fakeVoipms();
|
||
let url: string;
|
||
const creds = { username: 'me@example.com', password: 'api-pw' };
|
||
beforeAll(async () => {
|
||
await new Promise<void>((r) => fake.server.listen(0, '127.0.0.1', r));
|
||
url = `http://127.0.0.1:${(fake.server.address() as AddressInfo).port}/api/v1/rest.php`;
|
||
});
|
||
afterAll(() => fake.server.close());
|
||
|
||
it('sends the login and method as query parameters', async () => {
|
||
await voipmsApi(url, creds, 'getServersInfo', { foo: 'a b' });
|
||
const q = fake.seen.at(-1)!;
|
||
expect(Object.fromEntries(q)).toEqual({
|
||
api_username: 'me@example.com',
|
||
api_password: 'api-pw',
|
||
method: 'getServersInfo',
|
||
foo: 'a b',
|
||
});
|
||
});
|
||
|
||
it('errors carry the status and a message for people, never the password', async () => {
|
||
const e = await voipmsApi(url, { ...creds, password: 'wrong-pw' }, 'getDIDsInfo').catch(
|
||
(x: unknown) => x,
|
||
);
|
||
expect(e).toBeInstanceOf(VoipmsError);
|
||
expect((e as VoipmsError).status).toBe('invalid_credentials');
|
||
expect((e as VoipmsError).message).not.toContain('wrong-pw');
|
||
const down = await voipmsApi('http://127.0.0.1:9/rest.php', creds, 'x').catch((x) => x);
|
||
expect((down as VoipmsError).status).toBe('unreachable');
|
||
});
|
||
|
||
it('getDids normalizes numbers and skips what it can’t read; no_did is an empty list', async () => {
|
||
expect(await getDids(url, creds)).toEqual([
|
||
{ number: '13125551234', description: 'Main line', smsEnabled: true },
|
||
{ number: '13125555678', description: '', smsEnabled: false },
|
||
]);
|
||
fake.answers.getDIDsInfo = { status: 'no_did' };
|
||
expect(await getDids(url, creds)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!hasTestDb)('/api/trunks/:id voip.ms routes', () => {
|
||
const fake = fakeVoipms();
|
||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||
let trunkId: string;
|
||
beforeAll(async () => {
|
||
await new Promise<void>((r) => fake.server.listen(0, '127.0.0.1', r));
|
||
const port = (fake.server.address() as AddressInfo).port;
|
||
t = await createTestApp(undefined, {
|
||
VOIPMS_API_URL: `http://127.0.0.1:${port}/api/v1/rest.php`,
|
||
});
|
||
await t.clearMustChange(ADMIN.email);
|
||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||
req = (method, url, payload) =>
|
||
t.app.inject({
|
||
method: method as 'GET',
|
||
url,
|
||
payload: payload as object,
|
||
headers: { ...H, cookie },
|
||
});
|
||
trunkId = (
|
||
await req('POST', '/api/trunks', {
|
||
name: 'voip.ms',
|
||
serverHost: 'chicago1.voip.ms',
|
||
username: '123456_pbx',
|
||
password: 'pw',
|
||
})
|
||
).json().id;
|
||
});
|
||
afterAll(async () => {
|
||
await t?.close();
|
||
fake.server.close();
|
||
});
|
||
|
||
it('needs API credentials on the trunk', async () => {
|
||
const r = await req('GET', `/api/trunks/${trunkId}/voipms-servers`);
|
||
expect(r.statusCode).toBe(400);
|
||
expect(r.json().error.message).toMatch(/API username and password/);
|
||
// The balance on Reports says so instead of failing (owner, 2026-10-21).
|
||
expect((await req('GET', '/api/reports/balance')).json().items).toEqual([
|
||
{
|
||
trunkId,
|
||
trunkName: 'voip.ms',
|
||
balance: null,
|
||
error: expect.stringMatching(/API username and password/),
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('a wrong API password is a 502 with voip.ms’s reason', async () => {
|
||
await req('PATCH', `/api/trunks/${trunkId}`, {
|
||
voipmsApiUsername: 'me@example.com',
|
||
voipmsApiPassword: 'nope',
|
||
});
|
||
const r = await req('GET', `/api/trunks/${trunkId}/voipms-servers`);
|
||
expect(r.statusCode).toBe(502);
|
||
expect(r.json().error.message).toBe('voip.ms rejected the API username or password');
|
||
expect(r.body).not.toContain('nope');
|
||
const bal = await req('GET', '/api/reports/balance');
|
||
expect(bal.json().items[0]).toMatchObject({
|
||
balance: null,
|
||
error: 'voip.ms rejected the API username or password',
|
||
});
|
||
expect(bal.body).not.toContain('nope');
|
||
});
|
||
|
||
it('lists servers, previews and imports numbers (not routed, label from voip.ms)', async () => {
|
||
await req('PATCH', `/api/trunks/${trunkId}`, { voipmsApiPassword: 'api-pw' });
|
||
expect((await req('GET', '/api/reports/balance')).json().items).toEqual([
|
||
{ trunkId, trunkName: 'voip.ms', balance: 12.3456, error: null },
|
||
]);
|
||
expect(fake.seen.at(-1)!.get('method')).toBe('getBalance');
|
||
expect((await req('GET', `/api/trunks/${trunkId}/voipms-servers`)).json()).toEqual({
|
||
servers: ['chicago1.voip.ms', 'seattle1.voip.ms'],
|
||
});
|
||
await req('POST', '/api/dids', {
|
||
number: '3125555678',
|
||
trunkId,
|
||
destination: { type: 'hangup' },
|
||
});
|
||
const preview = (await req('GET', `/api/trunks/${trunkId}/import-dids`)).json().items;
|
||
expect(preview).toEqual([
|
||
{ number: '13125551234', description: 'Main line', smsEnabled: true, added: false },
|
||
{ number: '13125555678', description: '', smsEnabled: false, added: true },
|
||
]);
|
||
// a number that isn't on the account is ignored
|
||
const r = await req('POST', `/api/trunks/${trunkId}/import-dids`, {
|
||
numbers: ['13125551234', '19995550000'],
|
||
});
|
||
expect(r.json()).toEqual({ added: 1 });
|
||
const added = (await req('GET', '/api/dids')).json().items;
|
||
expect(added.find((d: { number: string }) => d.number === '13125551234')).toMatchObject({
|
||
label: 'Main line',
|
||
destination: { type: 'hangup' },
|
||
});
|
||
const again = await req('POST', `/api/trunks/${trunkId}/import-dids`, {
|
||
numbers: ['13125551234'],
|
||
});
|
||
expect(again.statusCode).toBe(400);
|
||
});
|
||
});
|