95efc23139
Scaffold mirrors tcp-ingestion conventions: ESM, strict TS, pnpm, vitest
with unit/integration split, ESLint flat config with no-floating-promises
+ no-misused-promises + import/no-restricted-paths (the new src/core/ →
src/domain/ boundary that protects Phase 1 from Phase 2 churn).
Core types in src/core/types.ts (Position, StreamRecord, DeviceState,
Metrics, AttributeValue) — Position is byte-equivalent to tcp-ingestion's
output. Codec in src/core/codec.ts implements sentinel reversal:
{__bigint:"..."} → bigint, {__buffer_b64:"..."} → Buffer, ISO timestamp
string → Date. CodecError surfaces malformed payload reasons with the
failing field named.
Config in src/config/load.ts (zod schema, all 13 env vars with defaults
and bounded numerics). Logger in src/observability/logger.ts matches
tcp-ingestion exactly: ISO timestamps, string level labels, pino-pretty
in development.
Postgres in src/db/: createPool with sane defaults and application_name,
connectWithRetry mirroring the ioredis retry pattern, a 30-line
migration runner using a schema_migrations table, and 0001_positions.sql
with the hypertable + (device_id, ts) unique index + ts DESC index.
Migration runner unit-tested against a mocked pg.Pool; the real
TimescaleDB round-trip is deferred to task 1.10 per spec.
Verification: typecheck, lint, build all clean; 73 unit tests passing
across 4 files. import/no-restricted-paths verified live by temporarily
adding a forbidden src/domain/ import.
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
/**
|
|
* 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: "<digits>" }
|
|
* Buffer → { __buffer_b64: "<base64>" } (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<string, unknown>)['type'] === 'Buffer' &&
|
|
Array.isArray((value as Record<string, unknown>)['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<string, string> {
|
|
return {
|
|
ts: position.timestamp.toISOString(),
|
|
device_id: position.device_id,
|
|
codec,
|
|
payload: JSON.stringify(position, jsonReplacer),
|
|
};
|
|
}
|
|
|
|
function makePosition(overrides: Partial<Position> = {}): 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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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);
|
|
});
|
|
});
|