Files
devops-platform-api/src/deploy-runs/deploy-runs.service.spec.ts
T
2026-06-11 23:22:17 +08:00

443 lines
14 KiB
TypeScript

import { AuditService } from '../audit/audit.service';
import { AppError } from '../common/errors/app-error';
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
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',
})),
updateRun: jest.fn((id: string, patch: { status?: DeployRunStatus }) =>
runs.some((run) => run.id === id)
? {
...(runs.find((run) => run.id === id) ?? baseRun),
...patch,
}
: null,
),
updateStep: jest.fn((id: string) =>
runs.some((run) => run.id === id)
? {
...(runs.find((run) => run.id === id) ?? baseRun),
status: 'canceled' as const,
}
: null,
),
} satisfies Pick<
DeployRunRepository,
'findMany' | 'findById' | 'create' | 'updateRun' | 'updateStep'
>;
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'>;
const jenkins = {
healthSummary: jest.fn(() => ({ status: 'ok' as const })),
cancelQueueItem: jest.fn(() =>
Promise.resolve({
target: 'queue' as const,
queueId: 'queue_001',
requested: true as const,
}),
),
stopBuild: jest.fn(() =>
Promise.resolve({
target: 'build' as const,
buildNumber: 7,
requested: true as const,
}),
),
} satisfies Pick<
JenkinsClient,
'healthSummary' | 'cancelQueueItem' | 'stopBuild'
>;
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,
jenkins as unknown as JenkinsClient,
),
};
}
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 a queued Jenkins execution before marking the run canceled', async () => {
const queuedRun: DeployRunSummary = {
...baseRun,
status: 'queued',
jenkinsQueueId: 'queue_001',
};
const { audit, jenkins, service } = createService({
runs: [queuedRun],
});
const run = await service.cancelRun(baseRun.id);
expect(run.status).toBe('canceled');
expect(jenkins.cancelQueueItem).toHaveBeenCalledWith('queue_001');
expect(jenkins.stopBuild).not.toHaveBeenCalled();
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEPLOY_CANCELED' }),
);
});
it('cancels a running Jenkins build before marking the run canceled', async () => {
const runningRun: DeployRunSummary = {
...baseRun,
status: 'running',
jenkinsBuildNumber: 7,
};
const { jenkins, service } = createService({
runs: [runningRun],
});
const run = await service.cancelRun(baseRun.id);
expect(run.status).toBe('canceled');
expect(jenkins.stopBuild).toHaveBeenCalledWith(
'my-project-dev/test-access-manage-develop',
7,
);
expect(jenkins.cancelQueueItem).not.toHaveBeenCalled();
});
it('rejects already terminal deploy runs before calling Jenkins', async () => {
const canceledRun: DeployRunSummary = {
...baseRun,
status: 'canceled',
jenkinsBuildNumber: 7,
};
const { jenkins, service } = createService({
runs: [canceledRun],
});
await expect(service.cancelRun(baseRun.id)).rejects.toBeInstanceOf(
AppError,
);
expect(jenkins.stopBuild).not.toHaveBeenCalled();
expect(jenkins.cancelQueueItem).not.toHaveBeenCalled();
});
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]',
},
]);
});
});