From 3564070bbe3ebe8abacb6cbf1db1ba3658559f03 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 30 Apr 2026 18:22:55 +0200 Subject: [PATCH] test: add unit tests for tasks-status-update helper and get-claude-context filter tasks-status-update.test.ts: 7 tests covering promote, idempotent promote, partial-siblings no-promote, demote, no-demote-when-not-done, task-always-updated, and custom tx client pass-through. get-claude-context-filter.test.ts: 3 tests verifying the OR tasks filter is passed, sprint_id/status filter is present, and no story query fires when there is no active sprint. --- __tests__/get-claude-context-filter.test.ts | 102 +++++++++++++++ __tests__/tasks-status-update.test.ts | 136 ++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 __tests__/get-claude-context-filter.test.ts create mode 100644 __tests__/tasks-status-update.test.ts diff --git a/__tests__/get-claude-context-filter.test.ts b/__tests__/get-claude-context-filter.test.ts new file mode 100644 index 0000000..38386dc --- /dev/null +++ b/__tests__/get-claude-context-filter.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { + mockProductFindFirst, + mockSprintFindFirst, + mockStoryFindFirst, + mockTodoFindMany, +} = vi.hoisted(() => ({ + mockProductFindFirst: vi.fn(), + mockSprintFindFirst: vi.fn(), + mockStoryFindFirst: vi.fn(), + mockTodoFindMany: vi.fn(), +})) + +vi.mock('../src/auth.js', () => ({ + getAuth: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) + +vi.mock('../src/prisma.js', () => ({ + prisma: { + product: { findFirst: mockProductFindFirst }, + sprint: { findFirst: mockSprintFindFirst }, + story: { findFirst: mockStoryFindFirst }, + todo: { findMany: mockTodoFindMany }, + }, +})) + +vi.mock('../src/errors.js', () => ({ + toolError: vi.fn((msg: string) => ({ isError: true, content: [{ type: 'text', text: msg }] })), + toolJson: vi.fn((data: unknown) => ({ content: [{ type: 'text', text: JSON.stringify(data) }] })), + withToolErrors: vi.fn(async (fn: () => Promise) => fn()), +})) + +import { registerGetClaudeContextTool } from '../src/tools/get-claude-context.js' + +// Minimal McpServer stub that captures the registered handler +function makeServer() { + let handler: ((args: Record) => Promise) | null = null + const server = { + registerTool: vi.fn((_name: string, _def: unknown, h: typeof handler) => { + handler = h + }), + call: async (args: Record) => { + if (!handler) throw new Error('tool not registered') + return handler(args) + }, + } + return server +} + +beforeEach(() => { + vi.clearAllMocks() + mockProductFindFirst.mockResolvedValue({ + id: 'prod-1', code: 'P1', name: 'Test', description: null, repo_url: null, definition_of_done: null, + }) + mockSprintFindFirst.mockResolvedValue({ id: 'sprint-1', sprint_goal: 'Goal', status: 'ACTIVE' }) + mockStoryFindFirst.mockResolvedValue(null) + mockTodoFindMany.mockResolvedValue([]) +}) + +describe('get_claude_context safety-net filter', () => { + it('passes OR tasks filter in next-story query', async () => { + const server = makeServer() + registerGetClaudeContextTool(server as never) + await server.call({ product_id: 'prod-1' }) + + expect(mockStoryFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [ + { tasks: { none: {} } }, + { tasks: { some: { status: { not: 'DONE' } } } }, + ], + }), + }), + ) + }) + + it('still filters on sprint_id and status', async () => { + const server = makeServer() + registerGetClaudeContextTool(server as never) + await server.call({ product_id: 'prod-1' }) + + expect(mockStoryFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + sprint_id: 'sprint-1', + status: { in: ['OPEN', 'IN_SPRINT'] }, + }), + }), + ) + }) + + it('does not query next story when no active sprint', async () => { + mockSprintFindFirst.mockResolvedValue(null) + const server = makeServer() + registerGetClaudeContextTool(server as never) + await server.call({ product_id: 'prod-1' }) + + expect(mockStoryFindFirst).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/tasks-status-update.test.ts b/__tests__/tasks-status-update.test.ts new file mode 100644 index 0000000..363a945 --- /dev/null +++ b/__tests__/tasks-status-update.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + task: { + update: vi.fn(), + findMany: vi.fn(), + }, + story: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '../src/prisma.js' +import { updateTaskStatusWithStoryPromotion } from '../src/lib/tasks-status-update.js' + +const mockPrisma = prisma as unknown as { + task: { update: ReturnType; findMany: ReturnType } + story: { findUniqueOrThrow: ReturnType; update: ReturnType } + $transaction: ReturnType +} + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.$transaction.mockImplementation( + async (run: (tx: typeof prisma) => Promise) => run(prisma), + ) +}) + +const TASK_BASE = { + id: 'task-1', + title: 'Task', + story_id: 'story-1', + implementation_plan: null, +} + +describe('updateTaskStatusWithStoryPromotion', () => { + it('promotes story to DONE when last sibling task transitions to DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe('promoted') + expect(result.storyId).toBe('story-1') + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) + + it('does not promote when story is already DONE (idempotent)', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('does not promote when not all siblings are DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'IN_PROGRESS' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }, { status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + + expect(result.storyStatusChange).toBe('demoted') + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'IN_SPRINT' }, + }) + }) + + it('does not demote when story is not DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('updates the task regardless of story-status change', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + + expect(mockPrisma.task.update).toHaveBeenCalledWith({ + where: { id: 'task-1' }, + data: { status: 'IN_PROGRESS' }, + select: expect.any(Object), + }) + }) + + it('uses the provided transaction client when passed', async () => { + const tx = { + task: { update: vi.fn(), findMany: vi.fn() }, + story: { findUniqueOrThrow: vi.fn(), update: vi.fn() }, + } + tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + tx.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any) + + expect(result.storyStatusChange).toBe('promoted') + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + expect(tx.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) +})