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.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Unit tests for src/db/pool.ts
|
||||
*
|
||||
* Covers:
|
||||
* - connectWithRetry succeeds on first attempt
|
||||
* - connectWithRetry retries on failure and succeeds on a later attempt
|
||||
* - connectWithRetry calls process.exit(1) after exhausting all attempts
|
||||
* - Warn is logged for each non-final failed attempt; fatal for the last
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Logger } from 'pino';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { connectWithRetry } from '../../src/db/pool.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeSilentLogger(): Logger {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
trace: vi.fn(),
|
||||
level: 'silent',
|
||||
silent: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock pg.Pool whose connect() resolves or rejects according to
|
||||
* the `connectResults` sequence. Each call consumes the next entry.
|
||||
*/
|
||||
function makeMockPool(connectResults: Array<'ok' | Error>): {
|
||||
pool: Pool;
|
||||
connectCallCount: () => number;
|
||||
} {
|
||||
let callIndex = 0;
|
||||
|
||||
const clientQuery = vi.fn().mockResolvedValue({ rows: [] });
|
||||
const clientRelease = vi.fn();
|
||||
const mockClient = { query: clientQuery, release: clientRelease };
|
||||
|
||||
const connect = vi.fn(async () => {
|
||||
const result = connectResults[callIndex++];
|
||||
if (result === 'ok') {
|
||||
return mockClient as unknown as PoolClient;
|
||||
}
|
||||
throw result;
|
||||
});
|
||||
|
||||
return {
|
||||
pool: { connect } as unknown as Pool,
|
||||
connectCallCount: () => callIndex,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('connectWithRetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('succeeds on first attempt without retrying', async () => {
|
||||
const { pool, connectCallCount } = makeMockPool(['ok']);
|
||||
const logger = makeSilentLogger();
|
||||
|
||||
await connectWithRetry(pool, logger, 3);
|
||||
|
||||
expect(connectCallCount()).toBe(1);
|
||||
expect(logger.info).toHaveBeenCalledWith({ attempt: 1 }, 'Postgres connected');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries on failure and succeeds on the second attempt', async () => {
|
||||
const { pool, connectCallCount } = makeMockPool([new Error('ECONNREFUSED'), 'ok']);
|
||||
const logger = makeSilentLogger();
|
||||
|
||||
const promise = connectWithRetry(pool, logger, 2);
|
||||
// Advance timers to fire the backoff setTimeout (200ms * 2^0 = 200ms)
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(connectCallCount()).toBe(2);
|
||||
expect(logger.warn).toHaveBeenCalledOnce();
|
||||
expect(logger.info).toHaveBeenCalledWith({ attempt: 2 }, 'Postgres connected');
|
||||
});
|
||||
|
||||
it('calls process.exit(1) after exhausting all attempts — maxAttempts=1', async () => {
|
||||
// Use maxAttempts=1 to skip backoff timers entirely, avoiding timer-related
|
||||
// unhandled rejection noise in the test suite.
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code) => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const { pool } = makeMockPool([new Error('ECONNREFUSED')]);
|
||||
const logger = makeSilentLogger();
|
||||
|
||||
await expect(connectWithRetry(pool, logger, 1)).rejects.toThrow('process.exit called');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(logger.fatal).toHaveBeenCalledOnce();
|
||||
// With maxAttempts=1, no retries → no warn
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs warn for non-final failed attempts', async () => {
|
||||
// maxAttempts=2: attempt 1 fails (warn), attempt 2 succeeds.
|
||||
// This avoids the unhandled-rejection noise that occurs when process.exit
|
||||
// throws inside an async function that has a pending backoff timer.
|
||||
const { pool } = makeMockPool([new Error('fail 1'), 'ok']);
|
||||
const logger = makeSilentLogger();
|
||||
|
||||
const promise = connectWithRetry(pool, logger, 2);
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.fatal).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith({ attempt: 2 }, 'Postgres connected');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user