/** * Unit tests for src/core/codec.ts * * Covers: * - Round-trip with bigint and Buffer attributes * - u64-max bigint sentinel * - Buffer with non-UTF-8 bytes * - timestamp ISO string → Date round-trip (no millisecond loss) * - All required fields present and correctly decoded * - Reject malformed JSON * - Reject missing required fields * - Reject invalid sentinel shapes * - Reject invalid priority values */ import { describe, it, expect } from 'vitest'; import { decodePosition, CodecError } from '../src/core/codec.js'; import type { Position } from '../src/core/types.js'; // --------------------------------------------------------------------------- // Helpers — mirror tcp-ingestion's serializePosition / jsonReplacer inline // so the test is self-contained and we can verify round-trip fidelity. // --------------------------------------------------------------------------- /** * JSON replacer that mirrors tcp-ingestion's `jsonReplacer` exactly. * bigint → { __bigint: "" } * Buffer → { __buffer_b64: "" } (handles both direct instance and toJSON shape) * Date → ISO string */ function jsonReplacer(_key: string, value: unknown): unknown { if (typeof value === 'bigint') { return { __bigint: value.toString() }; } if (value instanceof Uint8Array) { return { __buffer_b64: Buffer.from(value).toString('base64') }; } // Buffer.toJSON() shape — fired before replacer for nested Buffer properties if ( typeof value === 'object' && value !== null && (value as Record)['type'] === 'Buffer' && Array.isArray((value as Record)['data']) ) { const data = (value as { type: string; data: number[] }).data; return { __buffer_b64: Buffer.from(data).toString('base64') }; } if (value instanceof Date) { return value.toISOString(); } return value; } function serializePosition(position: Position, codec: string): Record { return { ts: position.timestamp.toISOString(), device_id: position.device_id, codec, payload: JSON.stringify(position, jsonReplacer), }; } function makePosition(overrides: Partial = {}): Position { return { device_id: 'TEST123456789', timestamp: new Date('2024-01-15T10:30:00.123Z'), latitude: 54.12345, longitude: 25.98765, altitude: 150, angle: 270, speed: 60, satellites: 8, priority: 1, attributes: {}, ...overrides, }; } // --------------------------------------------------------------------------- // 1. Round-trip — basic position (no special attributes) // --------------------------------------------------------------------------- describe('decodePosition — basic round-trip', () => { it('decodes all scalar fields correctly', () => { const original = makePosition(); const { payload } = serializePosition(original, '8'); const decoded = decodePosition(payload); expect(decoded.device_id).toBe(original.device_id); expect(decoded.timestamp).toEqual(original.timestamp); expect(decoded.latitude).toBe(original.latitude); expect(decoded.longitude).toBe(original.longitude); expect(decoded.altitude).toBe(original.altitude); expect(decoded.angle).toBe(original.angle); expect(decoded.speed).toBe(original.speed); expect(decoded.satellites).toBe(original.satellites); expect(decoded.priority).toBe(original.priority); }); it('timestamp round-trips without millisecond loss', () => { // Use a timestamp with non-zero milliseconds to verify precision is preserved const ts = new Date('2024-06-15T13:45:30.987Z'); const original = makePosition({ timestamp: ts }); const { payload } = serializePosition(original, '8'); const decoded = decodePosition(payload); expect(decoded.timestamp.getTime()).toBe(ts.getTime()); expect(decoded.timestamp.toISOString()).toBe('2024-06-15T13:45:30.987Z'); }); it('timestamp produces a Date instance (not a string)', () => { const original = makePosition(); const { payload } = serializePosition(original, '8'); const decoded = decodePosition(payload); expect(decoded.timestamp).toBeInstanceOf(Date); }); }); // --------------------------------------------------------------------------- // 2. Round-trip — bigint attributes // --------------------------------------------------------------------------- describe('decodePosition — bigint attributes', () => { it('round-trips a safe-integer bigint', () => { const original = makePosition({ attributes: { io_21: BigInt('12345') } }); const { payload } = serializePosition(original, '8'); const decoded = decodePosition(payload); expect(decoded.attributes['io_21']).toBe(BigInt('12345')); }); it('round-trips a u64-max bigint (exceeds Number.MAX_SAFE_INTEGER)', () => { const u64Max = BigInt('18446744073709551615'); const original = makePosition({ attributes: { io_240: u64Max } }); const { payload } = serializePosition(original, '8'); const decoded = decodePosition(payload); expect(decoded.attributes['io_240']).toBe(u64Max); }); it('round-trips zero bigint', () => { const original = makePosition({ attributes: { io_1: 0n } }); const { payload } = serializePosition(original, '8'); const decoded = decodePosition(payload); expect(decoded.attributes['io_1']).toBe(0n); }); }); // --------------------------------------------------------------------------- // 3. Round-trip — Buffer attributes // --------------------------------------------------------------------------- describe('decodePosition — Buffer attributes', () => { it('round-trips a Buffer with standard bytes', () => { const original = makePosition({ attributes: { io_nx: Buffer.from([0x01, 0x02, 0x03]) } }); const { payload } = serializePosition(original, '8E'); const decoded = decodePosition(payload); expect(Buffer.isBuffer(decoded.attributes['io_nx'])).toBe(true); expect(decoded.attributes['io_nx']).toEqual(Buffer.from([0x01, 0x02, 0x03])); }); it('round-trips a Buffer with non-UTF-8 bytes (0xde 0xad 0xbe 0xef)', () => { const raw = Buffer.from([0xde, 0xad, 0xbe, 0xef]); const original = makePosition({ attributes: { nx_raw: raw } }); const { payload } = serializePosition(original, '8E'); const decoded = decodePosition(payload); const attr = decoded.attributes['nx_raw']; expect(Buffer.isBuffer(attr)).toBe(true); expect(attr as Buffer).toEqual(raw); }); it('round-trips an empty Buffer', () => { const original = makePosition({ attributes: { empty: Buffer.alloc(0) } }); const { payload } = serializePosition(original, '8E'); const decoded = decodePosition(payload); const attr = decoded.attributes['empty']; expect(Buffer.isBuffer(attr)).toBe(true); expect((attr as Buffer).length).toBe(0); }); it('Buffer content is byte-equal to original (not just same length)', () => { const raw = Buffer.from([0xca, 0xfe, 0xba, 0xbe]); const original = makePosition({ attributes: { sig: raw } }); const { payload } = serializePosition(original, '8E'); const decoded = decodePosition(payload); const attr = decoded.attributes['sig'] as Buffer; for (let i = 0; i < raw.length; i++) { expect(attr[i]).toBe(raw[i]); } }); }); // --------------------------------------------------------------------------- // 4. Round-trip — mixed attributes // --------------------------------------------------------------------------- describe('decodePosition — mixed attributes round-trip', () => { it('round-trips position with number, bigint, and Buffer attributes together', () => { const original = makePosition({ attributes: { io_21: 42, io_240: BigInt('18446744073709551615'), io_nx: Buffer.from([0xab, 0xcd]), }, }); const { payload } = serializePosition(original, '16'); const decoded = decodePosition(payload); expect(decoded.attributes['io_21']).toBe(42); expect(decoded.attributes['io_240']).toBe(BigInt('18446744073709551615')); const nxAttr = decoded.attributes['io_nx'] as Buffer; expect(Buffer.isBuffer(nxAttr)).toBe(true); expect(nxAttr).toEqual(Buffer.from([0xab, 0xcd])); }); }); // --------------------------------------------------------------------------- // 5. Priority values // --------------------------------------------------------------------------- describe('decodePosition — priority', () => { it('accepts priority 0 (Low)', () => { const original = makePosition({ priority: 0 }); const { payload } = serializePosition(original, '8'); expect(() => decodePosition(payload)).not.toThrow(); expect(decodePosition(payload).priority).toBe(0); }); it('accepts priority 2 (Panic)', () => { const original = makePosition({ priority: 2 }); const { payload } = serializePosition(original, '8'); expect(decodePosition(payload).priority).toBe(2); }); }); // --------------------------------------------------------------------------- // 6. Error cases // --------------------------------------------------------------------------- describe('decodePosition — error cases', () => { it('throws CodecError on non-JSON input', () => { expect(() => decodePosition('not json at all')).toThrow(CodecError); }); it('throws CodecError on empty string', () => { expect(() => decodePosition('')).toThrow(CodecError); }); it('throws CodecError when payload is a JSON array (not object)', () => { expect(() => decodePosition('[]')).toThrow(CodecError); }); it('throws CodecError when payload is a JSON number', () => { expect(() => decodePosition('42')).toThrow(CodecError); }); it('throws CodecError when device_id is missing', () => { const pos = makePosition(); const { payload } = serializePosition(pos, '8'); const obj = JSON.parse(payload) as Record; delete obj['device_id']; expect(() => decodePosition(JSON.stringify(obj))).toThrow(CodecError); }); it('throws CodecError when device_id is empty string', () => { const obj = { device_id: '', timestamp: new Date().toISOString(), latitude: 0, longitude: 0, altitude: 0, angle: 0, speed: 0, satellites: 0, priority: 0, attributes: {}, }; expect(() => decodePosition(JSON.stringify(obj))).toThrow(CodecError); }); it('throws CodecError when timestamp is missing', () => { const pos = makePosition(); const { payload } = serializePosition(pos, '8'); const obj = JSON.parse(payload) as Record; delete obj['timestamp']; expect(() => decodePosition(JSON.stringify(obj))).toThrow(CodecError); }); it('throws CodecError when timestamp is an invalid date string', () => { const obj = { device_id: 'TEST123', timestamp: 'not-a-date', latitude: 0, longitude: 0, altitude: 0, angle: 0, speed: 0, satellites: 0, priority: 0, attributes: {}, }; expect(() => decodePosition(JSON.stringify(obj))).toThrow(CodecError); }); it('throws CodecError when a required numeric field is missing', () => { const pos = makePosition(); const { payload } = serializePosition(pos, '8'); const obj = JSON.parse(payload) as Record; delete obj['latitude']; expect(() => decodePosition(JSON.stringify(obj))).toThrow(CodecError); }); it('throws CodecError when priority is out of range (e.g. 3)', () => { const obj = { device_id: 'TEST123', timestamp: new Date().toISOString(), latitude: 0, longitude: 0, altitude: 0, angle: 0, speed: 0, satellites: 0, priority: 3, attributes: {}, }; expect(() => decodePosition(JSON.stringify(obj))).toThrow(CodecError); }); it('throws CodecError when __bigint value is not decimal digits', () => { const obj = { device_id: 'TEST123', timestamp: new Date().toISOString(), latitude: 0, longitude: 0, altitude: 0, angle: 0, speed: 0, satellites: 0, priority: 0, attributes: { io_bad: { __bigint: 'not-a-number' } }, }; expect(() => decodePosition(JSON.stringify(obj))).toThrow(CodecError); }); it('CodecError message names the failing field', () => { const obj = { device_id: 'TEST123', timestamp: new Date().toISOString(), latitude: 'oops', // wrong type longitude: 0, altitude: 0, angle: 0, speed: 0, satellites: 0, priority: 0, attributes: {}, }; expect(() => decodePosition(JSON.stringify(obj))).toThrow(/latitude/); }); it('throws CodecError when attributes value is not a valid AttributeValue (e.g. nested object)', () => { const obj = { device_id: 'TEST123', timestamp: new Date().toISOString(), latitude: 0, longitude: 0, altitude: 0, angle: 0, speed: 0, satellites: 0, priority: 0, // A plain nested object (not a sentinel) should fail validation attributes: { io_bad: { nested: 'value' } }, }; expect(() => decodePosition(JSON.stringify(obj))).toThrow(CodecError); }); });