195 lines
8.6 KiB
TypeScript
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;
|
|
}
|