Files
julian 95efc23139 Implement Phase 1 tasks 1.1-1.4 (scaffold + core types + config + Postgres)
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.
2026-04-30 21:35:59 +02:00

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);
});
});