feat: 初始化DevOps平台后端
This commit is contained in:
@@ -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]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user