Compare commits
5 Commits
9086aa753f
...
0956bf907f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0956bf907f | ||
|
|
6c730616b8 | ||
|
|
4f4e5fd91a | ||
|
|
ae29f38d24 | ||
|
|
406a8dfd2f |
140
docs/plans/2026-04-05-postgresql-integration-design.md
Normal file
140
docs/plans/2026-04-05-postgresql-integration-design.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# PostgreSQL 集成设计文档
|
||||
|
||||
**日期:** 2026-04-05
|
||||
**状态:** 已批准
|
||||
**分支:** dev-logging
|
||||
|
||||
## 目标
|
||||
|
||||
将 PostgreSQL 作为第三种可选数据库类型集成到 ERPAuto 中,与现有 MySQL、SQL Server 并列。通过引入 SqlDialect 抽象层,统一管理三种数据库的 SQL 方言差异,同时重构现有 DAO 层消除散落的 `isSqlServer` 判断。
|
||||
|
||||
## 背景
|
||||
|
||||
- PostgreSQL 数据库已通过 SSMA 从 SQL Server 迁移完成,表结构、schema 组织、列名完全一致
|
||||
- 连接信息:`postgresql://admin:***@192.168.31.83:5432/postgres`,数据库 `CompanyDB`
|
||||
- 共 15 个 schema、151 张表,`dbo` schema 包含 ERPAuto 直接使用的表
|
||||
|
||||
## 方案:抽象数据库方言层
|
||||
|
||||
### 1. SqlDialect 接口
|
||||
|
||||
新建 `src/main/types/sql-dialect.types.ts`:
|
||||
|
||||
```typescript
|
||||
export interface SqlDialect {
|
||||
readonly dbType: DatabaseType
|
||||
|
||||
// 表名引用
|
||||
quoteTableName(schema: string, table: string): string
|
||||
|
||||
// 参数占位符
|
||||
param(index: number): string
|
||||
params(count: number): string
|
||||
|
||||
// SQL 函数
|
||||
currentTimestamp(): string
|
||||
|
||||
// UPSERT
|
||||
upsert(p: {
|
||||
table: string
|
||||
keyColumns: string[]
|
||||
valueColumns: string[]
|
||||
placeholderCount: number
|
||||
startParamIndex: number
|
||||
}): string
|
||||
|
||||
// 分页
|
||||
paginate(p: {
|
||||
sql: string
|
||||
limit: number
|
||||
offset?: number
|
||||
paramIndex: number
|
||||
}): { sql: string; paramIndex: number }
|
||||
|
||||
// 批量限制
|
||||
maxBatchRows(columnsPerRow: number): number
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 三种方言实现
|
||||
|
||||
新建 `src/main/services/database/dialects/` 目录:
|
||||
|
||||
| 文件 | 数据库 | param(n) | quoteTableName | currentTimestamp | upsert | paginate |
|
||||
|------|--------|----------|---------------|-----------------|--------|----------|
|
||||
| `mysql-dialect.ts` | MySQL | `?` | `dbo_Table` | `NOW()` | `ON DUPLICATE KEY` | `LIMIT x OFFSET y` |
|
||||
| `sqlserver-dialect.ts` | SQL Server | `@p{n}` | `[dbo].[Table]` | `GETDATE()` | `MERGE` | `OFFSET/FETCH` |
|
||||
| `postgresql-dialect.ts` | PostgreSQL | `${n+1}` | `"dbo"."Table"` | `CURRENT_TIMESTAMP` | `ON CONFLICT` | `LIMIT x OFFSET y` |
|
||||
|
||||
方言工厂 `dialects/index.ts`:
|
||||
```typescript
|
||||
export function createDialect(type: DatabaseType): SqlDialect
|
||||
```
|
||||
|
||||
### 3. DAO 层重构
|
||||
|
||||
每个 DAO 新增 `dialect` 成员,替代原有的 `getTableName()`、`buildPlaceholders()` 和所有 `isSqlServer` 分支:
|
||||
|
||||
**删除:**
|
||||
- `getTableName()` 私有方法
|
||||
- `buildPlaceholders()` 私有方法
|
||||
- 所有 `isSqlServer` 局部变量和条件分支
|
||||
- `*_CONFIG` 中的 `TABLE_NAME_SQLSERVER` / `TABLE_NAME_MYSQL` → 合并为 `TABLE_SCHEMA` + `TABLE_NAME`
|
||||
|
||||
**新增:**
|
||||
- `private dialect: SqlDialect | null = null`
|
||||
- `private getDialect(): SqlDialect`
|
||||
|
||||
**涉及 DAO:**
|
||||
- `DiscreteMaterialPlanDAO` — 占位符、表名、批量大小
|
||||
- `MaterialsToBeDeletedDAO` — 占位符、表名、MERGE/ON DUPLICATE KEY → `upsert()`
|
||||
- `MaterialsTypeToBeDeletedDAO` — 同上
|
||||
- `ExtractorOperationHistoryDAO` — 占位符、表名、GETDATE()/NOW() → `currentTimestamp()`、分页 → `paginate()`
|
||||
|
||||
### 4. PostgreSQL 服务层
|
||||
|
||||
新建 `src/main/services/database/postgresql.ts`:
|
||||
- 使用 `pg` 驱动,`Pool` 连接池
|
||||
- 实现 `IDatabaseService` 接口
|
||||
- `query()` 直接传递参数数组给 `pg`
|
||||
- `transaction()` 使用 `client.query('BEGIN/COMMIT/ROLLBACK')`
|
||||
|
||||
### 5. 工厂、配置、TypeORM
|
||||
|
||||
**database/index.ts:** `create()` 新增 `'postgresql'` 分支,新增 `createPostgreSqlConfig()`
|
||||
|
||||
**database.types.ts:** `DatabaseType` 扩展为 `'mysql' | 'sqlserver' | 'postgresql'`,新增 `PostgreSqlConfig`
|
||||
|
||||
**data-source.ts:** TypeORM `type` 映射新增 `'postgres'`
|
||||
|
||||
**config.template.yaml:** 新增 `postgresql` 配置段
|
||||
|
||||
**package.json:** 新增 `pg` 依赖
|
||||
|
||||
## 改动范围
|
||||
|
||||
| 层 | 文件 | 动作 |
|
||||
|---|---|---|
|
||||
| 类型 | `types/database.types.ts` | 修改 |
|
||||
| 方言 | `database/dialects/index.ts` | 新建 |
|
||||
| 方言 | `database/dialects/mysql-dialect.ts` | 新建 |
|
||||
| 方言 | `database/dialects/sqlserver-dialect.ts` | 新建 |
|
||||
| 方言 | `database/dialects/postgresql-dialect.ts` | 新建 |
|
||||
| 服务 | `database/postgresql.ts` | 新建 |
|
||||
| 工厂 | `database/index.ts` | 修改 |
|
||||
| TypeORM | `database/data-source.ts` | 修改 |
|
||||
| DAO | `database/discrete-material-plan-dao.ts` | 重构 |
|
||||
| DAO | `database/materials-to-be-deleted-dao.ts` | 重构 |
|
||||
| DAO | `database/materials-type-to-be-deleted-dao.ts` | 重构 |
|
||||
| DAO | `database/extractor-operation-history-dao.ts` | 重构 |
|
||||
| 配置 | `config.template.yaml` | 修改 |
|
||||
| 依赖 | `package.json` | 修改 |
|
||||
|
||||
共 **4 个新文件 + 10 个修改文件**。
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- IPC 处理器新增(前端暂不需要直接切换 PostgreSQL)
|
||||
- Entity/Repository 的 TypeScript 类型适配(TypeORM 内部处理方言差异)
|
||||
- 数据迁移工具
|
||||
- 前端 UI 变更
|
||||
1299
docs/plans/2026-04-05-postgresql-integration-plan.md
Normal file
1299
docs/plans/2026-04-05-postgresql-integration-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ import { z } from 'zod'
|
||||
/**
|
||||
* 数据库类型枚举
|
||||
*/
|
||||
export const databaseTypeSchema = z.enum(['mysql', 'sqlserver'])
|
||||
export const databaseTypeSchema = z.enum(['mysql', 'sqlserver', 'postgresql'])
|
||||
export type DatabaseType = z.infer<typeof databaseTypeSchema>
|
||||
|
||||
/**
|
||||
@@ -57,13 +57,27 @@ export const sqlServerConfigSchema = z.object({
|
||||
trustServerCertificate: z.boolean().default(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* PostgreSQL 配置 Schema
|
||||
*/
|
||||
export const postgresqlConfigSchema = z.object({
|
||||
host: z.string().min(1, 'PostgreSQL host is required'),
|
||||
port: z.number().int().min(1).max(65535).default(5432),
|
||||
database: z.string().min(1, 'PostgreSQL database is required'),
|
||||
username: z.string().min(1, 'PostgreSQL username is required'),
|
||||
password: z.string(),
|
||||
maxPoolSize: z.number().int().min(1).max(100).default(10)
|
||||
})
|
||||
export type PostgreSqlConfigSchema = z.infer<typeof postgresqlConfigSchema>
|
||||
|
||||
/**
|
||||
* 数据库配置(包含两种数据库的完整配置)
|
||||
*/
|
||||
export const databaseConfigSchema = z.object({
|
||||
activeType: databaseTypeSchema.default('mysql'),
|
||||
mysql: mysqlConfigSchema,
|
||||
sqlserver: sqlServerConfigSchema
|
||||
sqlserver: sqlServerConfigSchema,
|
||||
postgresql: postgresqlConfigSchema
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -199,6 +213,7 @@ export type FullConfig = z.infer<typeof fullConfigSchema>
|
||||
export type DatabaseConfig = z.infer<typeof databaseConfigSchema>
|
||||
export type MySqlConfig = z.infer<typeof mysqlConfigSchema>
|
||||
export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
|
||||
export type PostgreSqlConfig = z.infer<typeof postgresqlConfigSchema>
|
||||
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
|
||||
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
|
||||
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
/**
|
||||
* Supported database types
|
||||
*/
|
||||
export type DatabaseType = 'mysql' | 'sqlserver'
|
||||
export type DatabaseType = 'mysql' | 'sqlserver' | 'postgresql'
|
||||
|
||||
/**
|
||||
* Standard query result interface
|
||||
@@ -102,3 +102,15 @@ export interface SqlServerConfig extends DatabaseConfig {
|
||||
trustServerCertificate?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL-specific configuration
|
||||
*/
|
||||
export interface PostgreSqlConfig extends DatabaseConfig {
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
password: string
|
||||
database: string
|
||||
maxPoolSize?: number
|
||||
}
|
||||
|
||||
80
src/main/types/sql-dialect.types.ts
Normal file
80
src/main/types/sql-dialect.types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* SQL Dialect Abstraction
|
||||
*
|
||||
* Provides a unified interface for database-specific SQL syntax differences.
|
||||
* Each database type implements this interface to encapsulate:
|
||||
* - Parameter placeholder format
|
||||
* - Table name quoting
|
||||
* - UPSERT syntax
|
||||
* - Pagination syntax
|
||||
* - Current timestamp function
|
||||
* - Batch size limits
|
||||
*/
|
||||
|
||||
import type { DatabaseType } from './database.types'
|
||||
|
||||
export interface SqlDialect {
|
||||
/** Database type identifier */
|
||||
readonly dbType: DatabaseType
|
||||
|
||||
/**
|
||||
* Quote a table name with schema prefix
|
||||
* MySQL: dbo_TableName
|
||||
* SQL Server: [dbo].[TableName]
|
||||
* PostgreSQL: "dbo"."TableName"
|
||||
*/
|
||||
quoteTableName(schema: string, table: string): string
|
||||
|
||||
/**
|
||||
* Get placeholder for parameter at given index (0-based)
|
||||
* MySQL: ?
|
||||
* SQL Server: @p0
|
||||
* PostgreSQL: $1
|
||||
*/
|
||||
param(index: number): string
|
||||
|
||||
/**
|
||||
* Get comma-separated placeholders for count parameters
|
||||
*/
|
||||
params(count: number): string
|
||||
|
||||
/**
|
||||
* Get current timestamp SQL function
|
||||
* MySQL: NOW()
|
||||
* SQL Server: GETDATE()
|
||||
* PostgreSQL: CURRENT_TIMESTAMP
|
||||
*/
|
||||
currentTimestamp(): string
|
||||
|
||||
/**
|
||||
* Generate UPSERT SQL for a single row
|
||||
* MySQL: INSERT ... ON DUPLICATE KEY UPDATE
|
||||
* SQL Server: MERGE ... USING ...
|
||||
* PostgreSQL: INSERT ... ON CONFLICT ... DO UPDATE SET
|
||||
*/
|
||||
upsert(params: {
|
||||
table: string
|
||||
keyColumns: string[]
|
||||
allColumns: string[]
|
||||
startParamIndex: number
|
||||
}): { sql: string; nextParamIndex: number }
|
||||
|
||||
/**
|
||||
* Append pagination clause to SQL
|
||||
* MySQL/PostgreSQL: LIMIT x OFFSET y
|
||||
* SQL Server: OFFSET x ROWS FETCH NEXT y ROWS ONLY
|
||||
*/
|
||||
paginate(params: {
|
||||
sql: string
|
||||
limit: number
|
||||
offset?: number
|
||||
paramIndex: number
|
||||
}): { sql: string; nextParamIndex: number }
|
||||
|
||||
/**
|
||||
* Maximum rows per batch given columns per row
|
||||
* SQL Server: ~71 (due to 2100 param limit)
|
||||
* MySQL/PostgreSQL: 1000
|
||||
*/
|
||||
maxBatchRows(columnsPerRow: number): number
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import logger from '../../src/main/services/logger/index'
|
||||
|
||||
// Mock the logger to avoid noisy output during tests
|
||||
vi.mock('../../src/main/services/logger/index', () => ({
|
||||
vi.mock('../../src/main/services/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
|
||||
@@ -1,36 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
CleanerService,
|
||||
createBatches,
|
||||
getMissingOrders,
|
||||
runWithConcurrency
|
||||
} from '../../src/main/services/erp/cleaner'
|
||||
import type { ShouldDeleteParams } from '../../src/main/services/erp/cleaner'
|
||||
|
||||
describe('Cleaner Service (Unit)', () => {
|
||||
describe('shouldDeleteMaterial', () => {
|
||||
// Create a mock cleaner service (no auth needed for this pure function test)
|
||||
const mockCleaner = {
|
||||
shouldDeleteMaterial: (params: ShouldDeleteParams): boolean => {
|
||||
const { rowNumber, pendingQty, materialCode, deleteSet } = params
|
||||
|
||||
// Check if material is in delete list
|
||||
if (!deleteSet.has(materialCode)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check row number range (2000-7999 are protected)
|
||||
if (rowNumber >= 2000 && rowNumber < 8000) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check pending quantity (must be empty)
|
||||
if (pendingQty && pendingQty.trim() !== '') {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
// CleanerService constructor requires ErpAuthService, but shouldDeleteMaterial doesn't use it
|
||||
const cleaner = new CleanerService({} as any)
|
||||
|
||||
it('should skip materials with row number 2000-7999', () => {
|
||||
const testCases = [
|
||||
@@ -42,7 +21,7 @@ describe('Cleaner Service (Unit)', () => {
|
||||
]
|
||||
|
||||
for (const tc of testCases) {
|
||||
const shouldDelete = mockCleaner.shouldDeleteMaterial({
|
||||
const shouldDelete = cleaner.shouldDeleteMaterial({
|
||||
rowNumber: tc.rowNumber,
|
||||
pendingQty: tc.pendingQty,
|
||||
materialCode: tc.materialCode,
|
||||
@@ -53,7 +32,7 @@ describe('Cleaner Service (Unit)', () => {
|
||||
})
|
||||
|
||||
it('should skip materials with non-empty pending quantity', () => {
|
||||
const result = mockCleaner.shouldDeleteMaterial({
|
||||
const result = cleaner.shouldDeleteMaterial({
|
||||
rowNumber: 100,
|
||||
pendingQty: '5',
|
||||
materialCode: 'TEST001',
|
||||
@@ -64,7 +43,7 @@ describe('Cleaner Service (Unit)', () => {
|
||||
})
|
||||
|
||||
it('should skip materials not in delete list', () => {
|
||||
const result = mockCleaner.shouldDeleteMaterial({
|
||||
const result = cleaner.shouldDeleteMaterial({
|
||||
rowNumber: 100,
|
||||
pendingQty: '',
|
||||
materialCode: 'NOT_IN_LIST',
|
||||
@@ -84,7 +63,7 @@ describe('Cleaner Service (Unit)', () => {
|
||||
]
|
||||
|
||||
for (const tc of testCases) {
|
||||
const shouldDelete = mockCleaner.shouldDeleteMaterial({
|
||||
const shouldDelete = cleaner.shouldDeleteMaterial({
|
||||
rowNumber: tc.rowNumber,
|
||||
pendingQty: tc.pendingQty,
|
||||
materialCode: tc.materialCode,
|
||||
@@ -97,7 +76,7 @@ describe('Cleaner Service (Unit)', () => {
|
||||
it('should handle multiple conditions correctly', () => {
|
||||
// Material in list, valid row, no pending qty = should delete
|
||||
expect(
|
||||
mockCleaner.shouldDeleteMaterial({
|
||||
cleaner.shouldDeleteMaterial({
|
||||
rowNumber: 100,
|
||||
pendingQty: '',
|
||||
materialCode: 'TEST001',
|
||||
@@ -107,7 +86,7 @@ describe('Cleaner Service (Unit)', () => {
|
||||
|
||||
// Material in list, protected row, no pending qty = should NOT delete
|
||||
expect(
|
||||
mockCleaner.shouldDeleteMaterial({
|
||||
cleaner.shouldDeleteMaterial({
|
||||
rowNumber: 7500,
|
||||
pendingQty: '',
|
||||
materialCode: 'TEST001',
|
||||
@@ -117,7 +96,7 @@ describe('Cleaner Service (Unit)', () => {
|
||||
|
||||
// Material in list, valid row, has pending qty = should NOT delete
|
||||
expect(
|
||||
mockCleaner.shouldDeleteMaterial({
|
||||
cleaner.shouldDeleteMaterial({
|
||||
rowNumber: 100,
|
||||
pendingQty: '10',
|
||||
materialCode: 'TEST001',
|
||||
@@ -127,7 +106,7 @@ describe('Cleaner Service (Unit)', () => {
|
||||
|
||||
// Material NOT in list = should NOT delete
|
||||
expect(
|
||||
mockCleaner.shouldDeleteMaterial({
|
||||
cleaner.shouldDeleteMaterial({
|
||||
rowNumber: 100,
|
||||
pendingQty: '',
|
||||
materialCode: 'OTHER',
|
||||
|
||||
@@ -1,59 +1,45 @@
|
||||
/**
|
||||
* Repository Unit Tests
|
||||
*
|
||||
* Tests for TypeORM repository patterns.
|
||||
* Note: These tests mock the database connections.
|
||||
* Behavior-based tests for MaterialsToBeDeletedRepository and DiscreteMaterialPlanRepository.
|
||||
* Verifies correct delegation to TypeORM methods, return values, and safe defaults on error.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createMockRepository, createMockQueryBuilder } from '../mocks'
|
||||
|
||||
// Mock TypeORM
|
||||
// --- Shared mock state ---
|
||||
|
||||
let mockRepo: ReturnType<typeof createMockRepository>
|
||||
|
||||
// Mock TypeORM decorators (entities still need them)
|
||||
vi.mock('typeorm', () => {
|
||||
// Create mock decorator functions
|
||||
const Entity = vi.fn()
|
||||
const PrimaryGeneratedColumn = vi.fn()
|
||||
const Column = vi.fn()
|
||||
const ManyToOne = vi.fn()
|
||||
const OneToMany = vi.fn()
|
||||
const ManyToMany = vi.fn()
|
||||
const JoinColumn = vi.fn()
|
||||
const JoinTable = vi.fn()
|
||||
const CreateDateColumn = vi.fn()
|
||||
const UpdateDateColumn = vi.fn()
|
||||
const DeleteDateColumn = vi.fn()
|
||||
const Index = vi.fn()
|
||||
const Unique = vi.fn()
|
||||
const Check = vi.fn()
|
||||
const Exclusion = vi.fn()
|
||||
const Generated = vi.fn()
|
||||
|
||||
const decorator = vi.fn()
|
||||
return {
|
||||
DataSource: vi.fn(() => ({
|
||||
initialize: vi.fn().mockResolvedValue({}),
|
||||
isInitialized: false,
|
||||
getRepository: vi.fn(),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
isInitialized: true,
|
||||
getRepository: vi.fn(() => mockRepo),
|
||||
destroy: vi.fn()
|
||||
})),
|
||||
Repository: vi.fn(),
|
||||
In: vi.fn((arr) => arr),
|
||||
// Add all the decorators that entities use
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
ManyToMany,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Index,
|
||||
Unique,
|
||||
Check,
|
||||
Exclusion,
|
||||
Generated,
|
||||
// Other TypeORM exports
|
||||
In: vi.fn((arr: unknown[]) => arr),
|
||||
Entity: decorator,
|
||||
PrimaryGeneratedColumn: decorator,
|
||||
Column: decorator,
|
||||
ManyToOne: decorator,
|
||||
OneToMany: decorator,
|
||||
ManyToMany: decorator,
|
||||
JoinColumn: decorator,
|
||||
JoinTable: decorator,
|
||||
CreateDateColumn: decorator,
|
||||
UpdateDateColumn: decorator,
|
||||
DeleteDateColumn: decorator,
|
||||
Index: decorator,
|
||||
Unique: decorator,
|
||||
Check: decorator,
|
||||
Exclusion: decorator,
|
||||
Generated: decorator,
|
||||
Between: vi.fn(),
|
||||
LessThan: vi.fn(),
|
||||
LessThanOrEqual: vi.fn(),
|
||||
@@ -76,40 +62,577 @@ vi.mock('../../src/main/services/logger', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/database/data-source', () => ({
|
||||
getDataSource: vi.fn(() => ({
|
||||
isInitialized: true,
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getRepository: vi.fn(() => mockRepo)
|
||||
}))
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MaterialsToBeDeletedRepository
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('MaterialsToBeDeletedRepository', () => {
|
||||
beforeEach(() => {
|
||||
let MaterialsToBeDeletedRepository: typeof import(
|
||||
'../../src/main/services/database/repositories/MaterialsToBeDeletedRepository'
|
||||
).MaterialsToBeDeletedRepository
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mockRepo = createMockRepository()
|
||||
const mod = await import(
|
||||
'../../src/main/services/database/repositories/MaterialsToBeDeletedRepository'
|
||||
)
|
||||
MaterialsToBeDeletedRepository = mod.MaterialsToBeDeletedRepository
|
||||
})
|
||||
|
||||
it('should be defined', async () => {
|
||||
const { MaterialsToBeDeletedRepository } =
|
||||
await import('../../src/main/services/database/repositories/MaterialsToBeDeletedRepository')
|
||||
expect(MaterialsToBeDeletedRepository).toBeDefined()
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('should create repository instance', async () => {
|
||||
const { MaterialsToBeDeletedRepository } =
|
||||
await import('../../src/main/services/database/repositories/MaterialsToBeDeletedRepository')
|
||||
// --- upsert ---
|
||||
|
||||
it('upsert: creates new entity when not found', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
expect(repo).toBeDefined()
|
||||
mockRepo.findOne!.mockResolvedValue(null)
|
||||
mockRepo.create!.mockReturnValue({ materialCode: 'MAT01', managerName: 'Alice' })
|
||||
|
||||
const result = await repo.upsert('MAT01', 'Alice')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { materialCode: 'MAT01' } })
|
||||
expect(mockRepo.create).toHaveBeenCalledWith({ materialCode: 'MAT01', managerName: 'Alice' })
|
||||
expect(mockRepo.save).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('upsert: updates existing entity', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
const existing = { materialCode: 'MAT01', managerName: 'Bob' }
|
||||
mockRepo.findOne!.mockResolvedValue(existing)
|
||||
|
||||
const result = await repo.upsert('MAT01', 'Alice')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(existing.managerName).toBe('Alice')
|
||||
expect(mockRepo.save).toHaveBeenCalledWith(existing)
|
||||
})
|
||||
|
||||
it('upsert: returns false on error', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.findOne!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.upsert('MAT01', 'Alice')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
// --- upsertBatch ---
|
||||
|
||||
it('upsertBatch: processes valid materials', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.findOne!.mockResolvedValue(null)
|
||||
mockRepo.create!.mockImplementation((data) => data)
|
||||
|
||||
const stats = await repo.upsertBatch([
|
||||
{ materialCode: 'M1', managerName: 'A' },
|
||||
{ materialCode: 'M2', managerName: 'B' }
|
||||
])
|
||||
|
||||
expect(stats.total).toBe(2)
|
||||
expect(stats.success).toBe(2)
|
||||
expect(stats.failed).toBe(0)
|
||||
})
|
||||
|
||||
it('upsertBatch: skips empty materialCodes', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
|
||||
const stats = await repo.upsertBatch([
|
||||
{ materialCode: '', managerName: 'A' },
|
||||
{ materialCode: ' ', managerName: 'B' },
|
||||
{ materialCode: 'M1', managerName: 'C' }
|
||||
])
|
||||
|
||||
expect(stats.total).toBe(3)
|
||||
expect(stats.success).toBe(1)
|
||||
expect(stats.failed).toBe(2)
|
||||
})
|
||||
|
||||
it('upsertBatch: returns stats with partial failures', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.findOne!.mockRejectedValue(new Error('fail'))
|
||||
|
||||
const stats = await repo.upsertBatch([{ materialCode: 'M1', managerName: 'A' }])
|
||||
|
||||
expect(stats.total).toBe(1)
|
||||
expect(stats.failed).toBe(1)
|
||||
})
|
||||
|
||||
// --- getAllMaterialCodes ---
|
||||
|
||||
it('getAllMaterialCodes: returns Set via QueryBuilder', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
const qb = createMockQueryBuilder({
|
||||
result: [{ materialCode: 'M1' }, { materialCode: 'M2' }]
|
||||
})
|
||||
mockRepo.createQueryBuilder!.mockReturnValue(qb)
|
||||
|
||||
const result = await repo.getAllMaterialCodes()
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(2)
|
||||
expect(result.has('M1')).toBe(true)
|
||||
expect(result.has('M2')).toBe(true)
|
||||
})
|
||||
|
||||
it('getAllMaterialCodes: returns empty Set on error', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.createQueryBuilder!.mockImplementation(() => {
|
||||
throw new Error('db fail')
|
||||
})
|
||||
|
||||
const result = await repo.getAllMaterialCodes()
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
// --- getAllRecords ---
|
||||
|
||||
it('getAllRecords: returns ordered records', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
const records = [{ materialCode: 'M1' }, { materialCode: 'M2' }]
|
||||
mockRepo.find!.mockResolvedValue(records)
|
||||
|
||||
const result = await repo.getAllRecords()
|
||||
|
||||
expect(result).toEqual(records)
|
||||
expect(mockRepo.find).toHaveBeenCalledWith({
|
||||
order: { managerName: 'ASC', materialCode: 'ASC' }
|
||||
})
|
||||
})
|
||||
|
||||
it('getAllRecords: returns empty array on error', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.getAllRecords()
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// --- getByManager ---
|
||||
|
||||
it('getByManager: filters by managerName', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
const records = [{ materialCode: 'M1', managerName: 'Alice' }]
|
||||
mockRepo.find!.mockResolvedValue(records)
|
||||
|
||||
const result = await repo.getByManager('Alice')
|
||||
|
||||
expect(result).toEqual(records)
|
||||
expect(mockRepo.find).toHaveBeenCalledWith({
|
||||
where: { managerName: 'Alice' },
|
||||
order: { materialCode: 'ASC' }
|
||||
})
|
||||
})
|
||||
|
||||
it('getByManager: returns empty array on error', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.getByManager('Alice')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// --- getManagers ---
|
||||
|
||||
it('getManagers: returns distinct names via QueryBuilder', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
const qb = createMockQueryBuilder({
|
||||
result: [{ managerName: 'Alice' }, { managerName: 'Bob' }]
|
||||
})
|
||||
mockRepo.createQueryBuilder!.mockReturnValue(qb)
|
||||
|
||||
const result = await repo.getManagers()
|
||||
|
||||
expect(result).toEqual(['Alice', 'Bob'])
|
||||
})
|
||||
|
||||
it('getManagers: returns empty array on error', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.createQueryBuilder!.mockImplementation(() => {
|
||||
throw new Error('db fail')
|
||||
})
|
||||
|
||||
const result = await repo.getManagers()
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// --- deleteByMaterialCode ---
|
||||
|
||||
it('deleteByMaterialCode: returns true when affected > 0', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.delete!.mockResolvedValue({ affected: 1 })
|
||||
|
||||
const result = await repo.deleteByMaterialCode('M1')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockRepo.delete).toHaveBeenCalledWith({ materialCode: 'M1' })
|
||||
})
|
||||
|
||||
it('deleteByMaterialCode: returns false when affected is 0', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.delete!.mockResolvedValue({ affected: 0 })
|
||||
|
||||
const result = await repo.deleteByMaterialCode('M1')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('deleteByMaterialCode: returns false on error', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.delete!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.deleteByMaterialCode('M1')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
// --- deleteByMaterialCodes ---
|
||||
|
||||
it('deleteByMaterialCodes: returns 0 for empty array', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
|
||||
const result = await repo.deleteByMaterialCodes([])
|
||||
|
||||
expect(result).toBe(0)
|
||||
expect(mockRepo.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deleteByMaterialCodes: returns affected count', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.delete!.mockResolvedValue({ affected: 3 })
|
||||
|
||||
const result = await repo.deleteByMaterialCodes(['M1', 'M2', 'M3'])
|
||||
|
||||
expect(result).toBe(3)
|
||||
})
|
||||
|
||||
it('deleteByMaterialCodes: returns 0 on error', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.delete!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.deleteByMaterialCodes(['M1'])
|
||||
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
// --- exists ---
|
||||
|
||||
it('exists: returns true when count > 0', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.count!.mockResolvedValue(1)
|
||||
|
||||
const result = await repo.exists('M1')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('exists: returns false when count is 0', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.count!.mockResolvedValue(0)
|
||||
|
||||
const result = await repo.exists('M1')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('exists: returns false on error', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.count!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.exists('M1')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
// --- countAll ---
|
||||
|
||||
it('countAll: returns count', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.count!.mockResolvedValue(42)
|
||||
|
||||
const result = await repo.countAll()
|
||||
|
||||
expect(result).toBe(42)
|
||||
})
|
||||
|
||||
it('countAll: returns 0 on error', async () => {
|
||||
const repo = new MaterialsToBeDeletedRepository()
|
||||
mockRepo.count!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.countAll()
|
||||
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DiscreteMaterialPlanRepository
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('DiscreteMaterialPlanRepository', () => {
|
||||
beforeEach(() => {
|
||||
let DiscreteMaterialPlanRepository: typeof import(
|
||||
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
|
||||
).DiscreteMaterialPlanRepository
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mockRepo = createMockRepository()
|
||||
const mod = await import(
|
||||
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
|
||||
)
|
||||
DiscreteMaterialPlanRepository = mod.DiscreteMaterialPlanRepository
|
||||
})
|
||||
|
||||
it('should be defined', async () => {
|
||||
const { DiscreteMaterialPlanRepository } =
|
||||
await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository')
|
||||
expect(DiscreteMaterialPlanRepository).toBeDefined()
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('should create repository instance', async () => {
|
||||
const { DiscreteMaterialPlanRepository } =
|
||||
await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository')
|
||||
// --- queryAll ---
|
||||
|
||||
it('queryAll: returns find results', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
expect(repo).toBeDefined()
|
||||
const records = [{ sourceNumber: 'S1' }, { sourceNumber: 'S2' }]
|
||||
mockRepo.find!.mockResolvedValue(records)
|
||||
|
||||
const result = await repo.queryAll()
|
||||
|
||||
expect(result).toEqual(records)
|
||||
})
|
||||
|
||||
it('queryAll: returns empty array on error', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.queryAll()
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// --- queryAllDistinctByMaterialCode ---
|
||||
|
||||
it('queryAllDistinctByMaterialCode: calls repo.query() with raw SQL', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
const mockQueryResult = [{ MaterialCode: 'M1', rn: 1 }]
|
||||
mockRepo = { ...createMockRepository(), query: vi.fn().mockResolvedValue(mockQueryResult) }
|
||||
// Re-import to pick up new mockRepo
|
||||
vi.resetModules()
|
||||
const mod = await import(
|
||||
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
|
||||
)
|
||||
const freshRepo = new mod.DiscreteMaterialPlanRepository()
|
||||
|
||||
const result = await freshRepo.queryAllDistinctByMaterialCode()
|
||||
|
||||
expect(result).toEqual(mockQueryResult)
|
||||
})
|
||||
|
||||
it('queryAllDistinctByMaterialCode: returns empty array on error', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
mockRepo = {
|
||||
...createMockRepository(),
|
||||
query: vi.fn().mockRejectedValue(new Error('db fail'))
|
||||
}
|
||||
vi.resetModules()
|
||||
const mod = await import(
|
||||
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
|
||||
)
|
||||
const freshRepo = new mod.DiscreteMaterialPlanRepository()
|
||||
|
||||
const result = await freshRepo.queryAllDistinctByMaterialCode()
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// --- queryBySourceNumbers ---
|
||||
|
||||
it('queryBySourceNumbers: returns empty array for empty input', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
|
||||
const result = await repo.queryBySourceNumbers([])
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(mockRepo.find).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queryBySourceNumbers: batches in groups of 2000', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
// Create 2500 source numbers to trigger 2 batches
|
||||
const sourceNumbers = Array.from({ length: 2500 }, (_, i) => `S${i}`)
|
||||
mockRepo.find!.mockResolvedValue([])
|
||||
|
||||
await repo.queryBySourceNumbers(sourceNumbers)
|
||||
|
||||
// Should be called twice: once for 2000, once for 500
|
||||
expect(mockRepo.find).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('queryBySourceNumbers: returns combined results', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
const records = [{ sourceNumber: 'S1' }, { sourceNumber: 'S2' }]
|
||||
mockRepo.find!.mockResolvedValue(records)
|
||||
|
||||
const result = await repo.queryBySourceNumbers(['S1', 'S2'])
|
||||
|
||||
expect(result).toEqual(records)
|
||||
})
|
||||
|
||||
it('queryBySourceNumbers: returns empty array on error', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.queryBySourceNumbers(['S1'])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// --- queryBySourceNumbersDistinct ---
|
||||
|
||||
it('queryBySourceNumbersDistinct: returns empty array for empty input', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
|
||||
const result = await repo.queryBySourceNumbersDistinct([])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('queryBySourceNumbersDistinct: calls repo.query() per batch', async () => {
|
||||
mockRepo = { ...createMockRepository(), query: vi.fn().mockResolvedValue([{ M: 'X' }]) }
|
||||
vi.resetModules()
|
||||
const mod = await import(
|
||||
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
|
||||
)
|
||||
const repo = new mod.DiscreteMaterialPlanRepository()
|
||||
|
||||
const result = await repo.queryBySourceNumbersDistinct(['S1', 'S2'])
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect((mockRepo as Record<string, unknown>).query).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queryBySourceNumbersDistinct: returns empty array on error', async () => {
|
||||
mockRepo = {
|
||||
...createMockRepository(),
|
||||
query: vi.fn().mockRejectedValue(new Error('db fail'))
|
||||
}
|
||||
vi.resetModules()
|
||||
const mod = await import(
|
||||
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
|
||||
)
|
||||
const repo = new mod.DiscreteMaterialPlanRepository()
|
||||
|
||||
const result = await repo.queryBySourceNumbersDistinct(['S1'])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// --- queryBySourceNumber ---
|
||||
|
||||
it('queryBySourceNumber: calls find with where clause', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
const records = [{ sourceNumber: 'S1', planNumber: 'P1' }]
|
||||
mockRepo.find!.mockResolvedValue(records)
|
||||
|
||||
const result = await repo.queryBySourceNumber('S1')
|
||||
|
||||
expect(result).toEqual(records)
|
||||
expect(mockRepo.find).toHaveBeenCalledWith({ where: { sourceNumber: 'S1' } })
|
||||
})
|
||||
|
||||
it('queryBySourceNumber: returns empty array on error', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.queryBySourceNumber('S1')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// --- queryByPlanNumber ---
|
||||
|
||||
it('queryByPlanNumber: calls find with where clause', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
const records = [{ sourceNumber: 'S1', planNumber: 'P1' }]
|
||||
mockRepo.find!.mockResolvedValue(records)
|
||||
|
||||
const result = await repo.queryByPlanNumber('P1')
|
||||
|
||||
expect(result).toEqual(records)
|
||||
expect(mockRepo.find).toHaveBeenCalledWith({ where: { planNumber: 'P1' } })
|
||||
})
|
||||
|
||||
it('queryByPlanNumber: returns empty array on error', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.queryByPlanNumber('P1')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// --- queryByPlanNumbers ---
|
||||
|
||||
it('queryByPlanNumbers: returns empty array for empty input', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
|
||||
const result = await repo.queryByPlanNumbers([])
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(mockRepo.find).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queryByPlanNumbers: calls find with In()', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
const records = [{ planNumber: 'P1' }, { planNumber: 'P2' }]
|
||||
mockRepo.find!.mockResolvedValue(records)
|
||||
|
||||
const result = await repo.queryByPlanNumbers(['P1', 'P2'])
|
||||
|
||||
expect(result).toEqual(records)
|
||||
expect(mockRepo.find).toHaveBeenCalledWith({
|
||||
where: { planNumber: ['P1', 'P2'] } // In() mock returns the array as-is
|
||||
})
|
||||
})
|
||||
|
||||
it('queryByPlanNumbers: returns empty array on error', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.queryByPlanNumbers(['P1'])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// --- countAll ---
|
||||
|
||||
it('countAll: returns count', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
mockRepo.count!.mockResolvedValue(99)
|
||||
|
||||
const result = await repo.countAll()
|
||||
|
||||
expect(result).toBe(99)
|
||||
})
|
||||
|
||||
it('countAll: returns 0 on error', async () => {
|
||||
const repo = new DiscreteMaterialPlanRepository()
|
||||
mockRepo.count!.mockRejectedValue(new Error('db fail'))
|
||||
|
||||
const result = await repo.countAll()
|
||||
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user