sms-gateway/test/teltonika.test.js
2026-03-04 19:30:01 +01:00

248 lines
8.3 KiB
JavaScript

/**
* Tests for the Teltonika API client token lifecycle.
* All HTTP calls are intercepted with a mock axios instance.
*/
const { test, before, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
// Provide an in-memory DB so db.js doesn't complain
process.env.DB_PATH = ':memory:';
process.env.ADMIN_USERNAME = 'admin';
process.env.ADMIN_PASSWORD = 'test';
const db = require('../src/db');
db.initDB();
db.setConfig('device_ip', '192.168.1.1');
db.setConfig('device_username', 'admin');
db.setConfig('device_password', 'secret');
db.setConfig('device_modem', '1-1');
// ── Minimal axios mock ────────────────────────────────────────────────────────
// We monkey-patch require cache so teltonika.js picks up our mock.
let mockResponses = []; // queue of { status, data } or Error
let requestLog = []; // recorded calls
function mockAxios(config) {
requestLog.push({ method: config.method?.toUpperCase(), url: config.url, data: config.data, params: config.params });
const next = mockResponses.shift();
if (!next) throw new Error('No mock response queued');
if (next instanceof Error) return Promise.reject(next);
if (next.status === 401) {
const err = new Error('Request failed with status code 401');
err.response = { status: 401 };
return Promise.reject(err);
}
return Promise.resolve({ status: next.status, data: next.data });
}
mockAxios.post = (url, data, opts) => mockAxios({ method: 'POST', url, data });
// Inject mock before requiring teltonika
require.cache[require.resolve('axios')] = { exports: mockAxios };
// Now load the client (fresh each test via cache manipulation)
function freshClient() {
// Remove cached module so we get a new singleton
delete require.cache[require.resolve('../src/teltonika')];
return require('../src/teltonika');
}
beforeEach(() => {
mockResponses = [];
requestLog = [];
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function loginResponse(expiresIn = 300) {
return { status: 200, data: { data: { token: 'tok-abc123', expires: expiresIn } } };
}
// ── Tests ─────────────────────────────────────────────────────────────────────
test('login: obtains token and sets expiry', async () => {
const client = freshClient();
mockResponses.push(loginResponse(300));
await client.login();
assert.equal(client.token, 'tok-abc123');
assert.ok(client.tokenExpires > Date.now());
assert.ok(client.tokenExpires <= Date.now() + 300_000 + 100);
});
test('_ensureToken: skips login when token is still valid', async () => {
const client = freshClient();
// Pre-set a valid token (expires far in the future)
client.token = 'existing-token';
client.tokenExpires = Date.now() + 600_000;
await client._ensureToken();
// No login call should have been made
assert.equal(requestLog.length, 0);
assert.equal(client.token, 'existing-token');
});
test('_ensureToken: triggers login when token is null', async () => {
const client = freshClient();
mockResponses.push(loginResponse());
await client._ensureToken();
assert.equal(requestLog.length, 1);
assert.ok(requestLog[0].url.endsWith('/login'));
assert.equal(client.token, 'tok-abc123');
});
test('_ensureToken: triggers login when token is expired', async () => {
const client = freshClient();
client.token = 'old-token';
client.tokenExpires = Date.now() - 1_000; // already expired
mockResponses.push(loginResponse());
await client._ensureToken();
assert.equal(client.token, 'tok-abc123');
assert.equal(requestLog.length, 1);
});
test('_ensureToken: triggers login when within 30s proactive window', async () => {
const client = freshClient();
client.token = 'expiring-soon';
client.tokenExpires = Date.now() + 20_000; // 20 s left → inside 30 s window
mockResponses.push(loginResponse());
await client._ensureToken();
assert.equal(client.token, 'tok-abc123');
});
test('concurrent _ensureToken calls share a single login request', async () => {
const client = freshClient();
// Only one login response queued — if two logins fired it would throw
mockResponses.push(loginResponse());
// Fire three concurrent _ensureToken calls
await Promise.all([
client._ensureToken(),
client._ensureToken(),
client._ensureToken(),
]);
// Exactly one POST /login
const loginCalls = requestLog.filter(r => r.url?.endsWith('/login'));
assert.equal(loginCalls.length, 1);
});
test('_request: re-authenticates and retries on 401 response', async () => {
const client = freshClient();
// Give it a seemingly-valid token
client.token = 'stale-token';
client.tokenExpires = Date.now() + 600_000;
// First API call → 401
mockResponses.push({ status: 401 });
// Re-login
mockResponses.push(loginResponse());
// Retry API call → success
mockResponses.push({ status: 200, data: { success: true } });
const response = await client._request('GET', '/messages');
assert.equal(response.data.success, true);
// Verify re-login happened
const loginCalls = requestLog.filter(r => r.url?.endsWith('/login'));
assert.equal(loginCalls.length, 1);
// Verify the original path was called twice (first attempt + retry)
const msgCalls = requestLog.filter(r => r.url?.endsWith('/messages'));
assert.equal(msgCalls.length, 2);
});
test('_request: does not retry on non-401 errors', async () => {
const client = freshClient();
client.token = 'valid-token';
client.tokenExpires = Date.now() + 600_000;
const networkErr = new Error('ECONNREFUSED');
networkErr.code = 'ECONNREFUSED';
mockResponses.push(networkErr);
await assert.rejects(
() => client._request('GET', '/messages'),
/ECONNREFUSED/
);
// No re-login
const loginCalls = requestLog.filter(r => r.url?.endsWith('/login'));
assert.equal(loginCalls.length, 0);
});
test('testConnection: clears existing token before testing', async () => {
const client = freshClient();
client.token = 'old';
client.tokenExpires = Date.now() + 999_999;
mockResponses.push(loginResponse());
const result = await client.testConnection();
assert.equal(result.success, true);
assert.equal(client.token, 'tok-abc123');
});
test('testConnection: returns success:false on failure', async () => {
const client = freshClient();
mockResponses.push(new Error('Network unreachable'));
const result = await client.testConnection();
assert.equal(result.success, false);
assert.ok(result.error);
});
test('readMessages: sends modem query parameter', async () => {
const client = freshClient();
client.token = 'valid-token';
client.tokenExpires = Date.now() + 600_000;
mockResponses.push({ status: 200, data: { data: [] } });
await client.readMessages();
const call = requestLog.find(r => r.url?.endsWith('/messages'));
assert.ok(call, 'GET /messages was not called');
assert.deepEqual(call.params, { modem: '1-1' });
});
test('readMessages: returns empty array when device returns no messages', async () => {
const client = freshClient();
client.token = 'valid-token';
client.tokenExpires = Date.now() + 600_000;
mockResponses.push({ status: 200, data: { data: [] } });
const messages = await client.readMessages();
assert.deepEqual(messages, []);
});
test('readMessages: parses messages from data array', async () => {
const client = freshClient();
client.token = 'valid-token';
client.tokenExpires = Date.now() + 600_000;
mockResponses.push({ status: 200, data: { data: [
{ id: 1, sender: '+46701234567', text: 'Hello', date: '2026-01-01 10:00:00', read: 1, type: 'inbox' },
{ id: 2, sender: '+46709999999', text: 'World', date: '2026-01-01 11:00:00', read: 0, type: 'inbox' },
]}});
const messages = await client.readMessages();
assert.equal(messages.length, 2);
assert.equal(messages[0].id, '1');
assert.equal(messages[0].sender, '+46701234567');
assert.equal(messages[0].text, 'Hello');
assert.equal(messages[0].type, 'inbox');
});