import { AuditService } from '../audit/audit.service'; import { AppError } from '../common/errors/app-error'; import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service'; import { ProjectSummary } from '../projects/project.types'; import { ProjectsService } from '../projects/projects.service'; import { DeployExecutionService } from './deploy-execution.service'; import { DeployRunRepository } from './deploy-run.repository'; import { CreateDeployRunInput, DeployRunStatus, DeployRunSummary, } from './deploy-run.types'; import { DeployRunsService } from './deploy-runs.service'; /** * 覆盖发布单第一阶段内存实现,后续接真实数据库时保持服务契约不变。 */ describe('DeployRunsService', () => { const baseRun: DeployRunSummary = { id: 'run_001', projectKey: 'access-manage', environment: 'test', ref: 'develop', status: 'pending', trigger: 'manual', operator: 'ops-admin', createdAt: '2026-06-11T00:00:00.000Z', steps: [], }; const project: ProjectSummary = { id: 'project_001', key: 'access-manage', name: 'access-manage', repositoryUrl: 'https://gitea.ops.mrzhan.top/my-project/access-manage.git', defaultBranch: 'develop', status: 'active', environments: [ { name: 'test', displayName: '测试环境', jenkinsJobPath: 'my-project-dev/test-access-manage-develop', releasePolicy: 'branch', refPattern: '^(develop|feature/.+|hotfix/.+)$', }, ], }; function createService(options?: { runs?: DeployRunSummary[]; project?: ProjectSummary; }) { const runs = options?.runs ?? [baseRun]; const resolvedProject = options?.project ?? project; const repository = { findMany: jest.fn(() => Promise.resolve(runs)), findById: jest.fn((id: string) => Promise.resolve(runs.find((run) => run.id === id) ?? null), ), create: jest.fn((input: CreateDeployRunInput) => ({ ...baseRun, id: input.idempotencyKey ?? 'generated_run', projectKey: input.projectKey, environment: input.environment, ref: input.ref, operator: input.operator, trigger: input.trigger ?? 'manual', })), updateMemoryStatus: jest.fn((id: string, status: DeployRunStatus) => id === baseRun.id ? { ...baseRun, status } : null, ), } satisfies Pick< DeployRunRepository, 'findMany' | 'findById' | 'create' | 'updateMemoryStatus' >; const projects = { getProject: jest.fn(() => Promise.resolve(resolvedProject), ), } satisfies Pick; const audit = { record: jest.fn(), } satisfies Pick; const notification = { sendDeployRunEvent: jest.fn(() => Promise.resolve({ status: 'skipped' as const, channel: 'wecom' as const, reason: 'Missing WECOM_WEBHOOK_URL', }), ), } satisfies Pick; const execution = { execute: jest.fn((run: DeployRunSummary) => Promise.resolve({ ...run, status: 'success' as const }), ), syncJenkinsRun: jest.fn((run: DeployRunSummary) => Promise.resolve({ ...run, status: 'success' as const }), ), } satisfies Pick; return { audit, execution, notification, projects, repository, service: new DeployRunsService( repository as unknown as DeployRunRepository, projects as unknown as ProjectsService, audit as unknown as AuditService, notification as unknown as DeployNotificationService, execution as unknown as DeployExecutionService, ), }; } it('creates a manual deploy run and records audit data', async () => { const { audit, execution, repository, service } = createService(); const run = await service.createRun({ projectKey: 'access-manage', environment: 'test', ref: 'develop', operator: 'ops-admin', idempotencyKey: 'idem_001', }); expect(run.id).toBe('idem_001'); expect(repository.create).toHaveBeenCalledWith( expect.objectContaining({ projectKey: 'access-manage' }), ); expect(audit.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'DEPLOY_REQUESTED', resourceId: 'idem_001', }), ); expect(execution.execute).toHaveBeenCalledWith( expect.objectContaining({ id: 'idem_001' }), expect.objectContaining({ key: 'access-manage' }), ); }); it('rejects unsupported environments before creating a run', async () => { const { execution, repository, service } = createService(); await expect( service.createRun({ projectKey: 'access-manage', environment: 'staging', ref: 'develop', operator: 'ops-admin', } as unknown as CreateDeployRunInput), ).rejects.toBeInstanceOf(AppError); expect(repository.create).not.toHaveBeenCalled(); expect(execution.execute).not.toHaveBeenCalled(); }); it('rejects project environments that are not configured before creating a run', async () => { const { audit, execution, repository, service } = createService({ project: { ...project, key: 'devops-platform-web', defaultBranch: 'master', environments: [ { name: 'production', displayName: '生产环境', jenkinsJobPath: 'devops-platform/prod-devops-platform-web-master', releasePolicy: 'master', refPattern: '^master$', }, ], }, }); await expect( service.createRun({ projectKey: 'devops-platform-web', environment: 'test', ref: 'master', operator: 'ops-admin', }), ).rejects.toBeInstanceOf(AppError); expect(repository.create).not.toHaveBeenCalled(); expect(execution.execute).not.toHaveBeenCalled(); expect(audit.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'DEPLOY_REJECTED', after: expect.objectContaining({ reason: 'Environment is not configured', }), }), ); }); it('rejects production refs that do not match tag release policy', async () => { const { audit, execution, repository, service } = createService({ project: { ...project, environments: [ { name: 'production', displayName: '生产环境', jenkinsJobPath: 'my-project-prod/prod-access-manage-tag', releasePolicy: 'tag', refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$', }, ], }, }); await expect( service.createRun({ projectKey: 'access-manage', environment: 'production', ref: 'master', operator: 'ops-admin', }), ).rejects.toBeInstanceOf(AppError); expect(repository.create).not.toHaveBeenCalled(); expect(execution.execute).not.toHaveBeenCalled(); expect(audit.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'DEPLOY_REJECTED', after: expect.objectContaining({ reason: 'Ref does not match release policy', }), }), ); }); it('allows master-only production releases for self-managed platform projects', async () => { const { execution, repository, service } = createService({ project: { ...project, key: 'devops-platform-web', defaultBranch: 'master', environments: [ { name: 'production', displayName: '生产环境', jenkinsJobPath: 'devops-platform/prod-devops-platform-web-master', releasePolicy: 'master', refPattern: '^master$', }, ], }, }); const run = await service.createRun({ projectKey: 'devops-platform-web', environment: 'production', ref: 'master', operator: 'ops-admin', idempotencyKey: 'self-managed-master', }); expect(run.id).toBe('self-managed-master'); expect(repository.create).toHaveBeenCalled(); expect(execution.execute).toHaveBeenCalled(); }); it('cancels an existing deploy run', async () => { const { audit, service } = createService(); const run = await service.cancelRun(baseRun.id); expect(run.status).toBe('canceled'); expect(audit.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'DEPLOY_CANCELED' }), ); }); it('retries a deploy run with retry trigger', async () => { const { repository, service } = createService(); const run = await service.retryRun(baseRun.id); expect(run.trigger).toBe('retry'); expect(repository.create).toHaveBeenCalledWith( expect.objectContaining({ projectKey: baseRun.projectKey, environment: baseRun.environment, trigger: 'retry', }), ); }); it('syncs all queued or running Jenkins runs with queue/build identifiers', async () => { const queuedRun: DeployRunSummary = { ...baseRun, id: 'run_queued', status: 'queued', jenkinsQueueId: 'queue_001', }; const runningRun: DeployRunSummary = { ...baseRun, id: 'run_running', status: 'running', jenkinsBuildNumber: 7, }; const ignoredRun: DeployRunSummary = { ...baseRun, id: 'run_running_without_jenkins', status: 'running', }; const { audit, execution, service } = createService({ runs: [queuedRun, runningRun, ignoredRun], }); const result = await service.syncJenkinsRuns(); expect(result.totalCandidates).toBe(2); expect(result.syncedRuns).toHaveLength(2); expect(result.failedRuns).toHaveLength(0); expect(execution.syncJenkinsRun).toHaveBeenCalledTimes(2); expect(execution.syncJenkinsRun).toHaveBeenCalledWith( queuedRun, expect.objectContaining({ key: queuedRun.projectKey }), ); expect(execution.syncJenkinsRun).toHaveBeenCalledWith( runningRun, expect.objectContaining({ key: runningRun.projectKey }), ); expect(audit.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'JENKINS_SYNC_SWEEP_COMPLETED', after: expect.objectContaining({ totalCandidates: 2, syncedCount: 2, failedCount: 0, }), }), ); }); it('returns a redacted error when one Jenkins run fails during bulk sync', async () => { const queuedRun: DeployRunSummary = { ...baseRun, id: 'run_queued', status: 'queued', jenkinsQueueId: 'queue_001', }; const { execution, service } = createService({ runs: [queuedRun] }); execution.syncJenkinsRun.mockRejectedValueOnce( new Error('abcdefghijklmnopqrstuvwxyz123456'), ); const result = await service.syncJenkinsRuns(); expect(result.totalCandidates).toBe(1); expect(result.syncedRuns).toHaveLength(0); expect(result.failedRuns).toEqual([ { id: 'run_queued', projectKey: 'access-manage', message: '[REDACTED]', }, ]); }); });