fix(test): add mock-driver tests for database services
- Add connected-path tests for mysql, sql-server, and postgresql using mocked drivers (mysql2/promise, mssql, pg) covering connect, query, transaction, and disconnect scenarios - Fix tautological assertion in auth-flow.test.ts (hasError >= 0 was always true) - Add tests/integration to vitest exclude list to prevent module cache pollution under isolate:false - Set isolate to true for CI, false for local dev (was: isolate false)
This commit is contained in:
@@ -85,8 +85,10 @@ test.describe('Authentication Flow', () => {
|
|||||||
const errorMessage = page.locator('.error, [role="alert"], .text-red')
|
const errorMessage = page.locator('.error, [role="alert"], .text-red')
|
||||||
const hasError = await errorMessage.count()
|
const hasError = await errorMessage.count()
|
||||||
|
|
||||||
// Either error shown or still on login page
|
// Verify login was rejected: either error shown or still on login page
|
||||||
expect(hasError >= 0).toBe(true)
|
const loginDialog = page.locator('[data-testid="login-dialog"]')
|
||||||
|
const isStillOnLoginPage = await loginDialog.isVisible().catch(() => false)
|
||||||
|
expect(hasError > 0 || isStillOnLoginPage).toBe(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,51 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for MySqlService
|
* Unit tests for MySqlService
|
||||||
* These tests do not require a MySQL instance
|
* Covers both unconnected state and connected-path operations using mocked mysql2/promise.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { MySqlService } from '@services/database/mysql'
|
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 = {
|
const mockConfig = {
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 3306,
|
port: 3306,
|
||||||
@@ -14,11 +54,30 @@ const mockConfig = {
|
|||||||
database: 'testdb'
|
database: 'testdb'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMockConnection() {
|
||||||
|
return {
|
||||||
|
ping: mockPing,
|
||||||
|
execute: mockExecute,
|
||||||
|
beginTransaction: mockBeginTransaction,
|
||||||
|
commit: mockCommit,
|
||||||
|
rollback: mockRollback,
|
||||||
|
end: mockEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('MySqlService Unit Tests', () => {
|
describe('MySqlService Unit Tests', () => {
|
||||||
let service: MySqlService
|
let service: MySqlService
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
service = new MySqlService(mockConfig)
|
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', () => {
|
describe('constructor', () => {
|
||||||
@@ -35,15 +94,33 @@ describe('MySqlService Unit Tests', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('connect', () => {
|
describe('connect', () => {
|
||||||
it('should throw error with invalid credentials', async () => {
|
it('should throw error when connection fails', async () => {
|
||||||
// This tests error handling without needing a real server
|
mockCreateConnection.mockRejectedValue(new Error('connect ECONNREFUSED'))
|
||||||
const invalidConfig = {
|
await expect(service.connect()).rejects.toThrow('Failed to connect to MySQL')
|
||||||
...mockConfig,
|
})
|
||||||
host: 'invalid-host-that-does-not-exist'
|
|
||||||
}
|
|
||||||
const invalidService = new MySqlService(invalidConfig)
|
|
||||||
|
|
||||||
await expect(invalidService.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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,6 +128,51 @@ describe('MySqlService Unit Tests', () => {
|
|||||||
it('should throw error when not connected', async () => {
|
it('should throw error when not connected', async () => {
|
||||||
await expect(service.query('SELECT 1')).rejects.toThrow('Not connected to MySQL')
|
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', () => {
|
describe('transaction', () => {
|
||||||
@@ -59,11 +181,56 @@ describe('MySqlService Unit Tests', () => {
|
|||||||
'Not connected to MySQL'
|
'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', () => {
|
describe('disconnect', () => {
|
||||||
it('should resolve when not connected', async () => {
|
it('should resolve when not connected', async () => {
|
||||||
await expect(service.disconnect()).resolves.not.toThrow()
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for PostgreSqlService
|
* Unit tests for PostgreSqlService
|
||||||
* These tests do not require a PostgreSQL instance
|
* Covers both unconnected state and connected-path operations using mocked pg driver.
|
||||||
|
* Also tests the prepareSql pure function (no mocks needed for those).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { PostgreSqlService, prepareSql } from '@main/services/database/postgresql'
|
import { PostgreSqlService, prepareSql } from '@main/services/database/postgresql'
|
||||||
|
|
||||||
|
// ---- Hoisted mock functions ----
|
||||||
|
const { mockPgPool, mockPgClient } = vi.hoisted(() => {
|
||||||
|
const client = {
|
||||||
|
query: vi.fn(),
|
||||||
|
release: vi.fn()
|
||||||
|
}
|
||||||
|
const pool = {
|
||||||
|
connect: vi.fn(() => client),
|
||||||
|
query: vi.fn(),
|
||||||
|
end: vi.fn()
|
||||||
|
}
|
||||||
|
return { mockPgPool: pool, mockPgClient: client }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock pg driver (must use regular function because source uses `new Pool(...)`)
|
||||||
|
vi.mock('pg', () => ({
|
||||||
|
Pool: vi.fn(function () {
|
||||||
|
return mockPgPool
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock logger
|
||||||
|
vi.mock('@main/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 = {
|
const mockConfig = {
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 5432,
|
port: 5432,
|
||||||
@@ -18,7 +54,13 @@ describe('PostgreSqlService Unit Tests', () => {
|
|||||||
let service: PostgreSqlService
|
let service: PostgreSqlService
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
service = new PostgreSqlService(mockConfig)
|
service = new PostgreSqlService(mockConfig)
|
||||||
|
mockPgPool.connect.mockResolvedValue(mockPgClient)
|
||||||
|
mockPgPool.query.mockResolvedValue({ rows: [], fields: [], rowCount: 0 })
|
||||||
|
mockPgPool.end.mockResolvedValue(undefined)
|
||||||
|
mockPgClient.query.mockResolvedValue({ rows: [] })
|
||||||
|
mockPgClient.release.mockReturnValue(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
@@ -40,10 +82,79 @@ describe('PostgreSqlService Unit Tests', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('connect', () => {
|
||||||
|
it('should throw error when pool creation fails', async () => {
|
||||||
|
mockPgPool.connect.mockRejectedValue(new Error('connection refused'))
|
||||||
|
const svc = new PostgreSqlService(mockConfig)
|
||||||
|
await expect(svc.connect()).rejects.toThrow('Failed to connect to PostgreSQL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should establish connection and release test client', async () => {
|
||||||
|
await service.connect()
|
||||||
|
expect(mockPgPool.connect).toHaveBeenCalled()
|
||||||
|
expect(mockPgClient.release).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 PostgreSQL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reset pool to null on connection failure', async () => {
|
||||||
|
mockPgPool.connect.mockRejectedValue(new Error('timeout'))
|
||||||
|
await expect(service.connect()).rejects.toThrow('Failed to connect to PostgreSQL')
|
||||||
|
expect(service.isConnected()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('query', () => {
|
describe('query', () => {
|
||||||
it('should throw error when not connected', async () => {
|
it('should throw error when not connected', async () => {
|
||||||
await expect(service.query('SELECT 1')).rejects.toThrow('Not connected to PostgreSQL')
|
await expect(service.query('SELECT 1')).rejects.toThrow('Not connected to PostgreSQL')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should execute query and return rows with columns', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockPgPool.query.mockResolvedValue({
|
||||||
|
rows: [{ ID: 1, Name: 'test' }],
|
||||||
|
fields: [{ name: 'ID' }, { name: 'Name' }],
|
||||||
|
rowCount: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.query('SELECT ID, Name FROM Users')
|
||||||
|
|
||||||
|
expect(result.rows).toEqual([{ ID: 1, Name: 'test' }])
|
||||||
|
expect(result.columns).toEqual(['ID', 'Name'])
|
||||||
|
expect(result.rowCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass params through to pool.query', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockPgPool.query.mockResolvedValue({ rows: [], fields: [], rowCount: 0 })
|
||||||
|
|
||||||
|
await service.query('SELECT * FROM Users WHERE ID = $1', [42])
|
||||||
|
|
||||||
|
expect(mockPgPool.query).toHaveBeenCalledWith(expect.any(String), [42])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fallback to rows.length when rowCount is null', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockPgPool.query.mockResolvedValue({
|
||||||
|
rows: [{ ID: 1 }, { ID: 2 }],
|
||||||
|
fields: [{ name: 'ID' }],
|
||||||
|
rowCount: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.query('SELECT ID FROM Users')
|
||||||
|
expect(result.rowCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wrap query errors with context', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockPgPool.query.mockRejectedValue(new Error('syntax error'))
|
||||||
|
|
||||||
|
await expect(service.query('INVALID SQL')).rejects.toThrow('PostgreSQL query failed')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('transaction', () => {
|
describe('transaction', () => {
|
||||||
@@ -52,13 +163,36 @@ describe('PostgreSqlService Unit Tests', () => {
|
|||||||
'Not connected to PostgreSQL'
|
'Not connected to PostgreSQL'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
describe('connect', () => {
|
it('should execute all queries within BEGIN/COMMIT and release client', async () => {
|
||||||
it('should throw error with invalid host', async () => {
|
await service.connect()
|
||||||
const invalidConfig = { ...mockConfig, host: 'invalid-host-that-does-not-exist' }
|
mockPgClient.query.mockResolvedValue({ rows: [] })
|
||||||
const invalidService = new PostgreSqlService(invalidConfig)
|
|
||||||
await expect(invalidService.connect()).rejects.toThrow('Failed to connect to PostgreSQL')
|
await service.transaction([
|
||||||
|
{ sql: 'SELECT 1', params: [1] },
|
||||||
|
{ sql: 'SELECT 2' }
|
||||||
|
])
|
||||||
|
|
||||||
|
// BEGIN + 2 queries + COMMIT
|
||||||
|
expect(mockPgClient.query).toHaveBeenCalledTimes(4)
|
||||||
|
expect(mockPgClient.query).toHaveBeenNthCalledWith(1, 'BEGIN')
|
||||||
|
expect(mockPgClient.query).toHaveBeenNthCalledWith(4, 'COMMIT')
|
||||||
|
expect(mockPgClient.release).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should rollback and release client on query failure', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockPgClient.query
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||||
|
.mockRejectedValueOnce(new Error('constraint violation')) // query fails
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // ROLLBACK
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.transaction([{ sql: 'SELECT 1', params: [1] }])
|
||||||
|
).rejects.toThrow('PostgreSQL transaction failed')
|
||||||
|
|
||||||
|
expect(mockPgClient.query).toHaveBeenCalledWith('ROLLBACK')
|
||||||
|
expect(mockPgClient.release).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -66,6 +200,23 @@ describe('PostgreSqlService Unit Tests', () => {
|
|||||||
it('should resolve when not connected', async () => {
|
it('should resolve when not connected', async () => {
|
||||||
await expect(service.disconnect()).resolves.not.toThrow()
|
await expect(service.disconnect()).resolves.not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should end pool and reset state', async () => {
|
||||||
|
await service.connect()
|
||||||
|
expect(service.isConnected()).toBe(true)
|
||||||
|
|
||||||
|
await service.disconnect()
|
||||||
|
|
||||||
|
expect(mockPgPool.end).toHaveBeenCalled()
|
||||||
|
expect(service.isConnected()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wrap disconnect errors', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockPgPool.end.mockRejectedValue(new Error('pool end failed'))
|
||||||
|
|
||||||
|
await expect(service.disconnect()).rejects.toThrow('Failed to disconnect from PostgreSQL')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -83,7 +234,8 @@ describe('prepareSql', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should quote column names in INSERT', () => {
|
it('should quote column names in INSERT', () => {
|
||||||
const sql = 'INSERT INTO "dbo"."BIPUsers" (UserName, Password, UserType) VALUES ($1, $2, $3)'
|
const sql =
|
||||||
|
'INSERT INTO "dbo"."BIPUsers" (UserName, Password, UserType) VALUES ($1, $2, $3)'
|
||||||
const result = prepareSql(sql)
|
const result = prepareSql(sql)
|
||||||
expect(result).toBe(
|
expect(result).toBe(
|
||||||
'INSERT INTO "dbo"."BIPUsers" ("UserName", "Password", "UserType") VALUES ($1, $2, $3)'
|
'INSERT INTO "dbo"."BIPUsers" ("UserName", "Password", "UserType") VALUES ($1, $2, $3)'
|
||||||
@@ -151,7 +303,9 @@ describe('prepareSql', () => {
|
|||||||
it('should quote underscore-containing column names', () => {
|
it('should quote underscore-containing column names', () => {
|
||||||
const sql = 'SELECT ERP_URL, ERP_Username, ERP_Password FROM "dbo"."BIPUsers"'
|
const sql = 'SELECT ERP_URL, ERP_Username, ERP_Password FROM "dbo"."BIPUsers"'
|
||||||
const result = prepareSql(sql)
|
const result = prepareSql(sql)
|
||||||
expect(result).toBe('SELECT "ERP_URL", "ERP_Username", "ERP_Password" FROM "dbo"."BIPUsers"')
|
expect(result).toBe(
|
||||||
|
'SELECT "ERP_URL", "ERP_Username", "ERP_Password" FROM "dbo"."BIPUsers"'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle ON CONFLICT DO UPDATE SET with EXCLUDED', () => {
|
it('should handle ON CONFLICT DO UPDATE SET with EXCLUDED', () => {
|
||||||
|
|||||||
@@ -1,11 +1,82 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for SqlServerService
|
* Unit tests for SqlServerService
|
||||||
* These tests do not require a SQL Server instance
|
* Covers both unconnected state and connected-path operations using mocked mssql driver.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { SqlServerService } from '@main/services/database/sql-server'
|
import { SqlServerService } from '@main/services/database/sql-server'
|
||||||
|
|
||||||
|
// ---- Hoisted mock functions ----
|
||||||
|
const {
|
||||||
|
mockPoolConnect,
|
||||||
|
mockPoolClose,
|
||||||
|
mockRequestInput,
|
||||||
|
mockRequestQuery,
|
||||||
|
mockTransactionBegin,
|
||||||
|
mockTransactionCommit,
|
||||||
|
mockTransactionRollback,
|
||||||
|
mockPool,
|
||||||
|
mockRequest,
|
||||||
|
mockTransaction
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
const request = {
|
||||||
|
input: vi.fn(),
|
||||||
|
query: vi.fn()
|
||||||
|
}
|
||||||
|
const transaction = {
|
||||||
|
begin: vi.fn(),
|
||||||
|
commit: vi.fn(),
|
||||||
|
rollback: vi.fn()
|
||||||
|
}
|
||||||
|
const pool = {
|
||||||
|
connect: vi.fn(),
|
||||||
|
request: vi.fn(() => request),
|
||||||
|
close: vi.fn(),
|
||||||
|
connected: true
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mockPoolConnect: pool.connect,
|
||||||
|
mockPoolClose: pool.close,
|
||||||
|
mockRequestInput: request.input,
|
||||||
|
mockRequestQuery: request.query,
|
||||||
|
mockTransactionBegin: transaction.begin,
|
||||||
|
mockTransactionCommit: transaction.commit,
|
||||||
|
mockTransactionRollback: transaction.rollback,
|
||||||
|
mockPool: pool,
|
||||||
|
mockRequest: request,
|
||||||
|
mockTransaction: transaction
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock mssql driver (must use regular functions because source uses `new`)
|
||||||
|
vi.mock('mssql', () => ({
|
||||||
|
default: {
|
||||||
|
ConnectionPool: vi.fn(function () {
|
||||||
|
return mockPool
|
||||||
|
}),
|
||||||
|
Transaction: vi.fn(function () {
|
||||||
|
return mockTransaction
|
||||||
|
}),
|
||||||
|
Request: vi.fn(function () {
|
||||||
|
return mockRequest
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock logger
|
||||||
|
vi.mock('@main/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 = {
|
const mockConfig = {
|
||||||
server: 'localhost',
|
server: 'localhost',
|
||||||
port: 1433,
|
port: 1433,
|
||||||
@@ -22,6 +93,18 @@ describe('SqlServerService Unit Tests', () => {
|
|||||||
let service: SqlServerService
|
let service: SqlServerService
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockPool.connected = true
|
||||||
|
mockPoolConnect.mockResolvedValue(undefined)
|
||||||
|
mockPoolClose.mockResolvedValue(undefined)
|
||||||
|
mockRequestInput.mockReturnThis()
|
||||||
|
mockRequestQuery.mockResolvedValue({
|
||||||
|
recordset: [],
|
||||||
|
rowsAffected: [0]
|
||||||
|
})
|
||||||
|
mockTransactionBegin.mockResolvedValue(undefined)
|
||||||
|
mockTransactionCommit.mockResolvedValue(undefined)
|
||||||
|
mockTransactionRollback.mockResolvedValue(undefined)
|
||||||
service = new SqlServerService(mockConfig)
|
service = new SqlServerService(mockConfig)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,15 +122,22 @@ describe('SqlServerService Unit Tests', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('connect', () => {
|
describe('connect', () => {
|
||||||
it('should throw error with invalid credentials', async () => {
|
it('should throw error when connection fails', async () => {
|
||||||
// This tests error handling without needing a real server
|
mockPool.connected = false
|
||||||
const invalidConfig = {
|
mockPoolConnect.mockRejectedValue(new Error('connection refused'))
|
||||||
...mockConfig,
|
const svc = new SqlServerService(mockConfig)
|
||||||
server: 'invalid-host-that-does-not-exist'
|
await expect(svc.connect()).rejects.toThrow('Failed to connect to SQL Server')
|
||||||
}
|
})
|
||||||
const invalidService = new SqlServerService(invalidConfig)
|
|
||||||
|
|
||||||
await expect(invalidService.connect()).rejects.toThrow('Failed to connect to SQL Server')
|
it('should establish connection via pool', async () => {
|
||||||
|
await service.connect()
|
||||||
|
expect(mockPoolConnect).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 SQL Server')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -55,6 +145,87 @@ describe('SqlServerService Unit Tests', () => {
|
|||||||
it('should throw error when not connected', async () => {
|
it('should throw error when not connected', async () => {
|
||||||
await expect(service.query('SELECT 1')).rejects.toThrow('Not connected to SQL Server')
|
await expect(service.query('SELECT 1')).rejects.toThrow('Not connected to SQL Server')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should execute SELECT and return rows with columns', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockRequestQuery.mockResolvedValue({
|
||||||
|
recordset: [{ ID: 1, Name: 'test' }, { ID: 2, Name: 'foo' }],
|
||||||
|
rowsAffected: [2]
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.query('SELECT ID, Name FROM Users')
|
||||||
|
|
||||||
|
expect(mockRequestQuery).toHaveBeenCalledWith('SELECT ID, Name FROM Users')
|
||||||
|
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 convert array params to @p0, @p1, ... format', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockRequestQuery.mockResolvedValue({ recordset: [], rowsAffected: [0] })
|
||||||
|
|
||||||
|
await service.query('SELECT * FROM Users WHERE ID = @p0 AND Name = @p1', [42, 'test'])
|
||||||
|
|
||||||
|
expect(mockRequestInput).toHaveBeenCalledWith('p0', 42)
|
||||||
|
expect(mockRequestInput).toHaveBeenCalledWith('p1', 'test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle INSERT/UPDATE with rowsAffected', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockRequestQuery.mockResolvedValue({
|
||||||
|
recordset: undefined,
|
||||||
|
rowsAffected: [5]
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.query('DELETE FROM Users WHERE Active = 0')
|
||||||
|
|
||||||
|
expect(result.rows).toEqual([])
|
||||||
|
expect(result.columns).toEqual([])
|
||||||
|
expect(result.rowCount).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fallback to rows.length when rowsAffected is missing', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockRequestQuery.mockResolvedValue({
|
||||||
|
recordset: [{ ID: 1 }, { ID: 2 }],
|
||||||
|
rowsAffected: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.query('SELECT ID FROM Users')
|
||||||
|
expect(result.rowCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wrap query errors with context', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockRequestQuery.mockRejectedValue(new Error('syntax error'))
|
||||||
|
|
||||||
|
await expect(service.query('INVALID SQL')).rejects.toThrow('SQL Server query failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('queryWithParams', () => {
|
||||||
|
it('should throw error when not connected', async () => {
|
||||||
|
await expect(
|
||||||
|
service.queryWithParams('SELECT @p0', { p0: { value: 1 } })
|
||||||
|
).rejects.toThrow('Not connected to SQL Server')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add typed params via request.input', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockRequestQuery.mockResolvedValue({ recordset: [{ ID: 1 }], rowsAffected: [1] })
|
||||||
|
|
||||||
|
await service.queryWithParams('SELECT @id', {
|
||||||
|
id: { value: 42 },
|
||||||
|
name: { value: 'test', type: 'NVarChar' as any }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockRequestInput).toHaveBeenCalledWith('id', 42)
|
||||||
|
expect(mockRequestInput).toHaveBeenCalledWith('name', 'NVarChar', 'test')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('transaction', () => {
|
describe('transaction', () => {
|
||||||
@@ -63,11 +234,56 @@ describe('SqlServerService Unit Tests', () => {
|
|||||||
'Not connected to SQL Server'
|
'Not connected to SQL Server'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should execute all queries and commit', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockRequestQuery.mockResolvedValue({ recordset: [], rowsAffected: [1] })
|
||||||
|
|
||||||
|
await service.transaction([
|
||||||
|
{ sql: 'INSERT INTO t VALUES (@p0)', params: [1] },
|
||||||
|
{ sql: 'UPDATE t SET x = 1' }
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(mockTransactionBegin).toHaveBeenCalled()
|
||||||
|
expect(mockRequestQuery).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockRequestInput).toHaveBeenCalledWith('p0', 1)
|
||||||
|
expect(mockTransactionCommit).toHaveBeenCalled()
|
||||||
|
expect(mockTransactionRollback).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should rollback on query failure', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockRequestQuery.mockRejectedValue(new Error('constraint violation'))
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.transaction([{ sql: 'INSERT INTO t VALUES (@p0)', params: [1] }])
|
||||||
|
).rejects.toThrow('SQL Server transaction failed')
|
||||||
|
|
||||||
|
expect(mockTransactionRollback).toHaveBeenCalled()
|
||||||
|
expect(mockTransactionCommit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('disconnect', () => {
|
describe('disconnect', () => {
|
||||||
it('should resolve when not connected', async () => {
|
it('should resolve when not connected', async () => {
|
||||||
await expect(service.disconnect()).resolves.not.toThrow()
|
await expect(service.disconnect()).resolves.not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should close pool and reset state', async () => {
|
||||||
|
await service.connect()
|
||||||
|
expect(service.isConnected()).toBe(true)
|
||||||
|
|
||||||
|
await service.disconnect()
|
||||||
|
|
||||||
|
expect(mockPoolClose).toHaveBeenCalled()
|
||||||
|
expect(service.isConnected()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wrap disconnect errors', async () => {
|
||||||
|
await service.connect()
|
||||||
|
mockPoolClose.mockRejectedValue(new Error('pool close failed'))
|
||||||
|
|
||||||
|
await expect(service.disconnect()).rejects.toThrow('Failed to disconnect from SQL Server')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/**/*.{test,spec}.{ts,tsx}'],
|
include: ['tests/**/*.{test,spec}.{ts,tsx}'],
|
||||||
exclude: ['node_modules', 'dist', 'out', 'tests/e2e'],
|
exclude: ['node_modules', 'dist', 'out', 'tests/e2e', 'tests/integration'],
|
||||||
setupFiles: ['tests/setup.ts'],
|
setupFiles: ['tests/setup.ts'],
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'test'
|
NODE_ENV: 'test'
|
||||||
},
|
},
|
||||||
// 性能优化配置
|
// CI 环境启用隔离以捕获跨文件状态污染;本地开发禁用以提升速度
|
||||||
isolate: false, // 禁用隔离(提升 30-50% 速度)
|
isolate: !!process.env.CI,
|
||||||
pool: 'threads', // 使用线程池
|
pool: 'threads', // 使用线程池
|
||||||
maxWorkers: 4,
|
maxWorkers: 4,
|
||||||
bail: process.env.CI ? 1 : undefined,
|
bail: process.env.CI ? 1 : undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user