Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions tests/unit/config/database.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

/**
* #606: database configuration (src/config/database.ts) has no unit tests.
*
* The module constructs a real `pg.Pool` and `drizzle()` instance at import
* time, so `pg` and `drizzle-orm/node-postgres` are mocked here the same way
* tracing.test.ts mocks the OpenTelemetry SDK — nothing actually connects to
* a database. `src/utils/logger.js` and `../database/schema.js` are mocked
* too, so this stays a pure unit test of the pool/connection configuration,
* not an integration test of drizzle or the schema.
*/

const poolInstances: Array<Record<string, unknown>> = [];

vi.mock("pg", () => {
const Pool = vi
.fn()
.mockImplementation((options: Record<string, unknown>) => {
const handlers: Record<string, (...args: unknown[]) => void> = {};
const instance = {
options,
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
handlers[event] = handler;
}),
end: vi.fn().mockResolvedValue(undefined),
_emit: (event: string, ...args: unknown[]) =>
handlers[event]?.(...args),
};
poolInstances.push(instance);
return instance;
});
return { Pool };
});

vi.mock("drizzle-orm/node-postgres", () => ({
drizzle: vi.fn((pool, opts) => ({ pool, opts })),
}));

vi.mock("../../../src/database/schema.js", () => ({}));

vi.mock("../../../src/utils/logger.js", () => ({
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));

async function loadDatabase(databaseUrl: string) {
vi.resetModules();
poolInstances.length = 0;
vi.stubEnv("DATABASE_URL", databaseUrl);
// REQUIRED_IN_TEST_MODE (src/config/index.ts) checks process.env directly
// for these before any fallback applies, so they must be set even though
// this test never uses their values.
vi.stubEnv(
"JWT_SECRET",
"test-secret-key-that-is-at-least-sixty-four-characters-long-for-tests",
);
vi.stubEnv("STELLAR_PLATFORM_SECRET", "S" + "A".repeat(55));
const { Pool } = await import("pg");
const dbModule = await import("../../../src/config/database.js");
const { logger } = await import("../../../src/utils/logger.js");
return {
Pool,
dbModule,
logger,
poolInstance: poolInstances[poolInstances.length - 1],
};
}

describe("database config (#606)", () => {
beforeEach(() => {
vi.unstubAllEnvs();
});

afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});

it("configures the pool with the DATABASE_URL from config", async () => {
const { poolInstance } = await loadDatabase(
"postgresql://user:pass@localhost:5432/chainlearn_test",
);

expect(poolInstance.options).toMatchObject({
connectionString: "postgresql://user:pass@localhost:5432/chainlearn_test",
});
});

it("sets a bounded pool size and timeouts so a misbehaving dependency can't exhaust connections or hang forever", async () => {
const { poolInstance } = await loadDatabase(
"postgresql://user:pass@localhost:5432/chainlearn_test",
);

expect(poolInstance.options).toMatchObject({
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
});

it("registers an error handler on the pool that logs instead of crashing the process", async () => {
const { poolInstance, logger } = await loadDatabase(
"postgresql://user:pass@localhost:5432/chainlearn_test",
);

expect(poolInstance.on).toHaveBeenCalledWith("error", expect.any(Function));

const err = new Error("connection terminated unexpectedly");
(poolInstance as { _emit: (e: string, ...a: unknown[]) => void })._emit(
"error",
err,
);

expect(logger.error).toHaveBeenCalledWith(
{ err },
"Unexpected PG pool error",
);
});

it("wires drizzle to the configured pool", async () => {
const { dbModule, poolInstance } = await loadDatabase(
"postgresql://user:pass@localhost:5432/chainlearn_test",
);

expect(dbModule.db).toBeDefined();
expect((dbModule.db as unknown as { pool: unknown }).pool).toBe(
poolInstance,
);
});

it("exports the pool itself alongside db", async () => {
const { dbModule, poolInstance } = await loadDatabase(
"postgresql://user:pass@localhost:5432/chainlearn_test",
);

expect(dbModule.pool).toBe(poolInstance);
});

it("closeDatabase() ends the pool", async () => {
const { dbModule, poolInstance } = await loadDatabase(
"postgresql://user:pass@localhost:5432/chainlearn_test",
);

await expect(dbModule.closeDatabase()).resolves.toBeUndefined();
expect(poolInstance.end).toHaveBeenCalledTimes(1);
});

it("accepts a postgres:// connection string too, not only postgresql://", async () => {
const { poolInstance } = await loadDatabase(
"postgres://user:pass@localhost:5432/chainlearn_test",
);

expect(poolInstance.options).toMatchObject({
connectionString: "postgres://user:pass@localhost:5432/chainlearn_test",
});
});
});
141 changes: 141 additions & 0 deletions tests/unit/tracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,24 @@ vi.mock("@opentelemetry/instrumentation-ioredis", () => ({
IORedisInstrumentation: vi.fn(),
}));

vi.mock("@opentelemetry/instrumentation-http", () => ({
HttpInstrumentation: vi.fn(),
}));

import { initTracing, shutdownTracing } from "../../src/tracing.js";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { FastifyInstrumentation } from "@opentelemetry/instrumentation-fastify";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { PgInstrumentation } from "@opentelemetry/instrumentation-pg";
import { IORedisInstrumentation } from "@opentelemetry/instrumentation-ioredis";
import {
trace,
context as otelContext,
SpanStatusCode,
} from "@opentelemetry/api";
import { AsyncHooksContextManager } from "@opentelemetry/context-async-hooks";

describe("Tracing", () => {
let originalEnv: string | undefined;
Expand Down Expand Up @@ -69,6 +85,51 @@ describe("Tracing", () => {
initTracing("custom-service");

expect(NodeSDK).toHaveBeenCalled();
expect(OTLPTraceExporter).toHaveBeenCalledWith({
url: "http://custom:4318/v1/traces",
});

delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
});

it("should default to the local collector endpoint when none is set", () => {
initTracing("test-service");

expect(OTLPTraceExporter).toHaveBeenCalledWith({
url: "http://localhost:4318/v1/traces",
});
});

it("should tag the resource with the given service name", () => {
initTracing("my-custom-service");

expect(resourceFromAttributes).toHaveBeenCalledWith(
expect.objectContaining({ "service.name": "my-custom-service" }),
);
});

it("should default the service name when none is passed", () => {
initTracing();

expect(resourceFromAttributes).toHaveBeenCalledWith(
expect.objectContaining({ "service.name": "chainlearn-api" }),
);
});

it("should register every expected auto-instrumentation", () => {
initTracing("test-service");

expect(FastifyInstrumentation).toHaveBeenCalledTimes(1);
expect(HttpInstrumentation).toHaveBeenCalledTimes(1);
expect(PgInstrumentation).toHaveBeenCalledTimes(1);
expect(IORedisInstrumentation).toHaveBeenCalledTimes(1);
});

it("should call start() on the constructed SDK instance", () => {
initTracing("test-service");

const sdkInstance = vi.mocked(NodeSDK).mock.results[0].value;
expect(sdkInstance.start).toHaveBeenCalledTimes(1);
});

it("should shutdown tracing gracefully", async () => {
Expand All @@ -80,4 +141,84 @@ describe("Tracing", () => {
it("should handle shutdown when SDK not initialized", async () => {
await expect(shutdownTracing()).resolves.toBeUndefined();
});

describe("span creation and context propagation", () => {
// These exercise the real @opentelemetry/api (the NodeSDK itself is
// mocked above, but the API package that instrumentation/application
// code calls into — tracer, span, context — is not, so this verifies
// the actual propagation semantics the mocked SDK can't). A real
// context manager is registered so context.with() actually propagates
// instead of being a no-op (the default NoopContextManager), mirroring
// what NodeSDK itself wires up in production.
const tracer = trace.getTracer("tracing-test");
const contextManager = new AsyncHooksContextManager();

beforeEach(() => {
contextManager.enable();
otelContext.setGlobalContextManager(contextManager);
});

afterEach(() => {
otelContext.disable();
});

it("creates a span and records its name and status", () => {
const span = tracer.startSpan("test-span");
span.setStatus({ code: SpanStatusCode.OK });
span.end();

// No real exporter is registered under the mocked SDK, so this
// asserts the API doesn't throw and returns a span the application
// can operate on — the core contract initTracing's callers rely on.
expect(span).toBeDefined();
expect(typeof span.spanContext().traceId).toBe("string");
expect(typeof span.spanContext().spanId).toBe("string");
});

it("propagates a span through context so a nested call sees it as active", () => {
const parentSpan = tracer.startSpan("parent-span");
const ctx = trace.setSpan(otelContext.active(), parentSpan);

let observedSpan: ReturnType<typeof trace.getSpan>;
otelContext.with(ctx, () => {
observedSpan = trace.getSpan(otelContext.active());
});

expect(observedSpan!).toBe(parentSpan);
parentSpan.end();
});

it("gives a child span created within the parent's context a matching trace id", () => {
const parentSpan = tracer.startSpan("parent-span");
const parentCtx = trace.setSpan(otelContext.active(), parentSpan);

let childSpan: ReturnType<typeof tracer.startSpan>;
otelContext.with(parentCtx, () => {
childSpan = tracer.startSpan(
"child-span",
undefined,
otelContext.active(),
);
});

expect(childSpan!.spanContext().traceId).toBe(
parentSpan.spanContext().traceId,
);

childSpan!.end();
parentSpan.end();
});

it("does not leak an active span outside of the context.with() callback", () => {
const span = tracer.startSpan("scoped-span");
const ctx = trace.setSpan(otelContext.active(), span);

otelContext.with(ctx, () => {
expect(trace.getSpan(otelContext.active())).toBe(span);
});

expect(trace.getSpan(otelContext.active())).not.toBe(span);
span.end();
});
});
});
Loading
Loading