diff --git a/.gitignore b/.gitignore index f500c1db..ad37355c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,15 @@ -# Evaluation internals -RUBRIC.md +# Dependencies +task-api/node_modules/ -# Node -node_modules/ -coverage/ -.nyc_output/ +# Test / tooling output +task-api/coverage/ +*.log -# Environment -.env -.env.local -.env.*.local - -# OS +# OS / editor .DS_Store -Thumbs.db - -# IDE -.vscode/ .idea/ -*.swp -*.swo -*~ +.vscode/ + +# Env (if added later) +.env +.env.* diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md new file mode 100644 index 00000000..4e90d4b6 --- /dev/null +++ b/DEVELOPMENT_PLAN.md @@ -0,0 +1,301 @@ +# Complete Development Plan - The Untested API + +## Objective + +Deliver a production-ready submission for the take-home assignment by: + +- Writing unit and integration tests +- Reaching at least 80% test coverage +- Discovering and documenting bugs +- Fixing one confirmed bug +- Implementing `PATCH /tasks/:id/assign` with tests +- Providing clear submission notes + +--- + +## Scope and Deliverables + +### Required Deliverables + +1. Test suite in `task-api/tests/` + - Unit tests for `src/services/taskService.js` + - Integration tests for routes using Supertest +2. Coverage report output (`npm run coverage`) +3. Bug report file (recommended: `BUG_REPORT.md`) +4. One implemented bug fix in source code +5. New endpoint: `PATCH /tasks/:id/assign` +6. Tests for new endpoint +7. Final short note with: + - What to test next + - What was surprising + - Questions before production release + +### Out of Scope + +- Database migration or external persistence +- Large refactor unrelated to assignment goals +- UI or frontend work + +--- + +## Project Understanding Checklist + +Read and map behavior in: + +- `task-api/src/app.js` +- `task-api/src/routes/tasks.js` +- `task-api/src/services/taskService.js` +- `task-api/src/utils/validators.js` + +Build a quick endpoint contract table while reading: + +- Expected request shape +- Validation rules +- Success status code and response +- Error paths (`400`, `404`, etc.) +- Data mutation side effects + +Note and track any spec mismatch: + +- `ASSIGNMENT.md` task status: `todo | in_progress | done` +- `README.md` task status: `pending | in-progress | completed` + +Record this as a clarification risk and keep implementation consistent with actual code behavior and tests. + +--- + +## Implementation Plan by Phase + +## Phase 1 - Environment and Baseline (Day 1, 1-2 hours) + +1. Setup + - `cd task-api` + - `npm install` + - `npm start` (verify server boots) +2. Baseline tests + - `npm test` + - `npm run coverage` +3. Capture initial status + - Existing failures (if any) + - Initial coverage percentage + +Exit criteria: + +- App runs successfully +- Tests/coverage commands execute locally + +## Phase 2 - Unit Tests for Service Layer (Day 1, 2-3 hours) + +Create tests for core service behaviors in `taskService.js`: + +- Create task + - valid input + - missing/invalid fields +- List tasks + - no filters + - status filter + - pagination behavior +- Update task + - task exists + - task missing + - invalid updates +- Delete task + - success path + - missing task +- Complete task + - transitions status and completion timestamp + - idempotency/duplicate completion behavior (if applicable) +- Stats + - counts by status + - overdue logic + +Test design rules: + +- Keep tests deterministic (especially dates) +- Reset in-memory data between tests +- Validate observable behavior, not private internals + +Exit criteria: + +- Service-level tests cover happy paths and edge conditions + +## Phase 3 - Integration Tests for API Routes (Day 1, 3-4 hours) + +Create route tests with Supertest: + +- `GET /tasks` + - returns list + - accepts valid query params + - rejects/handles invalid params +- `POST /tasks` + - creates task + - rejects invalid body +- `PUT /tasks/:id` + - updates existing task + - returns not found for missing id + - rejects invalid payload +- `DELETE /tasks/:id` + - returns `204` on success + - handles missing id +- `PATCH /tasks/:id/complete` + - marks complete and returns updated task + - handles missing id / invalid state transitions +- `GET /tasks/stats` + - verifies correct counts and overdue calculation + +Minimum quality bar per endpoint: + +- At least one happy path +- At least two edge/error scenarios + +Exit criteria: + +- All primary endpoints covered by integration tests + +## Phase 4 - Coverage Hardening to 80%+ (End of Day 1, 1-2 hours) + +Use coverage report to target weak branches: + +- Validation branches +- Not-found branches +- Date boundary logic +- Pagination bounds/defaults +- Optional-field handling + +Add only meaningful tests; avoid synthetic coverage padding. + +Exit criteria: + +- Total coverage >= 80% +- Branch coverage is reasonably strong in critical modules + +## Phase 5 - Bug Discovery and Report (Day 2, 1-2 hours) + +From failing tests or suspicious behavior, document bugs in `BUG_REPORT.md`. + +Template per bug: + +1. Title +2. Expected behavior +3. Actual behavior +4. Reproduction steps +5. Where it lives (file/function) +6. Why it happens (root cause) +7. Proposed fix + +Prioritize bugs with: + +- Incorrect API behavior +- Data integrity risk +- Spec/contract violation + +Exit criteria: + +- Clear, reproducible bug report tied to tests + +## Phase 6 - Fix One Bug (Day 2, 1-2 hours) + +Pick one bug from the report and implement the smallest correct fix: + +1. Add or keep a failing test that reproduces the bug +2. Implement source fix +3. Verify test now passes +4. Run full suite to ensure no regressions + +Exit criteria: + +- One bug fixed with regression protection tests + +## Phase 7 - Implement New Endpoint `/tasks/:id/assign` (Day 2, 2-3 hours) + +Endpoint contract: + +- Method: `PATCH /tasks/:id/assign` +- Body: `{ "assignee": "string" }` +- Success: returns updated task +- Not found: `404` if task id does not exist + +Validation decisions (recommended): + +- `assignee` must exist +- `assignee` must be a string +- Trim whitespace and reject empty result (`400`) +- Reassignment is allowed; latest valid value overwrites old value + +Test cases: + +- Assign success +- Missing `assignee` +- Non-string `assignee` +- Empty/whitespace-only `assignee` +- Task id not found (`404`) +- Reassign existing assignee (if policy allows) + +Implementation touchpoints: + +- `src/services/taskService.js` +- `src/routes/tasks.js` +- `src/utils/validators.js` (if centralized validation is used) + +Exit criteria: + +- Endpoint implemented and fully tested + +## Phase 8 - Final Verification and Submission (Day 2, 1 hour) + +Final commands: + +- `npm test` +- `npm run coverage` + +Final checks: + +- All tests pass +- Coverage target achieved +- Bug report included +- One bug fix included +- New assign endpoint + tests included +- Final reflection note included + +Submission package should contain: + +- Test files +- `BUG_REPORT.md` +- Source updates +- Short final note for reviewers + +--- + +## Suggested File Plan + +- `task-api/tests/taskService.test.js` +- `task-api/tests/tasks.routes.test.js` +- `BUG_REPORT.md` +- `DEVELOPMENT_PLAN.md` (this file) +- `FLOW_DIAGRAMS.md` (process + endpoint flows) + +--- + +## Risks and Mitigations + +1. Inconsistent status enums across docs + - Mitigation: enforce one enum in tests, note mismatch in final notes. +2. Flaky time-based assertions + - Mitigation: use fixed dates/mocked time and explicit timezone handling. +3. Shared in-memory state leaking across tests + - Mitigation: reset service state per test suite/test case. +4. Over-testing internals + - Mitigation: assert API/service behavior and contracts only. + +--- + +## Definition of Done + +The assignment is complete when: + +- Tests are comprehensive and stable +- Coverage is at or above target +- Bugs are documented with root causes +- One real bug is fixed with test proof +- Assign endpoint is implemented with robust validation +- Final submission notes are clear and technically grounded diff --git a/FLOW_DIAGRAMS.md b/FLOW_DIAGRAMS.md new file mode 100644 index 00000000..25b0b479 --- /dev/null +++ b/FLOW_DIAGRAMS.md @@ -0,0 +1,135 @@ +# Flow Diagrams - The Untested API + +This document provides implementation and testing flows in Mermaid format. + +--- + +## 1) Overall Development Workflow + +```mermaid +flowchart TD + A[Start Assignment] --> B[Read src files and API contracts] + B --> C[Run app and baseline tests] + C --> D[Write unit tests for taskService] + D --> E[Write integration tests for routes] + E --> F{Coverage >= 80%?} + F -- No --> G[Add missing edge and branch tests] + G --> F + F -- Yes --> H[Document bugs in BUG_REPORT.md] + H --> I[Pick one bug to fix] + I --> J[Implement fix and update tests] + J --> K[Implement PATCH /tasks/:id/assign] + K --> L[Add endpoint tests] + L --> M[Run full test suite and coverage] + M --> N[Prepare final submission notes] + N --> O[Submit branch/fork link] +``` + +--- + +## 2) Test Development Flow (TDD-leaning) + +```mermaid +flowchart LR + A[Select behavior to test] --> B[Write failing test] + B --> C[Run tests and confirm failure] + C --> D[Implement minimal code change] + D --> E[Run tests] + E --> F{Pass?} + F -- No --> D + F -- Yes --> G[Refactor if needed] + G --> H[Run full suite + coverage] +``` + +--- + +## 3) API Request Handling Flow + +```mermaid +flowchart TD + A[HTTP Request] --> B[Route match in routes/tasks.js] + B --> C[Validate params/body/query] + C --> D{Valid?} + D -- No --> E[Return 400 with error message] + D -- Yes --> F[Call taskService function] + F --> G{Resource found?} + G -- No --> H[Return 404] + G -- Yes --> I[Apply business logic/mutation] + I --> J[Return success response JSON] +``` + +--- + +## 4) Bug Discovery and Fix Flow + +```mermaid +flowchart TD + A[Test fails or unexpected behavior] --> B[Capture reproduction steps] + B --> C[Inspect service/route/validator code] + C --> D[Identify root cause] + D --> E[Write bug entry in BUG_REPORT.md] + E --> F[Create or keep failing regression test] + F --> G[Implement targeted fix] + G --> H[Run focused tests] + H --> I[Run full suite] + I --> J{Regression free?} + J -- No --> G + J -- Yes --> K[Mark bug as fixed] +``` + +--- + +## 5) New Endpoint Flow: PATCH /tasks/:id/assign + +```mermaid +flowchart TD + A[PATCH /tasks/:id/assign] --> B[Extract id and assignee] + B --> C[Validate assignee exists and is string] + C --> D{Valid type/content?} + D -- No --> E[Return 400] + D -- Yes --> F[Trim assignee] + F --> G{Trimmed assignee empty?} + G -- Yes --> E + G -- No --> H[Find task by id] + H --> I{Task exists?} + I -- No --> J[Return 404] + I -- Yes --> K[Set task.assignee = value] + K --> L[Return updated task 200] +``` + +--- + +## 6) Coverage Improvement Loop + +```mermaid +flowchart LR + A[Run npm run coverage] --> B[Review low coverage files] + B --> C[Identify uncovered branches] + C --> D[Add meaningful tests] + D --> E[Re-run coverage] + E --> F{Target reached?} + F -- No --> B + F -- Yes --> G[Finalize] +``` + +--- + +## 7) Submission Readiness Gate + +```mermaid +flowchart TD + A[All code changes done] --> B{All tests pass?} + B -- No --> C[Fix failing tests] + C --> B + B -- Yes --> D{Coverage >= 80%?} + D -- No --> E[Add targeted coverage tests] + E --> D + D -- Yes --> F{Bug report present?} + F -- No --> G[Write BUG_REPORT.md] + G --> F + F -- Yes --> H{Assign endpoint + tests complete?} + H -- No --> I[Finish endpoint implementation] + I --> H + H -- Yes --> J[Write final reflection notes] + J --> K[Ready to submit] +``` diff --git a/PR_TO_EMPLOYER.md b/PR_TO_EMPLOYER.md new file mode 100644 index 00000000..6fe0bfa1 --- /dev/null +++ b/PR_TO_EMPLOYER.md @@ -0,0 +1,119 @@ +# How to open a PR to the employer repo + +**Employer (upstream) repository:** +[https://github.com/rohit-ups/Take-Home-Assignment-The-Untested-API](https://github.com/rohit-ups/Take-Home-Assignment-The-Untested-API) + +You open a **Pull Request** from **your fork** → into **`rohit-ups/Take-Home-Assignment-The-Untested-API`** branch **`main`** (unless they told you another base branch). + +--- + +## Why use the “clone fork + copy files” method + +Your work folder was turned into its **own** git repo with new commits. It was **not** created by `git clone` of the employer repo, so its **commit history does not match** GitHub’s copy. Pushing straight to a fork often causes “unrelated histories” errors. + +The most reliable approach: **fork on GitHub → clone your fork → copy your finished files into that clone → commit → push → PR.** + +--- + +## Step 1 — Fork (in the browser) + +1. While signed into **your** GitHub account, open: + [https://github.com/rohit-ups/Take-Home-Assignment-The-Untested-API](https://github.com/rohit-ups/Take-Home-Assignment-The-Untested-API) +2. Click **Fork** (top right). +3. Create the fork under your account (same repo name is fine). + +After that, **your** repo will be: + +`https://github.com/YOUR_GITHUB_USERNAME/Take-Home-Assignment-The-Untested-API` + +--- + +## Step 2 — Clone **your** fork (new folder) + +Replace `YOUR_GITHUB_USERNAME`: + +```bash +cd ~/Downloads +git clone https://github.com/YOUR_GITHUB_USERNAME/Take-Home-Assignment-The-Untested-API.git take-home-pr +cd take-home-pr +``` + +(You can use SSH instead of HTTPS if you already use SSH keys with GitHub.) + +--- + +## Step 3 — Copy your completed work into the clone + +Your finished project (with tests, fixes, docs) is here: + +`/Users/tanya/Downloads/Take-Home-Assignment-The-Untested-API-main` + +Copy **everything** from that folder **into** `~/Downloads/take-home-pr`, **overwriting** matching files, **except** do not copy these into the fork (they are huge or machine-local): + +- `Take-Home-Assignment-The-Untested-API-main/task-api/node_modules/` +- `Take-Home-Assignment-The-Untested-API-main/task-api/coverage/` +- `Take-Home-Assignment-The-Untested-API-main/.git/` (do **not** overwrite the clone’s `.git` folder) + +Easiest on macOS: use **Finder** — drag folders like `task-api`, root markdown files, `.gitignore`, etc., into `take-home-pr`, choose **Replace** when asked. + +After copy, you should see in the clone, for example: + +- `task-api/tests/*.test.js` +- `task-api/BUG_REPORT.md` +- `SUBMISSION_NOTES.md`, `PUSH_AND_PR.md`, `PR_TO_EMPLOYER.md` (if you copied them) +- Root `ASSIGNMENT.md`, `README.md`, etc. + +--- + +## Step 4 — Commit and push from the clone + +```bash +cd ~/Downloads/take-home-pr +git status +git add -A +git commit -m "Complete take-home: tests, bug fixes, assign endpoint, docs" +git push -u origin main +``` + +If `git commit` says “nothing to commit”, either the copy did not land in this folder or files match exactly — run `git status` and fix paths. + +--- + +## Step 5 — Open the Pull Request on GitHub + +1. Open **your fork** in the browser: + `https://github.com/YOUR_GITHUB_USERNAME/Take-Home-Assignment-The-Untested-API` +2. GitHub usually shows a yellow banner: **Compare & pull request** — click it. + +If you do not see it: + +1. Go to the **upstream** repo: + [https://github.com/rohit-ups/Take-Home-Assignment-The-Untested-API](https://github.com/rohit-ups/Take-Home-Assignment-The-Untested-API) +2. Click **Pull requests** → **New pull request** +3. Set **base** repository: `rohit-ups/Take-Home-Assignment-The-Untested-API`, branch **`main`** +4. Set **head** repository: **your fork**, branch **`main`** +5. Create pull request + +--- + +## Step 6 — PR description (paste from your repo) + +Copy the “How to run”, coverage summary, and reflection bullets from **`SUBMISSION_NOTES.md`** into the PR description. Add a line that bugs are listed in **`task-api/BUG_REPORT.md`**. + +--- + +## If `git push` asks for a password + +GitHub no longer accepts account passwords for Git over HTTPS. Use a **Personal Access Token** or switch to **SSH**. Official guides: [Creating a personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token), [Connecting with SSH](https://docs.github.com/en/authentication/connecting-to-github-with-ssh). + +--- + +## Optional: keep `upstream` in your clone for updates + +```bash +cd ~/Downloads/take-home-pr +git remote add upstream https://github.com/rohit-ups/Take-Home-Assignment-The-Untested-API.git +git fetch upstream +``` + +You only need this if you want to pull future changes from the employer repo. diff --git a/PUSH_AND_PR.md b/PUSH_AND_PR.md new file mode 100644 index 00000000..8ff99014 --- /dev/null +++ b/PUSH_AND_PR.md @@ -0,0 +1,60 @@ +# Push this folder to GitHub and open a PR + +Your project is already a **git repo** with commits on branch `main`. I cannot log into your GitHub account from here, so you complete the last steps once you have a repo URL. + +## Step 1 — Create a place on GitHub for this code + +Pick **one** of these (whatever your employer said): + +**A) They gave you a repo to fork** + +1. Open their link in the browser. +2. Click **Fork** (top right). +3. After fork exists, copy your fork’s URL. It looks like: + - `https://github.com/YOUR_USERNAME/REPO_NAME.git` + or + - `git@github.com:YOUR_USERNAME/REPO_NAME.git` + +**B) They did not give a repo — use your own new repo** + +1. On GitHub: **New repository** (any name, e.g. `take-home-task-api`). +2. Leave “Initialize with README” **unchecked** (you already have files locally). +3. Copy the URL GitHub shows for “push an existing repository”. + +## Step 2 — Send me that link + +Reply with **exactly**: + +- The **HTTPS** or **SSH** clone URL of **your** fork or **your** new repo (example: `https://github.com/tanya/take-home-task-api.git`). + +Optional but helpful: + +- If they asked for the PR **into a specific branch** (e.g. `main` vs `develop`), say which. + +## Step 3 — You run these commands (or I run them after you confirm the URL) + +In Terminal: + +```bash +cd /Users/tanya/Downloads/Take-Home-Assignment-The-Untested-API-main +git remote add origin PASTE_YOUR_REPO_URL_HERE +git branch -M main +git push -u origin main +``` + +If `origin` already exists and is wrong: + +```bash +git remote remove origin +git remote add origin PASTE_YOUR_REPO_URL_HERE +git push -u origin main +``` + +## Step 4 — Open the PR + +- If you **forked** their repo: on GitHub, open **your fork** → you should see **Compare & pull request** → target = **their** `main` (or branch they specified). +- If you used **only your own repo**: there is no PR unless they gave you a second repo to open a PR **into** — then you’d need their instructions (sometimes they only want the link to your repo). + +## If GitHub asks for a password + +Use a **Personal Access Token** (HTTPS) or **SSH key** (SSH). GitHub’s docs: [HTTPS](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) and [SSH](https://docs.github.com/en/authentication/connecting-to-github-with-ssh). diff --git a/SUBMISSION_NOTES.md b/SUBMISSION_NOTES.md new file mode 100644 index 00000000..acc50964 --- /dev/null +++ b/SUBMISSION_NOTES.md @@ -0,0 +1,53 @@ +# Submission notes + +These are the notes submitted with the take-home (same content as the Pull Request description). Keeping this file in the repo makes the submission easy to find and review. + +## How to run + +```bash +cd task-api +npm install +npm test +npm run coverage +``` + +## Coverage + +Last run from `task-api` (`npm run coverage`), above the 80% target: + +``` +-----------------|---------|----------|---------|---------|------------------- +File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s +-----------------|---------|----------|---------|---------|------------------- +All files | 94.23 | 86.2 | 93.33 | 93.66 | + src | 69.23 | 75 | 0 | 69.23 | + app.js | 69.23 | 75 | 0 | 69.23 | 10-11,17-18 + src/routes | 100 | 91.66 | 100 | 100 | + tasks.js | 100 | 91.66 | 100 | 100 | 20-21 + src/services | 100 | 94.73 | 100 | 100 | + taskService.js | 100 | 94.73 | 100 | 100 | 22 + src/utils | 82.75 | 80 | 100 | 82.75 | + validators.js | 82.75 | 80 | 100 | 82.75 | 9,12,25,28,31 +-----------------|---------|----------|---------|---------|------------------- +Test Suites: 2 passed, 2 total +Tests: 32 passed, 32 total +``` + +## What I would test next with more time + +- Invalid `page` / `limit` (zero, negative, non-numeric) and consistent error responses across list endpoints. +- Concurrent requests against the in-memory store (race conditions are possible without a DB). +- Contract tests for response JSON schema (e.g. with a small schema validator). +- Load or soak tests if this were headed to production traffic. + +## What surprised me + +- The README status labels differ from `ASSIGNMENT.md` / code (`todo`, `in_progress`, `done` in code and validators). +- Pagination is easy to get wrong with off-by-one page indexing; tests caught that quickly. + +## Questions before shipping to production + +- Persistence: where should tasks live (Postgres, etc.) and what migration strategy? +- Auth: who can create, update, delete, or assign tasks? +- Idempotency and audit: should assign/complete be logged; do we need `ETag` / versioning? +- SLA and deployment: hosting, health checks, rate limits, and observability (metrics/traces). diff --git a/task-api/BUG_REPORT.md b/task-api/BUG_REPORT.md new file mode 100644 index 00000000..8a1f2c61 --- /dev/null +++ b/task-api/BUG_REPORT.md @@ -0,0 +1,19 @@ +# Bug Report + +## Bug 1: Pagination skips first page items + +- **Location:** `src/services/taskService.js` in `getPaginated(page, limit)` +- **Expected:** `page=1&limit=10` returns items 1-10. +- **Actual:** `page=1` starts from offset `10`, effectively skipping first records. +- **How discovered:** Added pagination tests in `tests/taskService.test.js` and `tests/tasks.routes.test.js`. +- **Root cause:** Offset used `page * limit` instead of `(page - 1) * limit`. +- **Fix:** Updated offset formula to `(page - 1) * limit`. + +## Bug 2: Completing task resets priority unexpectedly + +- **Location:** `src/services/taskService.js` in `completeTask(id)` +- **Expected:** Completing a task should not alter priority. +- **Actual:** Priority is forcibly changed to `"medium"` on completion. +- **How discovered:** Added completion tests that assert existing task fields remain stable except completion-related fields. +- **Root cause:** Hardcoded `priority: 'medium'` in completion update object. +- **Fix:** Removed the priority override so existing priority is preserved. diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index e8c370fe..0587ecc0 100644 --- a/task-api/src/routes/tasks.js +++ b/task-api/src/routes/tasks.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const taskService = require('../services/taskService'); -const { validateCreateTask, validateUpdateTask } = require('../utils/validators'); +const { validateCreateTask, validateUpdateTask, validateAssignTask } = require('../utils/validators'); router.get('/stats', (req, res) => { const stats = taskService.getStats(); @@ -69,4 +69,18 @@ router.patch('/:id/complete', (req, res) => { res.json(task); }); +router.patch('/:id/assign', (req, res) => { + const error = validateAssignTask(req.body); + if (error) { + return res.status(400).json({ error }); + } + + const task = taskService.assignTask(req.params.id, req.body.assignee.trim()); + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + res.json(task); +}); + module.exports = router; diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index f8e89189..bce96293 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -9,7 +9,7 @@ const findById = (id) => tasks.find((t) => t.id === id); const getByStatus = (status) => tasks.filter((t) => t.status.includes(status)); const getPaginated = (page, limit) => { - const offset = page * limit; + const offset = (page - 1) * limit; return tasks.slice(offset, offset + limit); }; @@ -66,7 +66,6 @@ const completeTask = (id) => { const updated = { ...task, - priority: 'medium', status: 'done', completedAt: new Date().toISOString(), }; @@ -76,6 +75,19 @@ const completeTask = (id) => { return updated; }; +const assignTask = (id, assignee) => { + const index = tasks.findIndex((t) => t.id === id); + if (index === -1) return null; + + const updated = { + ...tasks[index], + assignee, + }; + + tasks[index] = updated; + return updated; +}; + const _reset = () => { tasks = []; }; @@ -90,5 +102,6 @@ module.exports = { update, remove, completeTask, + assignTask, _reset, }; diff --git a/task-api/src/utils/validators.js b/task-api/src/utils/validators.js index 1e908ff5..9c3a3242 100644 --- a/task-api/src/utils/validators.js +++ b/task-api/src/utils/validators.js @@ -33,4 +33,16 @@ const validateUpdateTask = (body) => { return null; }; -module.exports = { validateCreateTask, validateUpdateTask }; +const validateAssignTask = (body) => { + if (!body || typeof body.assignee !== 'string') { + return 'assignee is required and must be a string'; + } + + if (body.assignee.trim() === '') { + return 'assignee must be a non-empty string'; + } + + return null; +}; + +module.exports = { validateCreateTask, validateUpdateTask, validateAssignTask }; diff --git a/task-api/tests/taskService.test.js b/task-api/tests/taskService.test.js new file mode 100644 index 00000000..cfc6f476 --- /dev/null +++ b/task-api/tests/taskService.test.js @@ -0,0 +1,125 @@ +const taskService = require('../src/services/taskService'); + +describe('taskService', () => { + beforeEach(() => { + taskService._reset(); + }); + + describe('create and query', () => { + test('creates task with defaults', () => { + const task = taskService.create({ title: 'Write tests' }); + + expect(task.id).toBeDefined(); + expect(task.title).toBe('Write tests'); + expect(task.description).toBe(''); + expect(task.status).toBe('todo'); + expect(task.priority).toBe('medium'); + expect(task.completedAt).toBeNull(); + expect(task.createdAt).toBeDefined(); + }); + + test('findById returns task for existing id', () => { + const task = taskService.create({ title: 'Find me' }); + const found = taskService.findById(task.id); + + expect(found).toEqual(task); + }); + + test('getByStatus returns only matching tasks', () => { + taskService.create({ title: 'A', status: 'todo' }); + taskService.create({ title: 'B', status: 'in_progress' }); + taskService.create({ title: 'C', status: 'done' }); + + const result = taskService.getByStatus('todo'); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe('A'); + }); + + test('getPaginated starts page 1 from first record', () => { + for (let i = 1; i <= 5; i += 1) { + taskService.create({ title: `Task ${i}` }); + } + + const pageOne = taskService.getPaginated(1, 2); + const pageTwo = taskService.getPaginated(2, 2); + + expect(pageOne.map((t) => t.title)).toEqual(['Task 1', 'Task 2']); + expect(pageTwo.map((t) => t.title)).toEqual(['Task 3', 'Task 4']); + }); + }); + + describe('update and remove', () => { + test('updates existing task and returns updated object', () => { + const task = taskService.create({ title: 'Old title', priority: 'low' }); + const updated = taskService.update(task.id, { title: 'New title', priority: 'high' }); + + expect(updated.title).toBe('New title'); + expect(updated.priority).toBe('high'); + }); + + test('update returns null for missing task', () => { + const updated = taskService.update('missing-id', { title: 'Nope' }); + expect(updated).toBeNull(); + }); + + test('remove deletes existing task', () => { + const task = taskService.create({ title: 'Delete me' }); + const removed = taskService.remove(task.id); + + expect(removed).toBe(true); + expect(taskService.findById(task.id)).toBeUndefined(); + }); + + test('remove returns false for missing task', () => { + expect(taskService.remove('missing-id')).toBe(false); + }); + }); + + describe('complete and assign', () => { + test('completeTask marks done and keeps current priority', () => { + const task = taskService.create({ title: 'Complete me', priority: 'high' }); + const completed = taskService.completeTask(task.id); + + expect(completed.status).toBe('done'); + expect(completed.priority).toBe('high'); + expect(completed.completedAt).toBeDefined(); + }); + + test('completeTask returns null for missing task', () => { + expect(taskService.completeTask('missing-id')).toBeNull(); + }); + + test('assignTask sets assignee on existing task', () => { + const task = taskService.create({ title: 'Assign me' }); + const updated = taskService.assignTask(task.id, 'Tanya'); + + expect(updated.assignee).toBe('Tanya'); + expect(taskService.findById(task.id).assignee).toBe('Tanya'); + }); + + test('assignTask returns null when task does not exist', () => { + expect(taskService.assignTask('missing-id', 'Tanya')).toBeNull(); + }); + }); + + describe('stats', () => { + test('getStats counts statuses and overdue tasks correctly', () => { + const past = '2020-01-01T00:00:00.000Z'; + const future = '2999-01-01T00:00:00.000Z'; + + taskService.create({ title: 'T1', status: 'todo', dueDate: past }); + taskService.create({ title: 'T2', status: 'in_progress', dueDate: future }); + taskService.create({ title: 'T3', status: 'done', dueDate: past }); + + const stats = taskService.getStats(); + + expect(stats).toEqual({ + todo: 1, + in_progress: 1, + done: 1, + overdue: 1, + }); + }); + }); +}); diff --git a/task-api/tests/tasks.routes.test.js b/task-api/tests/tasks.routes.test.js new file mode 100644 index 00000000..d8367a94 --- /dev/null +++ b/task-api/tests/tasks.routes.test.js @@ -0,0 +1,223 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +describe('tasks routes', () => { + beforeEach(() => { + taskService._reset(); + }); + + describe('POST /tasks', () => { + test('creates task with valid body', async () => { + const res = await request(app) + .post('/tasks') + .send({ title: 'API task', priority: 'high' }); + + expect(res.status).toBe(201); + expect(res.body.title).toBe('API task'); + expect(res.body.priority).toBe('high'); + }); + + test('returns 400 for missing title', async () => { + const res = await request(app).post('/tasks').send({ priority: 'low' }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/title is required/i); + }); + + test('returns 400 for invalid dueDate', async () => { + const res = await request(app) + .post('/tasks') + .send({ title: 'Bad due date', dueDate: 'not-a-date' }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/dueDate/i); + }); + }); + + describe('GET /tasks', () => { + test('returns all tasks', async () => { + taskService.create({ title: 'One' }); + taskService.create({ title: 'Two' }); + + const res = await request(app).get('/tasks'); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + }); + + test('filters by status', async () => { + taskService.create({ title: 'Todo', status: 'todo' }); + taskService.create({ title: 'Done', status: 'done' }); + + const res = await request(app).get('/tasks?status=done'); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].title).toBe('Done'); + }); + + test('supports pagination with page and limit', async () => { + for (let i = 1; i <= 5; i += 1) { + taskService.create({ title: `Task ${i}` }); + } + + const res = await request(app).get('/tasks?page=1&limit=2'); + + expect(res.status).toBe(200); + expect(res.body.map((t) => t.title)).toEqual(['Task 1', 'Task 2']); + }); + }); + + describe('PUT /tasks/:id', () => { + test('updates existing task', async () => { + const task = taskService.create({ title: 'Old title' }); + + const res = await request(app) + .put(`/tasks/${task.id}`) + .send({ title: 'New title', priority: 'high' }); + + expect(res.status).toBe(200); + expect(res.body.title).toBe('New title'); + expect(res.body.priority).toBe('high'); + }); + + test('returns 404 for missing task', async () => { + const res = await request(app) + .put('/tasks/missing-id') + .send({ title: 'No task' }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('Task not found'); + }); + + test('returns 400 for invalid update body', async () => { + const task = taskService.create({ title: 'Task title' }); + + const res = await request(app) + .put(`/tasks/${task.id}`) + .send({ title: '' }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/title must be a non-empty string/i); + }); + }); + + describe('DELETE /tasks/:id', () => { + test('returns 204 for successful delete', async () => { + const task = taskService.create({ title: 'Delete me' }); + + const res = await request(app).delete(`/tasks/${task.id}`); + + expect(res.status).toBe(204); + expect(taskService.findById(task.id)).toBeUndefined(); + }); + + test('returns 404 for missing task', async () => { + const res = await request(app).delete('/tasks/missing-id'); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('Task not found'); + }); + }); + + describe('PATCH /tasks/:id/complete', () => { + test('marks task complete', async () => { + const task = taskService.create({ title: 'Finish me', priority: 'high' }); + + const res = await request(app).patch(`/tasks/${task.id}/complete`); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('done'); + expect(res.body.priority).toBe('high'); + expect(res.body.completedAt).toBeDefined(); + }); + + test('returns 404 for missing task', async () => { + const res = await request(app).patch('/tasks/missing-id/complete'); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('Task not found'); + }); + }); + + describe('GET /tasks/stats', () => { + test('returns counts by status and overdue count', async () => { + const past = '2020-01-01T00:00:00.000Z'; + const future = '2999-01-01T00:00:00.000Z'; + + taskService.create({ title: 'Todo overdue', status: 'todo', dueDate: past }); + taskService.create({ title: 'In progress', status: 'in_progress', dueDate: future }); + taskService.create({ title: 'Done old', status: 'done', dueDate: past }); + + const res = await request(app).get('/tasks/stats'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + todo: 1, + in_progress: 1, + done: 1, + overdue: 1, + }); + }); + }); + + describe('PATCH /tasks/:id/assign', () => { + test('assigns task to a user', async () => { + const task = taskService.create({ title: 'Assignable task' }); + + const res = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: 'Tanya' }); + + expect(res.status).toBe(200); + expect(res.body.assignee).toBe('Tanya'); + }); + + test('trims assignee and allows reassignment', async () => { + const task = taskService.create({ title: 'Task' }); + + await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: 'Initial' }); + + const res = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: ' Updated ' }); + + expect(res.status).toBe(200); + expect(res.body.assignee).toBe('Updated'); + }); + + test('returns 400 for missing assignee', async () => { + const task = taskService.create({ title: 'Task' }); + + const res = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/assignee/i); + }); + + test('returns 400 for empty assignee', async () => { + const task = taskService.create({ title: 'Task' }); + + const res = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: ' ' }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/non-empty/i); + }); + + test('returns 404 when task does not exist', async () => { + const res = await request(app) + .patch('/tasks/missing-id/assign') + .send({ assignee: 'Tanya' }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('Task not found'); + }); + }); +});