From 569259d771828601e4fa86f555601bcbe62836ad Mon Sep 17 00:00:00 2001 From: Akhil Kumar Date: Fri, 15 May 2026 02:32:37 -0400 Subject: [PATCH 1/6] feat: add comprehensive tag filtering tests --- .beads/issues.jsonl | 4 ++++ feature_list.json | 2 +- progress.md | 12 ++++++++++++ tests/notes.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c27d931..d8c1f1f 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -5,3 +5,7 @@ {"_type":"issue","id":"gh-toy-69s","title":"Handle SIGINT gracefully (Ctrl-C)","description":"The tool should catch SIGINT (Ctrl-C) and exit cleanly without a Python traceback or incomplete output. Acceptance: Ctrl-C prints 'Interrupted.' and exits with code 130, no stack trace shown, partial output files cleaned up.","status":"open","priority":2,"issue_type":"feature","owner":"azure-pipeline@test.com","created_at":"2026-05-12T20:47:14Z","created_by":"Azure Pipeline","updated_at":"2026-05-12T20:47:14Z","external_ref":"gh-5","labels":["e2e-testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gh-toy-aqd","title":"Add JSON output mode via --json flag","description":"Add a --json flag so all command output can be emitted as machine-readable JSON for scripting and CI pipelines. Acceptance: --json flag accepted on any subcommand, output is valid JSON, human-readable output is default, errors also JSON-formatted.","status":"open","priority":2,"issue_type":"feature","owner":"azure-pipeline@test.com","created_at":"2026-05-12T20:47:13Z","created_by":"Azure Pipeline","updated_at":"2026-05-12T20:47:13Z","external_ref":"gh-4","labels":["e2e-testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gh-toy-s5k","title":"Tag filtering endpoint","description":"GET /api/notes?tag=work returns only notes with that tag. Already partially implemented - needs tests.","status":"open","priority":2,"issue_type":"feature","owner":"azure-pipeline@test.com","created_at":"2026-05-12T20:46:52Z","created_by":"Azure Pipeline","updated_at":"2026-05-12T20:46:52Z","labels":["e2e-testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"gh-toy-yjy","title":"Note","status":"open","priority":2,"issue_type":"task","assignee":"akhil@apralabs.com","owner":"akhil@apralabs.com","created_at":"2026-05-12T05:30:38Z","created_by":"Akhil Kumar","updated_at":"2026-05-12T19:58:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"gh-toy-06i","title":"Pagination","status":"open","priority":2,"issue_type":"task","owner":"akhil@apralabs.com","created_at":"2026-05-12T05:30:33Z","created_by":"Akhil Kumar","updated_at":"2026-05-12T05:30:33Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"gh-toy-gw1","title":"Full-text","status":"open","priority":2,"issue_type":"task","owner":"akhil@apralabs.com","created_at":"2026-05-12T05:30:24Z","created_by":"Akhil Kumar","updated_at":"2026-05-12T05:30:24Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"gh-toy-bzq","title":"Tag filtering endpoint","description":"GET /api/notes?tag=work returns only notes with that tag. Already partially implemented — needs tests.","status":"open","priority":2,"issue_type":"feature","owner":"azure-pipeline@test.com","created_at":"2026-05-09T03:13:59Z","created_by":"Azure Pipeline","updated_at":"2026-05-09T03:13:59Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/feature_list.json b/feature_list.json index 67be6c0..91077c7 100644 --- a/feature_list.json +++ b/feature_list.json @@ -2,7 +2,7 @@ { "name": "Tag filtering endpoint", "description": "GET /api/notes?tag=work returns only notes with that tag. Already partially implemented — needs tests.", - "passes": false + "passes": true }, { "name": "Full-text search", diff --git a/progress.md b/progress.md index e75b604..403cdd8 100644 --- a/progress.md +++ b/progress.md @@ -31,3 +31,15 @@ SESSION_DONE --- Status after 2 sessions: 2/4 features done, 2 remaining (Pagination, Note archiving) + +## Session 3 — 2026-05-15 + +### Feature: Tag filtering endpoint +- Tag filtering already implemented in src/api/notes.ts (lines 17-20) +- Existing test covered single-tag case; added two more tests: + - No match: returns empty array when tag doesn't exist on any note + - Multiple tags: note with multiple tags is returned when filtering by any one of them +- All 23 tests pass +- Updated feature_list.json: "Tag filtering endpoint" -> passes: true + +SESSION_DONE diff --git a/tests/notes.test.ts b/tests/notes.test.ts index 45553cf..d454105 100644 --- a/tests/notes.test.ts +++ b/tests/notes.test.ts @@ -39,6 +39,30 @@ describe("GET /api/notes", () => { expect(res.body[0].title).toBe("Tagged"); }); + it("returns empty array when no notes match tag", async () => { + await request(app) + .post("/api/notes") + .send({ title: "Work note", content: "Body", tags: ["work"] }); + + const res = await request(app).get("/api/notes?tag=nonexistent"); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it("returns note that has multiple tags when filtering by one of them", async () => { + await request(app) + .post("/api/notes") + .send({ title: "Multi-tag", content: "Body", tags: ["work", "important", "review"] }); + await request(app) + .post("/api/notes") + .send({ title: "Single-tag", content: "Body", tags: ["personal"] }); + + const res = await request(app).get("/api/notes?tag=important"); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].title).toBe("Multi-tag"); + }); + it("searches by query string", async () => { await request(app) .post("/api/notes") From db405caeb5eeab59b4e7425f70e4488a678159d1 Mon Sep 17 00:00:00 2001 From: Akhil Kumar Date: Fri, 15 May 2026 02:34:07 -0400 Subject: [PATCH 2/6] feat: add full-text search edge case tests --- feature_list.json | 2 +- progress.md | 15 ++++++++++++++ tests/notes.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/feature_list.json b/feature_list.json index 91077c7..692f455 100644 --- a/feature_list.json +++ b/feature_list.json @@ -7,7 +7,7 @@ { "name": "Full-text search", "description": "GET /api/notes?q=meeting searches title and content. Already partially implemented — needs edge case tests (empty query, no matches).", - "passes": false + "passes": true }, { "name": "Pagination support", diff --git a/progress.md b/progress.md index 403cdd8..1ae3a55 100644 --- a/progress.md +++ b/progress.md @@ -43,3 +43,18 @@ Status after 2 sessions: 2/4 features done, 2 remaining (Pagination, Note archiv - Updated feature_list.json: "Tag filtering endpoint" -> passes: true SESSION_DONE + +## Session 4 — 2026-05-15 + +### Feature: Full-text search +- Search by ?q= already implemented in src/api/notes.ts (lines 22-28) — case-insensitive, searches title and content +- Empty string query handled correctly: `if (q)` is falsy for "", so no filter applied (returns all) +- Added 4 edge case tests: + - Match in content (not title): verifies content field is searched + - No match returns empty array + - Empty query string returns all notes + - Case-insensitive: uppercase title found by lowercase query +- All 27 tests pass +- Updated feature_list.json: "Full-text search" -> passes: true + +SESSION_DONE diff --git a/tests/notes.test.ts b/tests/notes.test.ts index d454105..9c3d336 100644 --- a/tests/notes.test.ts +++ b/tests/notes.test.ts @@ -75,6 +75,54 @@ describe("GET /api/notes", () => { expect(res.body).toHaveLength(1); expect(res.body[0].title).toBe("Meeting notes"); }); + + it("searches in content field (not just title)", async () => { + await request(app) + .post("/api/notes") + .send({ title: "Daily log", content: "Had a team meeting today", tags: [] }); + await request(app) + .post("/api/notes") + .send({ title: "Random note", content: "Nothing interesting", tags: [] }); + + const res = await request(app).get("/api/notes?q=meeting"); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].title).toBe("Daily log"); + }); + + it("returns empty array when no notes match search query", async () => { + await request(app) + .post("/api/notes") + .send({ title: "Note one", content: "Some content", tags: [] }); + + const res = await request(app).get("/api/notes?q=zzznomatch"); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it("returns all notes when search query is empty string", async () => { + await request(app) + .post("/api/notes") + .send({ title: "Note one", content: "Content", tags: [] }); + await request(app) + .post("/api/notes") + .send({ title: "Note two", content: "Content", tags: [] }); + + const res = await request(app).get("/api/notes?q="); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + }); + + it("search is case-insensitive", async () => { + await request(app) + .post("/api/notes") + .send({ title: "MEETING AGENDA", content: "Quarterly review", tags: [] }); + + const res = await request(app).get("/api/notes?q=meeting"); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].title).toBe("MEETING AGENDA"); + }); }); describe("GET /api/notes/:id", () => { From c362fcb3c804d8c76df070eb7e941ce82645cd3f Mon Sep 17 00:00:00 2001 From: Akhil Kumar Date: Fri, 15 May 2026 02:35:52 -0400 Subject: [PATCH 3/6] feat: add pagination support to GET /api/notes --- feature_list.json | 2 +- progress.md | 12 +++++++ src/api/notes.ts | 10 ++++-- tests/notes.test.ts | 77 ++++++++++++++++++++++++++++++++++++--------- 4 files changed, 83 insertions(+), 18 deletions(-) diff --git a/feature_list.json b/feature_list.json index 692f455..b049baa 100644 --- a/feature_list.json +++ b/feature_list.json @@ -12,7 +12,7 @@ { "name": "Pagination support", "description": "GET /api/notes?page=1&limit=10 returns paginated results with total count in response. Add pagination metadata: { data: [...], total: N, page: N, limit: N }.", - "passes": false + "passes": true }, { "name": "Note archiving", diff --git a/progress.md b/progress.md index 1ae3a55..074a097 100644 --- a/progress.md +++ b/progress.md @@ -58,3 +58,15 @@ SESSION_DONE - Updated feature_list.json: "Full-text search" -> passes: true SESSION_DONE + +## Session 5 — 2026-05-15 + +### Feature: Pagination support +- Changed GET /api/notes response format from plain array to `{ data, total, page, limit }` +- Default page=1, limit=20; both params are optional +- Updated all 10 existing GET /api/notes tests to use `res.body.data` instead of `res.body` +- Added 3 new pagination tests: page+limit params, default values, page beyond total +- All 30 tests pass +- Updated feature_list.json: "Pagination support" -> passes: true + +SESSION_DONE diff --git a/src/api/notes.ts b/src/api/notes.ts index c024028..2a71352 100644 --- a/src/api/notes.ts +++ b/src/api/notes.ts @@ -10,7 +10,7 @@ const router = Router(); // TODO: Return 400 if tags array contains duplicates // TODO: Add updatedAt timestamp on PUT (currently not updated) -// List all notes, with optional tag filter and search +// List all notes, with optional tag filter, search, and pagination router.get("/", (req: Request, res: Response) => { let notes = noteStore.getAll(); @@ -27,7 +27,13 @@ router.get("/", (req: Request, res: Response) => { ); } - res.json(notes); + const total = notes.length; + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const limit = Math.max(1, parseInt(req.query.limit as string) || 20); + const start = (page - 1) * limit; + const data = notes.slice(start, start + limit); + + res.json({ data, total, page, limit }); }); // Get a single note by ID diff --git a/tests/notes.test.ts b/tests/notes.test.ts index 9c3d336..e53b369 100644 --- a/tests/notes.test.ts +++ b/tests/notes.test.ts @@ -10,7 +10,8 @@ describe("GET /api/notes", () => { it("returns empty array when no notes exist", async () => { const res = await request(app).get("/api/notes"); expect(res.status).toBe(200); - expect(res.body).toEqual([]); + expect(res.body.data).toEqual([]); + expect(res.body.total).toBe(0); }); it("returns all notes", async () => { @@ -23,7 +24,7 @@ describe("GET /api/notes", () => { const res = await request(app).get("/api/notes"); expect(res.status).toBe(200); - expect(res.body).toHaveLength(2); + expect(res.body.data).toHaveLength(2); }); it("filters by tag", async () => { @@ -35,8 +36,8 @@ describe("GET /api/notes", () => { .send({ title: "Untagged", content: "Body", tags: ["personal"] }); const res = await request(app).get("/api/notes?tag=work"); - expect(res.body).toHaveLength(1); - expect(res.body[0].title).toBe("Tagged"); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].title).toBe("Tagged"); }); it("returns empty array when no notes match tag", async () => { @@ -46,7 +47,7 @@ describe("GET /api/notes", () => { const res = await request(app).get("/api/notes?tag=nonexistent"); expect(res.status).toBe(200); - expect(res.body).toEqual([]); + expect(res.body.data).toEqual([]); }); it("returns note that has multiple tags when filtering by one of them", async () => { @@ -59,8 +60,8 @@ describe("GET /api/notes", () => { const res = await request(app).get("/api/notes?tag=important"); expect(res.status).toBe(200); - expect(res.body).toHaveLength(1); - expect(res.body[0].title).toBe("Multi-tag"); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].title).toBe("Multi-tag"); }); it("searches by query string", async () => { @@ -72,8 +73,8 @@ describe("GET /api/notes", () => { .send({ title: "Shopping list", content: "Milk, eggs", tags: [] }); const res = await request(app).get("/api/notes?q=meeting"); - expect(res.body).toHaveLength(1); - expect(res.body[0].title).toBe("Meeting notes"); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].title).toBe("Meeting notes"); }); it("searches in content field (not just title)", async () => { @@ -86,8 +87,8 @@ describe("GET /api/notes", () => { const res = await request(app).get("/api/notes?q=meeting"); expect(res.status).toBe(200); - expect(res.body).toHaveLength(1); - expect(res.body[0].title).toBe("Daily log"); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].title).toBe("Daily log"); }); it("returns empty array when no notes match search query", async () => { @@ -97,7 +98,7 @@ describe("GET /api/notes", () => { const res = await request(app).get("/api/notes?q=zzznomatch"); expect(res.status).toBe(200); - expect(res.body).toEqual([]); + expect(res.body.data).toEqual([]); }); it("returns all notes when search query is empty string", async () => { @@ -110,7 +111,7 @@ describe("GET /api/notes", () => { const res = await request(app).get("/api/notes?q="); expect(res.status).toBe(200); - expect(res.body).toHaveLength(2); + expect(res.body.data).toHaveLength(2); }); it("search is case-insensitive", async () => { @@ -120,8 +121,54 @@ describe("GET /api/notes", () => { const res = await request(app).get("/api/notes?q=meeting"); expect(res.status).toBe(200); - expect(res.body).toHaveLength(1); - expect(res.body[0].title).toBe("MEETING AGENDA"); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].title).toBe("MEETING AGENDA"); + }); + + it("paginates results with page and limit params", async () => { + for (let i = 1; i <= 25; i++) { + await request(app) + .post("/api/notes") + .send({ title: `Note ${i}`, content: "Content", tags: [] }); + } + + const page1 = await request(app).get("/api/notes?page=1&limit=10"); + expect(page1.status).toBe(200); + expect(page1.body.data).toHaveLength(10); + expect(page1.body.total).toBe(25); + expect(page1.body.page).toBe(1); + expect(page1.body.limit).toBe(10); + + const page3 = await request(app).get("/api/notes?page=3&limit=10"); + expect(page3.body.data).toHaveLength(5); + expect(page3.body.total).toBe(25); + expect(page3.body.page).toBe(3); + }); + + it("defaults to page 1 with limit 20", async () => { + for (let i = 1; i <= 25; i++) { + await request(app) + .post("/api/notes") + .send({ title: `Note ${i}`, content: "Content", tags: [] }); + } + + const res = await request(app).get("/api/notes"); + expect(res.body.data).toHaveLength(20); + expect(res.body.total).toBe(25); + expect(res.body.page).toBe(1); + expect(res.body.limit).toBe(20); + }); + + it("returns empty data array when page is beyond total", async () => { + await request(app) + .post("/api/notes") + .send({ title: "Only note", content: "Content", tags: [] }); + + const res = await request(app).get("/api/notes?page=5&limit=10"); + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + expect(res.body.total).toBe(1); + expect(res.body.page).toBe(5); }); }); From 3efa5fc0f9fae65134949760c47a6eed4cc08503 Mon Sep 17 00:00:00 2001 From: Akhil Kumar Date: Fri, 15 May 2026 02:38:29 -0400 Subject: [PATCH 4/6] feat: add note archiving with archive/unarchive endpoints --- feature_list.json | 2 +- progress.md | 15 ++++++++ src/api/notes.ts | 25 +++++++++++++ src/models/note.ts | 9 +++++ tests/notes.test.ts | 87 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 1 deletion(-) diff --git a/feature_list.json b/feature_list.json index b049baa..e62420d 100644 --- a/feature_list.json +++ b/feature_list.json @@ -17,6 +17,6 @@ { "name": "Note archiving", "description": "Add an 'archived' boolean field to notes. POST /api/notes/:id/archive and /api/notes/:id/unarchive endpoints. GET /api/notes excludes archived notes by default; GET /api/notes?include_archived=true includes them.", - "passes": false + "passes": true } ] diff --git a/progress.md b/progress.md index 074a097..9bf337e 100644 --- a/progress.md +++ b/progress.md @@ -70,3 +70,18 @@ SESSION_DONE - Updated feature_list.json: "Pagination support" -> passes: true SESSION_DONE + +## Session 6 — 2026-05-15 + +### Feature: Note archiving +- Added `archived: boolean` (required, default false) to Note interface in src/models/note.ts +- Added `setArchived(id, archived)` method to noteStore — dedicated method rather than widening UpdateNoteInput +- Updated POST /api/notes to set `archived: false` on creation +- Updated GET /api/notes to filter out archived notes before tag/q/pagination (so `total` stays accurate); `?include_archived=true` bypasses the filter +- Added POST /api/notes/:id/archive and POST /api/notes/:id/unarchive endpoints (200+body on success, 404 if not found) +- GET /api/notes/:id still returns archived notes (plan only says to exclude from collection) +- Added 8 tests covering: hide archived from default GET, include_archived=true, unarchive restores visibility, GET /:id works on archived, 404 on both endpoints, pagination total excludes archived, archive response shape +- All 38 tests pass +- Updated feature_list.json: "Note archiving" -> passes: true + +SESSION_DONE diff --git a/src/api/notes.ts b/src/api/notes.ts index 2a71352..dc64d0d 100644 --- a/src/api/notes.ts +++ b/src/api/notes.ts @@ -14,6 +14,10 @@ const router = Router(); router.get("/", (req: Request, res: Response) => { let notes = noteStore.getAll(); + if (req.query.include_archived !== "true") { + notes = notes.filter((n) => !n.archived); + } + const tag = req.query.tag as string | undefined; if (tag) { notes = notes.filter((n) => n.tags.includes(tag)); @@ -58,6 +62,7 @@ router.post("/", (req: Request, res: Response) => { const note: Note = { id: uuidv4(), ...result.data, + archived: false, createdAt: now, updatedAt: now, }; @@ -84,6 +89,26 @@ router.put("/:id", (req: Request<{ id: string }>, res: Response) => { res.json(updated); }); +// Archive a note +router.post("/:id/archive", (req: Request<{ id: string }>, res: Response) => { + const updated = noteStore.setArchived(req.params.id, true); + if (!updated) { + res.status(404).json({ error: "Note not found" }); + return; + } + res.json(updated); +}); + +// Unarchive a note +router.post("/:id/unarchive", (req: Request<{ id: string }>, res: Response) => { + const updated = noteStore.setArchived(req.params.id, false); + if (!updated) { + res.status(404).json({ error: "Note not found" }); + return; + } + res.json(updated); +}); + // Delete a note router.delete("/:id", (req: Request<{ id: string }>, res: Response) => { const deleted = noteStore.delete(req.params.id); diff --git a/src/models/note.ts b/src/models/note.ts index 2eb5f75..177615a 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -3,6 +3,7 @@ export interface Note { title: string; content: string; tags: string[]; + archived: boolean; createdAt: string; updatedAt: string; } @@ -42,6 +43,14 @@ export const noteStore = { return updated; }, + setArchived(id: string, archived: boolean): Note | undefined { + const existing = notes.get(id); + if (!existing) return undefined; + const updated = { ...existing, archived }; + notes.set(id, updated); + return updated; + }, + delete(id: string): boolean { return notes.delete(id); }, diff --git a/tests/notes.test.ts b/tests/notes.test.ts index e53b369..2818252 100644 --- a/tests/notes.test.ts +++ b/tests/notes.test.ts @@ -250,6 +250,93 @@ describe("DELETE /api/notes/:id", () => { }); }); +describe("Note archiving", () => { + it("archived note is excluded from GET /api/notes by default", async () => { + const create = await request(app) + .post("/api/notes") + .send({ title: "To archive", content: "Body", tags: [] }); + + await request(app).post(`/api/notes/${create.body.id}/archive`); + + const res = await request(app).get("/api/notes"); + expect(res.body.data.map((n: { id: string }) => n.id)).not.toContain(create.body.id); + expect(res.body.total).toBe(0); + }); + + it("archived note is included when include_archived=true", async () => { + const create = await request(app) + .post("/api/notes") + .send({ title: "Archived", content: "Body", tags: [] }); + + await request(app).post(`/api/notes/${create.body.id}/archive`); + + const res = await request(app).get("/api/notes?include_archived=true"); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].archived).toBe(true); + }); + + it("unarchived note is visible again in default GET", async () => { + const create = await request(app) + .post("/api/notes") + .send({ title: "Unarchive me", content: "Body", tags: [] }); + + await request(app).post(`/api/notes/${create.body.id}/archive`); + await request(app).post(`/api/notes/${create.body.id}/unarchive`); + + const res = await request(app).get("/api/notes"); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].archived).toBe(false); + }); + + it("GET /:id returns archived note", async () => { + const create = await request(app) + .post("/api/notes") + .send({ title: "Archived single", content: "Body", tags: [] }); + + await request(app).post(`/api/notes/${create.body.id}/archive`); + + const res = await request(app).get(`/api/notes/${create.body.id}`); + expect(res.status).toBe(200); + expect(res.body.archived).toBe(true); + }); + + it("archive returns 404 for non-existent note", async () => { + const res = await request(app).post("/api/notes/no-such-id/archive"); + expect(res.status).toBe(404); + }); + + it("unarchive returns 404 for non-existent note", async () => { + const res = await request(app).post("/api/notes/no-such-id/unarchive"); + expect(res.status).toBe(404); + }); + + it("pagination total counts only non-archived notes by default", async () => { + for (let i = 1; i <= 5; i++) { + await request(app) + .post("/api/notes") + .send({ title: `Note ${i}`, content: "Content", tags: [] }); + } + const list = await request(app).get("/api/notes"); + const firstId = list.body.data[0].id; + await request(app).post(`/api/notes/${firstId}/archive`); + + const res = await request(app).get("/api/notes"); + expect(res.body.total).toBe(4); + expect(res.body.data).toHaveLength(4); + }); + + it("archive endpoint returns updated note with archived=true", async () => { + const create = await request(app) + .post("/api/notes") + .send({ title: "Check response", content: "Body", tags: [] }); + + const res = await request(app).post(`/api/notes/${create.body.id}/archive`); + expect(res.status).toBe(200); + expect(res.body.archived).toBe(true); + expect(res.body.id).toBe(create.body.id); + }); +}); + describe("GET /health", () => { it("returns ok status", async () => { const res = await request(app).get("/health"); From 0d0e8ddcb1bfd5d9cbff8253af44bce9ea4c9c88 Mon Sep 17 00:00:00 2001 From: Akhil Kumar Date: Fri, 15 May 2026 02:39:39 -0400 Subject: [PATCH 5/6] feat: implement help and --version, add input validation and tests --- src/index.ts | 23 +++++++++++- src/utils/validation.ts | 14 +++---- tests/validation.test.ts | 79 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9e7603c..4492b1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,25 @@ -import app from "./app"; +import app from "./app"; +import { version } from "../package.json"; + +const args = process.argv.slice(2); + +if (args.includes("--version")) { + console.log(`v${version}`); + process.exit(0); +} + +if (args.includes("help")) { + console.log("NoteAPI - A simple REST API for managing notes"); + console.log(""); + console.log("Usage:"); + console.log(" npm start Start the server"); + console.log(" npm start -- --version Show version information"); + console.log(" npm start -- help Show this help message"); + console.log(""); + console.log("Environment Variables:"); + console.log(" PORT Port to listen on (default: 3000)"); + process.exit(0); +} const PORT = process.env.PORT ?? 3000; diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 9cfb289..885d2c8 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -1,4 +1,4 @@ -import { CreateNoteInput, UpdateNoteInput } from "../models/note"; +import { CreateNoteInput, UpdateNoteInput } from "../models/note"; export interface ValidationError { field: string; @@ -20,8 +20,8 @@ export function validateCreateInput( errors.push({ field: "title", message: "Title is required and must be a non-empty string" }); } - if (typeof obj.content !== "string") { - errors.push({ field: "content", message: "Content must be a string" }); + if (typeof obj.content !== "string" || obj.content.trim().length === 0) { + errors.push({ field: "content", message: "Content is required and must be a non-empty string" }); } if (obj.tags !== undefined) { @@ -36,7 +36,7 @@ export function validateCreateInput( valid: true, data: { title: (obj.title as string).trim(), - content: obj.content as string, + content: (obj.content as string).trim(), tags: (obj.tags as string[] | undefined) ?? [], }, }; @@ -57,8 +57,8 @@ export function validateUpdateInput( errors.push({ field: "title", message: "Title must be a non-empty string" }); } - if (obj.content !== undefined && typeof obj.content !== "string") { - errors.push({ field: "content", message: "Content must be a string" }); + if (obj.content !== undefined && (typeof obj.content !== "string" || obj.content.trim().length === 0)) { + errors.push({ field: "content", message: "Content must be a non-empty string" }); } if (obj.tags !== undefined) { @@ -71,7 +71,7 @@ export function validateUpdateInput( const data: UpdateNoteInput = {}; if (obj.title !== undefined) data.title = (obj.title as string).trim(); - if (obj.content !== undefined) data.content = obj.content as string; + if (obj.content !== undefined) data.content = (obj.content as string).trim(); if (obj.tags !== undefined) data.tags = obj.tags as string[]; return { valid: true, data }; diff --git a/tests/validation.test.ts b/tests/validation.test.ts index f55f70f..b10fbc9 100644 --- a/tests/validation.test.ts +++ b/tests/validation.test.ts @@ -1,4 +1,4 @@ -import { validateCreateInput, validateUpdateInput } from "../src/utils/validation"; +import { validateCreateInput, validateUpdateInput } from "../src/utils/validation"; describe("validateCreateInput", () => { it("accepts valid input with all fields", () => { @@ -10,10 +10,23 @@ describe("validateCreateInput", () => { expect(result.valid).toBe(true); if (result.valid) { expect(result.data.title).toBe("My Note"); + expect(result.data.content).toBe("Some content"); expect(result.data.tags).toEqual(["work", "urgent"]); } }); + it("trims title and content", () => { + const result = validateCreateInput({ + title: " My Note ", + content: " Some content ", + }); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data.title).toBe("My Note"); + expect(result.data.content).toBe("Some content"); + } + }); + it("defaults tags to empty array when omitted", () => { const result = validateCreateInput({ title: "Note", content: "Body" }); expect(result.valid).toBe(true); @@ -26,7 +39,31 @@ describe("validateCreateInput", () => { const result = validateCreateInput({ content: "Body" }); expect(result.valid).toBe(false); if (!result.valid) { - expect(result.errors[0].field).toBe("title"); + expect(result.errors.some(e => e.field === "title")).toBe(true); + } + }); + + it("rejects blank title", () => { + const result = validateCreateInput({ title: " ", content: "Body" }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some(e => e.field === "title")).toBe(true); + } + }); + + it("rejects missing content", () => { + const result = validateCreateInput({ title: "Title" }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some(e => e.field === "content")).toBe(true); + } + }); + + it("rejects blank content", () => { + const result = validateCreateInput({ title: "Title", content: " " }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some(e => e.field === "content")).toBe(true); } }); @@ -39,7 +76,7 @@ describe("validateCreateInput", () => { const result = validateCreateInput({ title: "Note", content: "Body", tags: [1, 2] }); expect(result.valid).toBe(false); if (!result.valid) { - expect(result.errors[0].field).toBe("tags"); + expect(result.errors.some(e => e.field === "tags")).toBe(true); } }); }); @@ -54,9 +91,45 @@ describe("validateUpdateInput", () => { } }); + it("trims updated fields", () => { + const result = validateUpdateInput({ title: " Updated ", content: " New Content " }); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data.title).toBe("Updated"); + expect(result.data.content).toBe("New Content"); + } + }); + it("rejects empty title string", () => { const result = validateUpdateInput({ title: "" }); expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some(e => e.field === "title")).toBe(true); + } + }); + + it("rejects blank title string", () => { + const result = validateUpdateInput({ title: " " }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some(e => e.field === "title")).toBe(true); + } + }); + + it("rejects empty content string", () => { + const result = validateUpdateInput({ content: "" }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some(e => e.field === "content")).toBe(true); + } + }); + + it("rejects blank content string", () => { + const result = validateUpdateInput({ content: " " }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some(e => e.field === "content")).toBe(true); + } }); it("accepts empty object (no-op update)", () => { From 6678fa8d7267bbf5c37929522b31ff40390a7fde Mon Sep 17 00:00:00 2001 From: Akhil Kumar Date: Fri, 15 May 2026 02:41:59 -0400 Subject: [PATCH 6/6] docs: add update comment to index.ts during e2e sprint --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 4492b1f..fef5eea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,3 +26,5 @@ const PORT = process.env.PORT ?? 3000; app.listen(PORT, () => { console.log(`NoteAPI running on http://localhost:${PORT}`); }); + +// Updated during e2e sprint