diff --git a/tests/unit/db/client.test.ts b/tests/unit/db/client.test.ts index c4e78860..f95d26cb 100644 --- a/tests/unit/db/client.test.ts +++ b/tests/unit/db/client.test.ts @@ -1,14 +1,43 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { _setTestDb, getDb } from '../../../src/db/client.js'; - -/** - * Tests for the _setTestDb override mechanism in getDb(). - * These tests only exercise the override path (where _testDbOverride !== null), - * so no real database connection is needed. - */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── +// vi.mock factories are hoisted to the top of the file, so any variables they +// reference must also be hoisted via vi.hoisted(). + +const { mockPoolEnd, mockPoolConstructor } = vi.hoisted(() => { + const mockPoolEnd = vi.fn().mockResolvedValue(undefined); + const mockPoolConstructor = vi.fn().mockImplementation(() => ({ end: mockPoolEnd })); + return { mockPoolEnd, mockPoolConstructor }; +}); + +vi.mock('pg', () => ({ + default: { + Pool: mockPoolConstructor, + }, +})); + +vi.mock('drizzle-orm/node-postgres', () => ({ + drizzle: vi.fn().mockReturnValue({ __isMockDrizzle: true }), +})); + +// ── Imports (after mocks) ───────────────────────────────────────────────────── + +import { _setTestDb, closeDb, getDb } from '../../../src/db/client.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Reset module-level pool/db singletons between tests. */ +async function resetDbState() { + // closeDb() resets pool + db to null; if pool is null it's a no-op so safe. + await closeDb(); + // Also clear the test override. + _setTestDb(null); +} + +// ── Tests: _setTestDb (pre-existing coverage, kept for regression) ──────────── + describe('_setTestDb', () => { afterEach(() => { - // Always clear to avoid polluting subsequent tests (isolate: false) _setTestDb(null); }); @@ -40,3 +69,141 @@ describe('_setTestDb', () => { expect(getDb()).toBe(newDb); }); }); + +// ── Tests: getDatabaseUrl (tested via getDb internals) ─────────────────────── + +describe('getDatabaseUrl', () => { + beforeEach(async () => { + await resetDbState(); + }); + + afterEach(async () => { + await resetDbState(); + }); + + it('uses DATABASE_URL when set', () => { + vi.stubEnv('DATABASE_URL', 'postgresql://user:pass@myhost:5432/mydb'); + vi.stubEnv('CASCADE_POSTGRES_HOST', ''); + + getDb(); + + expect(mockPoolConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + connectionString: 'postgresql://user:pass@myhost:5432/mydb', + }), + ); + }); + + it('falls back to CASCADE_POSTGRES_* vars with correct defaults', () => { + vi.stubEnv('DATABASE_URL', ''); + vi.stubEnv('CASCADE_POSTGRES_HOST', 'pg.example.com'); + vi.stubEnv('CASCADE_POSTGRES_PORT', ''); + vi.stubEnv('CASCADE_POSTGRES_USER', ''); + vi.stubEnv('CASCADE_POSTGRES_PASSWORD', ''); + vi.stubEnv('CASCADE_POSTGRES_DB', ''); + + getDb(); + + expect(mockPoolConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + connectionString: 'postgresql://postgres:@pg.example.com:6543/cascade', + }), + ); + }); + + it('respects custom CASCADE_POSTGRES_* values', () => { + vi.stubEnv('DATABASE_URL', ''); + vi.stubEnv('CASCADE_POSTGRES_HOST', 'custom-host'); + vi.stubEnv('CASCADE_POSTGRES_PORT', '5432'); + vi.stubEnv('CASCADE_POSTGRES_USER', 'myuser'); + vi.stubEnv('CASCADE_POSTGRES_PASSWORD', 'secret'); + vi.stubEnv('CASCADE_POSTGRES_DB', 'mydb'); + + getDb(); + + expect(mockPoolConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + connectionString: 'postgresql://myuser:secret@custom-host:5432/mydb', + }), + ); + }); + + it('throws when neither DATABASE_URL nor CASCADE_POSTGRES_HOST is set', () => { + vi.stubEnv('DATABASE_URL', ''); + vi.stubEnv('CASCADE_POSTGRES_HOST', ''); + + expect(() => getDb()).toThrow('DATABASE_URL or CASCADE_POSTGRES_HOST must be set'); + }); +}); + +// ── Tests: getDb ───────────────────────────────────────────────────────────── + +describe('getDb', () => { + beforeEach(async () => { + await resetDbState(); + vi.stubEnv('DATABASE_URL', 'postgresql://user:pass@localhost:5432/testdb'); + }); + + afterEach(async () => { + await resetDbState(); + }); + + it('creates pool with SSL disabled when DATABASE_SSL=false', () => { + vi.stubEnv('DATABASE_SSL', 'false'); + + getDb(); + + expect(mockPoolConstructor).toHaveBeenCalledWith(expect.objectContaining({ ssl: false })); + }); + + it('creates pool with rejectUnauthorized:false by default (DATABASE_SSL not set)', () => { + vi.stubEnv('DATABASE_SSL', ''); + + getDb(); + + expect(mockPoolConstructor).toHaveBeenCalledWith( + expect.objectContaining({ ssl: { rejectUnauthorized: false } }), + ); + }); + + it('returns singleton — second call returns same instance', () => { + const first = getDb(); + const second = getDb(); + + expect(first).toBe(second); + // Pool constructor should only be called once + expect(mockPoolConstructor).toHaveBeenCalledTimes(1); + }); +}); + +// ── Tests: closeDb ──────────────────────────────────────────────────────────── + +describe('closeDb', () => { + beforeEach(async () => { + await resetDbState(); + vi.stubEnv('DATABASE_URL', 'postgresql://user:pass@localhost:5432/testdb'); + }); + + afterEach(async () => { + await resetDbState(); + }); + + it('calls pool.end() and resets state', async () => { + getDb(); // creates pool + expect(mockPoolConstructor).toHaveBeenCalledTimes(1); + + await closeDb(); + expect(mockPoolEnd).toHaveBeenCalledTimes(1); + + // After close, calling getDb() should create a new pool + getDb(); + expect(mockPoolConstructor).toHaveBeenCalledTimes(2); + }); + + it('is a no-op when pool is already null', async () => { + // No getDb() call — pool is null + await closeDb(); + + expect(mockPoolEnd).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/utils/logging.test.ts b/tests/unit/utils/logging.test.ts new file mode 100644 index 00000000..958df256 --- /dev/null +++ b/tests/unit/utils/logging.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { LOG_LEVELS, getLogLevel, logger, setLogLevel } from '../../../src/utils/logging.js'; + +/** + * Tests for setLogLevel and getLogLevel in utils/logging.ts. + * The logger is a real llmist Logger instance; we mutate its settings.minLevel + * and restore it between tests. + */ +describe('setLogLevel / getLogLevel', () => { + let originalLevel: number; + + beforeEach(() => { + // Capture the current level so we can restore it after each test + originalLevel = logger.settings.minLevel; + }); + + afterEach(() => { + logger.settings.minLevel = originalLevel; + }); + + // ── setLogLevel ───────────────────────────────────────────────────────────── + + describe('setLogLevel', () => { + it('sets the correct numeric level for each valid level string', () => { + for (const [levelName, levelValue] of Object.entries(LOG_LEVELS)) { + setLogLevel(levelName); + expect(logger.settings.minLevel).toBe(levelValue); + } + }); + + it('accepts level strings in any case (case-insensitive)', () => { + setLogLevel('DEBUG'); + expect(logger.settings.minLevel).toBe(LOG_LEVELS.debug); + + setLogLevel('WARN'); + expect(logger.settings.minLevel).toBe(LOG_LEVELS.warn); + + setLogLevel('Error'); + expect(logger.settings.minLevel).toBe(LOG_LEVELS.error); + }); + + it('ignores invalid level string — leaves minLevel unchanged', () => { + setLogLevel('info'); // set to a known level first + const beforeLevel = logger.settings.minLevel; + + setLogLevel('notavalidlevel'); + + expect(logger.settings.minLevel).toBe(beforeLevel); + }); + + it('ignores empty string — leaves minLevel unchanged', () => { + setLogLevel('warn'); + const beforeLevel = logger.settings.minLevel; + + setLogLevel(''); + + expect(logger.settings.minLevel).toBe(beforeLevel); + }); + }); + + // ── getLogLevel ───────────────────────────────────────────────────────────── + + describe('getLogLevel', () => { + it('returns the string name for each known numeric level', () => { + for (const [levelName, levelValue] of Object.entries(LOG_LEVELS)) { + logger.settings.minLevel = levelValue; + expect(getLogLevel()).toBe(levelName); + } + }); + + it('defaults to "debug" when minLevel does not match any known level', () => { + // Use a numeric value that is not in LOG_LEVELS + logger.settings.minLevel = 999; + expect(getLogLevel()).toBe('debug'); + }); + + it('round-trips correctly after setLogLevel', () => { + setLogLevel('error'); + expect(getLogLevel()).toBe('error'); + + setLogLevel('silly'); + expect(getLogLevel()).toBe('silly'); + }); + }); +});