test: postgres integration via testcontainers
This commit is contained in:
@@ -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\./)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user