Files
BIPMaterialManager/tests/unit/mysql.test.ts
Misaka abad61758c refactor(audit): type-safe enums, expanded coverage, and crash-safe logging
Replace magic strings with AuditAction/AuditStatus enums across all consumers,
add logAuditWithCurrentUser() convenience wrapper, extend audit coverage to
data import, result export, app update, and ERP credentials operations, and
harden crash handlers with try/catch to prevent audit failures from cascading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 17:40:30 +08:00

240 lines
7.0 KiB
TypeScript

/**
* Unit tests for MySqlService
* Covers both unconnected state and connected-path operations using mocked mysql2/promise.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { MySqlService } from '@services/database/mysql'
// ---- Hoisted mock functions ----
const {
mockCreateConnection,
mockPing,
mockExecute,
mockBeginTransaction,
mockCommit,
mockRollback,
mockEnd
} = vi.hoisted(() => ({
mockCreateConnection: vi.fn(),
mockPing: vi.fn(),
mockExecute: vi.fn(),
mockBeginTransaction: vi.fn(),
mockCommit: vi.fn(),
mockRollback: vi.fn(),
mockEnd: vi.fn()
}))
// Mock mysql2/promise driver
vi.mock('mysql2/promise', () => ({
default: {
createConnection: mockCreateConnection
}
}))
// Mock logger
vi.mock('@services/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}),
trackDuration: async <T>(fn: () => Promise<T>) => {
const result = await fn()
return { result }
}
}))
const mockConfig = {
host: 'localhost',
port: 3306,
user: 'test',
password: 'test',
database: 'testdb'
}
function createMockConnection() {
return {
ping: mockPing,
execute: mockExecute,
beginTransaction: mockBeginTransaction,
commit: mockCommit,
rollback: mockRollback,
end: mockEnd
}
}
describe('MySqlService Unit Tests', () => {
let service: MySqlService
beforeEach(() => {
vi.clearAllMocks()
service = new MySqlService(mockConfig)
mockCreateConnection.mockResolvedValue(createMockConnection())
mockPing.mockResolvedValue(undefined)
mockExecute.mockResolvedValue([[], []])
mockBeginTransaction.mockResolvedValue(undefined)
mockCommit.mockResolvedValue(undefined)
mockRollback.mockResolvedValue(undefined)
mockEnd.mockResolvedValue(undefined)
})
describe('constructor', () => {
it('should create service with config', () => {
expect(service).toBeDefined()
expect(service.isConnected()).toBe(false)
})
})
describe('isConnected', () => {
it('should return false when not connected', () => {
expect(service.isConnected()).toBe(false)
})
})
describe('connect', () => {
it('should throw error when connection fails', async () => {
mockCreateConnection.mockRejectedValue(new Error('connect ECONNREFUSED'))
await expect(service.connect()).rejects.toThrow('Failed to connect to MySQL')
})
it('should establish connection and ping server', async () => {
await service.connect()
expect(mockCreateConnection).toHaveBeenCalledWith({
host: 'localhost',
port: 3306,
user: 'test',
password: 'test',
database: 'testdb'
})
expect(mockPing).toHaveBeenCalled()
expect(service.isConnected()).toBe(true)
})
it('should throw when already connected', async () => {
await service.connect()
await expect(service.connect()).rejects.toThrow('Already connected to MySQL')
})
it('should throw when ping fails after connection created', async () => {
mockPing.mockRejectedValue(new Error('ping failed'))
await expect(service.connect()).rejects.toThrow('Failed to connect to MySQL')
// Note: source sets connection before ping, so it remains non-null after ping failure
})
})
describe('query', () => {
it('should throw error when not connected', async () => {
await expect(service.query('SELECT 1')).rejects.toThrow('Not connected to MySQL')
})
it('should execute SELECT and return rows with columns', async () => {
await service.connect()
mockExecute.mockResolvedValue([
[
{ id: 1, name: 'test' },
{ id: 2, name: 'foo' }
],
[{ name: 'id' }, { name: 'name' }]
])
const result = await service.query('SELECT id, name FROM users')
expect(mockExecute).toHaveBeenCalledWith('SELECT id, name FROM users', undefined)
expect(result.rows).toEqual([
{ id: 1, name: 'test' },
{ id: 2, name: 'foo' }
])
expect(result.columns).toEqual(['id', 'name'])
expect(result.rowCount).toBe(2)
})
it('should execute INSERT/UPDATE and return affected rows', async () => {
await service.connect()
mockExecute.mockResolvedValue([{ affectedRows: 3, changedRows: 2 }, []])
const result = await service.query('UPDATE users SET active = ?', [true])
expect(mockExecute).toHaveBeenCalledWith('UPDATE users SET active = ?', [true])
expect(result.rows).toEqual([])
expect(result.rowCount).toBe(3)
})
it('should use changedRows when affectedRows is zero', async () => {
await service.connect()
mockExecute.mockResolvedValue([{ affectedRows: 0, changedRows: 5 }, []])
const result = await service.query('UPDATE users SET x = 1')
expect(result.rowCount).toBe(5)
})
it('should wrap query errors with context', async () => {
await service.connect()
mockExecute.mockRejectedValue(new Error('syntax error'))
await expect(service.query('INVALID SQL')).rejects.toThrow('MySQL query failed')
})
})
describe('transaction', () => {
it('should throw error when not connected', async () => {
await expect(service.transaction([{ sql: 'SELECT 1' }])).rejects.toThrow(
'Not connected to MySQL'
)
})
it('should execute all queries and commit', async () => {
await service.connect()
await service.transaction([
{ sql: 'INSERT INTO t VALUES (?)', params: [1] },
{ sql: 'UPDATE t SET x = ?' }
])
expect(mockBeginTransaction).toHaveBeenCalled()
expect(mockExecute).toHaveBeenCalledTimes(2)
expect(mockExecute).toHaveBeenNthCalledWith(1, 'INSERT INTO t VALUES (?)', [1])
expect(mockExecute).toHaveBeenNthCalledWith(2, 'UPDATE t SET x = ?', undefined)
expect(mockCommit).toHaveBeenCalled()
expect(mockRollback).not.toHaveBeenCalled()
})
it('should rollback on query failure', async () => {
await service.connect()
mockExecute.mockRejectedValueOnce(new Error('constraint violation'))
await expect(
service.transaction([{ sql: 'INSERT INTO t VALUES (?)', params: [1] }])
).rejects.toThrow('MySQL transaction failed')
expect(mockRollback).toHaveBeenCalled()
expect(mockCommit).not.toHaveBeenCalled()
})
})
describe('disconnect', () => {
it('should resolve when not connected', async () => {
await expect(service.disconnect()).resolves.not.toThrow()
})
it('should end connection and reset state', async () => {
await service.connect()
expect(service.isConnected()).toBe(true)
await service.disconnect()
expect(mockEnd).toHaveBeenCalled()
expect(service.isConnected()).toBe(false)
})
it('should wrap disconnect errors', async () => {
await service.connect()
mockEnd.mockRejectedValue(new Error('connection lost'))
await expect(service.disconnect()).rejects.toThrow('Failed to disconnect from MySQL')
})
})
})