Implement Phase 1 tasks 1.1-1.4 (scaffold + core shell + Teltonika framing)
- Project scaffold (Node 22 + TS 5 + pnpm + vitest + ESLint flat config) - Core shell: TCP server, session loop, adapter registry, types - Configuration (zod-validated env) and pino logger - Teltonika adapter: IMEI handshake, frame envelope, CRC-16/IBM, codec dispatch registry, DeviceAuthority seam (AllowAllAuthority default) Codec data parsers (1.5-1.7), Redis publisher (1.8), and downstream tasks remain. 36 tests covering CRC, framing, handshake, device authority, config, and core server. typecheck/lint/test/build all clean.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type * as net from 'node:net';
|
||||
import { readImeiHandshake, HandshakeError } from '../src/adapters/teltonika/handshake.js';
|
||||
|
||||
function makeMockSocket(): net.Socket & { push(buf: Buffer): void } {
|
||||
const emitter = new EventEmitter() as net.Socket & {
|
||||
push(buf: Buffer): void;
|
||||
destroyed: boolean;
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
emitter.destroyed = false;
|
||||
emitter.write = vi.fn().mockReturnValue(true);
|
||||
emitter.destroy = vi.fn(() => {
|
||||
emitter.destroyed = true;
|
||||
emitter.emit('close', false);
|
||||
});
|
||||
emitter.push = (buf: Buffer) => emitter.emit('data', buf);
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a Teltonika IMEI handshake frame:
|
||||
* [2B length big-endian][IMEI ASCII bytes]
|
||||
*/
|
||||
function encodeImeiHandshake(imei: string): Buffer {
|
||||
const imeiBytes = Buffer.from(imei, 'ascii');
|
||||
const header = Buffer.alloc(2);
|
||||
header.writeUInt16BE(imeiBytes.length, 0);
|
||||
return Buffer.concat([header, imeiBytes]);
|
||||
}
|
||||
|
||||
describe('readImeiHandshake', () => {
|
||||
it('parses a valid 15-digit IMEI without writing to the socket', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const imei = '356307042441013';
|
||||
const handshakeBytes = encodeImeiHandshake(imei);
|
||||
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
socket.push(handshakeBytes);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toBe(imei);
|
||||
|
||||
// Must NOT write anything — accept/reject is the session loop's job
|
||||
expect(socket.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('parses a valid IMEI arriving in two chunks (header + body split)', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const imei = '123456789012345';
|
||||
const handshakeBytes = encodeImeiHandshake(imei);
|
||||
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
// Split: first push just the 2-byte header, then the IMEI bytes
|
||||
socket.push(handshakeBytes.subarray(0, 2));
|
||||
socket.push(handshakeBytes.subarray(2));
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toBe(imei);
|
||||
});
|
||||
|
||||
it('parses the Teltonika doc example IMEI (356307042441013)', async () => {
|
||||
// From the doc: IMEI 356307042441013 encoded as 000F333536333037303432343431303133
|
||||
const socket = makeMockSocket();
|
||||
const frame = Buffer.from('000F333536333037303432343431303133', 'hex');
|
||||
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
socket.push(frame);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toBe('356307042441013');
|
||||
});
|
||||
|
||||
it('throws HandshakeError for IMEI with non-digit characters', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const badImei = 'ABCDEFGHIJKLMNO'; // 15 chars, not digits
|
||||
|
||||
const frame = encodeImeiHandshake(badImei);
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
socket.push(frame);
|
||||
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(HandshakeError);
|
||||
expect(socket.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws HandshakeError for IMEI that is too short (< 14 digits)', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const shortImei = '1234567890123'; // 13 digits
|
||||
|
||||
const frame = encodeImeiHandshake(shortImei);
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
socket.push(frame);
|
||||
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(HandshakeError);
|
||||
});
|
||||
|
||||
it('throws HandshakeError for zero-length IMEI', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const frame = Buffer.from([0x00, 0x00]); // length = 0
|
||||
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
socket.push(frame);
|
||||
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(HandshakeError);
|
||||
expect(socket.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws HandshakeError for excessively long IMEI length field (> 32)', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const frame = Buffer.alloc(2);
|
||||
frame.writeUInt16BE(33, 0); // length = 33
|
||||
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
socket.push(frame);
|
||||
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(HandshakeError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user