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 = { 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) { 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 = {}) { 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; }