import { NotFoundException } from '@nestjs/common'; import { DevTaskService } from './dev-task.service'; describe('DevTaskService domain writes', () => { const makeService = () => { const workActivity = { record: jest.fn().mockResolvedValue({ id: 'activity-1' }), }; const prisma = { user: { findFirst: createUserFindFirstMock(), }, version: { findUnique: jest.fn(), }, devTask: { create: jest.fn(), delete: jest.fn(), findFirst: jest.fn(), findMany: jest.fn(), update: jest.fn(), }, }; return { prisma, workActivity, service: new DevTaskService(prisma as any, workActivity as any), }; }; it('resolves assignee and creator names to user ids before relation writes', async () => { const { prisma, service } = makeService(); prisma.user.findFirst = createUserFindFirstMock({ 张三: 'dev-1', 产品经理: 'pm-1' }); prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); prisma.devTask.create.mockResolvedValue({ id: 'task-1', versionId: 'version-1', title: '开发登录' }); await service.create('version-1', { title: '开发登录', assigneeId: '张三', createdBy: '产品经理', } as any); expect(prisma.user.findFirst).toHaveBeenCalledTimes(2); expect(prisma.devTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ assigneeId: 'dev-1', creatorId: 'pm-1', }), }); }); it('resolves transfer assignee names before updating foreign keys', async () => { const { prisma, workActivity, service } = makeService(); prisma.user.findFirst = createUserFindFirstMock({ 李四: 'dev-2' }); prisma.devTask.findFirst.mockResolvedValue({ id: 'task-1', versionId: 'version-1', productId: 'product-1', projectId: 'project-1', title: '开发登录', assigneeId: 'dev-1', }); prisma.devTask.update.mockResolvedValue({ id: 'task-1', versionId: 'version-1', productId: 'product-1', projectId: 'project-1', title: '开发登录', assigneeId: 'dev-2', }); await service.transfer('version-1', 'task-1', '李四'); expect(prisma.devTask.update).toHaveBeenCalledWith({ where: { id_versionId: { id: 'task-1', versionId: 'version-1' } }, data: { assigneeId: 'dev-2' }, }); expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ fromAssigneeId: 'dev-1', toAssigneeId: 'dev-2' }), })); }); it('creates dev tasks directly under a version partition', async () => { const { prisma, workActivity, service } = makeService(); prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); prisma.devTask.create.mockResolvedValue({ id: 'task-1', versionId: 'version-1', title: '开发登录' }); await service.create('version-1', { requirementId: 'req-1', requirementProductId: 'product-1', taskNo: 'DEV-001', title: '开发登录', categoryId: 'cat-fe', assigneeId: 'member-1', priority: 'P1', expectedStartAt: '2026-07-08T09:00:00.000Z', expectedEndAt: '2026-07-08T18:00:00.000Z', estimateHours: 8, references: [{ type: 'requirement', id: 'REQ-001', label: 'REQ-001 登录' }], createdBy: 'member-pm', } as any); expect(prisma.devTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ versionId: 'version-1', productId: 'product-1', projectId: 'project-1', requirementId: 'req-1', requirementProductId: 'product-1', code: 'DEV-001', title: '开发登录', categoryId: 'cat-fe', assigneeId: 'member-1', priority: 1, expectedStartAt: new Date('2026-07-08T09:00:00.000Z'), expectedEndAt: new Date('2026-07-08T18:00:00.000Z'), estimateHours: 8, }), }); expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ versionId: 'version-1', sourceType: 'dev_task', sourceId: 'task-1', action: 'dev_task_created', })); }); it('changes status by id plus version id and records activity evidence', async () => { const { prisma, workActivity, service } = makeService(); prisma.devTask.findFirst.mockResolvedValue({ id: 'task-1', versionId: 'version-1', productId: 'product-1', projectId: 'project-1', status: 'todo', title: '开发登录', assigneeId: 'member-1', }); prisma.devTask.update.mockResolvedValue({ id: 'task-1', versionId: 'version-1', productId: 'product-1', projectId: 'project-1', status: 'in_progress', title: '开发登录', assigneeId: 'member-1', }); await service.updateStatus('version-1', 'task-1', 'in_progress'); expect(prisma.devTask.update).toHaveBeenCalledWith({ where: { id_versionId: { id: 'task-1', versionId: 'version-1' } }, data: expect.objectContaining({ status: 'in_progress', startDate: expect.any(Date), }), }); expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ versionId: 'version-1', sourceType: 'dev_task', sourceId: 'task-1', action: 'dev_task_started', })); }); it('blocks and unblocks tasks inside the version partition', async () => { const { prisma, workActivity, service } = makeService(); prisma.devTask.findFirst.mockResolvedValue({ id: 'task-1', versionId: 'version-1', productId: 'product-1', projectId: 'project-1', status: 'in_progress', title: '开发登录', assigneeId: 'member-1', }); prisma.devTask.update.mockResolvedValue({ id: 'task-1', versionId: 'version-1', productId: 'product-1', projectId: 'project-1', isBlocked: true, blockReason: '接口未就绪', title: '开发登录', assigneeId: 'member-1', }); await service.setBlocked('version-1', 'task-1', true, '接口未就绪'); expect(prisma.devTask.update).toHaveBeenCalledWith({ where: { id_versionId: { id: 'task-1', versionId: 'version-1' } }, data: { isBlocked: true, blockReason: '接口未就绪' }, }); expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ action: 'dev_task_blocked', metadata: expect.objectContaining({ blocker: '接口未就绪' }), })); }); it('rejects updates outside the version partition', async () => { const { prisma, service } = makeService(); prisma.devTask.findFirst.mockResolvedValue(null); await expect(service.update('version-1', 'missing-task', { title: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException); expect(prisma.devTask.update).not.toHaveBeenCalled(); }); }); function createUserFindFirstMock(mapping: Record = {}) { return jest.fn(({ where }: any) => { const refs = (where?.OR ?? []) .flatMap((condition: Record) => Object.values(condition)) .filter(Boolean); const ref = refs[0]; return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null); }); }