Files
devops-platform-api/src/deploy-runs/deploy-runs.service.spec.ts
T
湛兮 902817788d feat: 增加发布策略校验
- projects: 为环境补充 releasePolicy 和 refPattern,区分 branch、tag、master 发布策略
- deploy-runs: 创建发布单前校验项目状态、环境和 ref 规则
- repository: 数据库读取路径补齐发布策略推断
2026-06-11 21:27:20 +08:00

364 lines
11 KiB
TypeScript

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<ProjectsService, 'getProject'>;
const audit = {
record: jest.fn(),
} satisfies Pick<AuditService, 'record'>;
const notification = {
sendDeployRunEvent: jest.fn(() =>
Promise.resolve({
status: 'skipped' as const,
channel: 'wecom' as const,
reason: 'Missing WECOM_WEBHOOK_URL',
}),
),
} satisfies Pick<DeployNotificationService, 'sendDeployRunEvent'>;
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<DeployExecutionService, 'execute' | 'syncJenkinsRun'>;
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]',
},
]);
});
});