/** * 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'); });