From 15e82a9bf419177633d26f706ce83b3cc6238e1d Mon Sep 17 00:00:00 2001 From: AJ Enns Date: Wed, 24 Jun 2026 12:27:07 -0500 Subject: [PATCH] Add task title validation Validate task titles before creation and trim surrounding whitespace. This change adds shared validation so POST /tasks rejects missing, empty, or non-string titles with a clear 400 JSON error and avoids creating tasks for invalid input. It also ensures the store normalizes whitespace and adds coverage for both API and store-level behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sample-app/src/app.test.ts | 32 +++++++++++++++++++++++++++++- sample-app/src/tasks/routes.ts | 17 +++++++++++----- sample-app/src/tasks/store.test.ts | 14 +++++++++++++ sample-app/src/tasks/store.ts | 4 +++- sample-app/src/tasks/validation.ts | 9 +++++++++ 5 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 sample-app/src/tasks/validation.ts diff --git a/sample-app/src/app.test.ts b/sample-app/src/app.test.ts index c2d8dfe..b87df4a 100644 --- a/sample-app/src/app.test.ts +++ b/sample-app/src/app.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import request from 'supertest'; import { createApp } from './app.js'; @@ -23,6 +23,36 @@ describe('Task API', () => { expect(list.body).toHaveLength(1); }); + it('trims surrounding whitespace from task titles', async () => { + const app = createApp(); + + const created = await request(app).post('/tasks').send({ title: ' Trim me ' }); + expect(created.status).toBe(201); + expect(created.body.title).toBe('Trim me'); + + const list = await request(app).get('/tasks'); + expect(list.status).toBe(200); + expect(list.body).toHaveLength(1); + expect(list.body[0].title).toBe('Trim me'); + }); + + it.each([ + { body: {}, expectedMessage: 'Title must be a non-empty string' }, + { body: { title: '' }, expectedMessage: 'Title must be a non-empty string' }, + { body: { title: ' ' }, expectedMessage: 'Title must be a non-empty string' }, + { body: { title: 42 }, expectedMessage: 'Title must be a non-empty string' }, + ])('rejects invalid task titles: $body', async ({ body, expectedMessage }) => { + const app = createApp(); + + const res = await request(app).post('/tasks').send(body); + const list = await request(app).get('/tasks'); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: expectedMessage }); + expect(list.status).toBe(200); + expect(list.body).toEqual([]); + }); + it('marks a task completed via PATCH', async () => { const app = createApp(); const created = await request(app).post('/tasks').send({ title: 'Finish slides' }); diff --git a/sample-app/src/tasks/routes.ts b/sample-app/src/tasks/routes.ts index e3eabca..8888b6e 100644 --- a/sample-app/src/tasks/routes.ts +++ b/sample-app/src/tasks/routes.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import type { TaskStore } from './store.js'; +import { TASK_TITLE_ERROR, normalizeTaskTitle } from './validation.js'; export function createTaskRouter(store: TaskStore): Router { const router = Router(); @@ -10,11 +11,17 @@ export function createTaskRouter(store: TaskStore): Router { }); router.post('/', (req, res) => { - // Exercise 1: validate that req.body.title is a non-empty string and - // return 400 with a helpful message when it is not. - const { title } = req.body ?? {}; - const task = store.create(title); - res.status(201).json(task); + try { + const title = normalizeTaskTitle(req.body?.title); + const task = store.create(title); + return res.status(201).json(task); + } catch (error) { + if (error instanceof Error && error.message === TASK_TITLE_ERROR) { + return res.status(400).json({ error: error.message }); + } + + throw error; + } }); router.get('/:id', (req, res) => { diff --git a/sample-app/src/tasks/store.test.ts b/sample-app/src/tasks/store.test.ts index e8b0c4d..d16cfe3 100644 --- a/sample-app/src/tasks/store.test.ts +++ b/sample-app/src/tasks/store.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { TaskStore } from './store.js'; +import { TASK_TITLE_ERROR } from './validation.js'; describe('TaskStore', () => { it('creates a task with sensible defaults', () => { @@ -12,6 +13,19 @@ describe('TaskStore', () => { expect(task.createdAt).toBe(task.updatedAt); }); + it('trims surrounding whitespace from task titles', () => { + const store = new TaskStore(); + const task = store.create(' Write the talk '); + + expect(task.title).toBe('Write the talk'); + }); + + it('rejects blank task titles', () => { + const store = new TaskStore(); + + expect(() => store.create(' ')).toThrow(TASK_TITLE_ERROR); + }); + it('lists every created task', () => { const store = new TaskStore(); store.create('a'); diff --git a/sample-app/src/tasks/store.ts b/sample-app/src/tasks/store.ts index c381f04..3067731 100644 --- a/sample-app/src/tasks/store.ts +++ b/sample-app/src/tasks/store.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import type { Task } from './types.js'; +import { normalizeTaskTitle } from './validation.js'; /** * In-memory task store. Intentionally small so it is easy to reason about @@ -18,10 +19,11 @@ export class TaskStore { } create(title: string): Task { + const normalizedTitle = normalizeTaskTitle(title); const now = new Date().toISOString(); const task: Task = { id: randomUUID(), - title, + title: normalizedTitle, completed: false, createdAt: now, updatedAt: now, diff --git a/sample-app/src/tasks/validation.ts b/sample-app/src/tasks/validation.ts new file mode 100644 index 0000000..1859dd9 --- /dev/null +++ b/sample-app/src/tasks/validation.ts @@ -0,0 +1,9 @@ +export const TASK_TITLE_ERROR = 'Title must be a non-empty string'; + +export function normalizeTaskTitle(title: unknown): string { + if (typeof title !== 'string' || title.trim().length === 0) { + throw new Error(TASK_TITLE_ERROR); + } + + return title.trim(); +}