Files
BIPMaterialManager/tests/unit/postgresql.test.ts
Misaka 7601b5f176 fix(db): PostgreSQL P0 fixes - SQL_KEYWORDS expansion, timeout config, and tests
- Expand SQL_KEYWORDS from ~120 to 226+ words covering:
  - Window functions (ROW_NUMBER, RANK, LAG, LEAD, etc.)
  - CTEs (WITH, RECURSIVE, MATERIALIZED, etc.)
  - Advanced grouping (ROLLUP, CUBE, GROUPING SETS)
  - JSON operations, types, table sampling
  - Transaction control and other PostgreSQL-specific keywords
- Add connection pool timeout configuration:
  - connectionTimeoutMillis: 10s
  - statement_timeout: 30s (PostgreSQL level)
  - idleTimeoutMillis: 30s (connection cleanup)
  - query_timeout: 60s (driver-level fallback)
- Add 12 comprehensive edge case tests covering:
  - Window functions, CTEs, advanced grouping
  - CASE expressions, set operations, JSON operators
- All 38 tests pass

Production-ready: prevents hung queries and supports complex SQL.
2026-04-05 13:09:01 +08:00

431 lines
15 KiB
TypeScript

/**
* Unit tests for PostgreSqlService
* These tests do not require a PostgreSQL instance
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { PostgreSqlService, prepareSql } from '@main/services/database/postgresql'
const mockConfig = {
host: 'localhost',
port: 5432,
user: 'test',
password: 'test',
database: 'testdb'
}
describe('PostgreSqlService Unit Tests', () => {
let service: PostgreSqlService
beforeEach(() => {
service = new PostgreSqlService(mockConfig)
})
describe('constructor', () => {
it('should create service with config', () => {
expect(service).toBeDefined()
expect(service.isConnected()).toBe(false)
})
})
describe('type', () => {
it('should return postgresql', () => {
expect(service.type).toBe('postgresql')
})
})
describe('isConnected', () => {
it('should return false when not connected', () => {
expect(service.isConnected()).toBe(false)
})
})
describe('query', () => {
it('should throw error when not connected', async () => {
await expect(service.query('SELECT 1')).rejects.toThrow('Not connected to PostgreSQL')
})
})
describe('transaction', () => {
it('should throw error when not connected', async () => {
await expect(service.transaction([{ sql: 'SELECT 1' }])).rejects.toThrow(
'Not connected to PostgreSQL'
)
})
})
describe('connect', () => {
it('should throw error with invalid host', async () => {
const invalidConfig = { ...mockConfig, host: 'invalid-host-that-does-not-exist' }
const invalidService = new PostgreSqlService(invalidConfig)
await expect(invalidService.connect()).rejects.toThrow('Failed to connect to PostgreSQL')
})
})
describe('disconnect', () => {
it('should resolve when not connected', async () => {
await expect(service.disconnect()).resolves.not.toThrow()
})
})
})
describe('prepareSql', () => {
it('should quote unquoted column names in SELECT', () => {
const sql = 'SELECT ID, UserName, UserType FROM "dbo"."BIPUsers"'
const result = prepareSql(sql)
expect(result).toBe('SELECT "ID", "UserName", "UserType" FROM "dbo"."BIPUsers"')
})
it('should quote column names in WHERE clause', () => {
const sql = 'WHERE UserName = $1 AND Password = $2'
const result = prepareSql(sql)
expect(result).toBe('WHERE "UserName" = $1 AND "Password" = $2')
})
it('should quote column names in INSERT', () => {
const sql = 'INSERT INTO "dbo"."BIPUsers" (UserName, Password, UserType) VALUES ($1, $2, $3)'
const result = prepareSql(sql)
expect(result).toBe(
'INSERT INTO "dbo"."BIPUsers" ("UserName", "Password", "UserType") VALUES ($1, $2, $3)'
)
})
it('should quote column names in UPDATE SET', () => {
const sql = 'UPDATE "dbo"."BIPUsers" SET UserType = $1 WHERE UserName = $2'
const result = prepareSql(sql)
expect(result).toBe('UPDATE "dbo"."BIPUsers" SET "UserType" = $1 WHERE "UserName" = $2')
})
it('should quote column names in DELETE', () => {
const sql = 'DELETE FROM "dbo"."BIPUsers" WHERE UserName = $1'
const result = prepareSql(sql)
expect(result).toBe('DELETE FROM "dbo"."BIPUsers" WHERE "UserName" = $1')
})
it('should quote column names in ORDER BY', () => {
const sql = 'SELECT UserName FROM "dbo"."BIPUsers" ORDER BY UserName'
const result = prepareSql(sql)
expect(result).toBe('SELECT "UserName" FROM "dbo"."BIPUsers" ORDER BY "UserName"')
})
it('should not quote SQL keywords', () => {
const sql = 'SELECT ID FROM "dbo"."BIPUsers" WHERE UserName = $1'
const result = prepareSql(sql)
expect(result).not.toContain('"SELECT"')
expect(result).not.toContain('"FROM"')
expect(result).not.toContain('"WHERE"')
expect(result).not.toContain('"AND"')
})
it('should not quote already-quoted identifiers', () => {
const sql = 'SELECT "ID" FROM "dbo"."BIPUsers"'
const result = prepareSql(sql)
expect(result).toBe('SELECT "ID" FROM "dbo"."BIPUsers"')
})
it('should preserve string literals', () => {
const sql = "WHERE Status = 'active'"
const result = prepareSql(sql)
expect(result).toBe('WHERE "Status" = \'active\'')
})
it('should preserve string literals with escaped quotes', () => {
const sql = "WHERE UserName = 'O''Brien'"
const result = prepareSql(sql)
expect(result).toBe("WHERE \"UserName\" = 'O''Brien'")
})
it('should preserve $N parameter placeholders', () => {
const sql = 'WHERE UserName = $1 AND Password = $2'
const result = prepareSql(sql)
expect(result).toContain('$1')
expect(result).toContain('$2')
})
it('should handle COUNT(*) correctly', () => {
const sql = 'SELECT COUNT(*) as count FROM "dbo"."BIPUsers" WHERE UserName = $1'
const result = prepareSql(sql)
expect(result).toBe('SELECT COUNT(*) as count FROM "dbo"."BIPUsers" WHERE "UserName" = $1')
})
it('should quote underscore-containing column names', () => {
const sql = 'SELECT ERP_URL, ERP_Username, ERP_Password FROM "dbo"."BIPUsers"'
const result = prepareSql(sql)
expect(result).toBe('SELECT "ERP_URL", "ERP_Username", "ERP_Password" FROM "dbo"."BIPUsers"')
})
it('should handle ON CONFLICT DO UPDATE SET with EXCLUDED', () => {
const sql =
'INSERT INTO "dbo"."Materials" (MaterialCode, ManagerName) VALUES ($1, $2) ON CONFLICT ("MaterialCode") DO UPDATE SET "ManagerName" = EXCLUDED."ManagerName"'
const result = prepareSql(sql)
expect(result).toBe(
'INSERT INTO "dbo"."Materials" ("MaterialCode", "ManagerName") VALUES ($1, $2) ON CONFLICT ("MaterialCode") DO UPDATE SET "ManagerName" = EXCLUDED."ManagerName"'
)
})
it('should handle CURRENT_TIMESTAMP without quoting', () => {
const sql = 'INSERT INTO t (OperationTime) VALUES (CURRENT_TIMESTAMP)'
const result = prepareSql(sql)
expect(result).toBe('INSERT INTO "t" ("OperationTime") VALUES (CURRENT_TIMESTAMP)')
})
it('should handle LIMIT OFFSET without quoting', () => {
const sql = 'SELECT UserName FROM "dbo"."BIPUsers" LIMIT 10 OFFSET 20'
const result = prepareSql(sql)
expect(result).toBe('SELECT "UserName" FROM "dbo"."BIPUsers" LIMIT 10 OFFSET 20')
})
it('should return empty string for empty input', () => {
expect(prepareSql('')).toBe('')
})
it('should handle full BIPUsersDAO authenticate query', () => {
const sql = `
SELECT ID, UserName, UserType
FROM "dbo"."BIPUsers"
WHERE UserName = $1 AND Password = $2
`
const result = prepareSql(sql)
expect(result).toContain('"ID"')
expect(result).toContain('"UserName"')
expect(result).toContain('"UserType"')
expect(result).toContain('"Password"')
expect(result).toContain('$1')
expect(result).toContain('$2')
expect(result).toContain('"dbo"."BIPUsers"')
})
it('should handle full BIPUsersDAO userExists query', () => {
const sql = `
SELECT COUNT(*) as count
FROM "dbo"."BIPUsers"
WHERE UserName = $1
`
const result = prepareSql(sql)
expect(result).toContain('COUNT(*)')
expect(result).toContain('as count')
expect(result).toContain('"UserName"')
})
// ==================== Window Functions ====================
it('should handle ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)', () => {
const sql = `
SELECT UserName, ROW_NUMBER() OVER (PARTITION BY UserType ORDER BY CreatedAt DESC) as rn
FROM "dbo"."BIPUsers"
`
const result = prepareSql(sql)
expect(result).toContain('"UserName"')
expect(result).toContain('"UserType"')
expect(result).toContain('"CreatedAt"')
expect(result).not.toContain('"ROW_NUMBER"')
expect(result).not.toContain('"OVER"')
expect(result).not.toContain('"PARTITION"')
expect(result).not.toContain('"ORDER"')
})
it('should handle RANK() and DENSE_RANK()', () => {
const sql = `
SELECT MaterialCode, RANK() OVER (ORDER BY Quantity DESC) as rnk, DENSE_RANK() OVER (ORDER BY Quantity DESC) as drnk
FROM "dbo"."Materials"
`
const result = prepareSql(sql)
expect(result).toContain('"MaterialCode"')
expect(result).toContain('"Quantity"')
expect(result).not.toContain('"RANK"')
expect(result).not.toContain('"DENSE_RANK"')
})
it('should handle LAG() and LEAD()', () => {
const sql = `
SELECT OrderId, LAG(TotalAmount, 1) OVER (ORDER BY OrderDate) as prevAmount, LEAD(TotalAmount, 1) OVER (ORDER BY OrderDate) as nextAmount
FROM "dbo"."Orders"
`
const result = prepareSql(sql)
expect(result).toContain('"OrderId"')
expect(result).toContain('"TotalAmount"')
expect(result).toContain('"OrderDate"')
expect(result).not.toContain('"LAG"')
expect(result).not.toContain('"LEAD"')
})
// ==================== CTEs (Common Table Expressions) ====================
it('should handle WITH clause', () => {
const sql = `
WITH UserSummary AS (
SELECT UserId, COUNT(OrderId) as OrderCount
FROM "dbo"."Orders"
GROUP BY UserId
)
SELECT UserName, OrderCount
FROM UserSummary
JOIN "dbo"."BIPUsers" ON UserSummary.UserId = "dbo"."BIPUsers".ID
`
const result = prepareSql(sql)
expect(result).toContain('"UserId"')
expect(result).toContain('"OrderId"')
expect(result).toContain('"UserName"')
expect(result).not.toContain('"WITH"')
expect(result).not.toContain('"AS"')
expect(result).not.toContain('"FROM"')
expect(result).not.toContain('"JOIN"')
expect(result).not.toContain('"ON"')
})
it('should handle recursive CTE', () => {
const sql = `
WITH RECURSIVE CategoryTree AS (
SELECT CategoryId, ParentCategoryId, CategoryName, 0 as Level
FROM "dbo"."Categories"
WHERE ParentCategoryId IS NULL
UNION ALL
SELECT c.CategoryId, c.ParentCategoryId, c.CategoryName, ct.Level + 1
FROM "dbo"."Categories" c
INNER JOIN CategoryTree ct ON c.ParentCategoryId = ct.CategoryId
)
SELECT * FROM CategoryTree
`
const result = prepareSql(sql)
expect(result).toContain('"CategoryId"')
expect(result).toContain('"ParentCategoryId"')
expect(result).toContain('"CategoryName"')
expect(result).not.toContain('"WITH"')
expect(result).not.toContain('"RECURSIVE"')
expect(result).not.toContain('"UNION"')
expect(result).not.toContain('"ALL"')
expect(result).not.toContain('"INNER"')
expect(result).not.toContain('"JOIN"')
})
// ==================== Advanced Grouping ====================
it('should handle ROLLUP', () => {
const sql = `
SELECT DepartmentId, JobTitle, COUNT(*) as EmployeeCount
FROM "dbo"."Employees"
GROUP BY ROLLUP (DepartmentId, JobTitle)
`
const result = prepareSql(sql)
expect(result).toContain('"DepartmentId"')
expect(result).toContain('"JobTitle"')
expect(result).not.toContain('"GROUP"')
expect(result).not.toContain('"BY"')
expect(result).not.toContain('"ROLLUP"')
})
it('should handle CUBE', () => {
const sql = `
SELECT Year, Quarter, Region, SUM(SalesAmount) as TotalSales
FROM "dbo"."Sales"
GROUP BY CUBE (Year, Quarter, Region)
`
const result = prepareSql(sql)
expect(result).toContain('"Year"')
expect(result).toContain('"Quarter"')
expect(result).toContain('"Region"')
expect(result).toContain('"SalesAmount"')
expect(result).not.toContain('"CUBE"')
expect(result).not.toContain('"GROUP"')
expect(result).not.toContain('"BY"')
})
it('should handle GROUPING SETS', () => {
const sql = `
SELECT DepartmentId, JobTitle, COUNT(*) as EmployeeCount
FROM "dbo"."Employees"
GROUP BY GROUPING SETS ((DepartmentId, JobTitle), (DepartmentId), ())
`
const result = prepareSql(sql)
expect(result).toContain('"DepartmentId"')
expect(result).toContain('"JobTitle"')
expect(result).not.toContain('"GROUPING"')
expect(result).not.toContain('"SETS"')
expect(result).not.toContain('"GROUP"')
expect(result).not.toContain('"BY"')
})
// ==================== CASE Expressions ====================
it('should handle simple CASE', () => {
const sql = `
SELECT UserName, CASE UserType
WHEN 'admin' THEN 'Administrator'
WHEN 'user' THEN 'Regular User'
ELSE 'Guest'
END as UserRole
FROM "dbo"."BIPUsers"
`
const result = prepareSql(sql)
expect(result).toContain('"UserName"')
expect(result).toContain('"UserType"')
expect(result).not.toContain('"CASE"')
expect(result).not.toContain('"WHEN"')
expect(result).not.toContain('"THEN"')
expect(result).not.toContain('"ELSE"')
expect(result).not.toContain('"END"')
})
it('should handle searched CASE', () => {
const sql = `
SELECT OrderId, TotalAmount,
CASE
WHEN TotalAmount > 10000 THEN 'Large'
WHEN TotalAmount > 1000 THEN 'Medium'
ELSE 'Small'
END as OrderSize
FROM "dbo"."Orders"
`
const result = prepareSql(sql)
expect(result).toContain('"OrderId"')
expect(result).toContain('"TotalAmount"')
expect(result).not.toContain('"CASE"')
expect(result).not.toContain('"WHEN"')
expect(result).not.toContain('"THEN"')
expect(result).not.toContain('"ELSE"')
expect(result).not.toContain('"END"')
})
// ==================== Set Operations ====================
it('should handle UNION, UNION ALL, INTERSECT, EXCEPT', () => {
const sql = `
SELECT UserId FROM "dbo"."ActiveUsers"
UNION
SELECT UserId FROM "dbo"."PremiumUsers"
UNION ALL
SELECT UserId FROM "dbo"."TrialUsers"
INTERSECT
SELECT UserId FROM "dbo"."VerifiedUsers"
EXCEPT
SELECT UserId FROM "dbo"."BannedUsers"
`
const result = prepareSql(sql)
expect(result).toContain('"UserId"')
expect(result).not.toContain('"UNION"')
expect(result).not.toContain('"ALL"')
expect(result).not.toContain('"INTERSECT"')
expect(result).not.toContain('"EXCEPT"')
expect(result).not.toContain('"SELECT"')
expect(result).not.toContain('"FROM"')
})
// ==================== JSON Operators ====================
it('should handle -> and ->> operators', () => {
const sql = `
SELECT UserId, ProfileData->'address'->>'city' as City, ProfileData->'contact'->>'phone' as Phone
FROM "dbo"."Users"
WHERE ProfileData->'preferences'->>'newsletter' = 'true'
`
const result = prepareSql(sql)
expect(result).toContain('"UserId"')
expect(result).toContain('"ProfileData"')
expect(result).toContain('->')
expect(result).toContain('->>')
expect(result).not.toContain('"SELECT"')
expect(result).not.toContain('"FROM"')
expect(result).not.toContain('"WHERE"')
})
})