diff --git a/test/integration/postgres.test.ts b/test/integration/postgres.test.ts new file mode 100644 index 0000000..b4900b7 --- /dev/null +++ b/test/integration/postgres.test.ts @@ -0,0 +1,172 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { Registry } from '../../src/config/registry.js' +import { createRegistry } from '../../src/config/registry.js' +import type { Manager } from '../../src/db/manager.js' +import { createManager } from '../../src/db/manager.js' + +describe('postgres integration', () => { + let container: StartedPostgreSqlContainer + let dir: string + let registry: Registry + let manager: Manager + + beforeAll(async () => { + container = await new PostgreSqlContainer('postgres:17-alpine').start() + dir = mkdtempSync(join(tmpdir(), 'dbmole-int-pg-')) + registry = createRegistry({ storePath: join(dir, 'connections.json'), env: {} }) + manager = createManager(registry) + registry.add({ + name: 'pg', + type: 'postgres', + host: container.getHost(), + port: container.getPort(), + user: container.getUsername(), + password: container.getPassword(), + database: container.getDatabase(), + readonly: false + }) + registry.add({ + name: 'pg-ro', + type: 'postgres', + host: container.getHost(), + port: container.getPort(), + user: container.getUsername(), + password: container.getPassword(), + database: container.getDatabase(), + readonly: true + }) + }) + + afterAll(async () => { + await manager.disposeAll() + await container.stop() + rmSync(dir, { recursive: true, force: true }) + }) + + it('runs DDL, DML and SELECT with params', async () => { + const { driver } = await manager.get('pg') + await driver.query({ + sql: 'CREATE TABLE users (id serial PRIMARY KEY, name text NOT NULL)', + rowLimit: 10 + }) + const insert = await driver.query({ + sql: "INSERT INTO users (name) VALUES ('ada'), ('bob')", + rowLimit: 10 + }) + expect(insert.rowCount).toBe(2) + const select = await driver.query({ + sql: 'SELECT name FROM users WHERE id = $1', + params: [1], + rowLimit: 10 + }) + expect(select.columns).toEqual(['name']) + expect(select.rows).toEqual([['ada']]) + }) + + it('truncates SELECT results at rowLimit', async () => { + const { driver } = await manager.get('pg') + const result = await driver.query({ + sql: 'SELECT generate_series(1, 10)', + rowLimit: 3 + }) + expect(result.rows).toHaveLength(3) + expect(result.truncated).toBe(true) + }) + + it('rejects multi-statement and session-level sql', async () => { + const { driver } = await manager.get('pg') + await expect(driver.query({ sql: 'SELECT 1; SELECT 2', rowLimit: 10 })).rejects.toThrow( + /one SQL statement/ + ) + await expect(driver.query({ sql: 'BEGIN', rowLimit: 10 })).rejects.toThrow(/session-level/) + }) + + it('lists databases, tables and describes a table', async () => { + const { driver } = await manager.get('pg') + const databases = await driver.listDatabases() + expect(databases.map((d) => d.name)).toContain(container.getDatabase()) + + const tables = await driver.listTables({}) + expect(tables).toContainEqual(expect.objectContaining({ schema: 'public', name: 'users' })) + + await driver.query({ + sql: `CREATE TABLE orders ( + id serial PRIMARY KEY, + user_id integer NOT NULL REFERENCES users(id), + note text + )`, + rowLimit: 10 + }) + await driver.query({ + sql: 'CREATE INDEX orders_user_idx ON orders (user_id)', + rowLimit: 10 + }) + + const description = await driver.describeTable({ table: 'orders' }) + expect(description.primaryKey).toEqual(['id']) + expect(description.columns.map((c) => c.name)).toEqual(['id', 'user_id', 'note']) + expect(description.columns[1].nullable).toBe(false) + expect(description.indexes).toContainEqual( + expect.objectContaining({ + name: 'orders_user_idx', + columns: ['user_id'], + unique: false + }) + ) + expect(description.foreignKeys).toContainEqual( + expect.objectContaining({ columns: ['user_id'], referencedTable: 'users' }) + ) + }) + + it('describes composite foreign keys with paired columns', async () => { + const { driver } = await manager.get('pg') + await driver.query({ + sql: `CREATE TABLE parents ( + a integer NOT NULL, + b integer NOT NULL, + PRIMARY KEY (a, b) + )`, + rowLimit: 10 + }) + await driver.query({ + sql: `CREATE TABLE children ( + id serial PRIMARY KEY, + pa integer NOT NULL, + pb integer NOT NULL, + FOREIGN KEY (pa, pb) REFERENCES parents (a, b) + )`, + rowLimit: 10 + }) + const description = await driver.describeTable({ table: 'children' }) + expect(description.foreignKeys).toHaveLength(1) + expect(description.foreignKeys[0].columns).toEqual(['pa', 'pb']) + expect(description.foreignKeys[0].referencedTable).toBe('parents') + expect(description.foreignKeys[0].referencedColumns).toEqual(['a', 'b']) + }) + + it('describe_table of a missing table fails clearly', async () => { + const { driver } = await manager.get('pg') + await expect(driver.describeTable({ table: 'ghost' })).rejects.toThrow(/not found/) + }) + + it('enforces readonly: INSERT and DDL fail, SELECT works', async () => { + const { driver } = await manager.get('pg-ro') + const select = await driver.query({ sql: 'SELECT count(*) FROM users', rowLimit: 10 }) + expect(select.rows[0][0]).toBe('2') + await expect( + driver.query({ sql: "INSERT INTO users (name) VALUES ('mallory')", rowLimit: 10 }) + ).rejects.toThrow(/read-only/) + await expect( + driver.query({ sql: 'CREATE TABLE hacked (id int)', rowLimit: 10 }) + ).rejects.toThrow(/read-only/) + }) + + it('reports server version', async () => { + const { driver } = await manager.get('pg') + expect(await driver.serverVersion()).toMatch(/^17\./) + }) +})