Files
devops-platform-api/src/deploy-runs/deploy-runs.service.spec.ts
T
2026-06-12 01:16:41 +08:00

487 lines
15 KiB
TypeScript

import { AuditService } from '../audit/audit.service';
import { AuthService } from '../auth/auth.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) =>
Promise.resolve({
...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 }) =>
Promise.resolve(
runs.some((run) => run.id === id)
? {
...(runs.find((run) => run.id === id) ?? baseRun),
...patch,
}
: null,
),
),
updateStep: jest.fn((id: string) =>
Promise.resolve(
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'
>;
const auth = {
listAllowedProjectKeys: jest.fn(() =>
Promise.resolve(runs.map((run) => run.projectKey)),
),
requireProjectPermission: jest.fn(() => Promise.resolve()),
assertSuperAdmin: jest.fn(),
} satisfies Pick<
AuthService,
'listAllowedProjectKeys' | 'requireProjectPermission' | 'assertSuperAdmin'
>;
return {
audit,
auth,
execution,
jenkins,
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,
auth as unknown as AuthService,
),
};
}
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: '环境未配置',
}),
}),
);
});
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 不符合发布策略',
}),
}),
);
});
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',
actorName: queuedRun.operator,
}),
);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'DEPLOY_NOTIFICATION_SKIPPED',
actorName: queuedRun.operator,
}),
);
});
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 failedRun: DeployRunSummary = {
...baseRun,
status: 'failed',
};
const { repository, service } = createService({
runs: [failedRun],
});
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('rejects non-terminal retry requests before creating a new run', async () => {
const { repository, service } = createService();
await expect(service.retryRun(baseRun.id)).rejects.toBeInstanceOf(
AppError,
);
expect(repository.create).not.toHaveBeenCalled();
});
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]',
},
]);
});
});