diff --git a/test/integration/mysql.test.ts b/test/integration/mysql.test.ts new file mode 100644 index 0000000..a7e0702 --- /dev/null +++ b/test/integration/mysql.test.ts @@ -0,0 +1,150 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MySqlContainer, type StartedMySqlContainer } from '@testcontainers/mysql' +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('mysql integration', () => { + let container: StartedMySqlContainer + let dir: string + let registry: Registry + let manager: Manager + + beforeAll(async () => { + container = await new MySqlContainer('mysql:8.4').start() + dir = mkdtempSync(join(tmpdir(), 'dbmole-int-my-')) + registry = createRegistry({ storePath: join(dir, 'connections.json'), env: {} }) + manager = createManager(registry) + registry.add({ + name: 'my', + type: 'mysql', + host: container.getHost(), + port: container.getPort(), + user: container.getUsername(), + password: container.getUserPassword(), + database: container.getDatabase(), + readonly: false + }) + registry.add({ + name: 'my-ro', + type: 'mysql', + host: container.getHost(), + port: container.getPort(), + user: container.getUsername(), + password: container.getUserPassword(), + database: container.getDatabase(), + readonly: true + }) + }) + + afterAll(async () => { + await manager.disposeAll() + await container.stop() + rmSync(dir, { recursive: true, force: true }) + }) + + it('runs DDL, DML with lastInsertId and SELECT with params', async () => { + const { driver } = await manager.get('my') + await driver.query({ + sql: 'CREATE TABLE users (id int AUTO_INCREMENT PRIMARY KEY, name varchar(64) NOT NULL)', + rowLimit: 10 + }) + const insert = await driver.query({ + sql: "INSERT INTO users (name) VALUES ('ada'), ('bob')", + rowLimit: 10 + }) + expect(insert.rowCount).toBe(2) + expect(insert.lastInsertId).toBe('1') + const select = await driver.query({ + sql: 'SELECT name FROM users WHERE id = ?', + params: [2], + rowLimit: 10 + }) + expect(select.columns).toEqual(['name']) + expect(select.rows).toEqual([['bob']]) + }) + + it('preserves BIGINT precision beyond 2^53', async () => { + const { driver } = await manager.get('my') + await driver.query({ + sql: 'CREATE TABLE big (v bigint)', + rowLimit: 10 + }) + await driver.query({ + sql: 'INSERT INTO big (v) VALUES (9007199254740993)', + rowLimit: 10 + }) + const result = await driver.query({ sql: 'SELECT v FROM big', rowLimit: 10 }) + expect(result.rows[0][0]).toBe('9007199254740993') + }) + + it('rejects multi-statement and session-level sql', async () => { + const { driver } = await manager.get('my') + await expect(driver.query({ sql: 'SELECT 1; SELECT 2', rowLimit: 10 })).rejects.toThrow( + /one SQL statement/ + ) + await expect( + driver.query({ sql: 'SET SESSION TRANSACTION READ WRITE', rowLimit: 10 }) + ).rejects.toThrow(/session-level/) + }) + + it('lists databases and tables, describes a table', async () => { + const { driver } = await manager.get('my') + const databases = await driver.listDatabases() + expect(databases.map((d) => d.name)).toContain(container.getDatabase()) + + await driver.query({ + sql: `CREATE TABLE orders ( + id int AUTO_INCREMENT PRIMARY KEY, + user_id int NOT NULL, + note text, + KEY orders_user_idx (user_id), + CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) + )`, + rowLimit: 10 + }) + + const tables = await driver.listTables({}) + expect(tables.map((t) => t.name)).toEqual(expect.arrayContaining(['orders', 'users'])) + + 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.indexes).toContainEqual( + expect.objectContaining({ name: 'orders_user_idx', unique: false }) + ) + expect(description.foreignKeys).toContainEqual( + expect.objectContaining({ + name: 'fk_orders_user', + columns: ['user_id'], + referencedTable: 'users' + }) + ) + }) + + it('describe_table of a missing table fails clearly', async () => { + const { driver } = await manager.get('my') + 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('my-ro') + const select = await driver.query({ sql: 'SELECT count(*) FROM users', rowLimit: 10 }) + expect(Number(select.rows[0][0])).toBe(2) + await expect( + driver.query({ sql: "INSERT INTO users (name) VALUES ('mallory')", rowLimit: 10 }) + ).rejects.toThrow(/READ ONLY/i) + await expect( + driver.query({ sql: 'CREATE TABLE hacked (id int)', rowLimit: 10 }) + ).rejects.toThrow(/READ ONLY/i) + }) + + it('reports server version', async () => { + const { driver } = await manager.get('my') + expect(await driver.serverVersion()).toMatch(/^8\./) + }) +})