From a345649e33d1fb857722fa666bf779e87f59b9e9 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:18:26 -0500 Subject: [PATCH] fix: preserve template task fields on creation (#1431) --- docs/audits/TASK-TEMPLATE-FIELDS-1430.md | 11 ++++ .../__tests__/routes/tasks-coverage.test.ts | 51 +++++++++++++++++++ server/src/routes/tasks.ts | 20 ++++---- 3 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 docs/audits/TASK-TEMPLATE-FIELDS-1430.md diff --git a/docs/audits/TASK-TEMPLATE-FIELDS-1430.md b/docs/audits/TASK-TEMPLATE-FIELDS-1430.md new file mode 100644 index 00000000..a9d33f75 --- /dev/null +++ b/docs/audits/TASK-TEMPLATE-FIELDS-1430.md @@ -0,0 +1,11 @@ +# Template task fields at creation + +Issue #1430 was found during packaged macOS Create Task acceptance. A two-task blueprint previewed its subtasks and dependency correctly, but POST `/api/tasks` silently discarded both fields. The shared creation interface and task service already supported them; the route schema omitted them and stripped them during validation. + +Creation now accepts optional `subtasks` and `blockedBy` using the same validation contracts as updates. The unchanged subtask schema is shared by POST and PATCH. There is no storage migration, priority change, or UI change in this slice. + +Four new route regressions failed before the fix: valid fields never reached the service, and malformed subtasks, acceptance criteria, and dependency IDs were accepted instead of rejected. After the fix, all 60 tests in the combined candidate's exact task-route coverage file passed. Shared build, source server typecheck, changed-file formatting, and lint passed; lint retained five existing warnings. Separate specification and standards reviews found no actionable issues. + +The unsigned packaged macOS candidate was rebuilt with this fix and the separate Critical-priority change in #1429. At the native minimum 1180×760, the light and dark checks selected a synthetic two-task blueprint, entered a custom variable, created real tasks through the API, and verified interpolated subtask titles, acceptance criteria, and the second task's dependency on the first task's actual ID. Reloading the app and inspecting its authenticated task-list response confirmed those same values persisted. Only template discovery was supplied by a test fixture; task creation and persistence were real in an isolated temporary application profile. An initial extra readback request lacked app authentication; the harness was corrected to observe the application's own reload response. + +This native check is combined-candidate evidence, not standalone release or installed-app acceptance. The installed application and user data were unchanged. Full CI for the new combined revision, remaining UI consumers, maintained documentation media, and signed release acceptance remain separate work. diff --git a/server/src/__tests__/routes/tasks-coverage.test.ts b/server/src/__tests__/routes/tasks-coverage.test.ts index 0e52d1b9..7078847f 100644 --- a/server/src/__tests__/routes/tasks-coverage.test.ts +++ b/server/src/__tests__/routes/tasks-coverage.test.ts @@ -470,6 +470,57 @@ describe('Tasks Routes (actual module)', () => { }); describe('POST /api/tasks', () => { + it('preserves template subtasks and blueprint dependencies on creation', async () => { + const subtasks = [ + { + id: 'subtask-1', + title: 'Verify release', + completed: false, + created: '2026-09-01T12:00:00Z', + acceptanceCriteria: ['Evidence recorded'], + criteriaChecked: [false], + }, + ]; + const blockedBy = ['prerequisite-task']; + mockTaskService.createTask.mockImplementation(async (input) => ({ + id: 'blueprint-task', + ...input, + })); + const res = await request(app).post('/api/tasks').send({ + title: 'Blueprint follow-up', + subtasks, + blockedBy, + }); + expect(res.status).toBe(201); + expect(mockTaskService.createTask).toHaveBeenCalledWith( + expect.objectContaining({ subtasks, blockedBy }) + ); + expect(res.body.subtasks).toEqual(subtasks); + expect(res.body.blockedBy).toEqual(blockedBy); + }); + + it.each([ + { subtasks: [{ id: 'bad', title: 'Missing required fields' }] }, + { + subtasks: [ + { + id: 'bad', + title: 'Bad criteria', + completed: false, + created: '2026-09-01', + acceptanceCriteria: [42], + }, + ], + }, + { blockedBy: [42] }, + ])('rejects malformed template task fields: %j', async (fields) => { + const res = await request(app) + .post('/api/tasks') + .send({ title: 'Invalid blueprint', ...fields }); + expect(res.status).toBe(400); + expect(mockTaskService.createTask).not.toHaveBeenCalled(); + }); + it('accepts critical priority on creation', async () => { mockTaskService.createTask.mockImplementation(async (input) => ({ id: 'critical-task', diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index ba710ff0..26439576 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -190,6 +190,15 @@ const reviewCommentSchema = z.object({ export const reviewScoresSchema = z.array(z.number().int().min(0).max(10)).length(4); +const subtaskSchema = z.object({ + id: z.string(), + title: z.string(), + completed: z.boolean(), + created: z.string(), + acceptanceCriteria: z.array(z.string()).optional(), + criteriaChecked: z.array(z.boolean()).optional(), +}); + const createTaskSchema = z.object({ title: z.string().min(1).max(200), description: z.string().optional().default(''), @@ -202,6 +211,8 @@ const createTaskSchema = z.object({ executionPolicy: TaskExecutionPolicySchema.optional(), reviewScores: reviewScoresSchema.optional(), reviewComments: z.array(reviewCommentSchema).optional(), + subtasks: z.array(subtaskSchema).optional(), + blockedBy: z.array(z.string()).optional(), }); const gitSchema = z @@ -332,15 +343,6 @@ const reviewStateSchema = z.object({ summary: z.string().optional(), }); -const subtaskSchema = z.object({ - id: z.string(), - title: z.string(), - completed: z.boolean(), - created: z.string(), - acceptanceCriteria: z.array(z.string()).optional(), - criteriaChecked: z.array(z.boolean()).optional(), -}); - const githubSchema = z .object({ issueNumber: z.number().int().positive(),