Files
ftb-project-management/apps/server/src/modules/test-case/test-case.service.ts
2026-07-08 14:15:49 +08:00

195 lines
8.6 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateTestCaseDto } from './dto/create-test-case.dto';
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
@Injectable()
export class TestCaseService {
constructor(
private readonly prisma: PrismaService,
private readonly workActivity: WorkActivityService,
) {}
async create(versionId: string, dto: CreateTestCaseDto) {
const version = await this.ensureVersion(versionId);
const item = await this.prisma.testCase.create({
data: {
...this.toTestCaseData(dto),
versionId,
productId: version.productId,
projectId: version.projectId,
code: dto.code?.trim() || dto.caseNo?.trim() || createFallbackCode('TC'),
title: dto.title,
status: dto.status ?? 'pending',
},
});
const activity = await this.recordTestCaseActivity(item, 'test_case_created', 'creation', `新建测试用例:${item.title}`);
return { item, activities: [activity] };
}
async createMany(versionId: string, dtos: CreateTestCaseDto[]) {
if (dtos.length === 0) return { items: [], activities: [] };
const version = await this.ensureVersion(versionId);
const rows = dtos.map((dto) => ({
...this.toTestCaseData(dto),
versionId,
productId: version.productId,
projectId: version.projectId,
code: dto.code?.trim() || dto.caseNo?.trim() || createFallbackCode('TC'),
title: dto.title,
status: dto.status ?? 'pending',
}));
await this.prisma.testCase.createMany({ data: rows, skipDuplicates: true });
const items = await this.prisma.testCase.findMany({
where: { versionId, code: { in: rows.map((row) => row.code) } },
});
const activities = await Promise.all(items.map((item) => (
this.recordTestCaseActivity(item, 'test_case_created', 'creation', `新建测试用例:${item.title}`)
)));
return { items, activities };
}
findAll(versionId: string) {
return this.prisma.testCase.findMany({
where: { versionId },
orderBy: [{ roundNo: 'asc' }, { code: 'asc' }],
});
}
async update(versionId: string, id: string, dto: UpdateTestCaseDto) {
await this.ensureTestCaseInVersion(versionId, id);
const item = await this.prisma.testCase.update({
where: { id_versionId: { id, versionId } },
data: this.toTestCaseData(dto),
});
return { item, activities: [] };
}
async updateStatus(versionId: string, id: string, status: string) {
const current = await this.ensureTestCaseInVersion(versionId, id);
const data: Record<string, unknown> = { status };
if (status === 'running' && !current.startedAt) data.startedAt = new Date();
if ((status === 'passed' || status === 'failed' || status === 'blocked') && !current.completedAt) {
data.completedAt = new Date();
}
const item = await this.prisma.testCase.update({
where: { id_versionId: { id, versionId } },
data,
});
const activity = await this.recordStatusActivity(item, current.status, status);
return { item, activities: activity ? [activity] : [] };
}
async remove(versionId: string, id: string) {
await this.ensureTestCaseInVersion(versionId, id);
return this.prisma.testCase.delete({ where: { id_versionId: { id, versionId } } });
}
private toTestCaseData(dto: Partial<CreateTestCaseDto>) {
return {
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
...(dto.categoryId !== undefined && { categoryId: emptyToNull(dto.categoryId) }),
...(dto.code !== undefined || dto.caseNo !== undefined ? { code: dto.code?.trim() || dto.caseNo?.trim() } : {}),
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description ?? '' }),
...(dto.status !== undefined && { status: dto.status }),
...(dto.roundNo !== undefined && { roundNo: normalizeRoundNo(dto.roundNo) }),
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}),
...(dto.plannedTestAt !== undefined && { plannedTestAt: parseOptionalDate(dto.plannedTestAt) }),
...(dto.plannedEndAt !== undefined && { plannedEndAt: parseOptionalDate(dto.plannedEndAt) }),
...(dto.startedAt !== undefined && { startedAt: parseOptionalDate(dto.startedAt) }),
...(dto.completedAt !== undefined && { completedAt: parseOptionalDate(dto.completedAt) }),
...(dto.estimateHours !== undefined && { estimateHours: dto.estimateHours }),
...(dto.aiEstimateHours !== undefined && { aiEstimateHours: dto.aiEstimateHours }),
...(dto.references !== undefined && { references: toJsonInput(dto.references) }),
...(dto.aiDraft !== undefined && { aiDraft: dto.aiDraft }),
...(dto.aiDraftAt !== undefined && { aiDraftAt: parseOptionalDate(dto.aiDraftAt) }),
};
}
private async ensureVersion(versionId: string) {
const version = await this.prisma.version.findUnique({ where: { id: versionId } });
if (!version) throw new NotFoundException('版本不存在');
if (!version.projectId) throw new BadRequestException('测试用例所属版本必须归属于项目');
return { ...version, projectId: version.projectId };
}
private async ensureTestCaseInVersion(versionId: string, id: string) {
const testCase = await this.prisma.testCase.findFirst({ where: { id, versionId } });
if (!testCase) throw new NotFoundException('测试用例不存在');
return testCase;
}
private recordStatusActivity(testCase: any, fromStatus: string, toStatus: string) {
if (toStatus === 'running') {
return this.recordTestCaseActivity(testCase, 'test_case_started', 'progress', `开始测试:${testCase.title}`, { fromStatus, toStatus });
}
if (toStatus === 'passed') {
return this.recordTestCaseActivity(testCase, 'test_case_passed', 'delivery', `测试通过:${testCase.title}`, { fromStatus, toStatus });
}
if (toStatus === 'failed') {
return this.recordTestCaseActivity(testCase, 'test_case_failed', 'risk', `测试不通过:${testCase.title}`, { fromStatus, toStatus });
}
if (toStatus === 'blocked') {
return this.recordTestCaseActivity(testCase, 'test_case_blocked', 'risk', `测试阻塞:${testCase.title}`, { fromStatus, toStatus });
}
return undefined;
}
private recordTestCaseActivity(testCase: any, action: string, category: string, summary: string, metadata: Record<string, unknown> = {}) {
return this.workActivity.record({
versionId: testCase.versionId,
productId: testCase.productId,
projectId: testCase.projectId,
actorId: testCase.assigneeId ?? testCase.creatorId,
sourceType: 'test_case',
sourceId: testCase.id,
action,
category,
title: testCase.title,
summary,
metadata,
});
}
}
function createFallbackCode(prefix: string) {
return `${prefix}-${Date.now().toString(36).toUpperCase()}`;
}
function emptyToNull(value: string | null | undefined): string | null {
if (value === null) return null;
if (value === undefined) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function parseOptionalDate(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date : null;
}
function normalizeRoundNo(value: number | null | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) return 1;
return Math.floor(value);
}
function parsePriority(value: string | number | null | undefined): number | undefined {
if (value === null || value === undefined || value === '') return undefined;
if (typeof value === 'number') return Number.isFinite(value) ? Math.max(0, Math.min(4, Math.floor(value))) : undefined;
const match = /^P([0-4])$/i.exec(value.trim());
if (match) return Number(match[1]);
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined;
}
function toJsonInput(value: unknown): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue;
}