248 lines
8.3 KiB
JavaScript
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');
|
|
});
|