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>
This commit is contained in:
Misaka
2026-04-06 17:40:30 +08:00
parent d0c745e243
commit abad61758c
17 changed files with 269 additions and 157 deletions

View File

@@ -1,32 +1,42 @@
import { app } from 'electron'
import logger from '../services/logger/index'
import { logAudit, closeAuditLogger } from '../services/logger/audit-logger'
import { AuditAction, AuditStatus } from '../types/audit.types'
import { serializeError } from '../services/logger/error-utils'
export function setupProcessGuards(): void {
process.on('uncaughtException', (err) => {
logger.error('Uncaught exception', { error: err })
logAudit('SYSTEM_CRASH', 'system', {
try {
logAudit(AuditAction.SYSTEM_CRASH, 'system', {
username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process',
status: 'failure',
status: AuditStatus.FAILURE,
metadata: { error: err.message, stack: err.stack }
})
} catch (auditError) {
logger.error('Failed to write crash audit log', { error: auditError })
} finally {
setTimeout(() => process.exit(1), 1000)
}
})
process.on('unhandledRejection', (reason) => {
const errorMeta =
reason instanceof Error ? { error: serializeError(reason) } : { reason: String(reason) }
logger.error('Unhandled Rejection', errorMeta)
logAudit('SYSTEM_ERROR', 'system', {
try {
logAudit(AuditAction.SYSTEM_ERROR, 'system', {
username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process',
status: 'failure',
status: AuditStatus.FAILURE,
metadata: errorMeta
})
} catch (auditError) {
logger.error('Failed to write unhandled rejection audit log', { error: auditError })
}
})
app.on('render-process-gone', (_, webContents, details) => {

View File

@@ -5,7 +5,8 @@ import { OrderNumberResolver } from '../services/erp/order-resolver'
import { create, type IDatabaseService } from '../services/database'
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import { logAuditWithCurrentUser } from '../services/logger/audit-logger'
import { AuditAction, AuditStatus } from '../types/audit.types'
import { SessionManager } from '../services/user/session-manager'
import { withErrorHandling, type IpcResult } from './index'
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
@@ -278,24 +279,17 @@ export function registerExtractorHandlers(): void {
}
// Audit log: EXTRACT (non-blocking)
const os = await import('os')
if (currentUser) {
const auditStatus: 'success' | 'failure' | 'partial' =
const auditStatus: AuditStatus =
result.errors.length > 0 && result.recordCount > 0
? 'partial'
? AuditStatus.PARTIAL
: result.errors.length > 0
? 'failure'
: 'success'
logAudit('EXTRACT', String(currentUser.id), {
username: currentUser.username,
computerName: os.hostname(),
resource: 'MATERIAL_PLAN',
status: auditStatus,
metadata: {
? AuditStatus.FAILURE
: AuditStatus.SUCCESS
logAuditWithCurrentUser(AuditAction.EXTRACT, 'MATERIAL_PLAN', auditStatus, {
orderCount: validOrderNumbers.length,
recordCount: result.recordCount,
errorCount: result.errors.length
}
})
}

View File

@@ -5,7 +5,8 @@ import { UserErpConfigService } from '../services/user/user-erp-config-service'
import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import { logAuditWithCurrentUser } from '../services/logger/audit-logger'
import { AuditAction, AuditStatus } from '../types/audit.types'
import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../types/settings.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors'
@@ -67,13 +68,9 @@ export function registerSettingsHandlers(): void {
})
// Audit log: SETTINGS_CHANGE (non-blocking)
const os = await import('os')
logAudit('SETTINGS_CHANGE', String(currentUser.id), {
username: currentUser.username,
computerName: os.hostname(),
resource: 'ERP_CONFIG',
status: 'success',
metadata: { changeType: 'erp_credentials', usernameChanged: !!settings.erp.username }
logAuditWithCurrentUser(AuditAction.SETTINGS_CHANGE, 'ERP_CONFIG', AuditStatus.SUCCESS, {
changeType: 'erp_credentials',
usernameChanged: !!settings.erp.username
})
}

View File

@@ -3,6 +3,7 @@ import { SessionManager } from '../user/session-manager'
import { UpdateService } from '../update/update-service'
import { createLogger, run, getRequestId, getContext } from '../logger'
import { logAudit } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
import { ValidationError } from '../../types/errors'
import type { UserInfo } from '../../types/user.types'
import type {
@@ -73,11 +74,11 @@ export class AuthApplicationService {
userId: userInfo.id
})
this.writeAuditLog('LOGIN', String(userInfo.id), {
this.writeAuditLog(AuditAction.LOGIN, String(userInfo.id), {
username: userInfo.username,
computerName: hostname(),
resource: 'ERP_SYSTEM',
status: 'success',
status: AuditStatus.SUCCESS,
metadata: { loginType: 'silent', userType: userInfo.userType }
})
@@ -127,11 +128,11 @@ export class AuthApplicationService {
const userInfo = this.sessionManager.getUserInfo()
if (!success || !userInfo) {
this.writeAuditLog('LOGIN', '0', {
this.writeAuditLog(AuditAction.LOGIN, '0', {
username,
computerName: hostname(),
resource: 'ERP_SYSTEM',
status: 'failure',
status: AuditStatus.FAILURE,
metadata: { loginType: 'credentials', reason: 'invalid_credentials' }
})
@@ -155,11 +156,11 @@ export class AuthApplicationService {
})
await this.updateService.setUserContext(userInfo.userType)
this.writeAuditLog('LOGIN', String(userInfo.id), {
this.writeAuditLog(AuditAction.LOGIN, String(userInfo.id), {
username: userInfo.username,
computerName: hostname(),
resource: 'ERP_SYSTEM',
status: 'success',
status: AuditStatus.SUCCESS,
metadata: { loginType: 'credentials', userType: userInfo.userType }
})
@@ -204,11 +205,11 @@ export class AuthApplicationService {
})
if (userInfo) {
this.writeAuditLog('LOGOUT', String(userInfo.id), {
this.writeAuditLog(AuditAction.LOGOUT, String(userInfo.id), {
username: userInfo.username,
computerName: hostname(),
resource: 'ERP_SYSTEM',
status: 'success',
status: AuditStatus.SUCCESS,
metadata: { userType: userInfo.userType }
})
}
@@ -310,7 +311,7 @@ export class AuthApplicationService {
}
private writeAuditLog(
action: 'LOGIN' | 'LOGOUT',
action: AuditAction.LOGIN | AuditAction.LOGOUT,
actorId: string,
payload: Parameters<typeof logAudit>[2]
): void {

View File

@@ -13,7 +13,8 @@ import { RustfsService } from '../rustfs'
import { SessionManager } from '../user/session-manager'
import { UserErpConfigService } from '../user/user-erp-config-service'
import { createLogger } from '../logger'
import { logAudit } from '../logger/audit-logger'
import { logAuditWithCurrentUser } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
import { DatabaseQueryError, ErpConnectionError, ValidationError } from '../../types/errors'
import type {
@@ -278,19 +279,14 @@ export class CleanerApplicationService {
return
}
const status: 'success' | 'failure' | 'partial' =
const status: AuditStatus =
result.errors.length > 0 && result.materialsDeleted > 0
? 'partial'
? AuditStatus.PARTIAL
: result.errors.length > 0
? 'failure'
: 'success'
? AuditStatus.FAILURE
: AuditStatus.SUCCESS
logAudit('CLEAN', String(currentUser.id), {
username: currentUser.username,
computerName: (await import('os')).hostname(),
resource: 'MATERIAL_PLAN',
status,
metadata: {
logAuditWithCurrentUser(AuditAction.CLEAN, 'MATERIAL_PLAN', status, {
orderCount,
dryRun: input.dryRun ?? false,
queryBatchSize: input.queryBatchSize ?? 100,
@@ -298,7 +294,6 @@ export class CleanerApplicationService {
materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length
}
})
}

View File

@@ -10,6 +10,8 @@
*/
import { createLogger } from '../logger'
import { logAuditWithCurrentUser } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
import { DiscreteMaterialPlanDAO, type MaterialPlanRecord } from './discrete-material-plan-dao'
const log = createLogger('DataImportService')
@@ -148,6 +150,20 @@ export class DataImportService {
}
}
// Audit log: DATA_IMPORT
logAuditWithCurrentUser(
AuditAction.DATA_IMPORT,
'MATERIAL_PLAN',
result.success ? AuditStatus.SUCCESS : AuditStatus.FAILURE,
{
recordsRead: result.recordsRead,
recordsDeleted: result.recordsDeleted,
recordsImported: result.recordsImported,
uniqueSourceNumbers: result.uniqueSourceNumbers,
errorCount: result.errors.length
}
)
return result
}

View File

@@ -3,6 +3,8 @@ import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import { createLogger } from '../logger'
import { logAuditWithCurrentUser } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
import type { ExportResultItem, ExportResultResponse } from '../../types/cleaner.types'
const log = createLogger('ResultExporter')
@@ -37,8 +39,8 @@ export class ResultExporter {
* @returns Export result with file path or error
*/
async exportValidationResults(items: ExportResultItem[]): Promise<ExportResultResponse> {
try {
const filePath = path.join(this.exportDir, this.fileName)
try {
log.info('Exporting validation results', { count: items.length, path: filePath })
const workbook = new ExcelJS.Workbook()
@@ -97,6 +99,12 @@ export class ResultExporter {
await workbook.xlsx.writeFile(filePath)
log.info('Export completed', { path: filePath, rows: items.length })
// Audit log: RESULT_EXPORT success
logAuditWithCurrentUser(AuditAction.RESULT_EXPORT, 'VALIDATION_RESULT', AuditStatus.SUCCESS, {
itemCount: items.length,
filePath
})
return {
success: true,
filePath
@@ -104,6 +112,14 @@ export class ResultExporter {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
log.error('Export failed', { error: errorMessage })
// Audit log: RESULT_EXPORT failure
logAuditWithCurrentUser(AuditAction.RESULT_EXPORT, 'VALIDATION_RESULT', AuditStatus.FAILURE, {
itemCount: items.length,
filePath,
error: errorMessage
})
return {
success: false,
error: errorMessage

View File

@@ -6,33 +6,12 @@
import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path'
import { hostname } from 'os'
import { app } from 'electron'
import { getLogDir } from './shared'
/**
* Audit log entry structure
* All 8 required fields for comprehensive audit tracking
*/
export interface AuditEntry {
/** ISO 8601 timestamp of the audit event */
timestamp: string
/** The action that was performed (e.g., 'LOGIN', 'EXTRACT', 'DELETE') */
action: string
/** User ID who performed the action */
userId: string
/** Username of the user who performed the action */
username: string
/** Computer name from which the action was performed */
computerName: string
/** Application version when the action was performed */
appVersion: string
/** The resource that was affected (e.g., table name, file path) */
resource: string
/** Status of the action: 'success' | 'failure' | 'partial' */
status: 'success' | 'failure' | 'partial'
/** Additional metadata about the audit event */
metadata: Record<string, unknown>
}
import { SessionManager } from '../user/session-manager'
import type { AuditEntry } from '../../types/audit.types'
import { AuditAction, AuditStatus } from '../../types/audit.types'
/**
* JSONL formatter - outputs one JSON object per line
@@ -91,16 +70,17 @@ export function applyAuditConfig(retentionDays: number): void {
* @param details - Additional details including username, computerName, resource, status, and optional metadata
*/
export function logAudit(
action: string,
action: AuditAction,
userId: string,
details: {
username: string
computerName: string
resource: string
status: 'success' | 'failure' | 'partial'
status: AuditStatus
metadata?: Record<string, unknown>
}
): void {
try {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action,
@@ -110,12 +90,38 @@ export function logAudit(
appVersion: app.getVersion(),
resource: details.resource,
status: details.status,
metadata: details.metadata || {}
metadata: details.metadata ?? {}
}
// Write as JSONL - one JSON object per line
// Using info level with the entry stringified as the message
auditLogger.info(JSON.stringify(entry))
} catch (error) {
console.error('Audit logging failed:', error)
}
}
/** Cached hostname — invariant for the app lifecycle */
const cachedHostname = hostname()
/**
* Audit log shortcut that auto-resolves the current user context.
* Falls back to 'anonymous' if no user is logged in, so the record is always written.
*/
export function logAuditWithCurrentUser(
action: AuditAction,
resource: string,
status: AuditStatus,
metadata?: Record<string, unknown>
): void {
const user = SessionManager.getInstance().getUserInfo()
logAudit(action, user ? String(user.id) : 'anonymous', {
username: user?.username ?? 'anonymous',
computerName: cachedHostname,
resource,
status,
metadata: metadata ?? {}
})
}
/**

View File

@@ -1,6 +1,8 @@
import * as fs from 'fs'
import { ConfigManager } from '../config/config-manager'
import { createLogger } from '../logger'
import { logAuditWithCurrentUser } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
import type { UpdateConfig } from '../../types/config.schema'
import type { UserType } from '../../types/user.types'
import type {
@@ -281,10 +283,21 @@ export class UpdateService {
log.error('Update package hash mismatch', {
version: request.version,
channel: request.channel,
expectedHash: request.sha256,
actualHash: hash
expectedHash: request.sha256.substring(0, 16),
actualHash: hash.substring(0, 16)
})
await fs.promises.rm(downloadPath, { force: true })
// Audit log: APP_UPDATE download hash mismatch
logAuditWithCurrentUser(AuditAction.APP_UPDATE, 'UPDATE_PACKAGE', AuditStatus.FAILURE, {
version: request.version,
channel: request.channel,
phase: 'download',
error: 'Hash mismatch',
expectedHash: request.sha256.substring(0, 16),
actualHash: hash.substring(0, 16)
})
throw new Error('更新包校验失败,文件哈希不匹配')
}
@@ -294,6 +307,13 @@ export class UpdateService {
downloadPath
})
// Audit log: APP_UPDATE download success
logAuditWithCurrentUser(AuditAction.APP_UPDATE, 'UPDATE_PACKAGE', AuditStatus.SUCCESS, {
version: request.version,
channel: request.channel,
phase: 'download'
})
this.publishStatus({
phase: 'downloaded',
progress: 100,
@@ -336,11 +356,29 @@ export class UpdateService {
error: undefined
})
try {
await this.installer.installDownloadedRelease(downloaded)
log.info('Update installation completed', {
version: downloaded.version,
channel: downloaded.channel
})
// Audit log: APP_UPDATE install success
logAuditWithCurrentUser(AuditAction.APP_UPDATE, 'UPDATE_PACKAGE', AuditStatus.SUCCESS, {
version: downloaded.version,
channel: downloaded.channel,
phase: 'install'
})
} catch (installError) {
const msg = installError instanceof Error ? installError.message : String(installError)
logAuditWithCurrentUser(AuditAction.APP_UPDATE, 'UPDATE_PACKAGE', AuditStatus.FAILURE, {
version: downloaded.version,
channel: downloaded.channel,
phase: 'install',
error: msg
})
throw installError
}
}
private ensureInitialized(): void {

View File

@@ -14,6 +14,8 @@
import { BIPUsersDAO } from './bip-users-dao'
import { SessionManager } from './session-manager'
import { createLogger } from '../logger'
import { logAuditWithCurrentUser } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
const log = createLogger('UserErpConfigService')
@@ -131,9 +133,22 @@ export class UserErpConfigService {
log.error('Failed to update ERP credentials', { username: currentUser.username })
}
logAuditWithCurrentUser(
AuditAction.ERP_CREDENTIALS_UPDATE,
'ERP_CREDENTIALS',
success ? AuditStatus.SUCCESS : AuditStatus.FAILURE,
{ targetUsername: currentUser.username, updateType: 'self' }
)
return success
} catch (error) {
log.error('Error updating current user ERP credentials', { error })
logAuditWithCurrentUser(
AuditAction.ERP_CREDENTIALS_UPDATE,
'ERP_CREDENTIALS',
AuditStatus.FAILURE,
{ targetUsername: 'unknown', updateType: 'self', error: String(error) }
)
return false
}
}
@@ -159,9 +174,22 @@ export class UserErpConfigService {
log.error('Failed to update ERP credentials', { username })
}
logAuditWithCurrentUser(
AuditAction.ERP_CREDENTIALS_UPDATE,
'ERP_CREDENTIALS',
success ? AuditStatus.SUCCESS : AuditStatus.FAILURE,
{ targetUsername: username, updateType: 'admin' }
)
return success
} catch (error) {
log.error('Error updating user ERP credentials', { error })
logAuditWithCurrentUser(
AuditAction.ERP_CREDENTIALS_UPDATE,
'ERP_CREDENTIALS',
AuditStatus.FAILURE,
{ targetUsername: username, updateType: 'admin', error: String(error) }
)
return false
}
}

View File

@@ -10,23 +10,31 @@ export enum AuditAction {
LOGOUT = 'LOGOUT',
EXTRACT = 'EXTRACT',
CLEAN = 'CLEAN',
SETTINGS_CHANGE = 'SETTINGS_CHANGE'
SETTINGS_CHANGE = 'SETTINGS_CHANGE',
SYSTEM_CRASH = 'SYSTEM_CRASH',
SYSTEM_ERROR = 'SYSTEM_ERROR',
DATA_IMPORT = 'DATA_IMPORT',
RESULT_EXPORT = 'RESULT_EXPORT',
APP_UPDATE = 'APP_UPDATE',
ERP_CREDENTIALS_UPDATE = 'ERP_CREDENTIALS_UPDATE'
}
/**
* Audit status enumeration
* Note: Values are lowercase to match JSON logging conventions
*/
export enum AuditStatus {
SUCCESS = 'SUCCESS',
FAILURE = 'FAILURE'
SUCCESS = 'success',
FAILURE = 'failure',
PARTIAL = 'partial'
}
/**
* Audit entry interface
*/
export interface AuditEntry {
/** Timestamp of the action */
timestamp: Date
/** ISO timestamp of the action */
timestamp: string
/** Action performed */
action: AuditAction
/** User ID who performed the action */
@@ -38,9 +46,9 @@ export interface AuditEntry {
/** Application version when action was performed */
appVersion: string
/** Resource affected by the action */
resource?: string
resource: string
/** Status of the action */
status: AuditStatus
/** Additional metadata in JSON format */
metadata?: string
/** Additional metadata */
metadata: Record<string, unknown>
}

View File

@@ -419,11 +419,11 @@ export class AuditLogFactory {
*
* @example
* // Successful login audit
* const entry = AuditLogFactory.createAuditLog('LOGIN', 'SUCCESS')
* const entry = AuditLogFactory.createAuditLog(AuditAction.LOGIN, AuditStatus.SUCCESS)
*
* @example
* // Failed extract audit
* const entry = AuditLogFactory.createAuditLog('EXTRACT', 'FAILURE', { resource: 'Order SC123' })
* const entry = AuditLogFactory.createAuditLog(AuditAction.EXTRACT, AuditStatus.FAILURE, { resource: 'Order SC123' })
*/
static createAuditLog(
action: AuditAction = AuditAction.LOGIN,
@@ -431,13 +431,15 @@ export class AuditLogFactory {
overrides?: Partial<AuditEntry>
): AuditEntry {
return {
timestamp: new Date(),
timestamp: new Date().toISOString(),
action,
userId: 'USR-001',
username: 'test_user',
computerName: 'TEST-PC',
appVersion: '1.0.0',
resource: 'test-resource',
status,
metadata: {},
...overrides
}
}

View File

@@ -91,7 +91,7 @@ describe('AuditLogFactory', () => {
it('creates audit log with default values', () => {
const entry = AuditLogFactory.createAuditLog()
expect(entry.timestamp).toBeInstanceOf(Date)
expect(typeof entry.timestamp).toBe('string')
expect(entry.action).toBe(AuditAction.LOGIN)
expect(entry.status).toBe(AuditStatus.SUCCESS)
expect(entry.userId).toBe('USR-001')

View File

@@ -8,6 +8,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { AuditAction, AuditStatus } from '../../src/main/types/audit.types'
describe('Audit Logger', () => {
let auditLoggerModule: typeof import('../../src/main/services/logger/audit-logger')
@@ -32,11 +33,11 @@ describe('Audit Logger', () => {
const { logAudit, applyAuditConfig } = auditLoggerModule
applyAuditConfig(30)
logAudit('LOGIN', 'user-001', {
logAudit(AuditAction.LOGIN, 'user-001', {
username: 'alice',
computerName: 'PC-001',
resource: 'ERP_SYSTEM',
status: 'success',
status: AuditStatus.SUCCESS,
metadata: { sessionId: 'abc' }
})
@@ -60,26 +61,26 @@ describe('Audit Logger', () => {
applyAuditConfig(30)
logAudit('EXTRACT', 'user1', {
logAudit(AuditAction.EXTRACT, 'user1', {
username: 'extractor',
computerName: 'PC-001',
resource: 'materials',
status: 'success'
status: AuditStatus.SUCCESS
})
logAudit('DELETE', 'user2', {
logAudit(AuditAction.CLEAN, 'user2', {
username: 'cleaner',
computerName: 'PC-002',
resource: 'temp_files',
status: 'failure',
status: AuditStatus.FAILURE,
metadata: { error: 'Permission denied' }
})
logAudit('UPDATE', 'user3', {
logAudit(AuditAction.APP_UPDATE, 'user3', {
username: 'updater',
computerName: 'PC-003',
resource: 'config',
status: 'partial',
status: AuditStatus.PARTIAL,
metadata: { updated: 5, failed: 2 }
})
@@ -95,11 +96,11 @@ describe('Audit Logger', () => {
applyAuditConfig(30)
logAudit('PING', 'user-no-meta', {
logAudit(AuditAction.SYSTEM_ERROR, 'user-no-meta', {
username: 'tester',
computerName: 'PC-001',
resource: 'ERP',
status: 'success'
status: AuditStatus.SUCCESS
})
const entry = JSON.parse(infoSpy.mock.calls[0][0])
@@ -111,11 +112,11 @@ describe('Audit Logger', () => {
applyAuditConfig(30)
logAudit('LOGIN_ATTEMPT', 'user-special', {
logAudit(AuditAction.LOGIN, 'user-special', {
username: 'user.name+test@example.com',
computerName: 'DESKTOP-特殊字符-001',
resource: 'ERP/子系统',
status: 'failure',
status: AuditStatus.FAILURE,
metadata: { reason: '密码错误', attempt: 3 }
})

View File

@@ -132,7 +132,10 @@ describe('MySqlService Unit Tests', () => {
it('should execute SELECT and return rows with columns', async () => {
await service.connect()
mockExecute.mockResolvedValue([
[{ id: 1, name: 'test' }, { id: 2, name: 'foo' }],
[
{ id: 1, name: 'test' },
{ id: 2, name: 'foo' }
],
[{ name: 'id' }, { name: 'name' }]
])

View File

@@ -168,10 +168,7 @@ describe('PostgreSqlService Unit Tests', () => {
await service.connect()
mockPgClient.query.mockResolvedValue({ rows: [] })
await service.transaction([
{ sql: 'SELECT 1', params: [1] },
{ sql: 'SELECT 2' }
])
await service.transaction([{ sql: 'SELECT 1', params: [1] }, { sql: 'SELECT 2' }])
// BEGIN + 2 queries + COMMIT
expect(mockPgClient.query).toHaveBeenCalledTimes(4)
@@ -187,9 +184,9 @@ describe('PostgreSqlService Unit Tests', () => {
.mockRejectedValueOnce(new Error('constraint violation')) // query fails
.mockResolvedValueOnce({ rows: [] }) // ROLLBACK
await expect(
service.transaction([{ sql: 'SELECT 1', params: [1] }])
).rejects.toThrow('PostgreSQL transaction failed')
await expect(service.transaction([{ sql: 'SELECT 1', params: [1] }])).rejects.toThrow(
'PostgreSQL transaction failed'
)
expect(mockPgClient.query).toHaveBeenCalledWith('ROLLBACK')
expect(mockPgClient.release).toHaveBeenCalled()
@@ -234,8 +231,7 @@ describe('prepareSql', () => {
})
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)
expect(result).toBe(
'INSERT INTO "dbo"."BIPUsers" ("UserName", "Password", "UserType") VALUES ($1, $2, $3)'
@@ -303,9 +299,7 @@ describe('prepareSql', () => {
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"'
)
expect(result).toBe('SELECT "ERP_URL", "ERP_Username", "ERP_Password" FROM "dbo"."BIPUsers"')
})
it('should handle ON CONFLICT DO UPDATE SET with EXCLUDED', () => {

View File

@@ -149,7 +149,10 @@ describe('SqlServerService Unit Tests', () => {
it('should execute SELECT and return rows with columns', async () => {
await service.connect()
mockRequestQuery.mockResolvedValue({
recordset: [{ ID: 1, Name: 'test' }, { ID: 2, Name: 'foo' }],
recordset: [
{ ID: 1, Name: 'test' },
{ ID: 2, Name: 'foo' }
],
rowsAffected: [2]
})
@@ -209,9 +212,9 @@ describe('SqlServerService Unit Tests', () => {
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')
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 () => {