feat: 初始化DevOps平台后端

This commit is contained in:
湛兮
2026-06-11 20:49:59 +08:00
commit caec5a618d
88 changed files with 11893 additions and 0 deletions
+250
View File
@@ -0,0 +1,250 @@
import { AuditService } from '../audit/audit.service';
import { AppError } from '../common/errors/app-error';
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
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: [],
};
function createService(options?: { runs?: DeployRunSummary[] }) {
const runs = options?.runs ?? [baseRun];
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({
id: 'project_001',
key: 'access-manage',
name: 'access-manage',
repositoryUrl:
'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
defaultBranch: 'develop',
status: 'active' as const,
environments: [
{
name: 'test' as const,
displayName: '测试环境',
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
},
],
}),
),
} 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('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]',
},
]);
});
});