443 lines
14 KiB
TypeScript
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]',
|
|
},
|
|
]);
|
|
});
|
|
});
|