diff --git a/docs/P2_TEST_FIX_PLAN.md b/docs/P2_TEST_FIX_PLAN.md index 8b85c56..0df213b 100644 --- a/docs/P2_TEST_FIX_PLAN.md +++ b/docs/P2_TEST_FIX_PLAN.md @@ -11,20 +11,20 @@ ### 失败测试分布 -| 测试文件 | 失败数量 | 根因分类 | 预计工时 | -|---------|---------|---------|---------| -| `logger.test.ts` | 11 failures | Winston format mock + 循环依赖 | 2-3h | -| `update-service.test.ts` | 1 failure | Mock 参数不匹配 | 30min | -| `update-installer.test.ts` | 1 failure | 路径断言错误 | 15min | -| **总计** | **13 failures** | - | **~3-4h** | +| 测试文件 | 失败数量 | 根因分类 | 预计工时 | +| -------------------------- | --------------- | ------------------------------ | --------- | +| `logger.test.ts` | 11 failures | Winston format mock + 循环依赖 | 2-3h | +| `update-service.test.ts` | 1 failure | Mock 参数不匹配 | 30min | +| `update-installer.test.ts` | 1 failure | 路径断言错误 | 15min | +| **总计** | **13 failures** | - | **~3-4h** | ### 测试通过率 -| 指标 | 当前 | 修复后 | -|------|------|--------| -| 失败套件 | 3 suites | 0 suites | -| 失败测试 | 13 tests | 0 tests | -| 通过率 | 95% (311/327) | 100% (327/327) | +| 指标 | 当前 | 修复后 | +| -------- | ------------- | -------------- | +| 失败套件 | 3 suites | 0 suites | +| 失败测试 | 13 tests | 0 tests | +| 通过率 | 95% (311/327) | 100% (327/327) | --- @@ -42,6 +42,7 @@ #### 问题诊断 **失败模式**: + ``` TypeError: __vite_ssr_import_0__.default.format(...) is not a function at src/main/services/logger/index.ts:114:4 @@ -54,6 +55,7 @@ TypeError: __vite_ssr_import_0__.default.format(...) is not a function 3. **具体表现**: 第 114 行的 `winston.format()` 链式调用在 mock 环境中返回 undefined **调用栈**: + ``` logger.test.ts → imports logger.ts @@ -63,6 +65,7 @@ logger.test.ts ``` **文件位置**: + - 测试文件:`tests/unit/logger.test.ts` - 被 mock 文件:`src/main/services/logger/index.ts:100-116` - Setup mock: `tests/setup.ts` (无 winston mock 冲突) @@ -135,11 +138,13 @@ vi.mock('winston', () => ({ **方案 B: 将 logger.test.ts 转为集成测试 (2 小时)** 如果 mock 过于复杂,可以考虑: + - 使用 vi.resetModules() 确保每次测试都重新加载 - 使用 vi.mock(importOriginal) 混合真实模块 - 或完全重写测试,只测试 logger 的公共 API **预期结果**: + - ✅ 18/18 tests passing - ✅ format().combine().timestamp().printf() 链式调用正常工作 - ✅ logger 创建、子 logger、日志输出测试全部通过 @@ -165,8 +170,9 @@ vi.mock('winston', () => ({ **失败测试**: `checks updates for user and auto-downloads available recommendation` **错误信息**: + ``` -AssertionError: expected "vi.fn()" to be called with arguments: +AssertionError: expected "vi.fn()" to be called with arguments: ['stable/1.1.0.exe', 'preview/1.1.0.exe'] Number of calls: 0 @@ -175,6 +181,7 @@ Number of calls: 0 **根因**: Mock 调用参数与实际调用不匹配 **代码位置**: + - 测试文件:`tests/unit/update-service.test.ts:165-175` - 被测文件:`src/main/services/update/update-service.ts` @@ -200,12 +207,14 @@ it('checks updates for user and auto-downloads available recommendation', async **步骤 2.2.3**: 更新测试断言 **选项 A: 匹配实际调用** + ```typescript // 如果实际只调用了一个参数 expect(mockDownload).toHaveBeenCalledWith('stable/1.1.0.exe') ``` **选项 B: 使用更松散的断言** + ```typescript // 如果参数顺序或数量有变化 expect(mockDownload).toHaveBeenCalled() @@ -213,14 +222,12 @@ expect(mockDownload.mock.calls[0]).toContain('stable/1.1.0.exe') ``` **选项 C: 调整 mock 设置** + ```typescript // 确保 mock 正确设置 mockDownload.mockClear() // ... 触发动作 ... -expect(mockDownload).toHaveBeenCalledWith( - expect.stringContaining('stable'), - expect.any(String) -) +expect(mockDownload).toHaveBeenCalledWith(expect.stringContaining('stable'), expect.any(String)) ``` #### 成功标准 @@ -243,8 +250,9 @@ expect(mockDownload).toHaveBeenCalledWith( **失败测试**: `builds downloaded package path under userData pending-update` **错误信息**: + ``` -AssertionError: expected 'D:\...\test-user-data\pending-update\stable-1.2.3.exe' +AssertionError: expected 'D:\...\test-user-data\pending-update\stable-1.2.3.exe' to contain 'logs\pending-update' Expected: "logs\pending-update" @@ -254,6 +262,7 @@ Received: "D:\...\test-user-data\pending-update\stable-1.2.3.exe" **根因**: Electron mock 的 `app.getPath('userData')` 返回 `test-user-data`,但测试期望路径包含 `logs` **代码位置**: + - 测试文件:`tests/unit/update-installer.test.ts:13-16` - Setup mock: `tests/setup.ts:17-24` @@ -286,6 +295,7 @@ userData: path.join(process.cwd(), 'logs') ``` **推荐**: 方案 2.3.1 (测试适应 mock) + - 理由:mock 是为了测试隔离,测试应该适应 mock 环境 #### 成功标准 @@ -310,6 +320,7 @@ npm run test:run tests/unit/logger.test.ts ``` **失败时排查**: + 1. 检查 vi.mock 是否在文件顶部 (hoisted) 2. 清除 vitest 缓存:`npx vitest --clearCache` 3. 检查是否有多个 winston mock 冲突 @@ -354,11 +365,11 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed" ### 技术指标 -| 指标 | 修复前 | 修复后 | 验证命令 | -|------|-------|-------|---------| -| 失败套件 | 3 suites | 0 suites | `npm run test:run` | -| 失败测试 | 13 tests | 0 tests | `npm run test:run` | -| 通过率 | 95% (311/327) | **100%** (327/327) | 测试报告 | +| 指标 | 修复前 | 修复后 | 验证命令 | +| -------- | ------------- | ------------------ | ------------------ | +| 失败套件 | 3 suites | 0 suites | `npm run test:run` | +| 失败测试 | 13 tests | 0 tests | `npm run test:run` | +| 通过率 | 95% (311/327) | **100%** (327/327) | 测试报告 | ### 验收条件 @@ -373,11 +384,11 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed" ### 技术风险 -| 风险 | 可能性 | 影响 | 缓解措施 | -|------|--------|------|---------| -| logger mock 实现复杂 | 中 | 高 | 采用延迟 mock,先跑通一部分测试 | -| 链式调用 mock 不完整 | 高 | 中 | 使用 createFormatFn 工厂函数 | -| 循环依赖难解耦 | 低 | 高 | 只修复 mock,不重构依赖关系 | +| 风险 | 可能性 | 影响 | 缓解措施 | +| -------------------- | ------ | ---- | ------------------------------- | +| logger mock 实现复杂 | 中 | 高 | 采用延迟 mock,先跑通一部分测试 | +| 链式调用 mock 不完整 | 高 | 中 | 使用 createFormatFn 工厂函数 | +| 循环依赖难解耦 | 低 | 高 | 只修复 mock,不重构依赖关系 | ### 时间风险 @@ -386,6 +397,7 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed" - **保守估计**: 6 小时 (遇到意外问题) **风险缓解**: 如果 logger mock 问题超过 3 小时无法解决,考虑: + 1. 暂时跳过 logger.test.ts (保持 95% 通过率) 2. 先修复简单的 update 测试 (13 failures → 2 failures) 3. 记录问题,后续专门花精力解决 @@ -401,6 +413,7 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed" **实际工时**: X 小时 **修复步骤**: + 1. [ ] 诊断 mock 问题 2. [ ] 实现 formatFn 工厂 3. [ ] 添加所有链式方法 @@ -408,10 +421,12 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed" 5. [ ] 验证测试通过 **遇到的问题**: + - 问题 1: [描述] → 解决方案: [方案] - 问题 2: [描述] → 解决方案: [方案] **关键代码**: + ```typescript // 最终有效的 mock 实现 ``` @@ -424,7 +439,8 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed" **结束时间**: HH:MM **实际工时**: X 分钟 -**修复方式**: +**修复方式**: + - [ ] 修改断言 - [ ] 修改 mock 参数 - [ ] 其他: [描述] @@ -439,7 +455,8 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed" **结束时间**: HH:MM **实际工时**: X 分钟 -**修复方式**: +**修复方式**: + - [ ] 修改断言 - [ ] 修改 mock - [ ] 其他: [描述] diff --git a/docs/plans/2026-04-05-postgresql-integration-design.md b/docs/plans/2026-04-05-postgresql-integration-design.md index 62dd946..f49c00f 100644 --- a/docs/plans/2026-04-05-postgresql-integration-design.md +++ b/docs/plans/2026-04-05-postgresql-integration-design.md @@ -44,12 +44,10 @@ export interface SqlDialect { }): string // 分页 - paginate(p: { + paginate(p: { sql: string; limit: number; offset?: number; paramIndex: number }): { sql: string - limit: number - offset?: number paramIndex: number - }): { sql: string; paramIndex: number } + } // 批量限制 maxBatchRows(columnsPerRow: number): number @@ -60,13 +58,14 @@ export interface SqlDialect { 新建 `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` | +| 文件 | 数据库 | 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 ``` @@ -76,16 +75,19 @@ export function createDialect(type: DatabaseType): SqlDialect 每个 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` — 同上 @@ -94,6 +96,7 @@ export function createDialect(type: DatabaseType): SqlDialect ### 4. PostgreSQL 服务层 新建 `src/main/services/database/postgresql.ts`: + - 使用 `pg` 驱动,`Pool` 连接池 - 实现 `IDatabaseService` 接口 - `query()` 直接传递参数数组给 `pg` @@ -113,22 +116,22 @@ export function createDialect(type: DatabaseType): SqlDialect ## 改动范围 -| 层 | 文件 | 动作 | -|---|---|---| -| 类型 | `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` | 修改 | +| 层 | 文件 | 动作 | +| ------- | ---------------------------------------------- | ---- | +| 类型 | `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 个修改文件**。 diff --git a/docs/plans/2026-04-05-postgresql-integration-plan.md b/docs/plans/2026-04-05-postgresql-integration-plan.md index 7da5165..8593052 100644 --- a/docs/plans/2026-04-05-postgresql-integration-plan.md +++ b/docs/plans/2026-04-05-postgresql-integration-plan.md @@ -13,6 +13,7 @@ ## Task 1: SqlDialect Interface & Type Definitions **Files:** + - Create: `src/main/types/sql-dialect.types.ts` - Modify: `src/main/types/database.types.ts:11` (extend `DatabaseType`) - Modify: `src/main/types/config.schema.ts:15` (extend Zod enum) @@ -20,11 +21,13 @@ **Step 1: Add `'postgresql'` to `DatabaseType`** In `src/main/types/database.types.ts:11`, change: + ```typescript export type DatabaseType = 'mysql' | 'sqlserver' | 'postgresql' ``` Add after line 104 (after `SqlServerConfig`): + ```typescript /** * PostgreSQL-specific configuration @@ -44,11 +47,13 @@ export interface PostgreSqlConfig extends DatabaseConfig { In `src/main/types/config.schema.ts`: Change line 15: + ```typescript export const databaseTypeSchema = z.enum(['mysql', 'sqlserver', 'postgresql']) ``` Add after `sqlServerConfigSchema` (after line 58): + ```typescript /** * PostgreSQL 配置 Schema @@ -65,6 +70,7 @@ export type PostgreSqlConfig = z.infer ``` Change `databaseConfigSchema` (line 63): + ```typescript export const databaseConfigSchema = z.object({ activeType: databaseTypeSchema.default('mysql'), @@ -77,6 +83,7 @@ export const databaseConfigSchema = z.object({ **Step 3: Create `SqlDialect` interface** Create `src/main/types/sql-dialect.types.ts`: + ```typescript /** * SQL Dialect Abstraction @@ -152,12 +159,10 @@ export interface SqlDialect { * * @returns Object with modified sql and next param index */ - paginate(params: { + paginate(params: { sql: string; limit: number; offset?: number; paramIndex: number }): { sql: string - limit: number - offset?: number - paramIndex: number - }): { sql: string; nextParamIndex: number } + nextParamIndex: number + } /** * Maximum rows per batch given columns per row @@ -185,6 +190,7 @@ git commit -m "feat(db): add SqlDialect interface and PostgreSQL type definition ## Task 2: Implement Three Dialect Classes **Files:** + - Create: `src/main/services/database/dialects/mysql-dialect.ts` - Create: `src/main/services/database/dialects/sqlserver-dialect.ts` - Create: `src/main/services/database/dialects/postgresql-dialect.ts` @@ -196,6 +202,7 @@ git commit -m "feat(db): add SqlDialect interface and PostgreSQL type definition **Step 1: Write tests for all three dialects** Create `tests/unit/dialects/mysql-dialect.test.ts`: + ```typescript import { describe, it, expect } from 'vitest' import { MySqlDialect } from '@main/services/database/dialects/mysql-dialect' @@ -255,6 +262,7 @@ describe('MySqlDialect', () => { ``` Create `tests/unit/dialects/sqlserver-dialect.test.ts`: + ```typescript import { describe, it, expect } from 'vitest' import { SqlServerDialect } from '@main/services/database/dialects/sqlserver-dialect' @@ -327,6 +335,7 @@ describe('SqlServerDialect', () => { ``` Create `tests/unit/dialects/postgresql-dialect.test.ts`: + ```typescript import { describe, it, expect } from 'vitest' import { PostgreSqlDialect } from '@main/services/database/dialects/postgresql-dialect' @@ -393,6 +402,7 @@ Expected: FAIL — modules not found **Step 3: Implement MySqlDialect** Create `src/main/services/database/dialects/mysql-dialect.ts`: + ```typescript import type { SqlDialect } from '../../../types/sql-dialect.types' import type { DatabaseType } from '../../../types/database.types' @@ -437,12 +447,10 @@ export class MySqlDialect implements SqlDialect { return { sql, nextParamIndex: p.startParamIndex + p.allColumns.length } } - paginate(p: { + paginate(p: { sql: string; limit: number; offset?: number; paramIndex: number }): { sql: string - limit: number - offset?: number - paramIndex: number - }): { sql: string; nextParamIndex: number } { + nextParamIndex: number + } { let sql = p.sql if (p.offset !== undefined) { sql += ` LIMIT ${p.limit} OFFSET ${p.offset}` @@ -461,6 +469,7 @@ export class MySqlDialect implements SqlDialect { **Step 4: Implement SqlServerDialect** Create `src/main/services/database/dialects/sqlserver-dialect.ts`: + ```typescript import type { SqlDialect } from '../../../types/sql-dialect.types' import type { DatabaseType } from '../../../types/database.types' @@ -490,21 +499,15 @@ export class SqlServerDialect implements SqlDialect { allColumns: string[] startParamIndex: number }): { sql: string; nextParamIndex: number } { - const sourceValues = p.allColumns - .map((_, i) => `@p${p.startParamIndex + i}`) - .join(', ') + const sourceValues = p.allColumns.map((_, i) => `@p${p.startParamIndex + i}`).join(', ') const sourceColumns = p.allColumns.join(', ') - const keyMatch = p.keyColumns - .map((col) => `target.${col} = source.${col}`) - .join(' AND ') + const keyMatch = p.keyColumns.map((col) => `target.${col} = source.${col}`).join(' AND ') const updateSet = p.allColumns .filter((col) => !p.keyColumns.includes(col)) .map((col) => `${col} = source.${col}`) .join(', ') const insertColumns = p.allColumns.join(', ') - const insertValues = p.allColumns - .map((col) => `source.${col}`) - .join(', ') + const insertValues = p.allColumns.map((col) => `source.${col}`).join(', ') const sql = ` MERGE ${p.table} AS target @@ -516,12 +519,10 @@ export class SqlServerDialect implements SqlDialect { return { sql, nextParamIndex: p.startParamIndex + p.allColumns.length } } - paginate(p: { + paginate(p: { sql: string; limit: number; offset?: number; paramIndex: number }): { sql: string - limit: number - offset?: number - paramIndex: number - }): { sql: string; nextParamIndex: number } { + nextParamIndex: number + } { let sql = p.sql let nextIndex = p.paramIndex @@ -547,6 +548,7 @@ export class SqlServerDialect implements SqlDialect { **Step 5: Implement PostgreSqlDialect** Create `src/main/services/database/dialects/postgresql-dialect.ts`: + ```typescript import type { SqlDialect } from '../../../types/sql-dialect.types' import type { DatabaseType } from '../../../types/database.types' @@ -577,9 +579,7 @@ export class PostgreSqlDialect implements SqlDialect { allColumns: string[] startParamIndex: number }): { sql: string; nextParamIndex: number } { - const placeholders = p.allColumns - .map((_, i) => `$${p.startParamIndex + i + 1}`) - .join(', ') + const placeholders = p.allColumns.map((_, i) => `$${p.startParamIndex + i + 1}`).join(', ') const columns = p.allColumns.join(', ') const conflictKeys = p.keyColumns.map((k) => `"${k}"`).join(', ') const updateSet = p.allColumns @@ -595,12 +595,10 @@ export class PostgreSqlDialect implements SqlDialect { return { sql, nextParamIndex: p.startParamIndex + p.allColumns.length } } - paginate(p: { + paginate(p: { sql: string; limit: number; offset?: number; paramIndex: number }): { sql: string - limit: number - offset?: number - paramIndex: number - }): { sql: string; nextParamIndex: number } { + nextParamIndex: number + } { let sql = p.sql if (p.offset !== undefined) { sql += ` LIMIT ${p.limit} OFFSET ${p.offset}` @@ -619,6 +617,7 @@ export class PostgreSqlDialect implements SqlDialect { **Step 6: Create dialect factory index** Create `src/main/services/database/dialects/index.ts`: + ```typescript export { MySqlDialect } from './mysql-dialect' export { SqlServerDialect } from './sqlserver-dialect' @@ -659,12 +658,14 @@ git commit -m "feat(db): implement SqlDialect abstraction with MySQL, SQL Server ## Task 3: PostgreSqlService Implementation **Files:** + - Create: `src/main/services/database/postgresql.ts` - Test: `tests/unit/postgresql.test.ts` **Step 1: Write tests** Create `tests/unit/postgresql.test.ts`: + ```typescript import { describe, it, expect, beforeEach } from 'vitest' import { PostgreSqlService } from '@main/services/database/postgresql' @@ -743,6 +744,7 @@ Run: `npm install pg && npm install -D @types/pg` **Step 4: Implement PostgreSqlService** Create `src/main/services/database/postgresql.ts`: + ```typescript import { Pool } from 'pg' import type { @@ -902,6 +904,7 @@ git commit -m "feat(db): add PostgreSqlService with pg driver" ## Task 4: Refactor DiscreteMaterialPlanDAO **Files:** + - Modify: `src/main/services/database/discrete-material-plan-dao.ts` - Modify: `src/main/services/database/materials-to-be-deleted-dao.ts` - Modify: `src/main/services/database/materials-type-to-be-deleted-dao.ts` @@ -914,11 +917,13 @@ This is the largest task. Each DAO follows the same pattern — replace `isSqlSe **Step 1: Add dialect import and member** At top of file, add import: + ```typescript import { createDialect, type SqlDialect } from './dialects' ``` In class body, add: + ```typescript private dialect: SqlDialect | null = null @@ -933,6 +938,7 @@ private getDialect(): SqlDialect { **Step 2: Remove `getTableName()` and `buildPlaceholders()` methods** Replace with: + ```typescript private getTableName(): string { return this.getDialect().quoteTableName('dbo', 'DiscreteMaterialPlanData') @@ -944,11 +950,14 @@ Delete `buildPlaceholders()` entirely — replaced by `dialect.params()`. **Step 3: Replace all `isSqlServer` local variables** Replace patterns like: + ```typescript const isSqlServer = dbService.type === 'sqlserver' const placeholder = isSqlServer ? '@p0' : '?' ``` + With: + ```typescript const dialect = this.getDialect() const placeholder = dialect.param(0) @@ -961,6 +970,7 @@ Replace `isSqlServer ? '@p0' : '?'` single placeholders with `dialect.param(0)`. **Step 4: Refactor `buildRowValues()`** Change from: + ```typescript if (isSqlServer) { return `@p${values.length - 1}` @@ -968,7 +978,9 @@ if (isSqlServer) { return '?' } ``` + To: + ```typescript return this.getDialect().param(values.length - 1) ``` @@ -976,13 +988,16 @@ return this.getDialect().param(values.length - 1) **Step 5: Refactor `batchInsert()` batch size** Change from: + ```typescript const isSqlServer = dbService.type === 'sqlserver' const effectiveBatchSize = isSqlServer ? Math.min(batchSize, Math.floor(sqlServerMaxParams / columnsPerRow)) : batchSize ``` + To: + ```typescript const dialect = this.getDialect() const effectiveBatchSize = Math.min(batchSize, dialect.maxBatchRows(columnsPerRow)) @@ -995,6 +1010,7 @@ Same pattern as above, plus refactor UPSERT methods: **Step 6: Replace MERGE/ON DUPLICATE KEY with `dialect.upsert()`** In `upsertMaterial()`, replace the entire if/else block: + ```typescript if (isSqlServer) { // MERGE ... @@ -1002,7 +1018,9 @@ if (isSqlServer) { // ON DUPLICATE KEY UPDATE ... } ``` + With: + ```typescript const dialect = this.getDialect() const { sql: sqlString } = dialect.upsert({ @@ -1034,6 +1052,7 @@ Additionally, refactor `updateMaterial()` — the entire if/else block that dupl Plus specific changes: In `insertBatchRecords()`, replace the if/else with: + ```typescript const dialect = this.getDialect() const sqlString = ` @@ -1045,6 +1064,7 @@ const sqlString = ` ``` In `getBatches()`, replace pagination logic with: + ```typescript if (options?.limit) { const result = dialect.paginate({ @@ -1075,6 +1095,7 @@ git commit -m "refactor(db): replace isSqlServer checks with SqlDialect abstract ## Task 5: Database Factory & Config Integration **Files:** + - Modify: `src/main/services/database/index.ts` - Modify: `src/main/services/config/config-manager.ts:47-69,302-308` - Modify: `src/main/services/database/data-source.ts` @@ -1085,12 +1106,14 @@ git commit -m "refactor(db): replace isSqlServer checks with SqlDialect abstract In `src/main/services/database/index.ts`: Add imports: + ```typescript import { PostgreSqlService } from './postgresql' import type { PostgreSqlConfig } from '../../types/database.types' ``` Add config factory: + ```typescript export function createPostgreSqlConfig(): PostgreSqlConfig { const configManager = ConfigManager.getInstance() @@ -1107,6 +1130,7 @@ export function createPostgreSqlConfig(): PostgreSqlConfig { ``` Update `create()` function (line 89): + ```typescript if (dbType === 'postgresql') { log.info('Creating PostgreSQL database service') @@ -1121,6 +1145,7 @@ if (dbType === 'postgresql') { ``` Add to re-exports: + ```typescript export { PostgreSqlService } from './postgresql' ``` @@ -1128,6 +1153,7 @@ export { PostgreSqlService } from './postgresql' **Step 2: Update ConfigManager defaults** In `src/main/services/config/config-manager.ts`, add to `DEFAULT_CONFIG.database` (after line 69): + ```typescript postgresql: { host: 'localhost', @@ -1140,6 +1166,7 @@ postgresql: { ``` Update `getActiveDatabaseConfig()` (line 302-308): + ```typescript public getActiveDatabaseConfig(): MySqlConfig | SqlServerConfig | PostgreSqlConfig { const { activeType, mysql, sqlserver, postgresql } = this.config.database @@ -1156,19 +1183,24 @@ public getActiveDatabaseConfig(): MySqlConfig | SqlServerConfig | PostgreSqlConf In `src/main/services/database/data-source.ts`: Update `getDatabaseType()`: + ```typescript function getDatabaseType(): 'mysql' | 'mssql' | 'postgres' { const configManager = ConfigManager.getInstance() const dbType = configManager.getDatabaseType() switch (dbType) { - case 'sqlserver': return 'mssql' - case 'postgresql': return 'postgres' - default: return 'mysql' + case 'sqlserver': + return 'mssql' + case 'postgresql': + return 'postgres' + default: + return 'mysql' } } ``` Add postgres branch in `buildDataSourceOptions()`: + ```typescript if (type === 'mssql') { // ... existing mssql config @@ -1191,17 +1223,19 @@ if (type === 'mssql') { **Step 4: Update config template** In `config.template.yaml`, add after sqlserver section: + ```yaml - postgresql: - host: - port: 5432 - database: - username: - password: - maxPoolSize: 10 +postgresql: + host: + port: 5432 + database: + username: + password: + maxPoolSize: 10 ``` Update header comment to mention postgresql: + ```yaml # 3. 设置 database.activeType 为 mysql、sqlserver 或 postgresql ``` @@ -1228,6 +1262,7 @@ git commit -m "feat(db): integrate PostgreSQL into factory, config, and TypeORM ## Task 6: Verification & Smoke Test **Files:** + - Test: Manual integration verification **Step 1: Run full typecheck** @@ -1252,6 +1287,7 @@ Run: `npm run format` **Step 5: Verify config.yaml can be parsed with postgresql section** Create a temporary test that validates the config schema accepts postgresql: + ```typescript // In a scratch test file import { validateConfig } from '../../src/main/types/config.schema' @@ -1262,7 +1298,13 @@ const config = { activeType: 'postgresql', mysql: { host: 'localhost', port: 3306, database: 'test', username: 'root', password: '' }, sqlserver: { server: 'localhost', port: 1433, database: 'test', username: 'sa', password: '' }, - postgresql: { host: '192.168.31.83', port: 5432, database: 'CompanyDB', username: 'admin', password: 'test' } + postgresql: { + host: '192.168.31.83', + port: 5432, + database: 'CompanyDB', + username: 'admin', + password: 'test' + } }, paths: { dataDir: './data/' }, extraction: {}, @@ -1287,13 +1329,13 @@ git commit -m "chore: format and verify PostgreSQL integration" ## Summary -| Task | Description | New Files | Modified Files | -|------|-------------|-----------|---------------| -| 1 | Types & SqlDialect interface | 1 | 2 | -| 2 | Three dialect implementations + tests | 4 + 3 tests | 0 | -| 3 | PostgreSqlService + test | 1 + 1 test | 1 (package.json) | -| 4 | Refactor all 4 DAOs | 0 | 4 | -| 5 | Factory, config, TypeORM integration | 0 | 4 | -| 6 | Verification | 0 | 0 | +| Task | Description | New Files | Modified Files | +| ---- | ------------------------------------- | ----------- | ---------------- | +| 1 | Types & SqlDialect interface | 1 | 2 | +| 2 | Three dialect implementations + tests | 4 + 3 tests | 0 | +| 3 | PostgreSqlService + test | 1 + 1 test | 1 (package.json) | +| 4 | Refactor all 4 DAOs | 0 | 4 | +| 5 | Factory, config, TypeORM integration | 0 | 4 | +| 6 | Verification | 0 | 0 | **Total:** 6 tasks, ~14 files touched (9 new, 10 modified), 5 commits diff --git a/src/main/bootstrap/process-guards.ts b/src/main/bootstrap/process-guards.ts index 2ece49f..da0a7da 100644 --- a/src/main/bootstrap/process-guards.ts +++ b/src/main/bootstrap/process-guards.ts @@ -18,9 +18,7 @@ export function setupProcessGuards(): void { process.on('unhandledRejection', (reason) => { const errorMeta = - reason instanceof Error - ? { error: serializeError(reason) } - : { reason: String(reason) } + reason instanceof Error ? { error: serializeError(reason) } : { reason: String(reason) } logger.error('Unhandled Rejection', errorMeta) logAudit('SYSTEM_ERROR', 'system', { username: 'system', diff --git a/src/main/types/sql-dialect.types.ts b/src/main/types/sql-dialect.types.ts index 155f043..3a3f077 100644 --- a/src/main/types/sql-dialect.types.ts +++ b/src/main/types/sql-dialect.types.ts @@ -64,12 +64,10 @@ export interface SqlDialect { * MySQL/PostgreSQL: LIMIT x OFFSET y * SQL Server: OFFSET x ROWS FETCH NEXT y ROWS ONLY */ - paginate(params: { + paginate(params: { sql: string; limit: number; offset?: number; paramIndex: number }): { sql: string - limit: number - offset?: number - paramIndex: number - }): { sql: string; nextParamIndex: number } + nextParamIndex: number + } /** * Maximum rows per batch given columns per row diff --git a/tests/integration/cleaner.test.ts b/tests/integration/cleaner.test.ts index b8e40af..fcda225 100644 --- a/tests/integration/cleaner.test.ts +++ b/tests/integration/cleaner.test.ts @@ -21,16 +21,20 @@ describe('Cleaner Service (Integration)', () => { const hasCredentials = !!(config.url && config.username && config.password) describe('Dry-run mode', () => { - it.skipIf(!hasCredentials)('should initialize with dry-run mode', async () => { - const authService = new ErpAuthService(config) - await authService.login() + it.skipIf(!hasCredentials)( + 'should initialize with dry-run mode', + async () => { + const authService = new ErpAuthService(config) + await authService.login() - const cleaner = new CleanerService(authService, { dryRun: true }) + const cleaner = new CleanerService(authService, { dryRun: true }) - expect(cleaner.isDryRun()).toBe(true) + expect(cleaner.isDryRun()).toBe(true) - await authService.close() - }, 30000) + await authService.close() + }, + 30000 + ) it.skipIf(!hasCredentials)( 'should track materials to delete without actually deleting (dry-run)', @@ -83,115 +87,131 @@ describe('Cleaner Service (Integration)', () => { }) describe('Order processing', () => { - it.skipIf(!hasCredentials)('should process single order and return details', async () => { - const authService = new ErpAuthService(config) - await authService.login() + it.skipIf(!hasCredentials)( + 'should process single order and return details', + async () => { + const authService = new ErpAuthService(config) + await authService.login() - const orderContent = await fs.readFile(productionIdFile, 'utf-8') - const orderNumbers = orderContent - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .slice(0, 1) // Test single order + const orderContent = await fs.readFile(productionIdFile, 'utf-8') + const orderNumbers = orderContent + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .slice(0, 1) // Test single order - const cleaner = new CleanerService(authService, { dryRun: true }) + const cleaner = new CleanerService(authService, { dryRun: true }) - const result = await cleaner.clean({ - orderNumbers, - materialCodes: [], // Empty list - nothing to delete - dryRun: true - }) + const result = await cleaner.clean({ + orderNumbers, + materialCodes: [], // Empty list - nothing to delete + dryRun: true + }) - expect(result.ordersProcessed).toBe(1) - expect(result.details).toHaveLength(1) - expect(result.details[0].orderNumber).toBe(orderNumbers[0]) + expect(result.ordersProcessed).toBe(1) + expect(result.details).toHaveLength(1) + expect(result.details[0].orderNumber).toBe(orderNumbers[0]) - await authService.close() - }, 60000) + await authService.close() + }, + 60000 + ) - it.skipIf(!hasCredentials)('should handle order with "审批通过" status', async () => { - const authService = new ErpAuthService(config) - await authService.login() + it.skipIf(!hasCredentials)( + 'should handle order with "审批通过" status', + async () => { + const authService = new ErpAuthService(config) + await authService.login() - const orderContent = await fs.readFile(productionIdFile, 'utf-8') - const orderNumbers = orderContent - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .slice(0, 1) + const orderContent = await fs.readFile(productionIdFile, 'utf-8') + const orderNumbers = orderContent + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .slice(0, 1) - const cleaner = new CleanerService(authService, { dryRun: true }) + const cleaner = new CleanerService(authService, { dryRun: true }) - const result = await cleaner.clean({ - orderNumbers, - materialCodes: [], - dryRun: true - }) + const result = await cleaner.clean({ + orderNumbers, + materialCodes: [], + dryRun: true + }) - // Order details should include status information - const detail = result.details[0] - console.log( - `Order ${detail.orderNumber} - Materials deleted: ${detail.materialsDeleted}, Skipped: ${detail.materialsSkipped}` - ) + // Order details should include status information + const detail = result.details[0] + console.log( + `Order ${detail.orderNumber} - Materials deleted: ${detail.materialsDeleted}, Skipped: ${detail.materialsSkipped}` + ) - expect(detail).toBeDefined() + expect(detail).toBeDefined() - await authService.close() - }, 60000) + await authService.close() + }, + 60000 + ) - it.skipIf(!hasCredentials)('should handle multiple orders with progress callback', async () => { - const authService = new ErpAuthService(config) - await authService.login() + it.skipIf(!hasCredentials)( + 'should handle multiple orders with progress callback', + async () => { + const authService = new ErpAuthService(config) + await authService.login() - const orderContent = await fs.readFile(productionIdFile, 'utf-8') - const orderNumbers = orderContent - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .slice(0, 3) // Test 3 orders + const orderContent = await fs.readFile(productionIdFile, 'utf-8') + const orderNumbers = orderContent + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .slice(0, 3) // Test 3 orders - const progressMessages: string[] = [] + const progressMessages: string[] = [] - const cleaner = new CleanerService(authService, { dryRun: true }) + const cleaner = new CleanerService(authService, { dryRun: true }) - const result = await cleaner.clean({ - orderNumbers, - materialCodes: [], - dryRun: true, - onProgress: (message, progress) => { - progressMessages.push(`${progress?.toFixed(0)}%: ${message}`) - } - }) + const result = await cleaner.clean({ + orderNumbers, + materialCodes: [], + dryRun: true, + onProgress: (message, progress) => { + progressMessages.push(`${progress?.toFixed(0)}%: ${message}`) + } + }) - expect(result.ordersProcessed).toBe(3) - expect(progressMessages.length).toBeGreaterThan(0) + expect(result.ordersProcessed).toBe(3) + expect(progressMessages.length).toBeGreaterThan(0) - console.log('Progress messages:', progressMessages.slice(0, 5)) + console.log('Progress messages:', progressMessages.slice(0, 5)) - await authService.close() - }, 180000) + await authService.close() + }, + 180000 + ) }) describe('Error handling', () => { - it.skipIf(!hasCredentials)('should continue processing after order error', async () => { - const authService = new ErpAuthService(config) - await authService.login() + it.skipIf(!hasCredentials)( + 'should continue processing after order error', + async () => { + const authService = new ErpAuthService(config) + await authService.login() - const orderNumbers = ['INVALID_ORDER_12345', 'INVALID_ORDER_67890'] + const orderNumbers = ['INVALID_ORDER_12345', 'INVALID_ORDER_67890'] - const cleaner = new CleanerService(authService, { dryRun: true }) + const cleaner = new CleanerService(authService, { dryRun: true }) - const result = await cleaner.clean({ - orderNumbers, - materialCodes: [], - dryRun: true - }) + const result = await cleaner.clean({ + orderNumbers, + materialCodes: [], + dryRun: true + }) - // Should still process (even if with errors) - expect(result.details.length).toBeGreaterThan(0) + // Should still process (even if with errors) + expect(result.details.length).toBeGreaterThan(0) - await authService.close() - }, 120000) + await authService.close() + }, + 120000 + ) }) describe('Navigation', () => { diff --git a/tests/integration/erp-auth.test.ts b/tests/integration/erp-auth.test.ts index bc6b71e..32ce0e7 100644 --- a/tests/integration/erp-auth.test.ts +++ b/tests/integration/erp-auth.test.ts @@ -19,22 +19,30 @@ describe('ERP Authentication Service (Integration)', () => { authService = new ErpAuthService(config) }) - it.skipIf(!hasCredentials)('should login successfully', async () => { - const session = await authService.login() + it.skipIf(!hasCredentials)( + 'should login successfully', + async () => { + const session = await authService.login() - expect(session).toBeDefined() - expect(session.browser).toBeDefined() - expect(session.context).toBeDefined() - expect(session.page).toBeDefined() - expect(session.isLoggedIn).toBe(true) - }, 30000) + expect(session).toBeDefined() + expect(session.browser).toBeDefined() + expect(session.context).toBeDefined() + expect(session.page).toBeDefined() + expect(session.isLoggedIn).toBe(true) + }, + 30000 + ) - it.skipIf(!hasCredentials)('should navigate to main page after login', async () => { - const session = await authService.login() + it.skipIf(!hasCredentials)( + 'should navigate to main page after login', + async () => { + const session = await authService.login() - const url = session.page.url() - expect(url).toContain(config.url) - }, 30000) + const url = session.page.url() + expect(url).toContain(config.url) + }, + 30000 + ) afterAll(async () => { if (hasCredentials && authService) { diff --git a/tests/integration/extractor.test.ts b/tests/integration/extractor.test.ts index f1dbf16..286d17d 100644 --- a/tests/integration/extractor.test.ts +++ b/tests/integration/extractor.test.ts @@ -18,117 +18,129 @@ describe('Extractor Service (Integration)', () => { // Check if we have ERP credentials const hasCredentials = !!(config.url && config.username && config.password) - it.skipIf(!hasCredentials)('should extract data for single order number', async () => { - // Create fresh auth service for this test - const authService = new ErpAuthService(config) - await authService.login() + it.skipIf(!hasCredentials)( + 'should extract data for single order number', + async () => { + // Create fresh auth service for this test + const authService = new ErpAuthService(config) + await authService.login() - const extractor = new ExtractorService(authService) + const extractor = new ExtractorService(authService) - const result = await extractor.extract({ - orderNumbers: [testOrderNumber] - }) + const result = await extractor.extract({ + orderNumbers: [testOrderNumber] + }) - expect(result.downloadedFiles).toHaveLength(1) - expect(result.errors).toHaveLength(0) + expect(result.downloadedFiles).toHaveLength(1) + expect(result.errors).toHaveLength(0) - // Verify file exists - const filePath = result.downloadedFiles[0] - const stats = await fs.stat(filePath) - expect(stats.size).toBeGreaterThan(0) - - // Clean up - await authService.close() - }, 60000) - - it.skipIf(!hasCredentials)('should extract data for multiple order numbers', async () => { - // Create fresh auth service for this test - const authService = new ErpAuthService(config) - await authService.login() - - const extractor = new ExtractorService(authService) - - // Read order numbers from productionID.txt file - const productionIdFile = path.join(process.cwd(), '../references/demo/productionID.txt') - const content = await fs.readFile(productionIdFile, 'utf-8') - const orderNumbers = content - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .slice(0, 5) // Test first 5 orders - - console.log(`Testing with ${orderNumbers.length} order numbers:`, orderNumbers) - - const result = await extractor.extract({ - orderNumbers, - batchSize: 100 // Process all in one batch - }) - - console.log(`Downloaded ${result.downloadedFiles.length} files`) - if (result.errors.length > 0) { - console.log('Errors:', result.errors) - } - - expect(result.downloadedFiles.length).toBeGreaterThanOrEqual(1) - - // Clean up - await authService.close() - }, 120000) // Increase timeout to 2 minutes - - it.skipIf(!hasCredentials)('should extract data for 300 orders with batch size 70', async () => { - // Create fresh auth service for this test - const authService = new ErpAuthService(config) - await authService.login() - - const extractor = new ExtractorService(authService) - - // Read all order numbers from productionID.txt file - const productionIdFile = path.join(process.cwd(), '../references/demo/productionID.txt') - const content = await fs.readFile(productionIdFile, 'utf-8') - const orderNumbers = content - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - - console.log(`Testing with ${orderNumbers.length} order numbers`) - console.log(`Batch size: 70, Expected batches: ${Math.ceil(orderNumbers.length / 70)}`) - - const startTime = Date.now() - - const result = await extractor.extract({ - orderNumbers, - batchSize: 70 // Process 70 orders per batch - }) - - const endTime = Date.now() - const duration = ((endTime - startTime) / 1000).toFixed(2) - - console.log(`\n=== Extraction Summary ===`) - console.log(`Total orders: ${orderNumbers.length}`) - console.log(`Batch size: 70`) - console.log(`Expected batches: ${Math.ceil(orderNumbers.length / 70)}`) - console.log(`Downloaded files: ${result.downloadedFiles.length}`) - console.log(`Total duration: ${duration}s`) - console.log( - `Average time per batch: ${(duration / result.downloadedFiles.length).toFixed(2)}s` - ) - - if (result.errors.length > 0) { - console.log(`\nErrors encountered: ${result.errors.length}`) - result.errors.forEach((err, idx) => console.log(` ${idx + 1}. ${err}`)) - } - - // Verify results - expect(result.downloadedFiles.length).toBeGreaterThanOrEqual(1) - - // Verify each downloaded file exists and has content - for (const filePath of result.downloadedFiles) { + // Verify file exists + const filePath = result.downloadedFiles[0] const stats = await fs.stat(filePath) - console.log(` - ${path.basename(filePath)}: ${(stats.size / 1024).toFixed(2)} KB`) expect(stats.size).toBeGreaterThan(0) - } - // Clean up - await authService.close() - }, 600000) // 10 minutes timeout for large batch test + // Clean up + await authService.close() + }, + 60000 + ) + + it.skipIf(!hasCredentials)( + 'should extract data for multiple order numbers', + async () => { + // Create fresh auth service for this test + const authService = new ErpAuthService(config) + await authService.login() + + const extractor = new ExtractorService(authService) + + // Read order numbers from productionID.txt file + const productionIdFile = path.join(process.cwd(), '../references/demo/productionID.txt') + const content = await fs.readFile(productionIdFile, 'utf-8') + const orderNumbers = content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .slice(0, 5) // Test first 5 orders + + console.log(`Testing with ${orderNumbers.length} order numbers:`, orderNumbers) + + const result = await extractor.extract({ + orderNumbers, + batchSize: 100 // Process all in one batch + }) + + console.log(`Downloaded ${result.downloadedFiles.length} files`) + if (result.errors.length > 0) { + console.log('Errors:', result.errors) + } + + expect(result.downloadedFiles.length).toBeGreaterThanOrEqual(1) + + // Clean up + await authService.close() + }, + 120000 + ) // Increase timeout to 2 minutes + + it.skipIf(!hasCredentials)( + 'should extract data for 300 orders with batch size 70', + async () => { + // Create fresh auth service for this test + const authService = new ErpAuthService(config) + await authService.login() + + const extractor = new ExtractorService(authService) + + // Read all order numbers from productionID.txt file + const productionIdFile = path.join(process.cwd(), '../references/demo/productionID.txt') + const content = await fs.readFile(productionIdFile, 'utf-8') + const orderNumbers = content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + + console.log(`Testing with ${orderNumbers.length} order numbers`) + console.log(`Batch size: 70, Expected batches: ${Math.ceil(orderNumbers.length / 70)}`) + + const startTime = Date.now() + + const result = await extractor.extract({ + orderNumbers, + batchSize: 70 // Process 70 orders per batch + }) + + const endTime = Date.now() + const duration = ((endTime - startTime) / 1000).toFixed(2) + + console.log(`\n=== Extraction Summary ===`) + console.log(`Total orders: ${orderNumbers.length}`) + console.log(`Batch size: 70`) + console.log(`Expected batches: ${Math.ceil(orderNumbers.length / 70)}`) + console.log(`Downloaded files: ${result.downloadedFiles.length}`) + console.log(`Total duration: ${duration}s`) + console.log( + `Average time per batch: ${(duration / result.downloadedFiles.length).toFixed(2)}s` + ) + + if (result.errors.length > 0) { + console.log(`\nErrors encountered: ${result.errors.length}`) + result.errors.forEach((err, idx) => console.log(` ${idx + 1}. ${err}`)) + } + + // Verify results + expect(result.downloadedFiles.length).toBeGreaterThanOrEqual(1) + + // Verify each downloaded file exists and has content + for (const filePath of result.downloadedFiles) { + const stats = await fs.stat(filePath) + console.log(` - ${path.basename(filePath)}: ${(stats.size / 1024).toFixed(2)} KB`) + expect(stats.size).toBeGreaterThan(0) + } + + // Clean up + await authService.close() + }, + 600000 + ) // 10 minutes timeout for large batch test }) diff --git a/tests/mocks/index.ts b/tests/mocks/index.ts index ccde84f..7b6bb33 100644 --- a/tests/mocks/index.ts +++ b/tests/mocks/index.ts @@ -689,11 +689,12 @@ export function createMockRepository(options?: { return { find: vi.fn().mockResolvedValue(mockFindResult), findOne: vi.fn().mockResolvedValue(mockFindResult[0] ?? null), - create: vi.fn((plainObject?: Record) => plainObject ?? ({})), + create: vi.fn((plainObject?: Record) => plainObject ?? {}), save: vi.fn().mockImplementation((entity: Record) => Promise.resolve(entity)), delete: vi.fn().mockResolvedValue({ affected: 1 }), count: vi.fn().mockResolvedValue(mockFindResult.length), - createQueryBuilder: vi .fn() + createQueryBuilder: vi + .fn() .mockImplementation(() => createMockQueryBuilder({ result: mockFindResult })) } } @@ -724,9 +725,12 @@ export function createMockDataSource( destroy: vi.fn().mockResolvedValue(undefined), isInitialized, getRepository: vi.fn().mockReturnValue(mockRepo), - create: vi.fn().mockImplementation( - (_entityClass: unknown, plainObject?: Record) => plainObject ?? ({} as Record) - ), + create: vi + .fn() + .mockImplementation( + (_entityClass: unknown, plainObject?: Record) => + plainObject ?? ({} as Record) + ), save: vi.fn().mockImplementation((entity: Record) => Promise.resolve(entity)), createQueryBuilder: vi .fn() diff --git a/tests/unit/dialects/mysql-dialect.test.ts b/tests/unit/dialects/mysql-dialect.test.ts index 7023873..5106c52 100644 --- a/tests/unit/dialects/mysql-dialect.test.ts +++ b/tests/unit/dialects/mysql-dialect.test.ts @@ -20,9 +20,7 @@ describe('MySqlDialect', () => { }) it('should handle arbitrary schema and table names', () => { - expect(dialect.quoteTableName('my_schema', 'my_table')).toBe( - 'my_schema_my_table' - ) + expect(dialect.quoteTableName('my_schema', 'my_table')).toBe('my_schema_my_table') }) }) diff --git a/tests/unit/dialects/postgresql-dialect.test.ts b/tests/unit/dialects/postgresql-dialect.test.ts index 648de2b..e2f58e1 100644 --- a/tests/unit/dialects/postgresql-dialect.test.ts +++ b/tests/unit/dialects/postgresql-dialect.test.ts @@ -20,9 +20,7 @@ describe('PostgreSqlDialect', () => { }) it('should handle arbitrary names', () => { - expect(dialect.quoteTableName('my_schema', 'my_table')).toBe( - '"my_schema"."my_table"' - ) + expect(dialect.quoteTableName('my_schema', 'my_table')).toBe('"my_schema"."my_table"') }) }) diff --git a/tests/unit/dialects/sqlserver-dialect.test.ts b/tests/unit/dialects/sqlserver-dialect.test.ts index a88e511..21ad7e1 100644 --- a/tests/unit/dialects/sqlserver-dialect.test.ts +++ b/tests/unit/dialects/sqlserver-dialect.test.ts @@ -20,9 +20,7 @@ describe('SqlServerDialect', () => { }) it('should handle arbitrary names', () => { - expect(dialect.quoteTableName('my_schema', 'my_table')).toBe( - '[my_schema].[my_table]' - ) + expect(dialect.quoteTableName('my_schema', 'my_table')).toBe('[my_schema].[my_table]') }) }) diff --git a/tests/unit/repositories.test.ts b/tests/unit/repositories.test.ts index 4fa5495..0f4bc47 100644 --- a/tests/unit/repositories.test.ts +++ b/tests/unit/repositories.test.ts @@ -74,16 +74,13 @@ vi.mock('../../src/main/services/database/data-source', () => ({ // MaterialsToBeDeletedRepository // --------------------------------------------------------------------------- describe('MaterialsToBeDeletedRepository', () => { - let MaterialsToBeDeletedRepository: typeof import( - '../../src/main/services/database/repositories/MaterialsToBeDeletedRepository' - ).MaterialsToBeDeletedRepository + 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' - ) + const mod = + await import('../../src/main/services/database/repositories/MaterialsToBeDeletedRepository') MaterialsToBeDeletedRepository = mod.MaterialsToBeDeletedRepository }) @@ -384,16 +381,13 @@ describe('MaterialsToBeDeletedRepository', () => { // DiscreteMaterialPlanRepository // --------------------------------------------------------------------------- describe('DiscreteMaterialPlanRepository', () => { - let DiscreteMaterialPlanRepository: typeof import( - '../../src/main/services/database/repositories/DiscreteMaterialPlanRepository' - ).DiscreteMaterialPlanRepository + 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' - ) + const mod = + await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository') DiscreteMaterialPlanRepository = mod.DiscreteMaterialPlanRepository }) @@ -430,9 +424,8 @@ describe('DiscreteMaterialPlanRepository', () => { 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 mod = + await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository') const freshRepo = new mod.DiscreteMaterialPlanRepository() const result = await freshRepo.queryAllDistinctByMaterialCode() @@ -447,9 +440,8 @@ describe('DiscreteMaterialPlanRepository', () => { query: vi.fn().mockRejectedValue(new Error('db fail')) } vi.resetModules() - const mod = await import( - '../../src/main/services/database/repositories/DiscreteMaterialPlanRepository' - ) + const mod = + await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository') const freshRepo = new mod.DiscreteMaterialPlanRepository() const result = await freshRepo.queryAllDistinctByMaterialCode() @@ -512,9 +504,8 @@ describe('DiscreteMaterialPlanRepository', () => { 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 mod = + await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository') const repo = new mod.DiscreteMaterialPlanRepository() const result = await repo.queryBySourceNumbersDistinct(['S1', 'S2']) @@ -529,9 +520,8 @@ describe('DiscreteMaterialPlanRepository', () => { query: vi.fn().mockRejectedValue(new Error('db fail')) } vi.resetModules() - const mod = await import( - '../../src/main/services/database/repositories/DiscreteMaterialPlanRepository' - ) + const mod = + await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository') const repo = new mod.DiscreteMaterialPlanRepository() const result = await repo.queryBySourceNumbersDistinct(['S1'])