Files
processor/test/config.test.ts
T
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

248 lines
8.4 KiB
TypeScript

/**
* Unit tests for src/config/load.ts
*
* Covers:
* - Parses all defaults correctly when only required vars are provided
* - Missing required vars throw with the right message
* - Invalid URLs throw (wrong protocol, not a URL)
* - Bounded numerics throw on out-of-range values
* - REDIS_CONSUMER_NAME defaults to INSTANCE_ID
* - Explicit REDIS_CONSUMER_NAME overrides INSTANCE_ID
*/
import { describe, it, expect } from 'vitest';
import { loadConfig } from '../src/config/load.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Minimal valid env — only required fields. */
function validEnv(overrides: Record<string, string> = {}): Record<string, string> {
return {
REDIS_URL: 'redis://localhost:6379',
POSTGRES_URL: 'postgres://postgres:pass@localhost:5432/trm',
...overrides,
};
}
// ---------------------------------------------------------------------------
// 1. Happy path — defaults
// ---------------------------------------------------------------------------
describe('loadConfig — defaults', () => {
it('parses successfully with only required vars', () => {
const config = loadConfig(validEnv());
expect(config.REDIS_URL).toBe('redis://localhost:6379');
expect(config.POSTGRES_URL).toBe('postgres://postgres:pass@localhost:5432/trm');
});
it('applies default NODE_ENV=production', () => {
const config = loadConfig(validEnv());
expect(config.NODE_ENV).toBe('production');
});
it('applies default INSTANCE_ID=processor-1', () => {
const config = loadConfig(validEnv());
expect(config.INSTANCE_ID).toBe('processor-1');
});
it('applies default LOG_LEVEL=info', () => {
const config = loadConfig(validEnv());
expect(config.LOG_LEVEL).toBe('info');
});
it('applies default REDIS_TELEMETRY_STREAM=telemetry:t', () => {
const config = loadConfig(validEnv());
expect(config.REDIS_TELEMETRY_STREAM).toBe('telemetry:t');
});
it('applies default REDIS_CONSUMER_GROUP=processor', () => {
const config = loadConfig(validEnv());
expect(config.REDIS_CONSUMER_GROUP).toBe('processor');
});
it('defaults REDIS_CONSUMER_NAME to INSTANCE_ID', () => {
const config = loadConfig(validEnv({ INSTANCE_ID: 'my-instance' }));
expect(config.REDIS_CONSUMER_NAME).toBe('my-instance');
});
it('respects explicit REDIS_CONSUMER_NAME override', () => {
const config = loadConfig(
validEnv({ INSTANCE_ID: 'instance-a', REDIS_CONSUMER_NAME: 'consumer-override' }),
);
expect(config.REDIS_CONSUMER_NAME).toBe('consumer-override');
});
it('applies default METRICS_PORT=9090', () => {
const config = loadConfig(validEnv());
expect(config.METRICS_PORT).toBe(9090);
});
it('applies default BATCH_SIZE=100', () => {
const config = loadConfig(validEnv());
expect(config.BATCH_SIZE).toBe(100);
});
it('applies default BATCH_BLOCK_MS=5000', () => {
const config = loadConfig(validEnv());
expect(config.BATCH_BLOCK_MS).toBe(5_000);
});
it('applies default WRITE_BATCH_SIZE=50', () => {
const config = loadConfig(validEnv());
expect(config.WRITE_BATCH_SIZE).toBe(50);
});
it('applies default DEVICE_STATE_LRU_CAP=10000', () => {
const config = loadConfig(validEnv());
expect(config.DEVICE_STATE_LRU_CAP).toBe(10_000);
});
});
// ---------------------------------------------------------------------------
// 2. Missing required vars
// ---------------------------------------------------------------------------
describe('loadConfig — missing required vars', () => {
it('throws when REDIS_URL is missing', () => {
expect(() => loadConfig({ POSTGRES_URL: 'postgres://localhost:5432/trm' })).toThrow(
/REDIS_URL/,
);
});
it('throws when POSTGRES_URL is missing', () => {
expect(() => loadConfig({ REDIS_URL: 'redis://localhost:6379' })).toThrow(/POSTGRES_URL/);
});
it('throws when both required vars are missing', () => {
expect(() => loadConfig({})).toThrow(/Configuration error/);
});
it('error message mentions every failing field', () => {
let message = '';
try {
loadConfig({});
} catch (err) {
message = err instanceof Error ? err.message : '';
}
expect(message).toMatch(/REDIS_URL/);
expect(message).toMatch(/POSTGRES_URL/);
});
});
// ---------------------------------------------------------------------------
// 3. URL validation
// ---------------------------------------------------------------------------
describe('loadConfig — URL validation', () => {
it('accepts redis:// URLs', () => {
expect(() => loadConfig(validEnv({ REDIS_URL: 'redis://redis:6379' }))).not.toThrow();
});
it('accepts rediss:// (TLS) URLs', () => {
expect(() => loadConfig(validEnv({ REDIS_URL: 'rediss://redis:6380' }))).not.toThrow();
});
it('rejects REDIS_URL with wrong protocol (http)', () => {
expect(() => loadConfig(validEnv({ REDIS_URL: 'http://localhost:6379' }))).toThrow(
/REDIS_URL/,
);
});
it('rejects REDIS_URL that is not a URL at all', () => {
expect(() => loadConfig(validEnv({ REDIS_URL: 'not-a-url' }))).toThrow(/REDIS_URL/);
});
it('accepts postgres:// URLs', () => {
expect(() =>
loadConfig(validEnv({ POSTGRES_URL: 'postgres://user:pass@db:5432/mydb' })),
).not.toThrow();
});
it('accepts postgresql:// URLs', () => {
expect(() =>
loadConfig(validEnv({ POSTGRES_URL: 'postgresql://user:pass@db:5432/mydb' })),
).not.toThrow();
});
it('rejects POSTGRES_URL with wrong protocol (mysql)', () => {
expect(() =>
loadConfig(validEnv({ POSTGRES_URL: 'mysql://localhost:3306/db' })),
).toThrow(/POSTGRES_URL/);
});
it('rejects POSTGRES_URL that is not a URL at all', () => {
expect(() => loadConfig(validEnv({ POSTGRES_URL: 'localhost/db' }))).toThrow(/POSTGRES_URL/);
});
});
// ---------------------------------------------------------------------------
// 4. Bounded numerics
// ---------------------------------------------------------------------------
describe('loadConfig — bounded numerics', () => {
it('rejects BATCH_SIZE below minimum (0)', () => {
expect(() => loadConfig(validEnv({ BATCH_SIZE: '0' }))).toThrow(/BATCH_SIZE/);
});
it('rejects BATCH_SIZE above maximum (10001)', () => {
expect(() => loadConfig(validEnv({ BATCH_SIZE: '10001' }))).toThrow(/BATCH_SIZE/);
});
it('accepts BATCH_SIZE at boundary values (1, 10000)', () => {
expect(() => loadConfig(validEnv({ BATCH_SIZE: '1' }))).not.toThrow();
expect(() => loadConfig(validEnv({ BATCH_SIZE: '10000' }))).not.toThrow();
});
it('rejects BATCH_BLOCK_MS above maximum (60001)', () => {
expect(() => loadConfig(validEnv({ BATCH_BLOCK_MS: '60001' }))).toThrow(/BATCH_BLOCK_MS/);
});
it('accepts BATCH_BLOCK_MS=0 (no blocking)', () => {
const config = loadConfig(validEnv({ BATCH_BLOCK_MS: '0' }));
expect(config.BATCH_BLOCK_MS).toBe(0);
});
it('rejects WRITE_BATCH_SIZE below minimum (0)', () => {
expect(() => loadConfig(validEnv({ WRITE_BATCH_SIZE: '0' }))).toThrow(/WRITE_BATCH_SIZE/);
});
it('rejects WRITE_BATCH_SIZE above maximum (1001)', () => {
expect(() => loadConfig(validEnv({ WRITE_BATCH_SIZE: '1001' }))).toThrow(/WRITE_BATCH_SIZE/);
});
it('rejects DEVICE_STATE_LRU_CAP below minimum (99)', () => {
expect(() => loadConfig(validEnv({ DEVICE_STATE_LRU_CAP: '99' }))).toThrow(
/DEVICE_STATE_LRU_CAP/,
);
});
it('rejects DEVICE_STATE_LRU_CAP above maximum (1000001)', () => {
expect(() => loadConfig(validEnv({ DEVICE_STATE_LRU_CAP: '1000001' }))).toThrow(
/DEVICE_STATE_LRU_CAP/,
);
});
it('rejects non-numeric METRICS_PORT', () => {
expect(() => loadConfig(validEnv({ METRICS_PORT: 'abc' }))).toThrow(/METRICS_PORT/);
});
});
// ---------------------------------------------------------------------------
// 5. LOG_LEVEL validation
// ---------------------------------------------------------------------------
describe('loadConfig — LOG_LEVEL', () => {
it('accepts all valid pino levels', () => {
const levels = ['fatal', 'error', 'warn', 'info', 'debug', 'trace'] as const;
for (const level of levels) {
expect(() => loadConfig(validEnv({ LOG_LEVEL: level }))).not.toThrow();
}
});
it('rejects an invalid log level', () => {
expect(() => loadConfig(validEnv({ LOG_LEVEL: 'verbose' }))).toThrow(/LOG_LEVEL/);
});
});