Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions BUG_REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Bug Report

## Bug 1: Pagination starts from the wrong offset (Fixed)

- Expected behavior: `GET /tasks?page=1&limit=2` should return the first two tasks.
- Actual behavior: Page 1 skipped the first two tasks because offset used `page * limit`.
- Discovery method: Unit test on pagination behavior and route integration test for `GET /tasks?page=1&limit=2`.
- Root cause: Offset calculation in `getPaginated` used `page * limit` instead of `(page - 1) * limit`.
- Fix implemented: Updated offset formula to `(page - 1) * limit` in `src/services/taskService.js`.

## Bug 2: Status filtering is too permissive (Not fixed)

- Expected behavior: `GET /tasks?status=done` should match only tasks with status exactly `done`.
- Actual behavior: Filtering uses substring matching (`includes`), so partial values can produce unexpected matches.
- Discovery method: Code review while writing service tests around status filtering.
- Root cause: `getByStatus` uses `t.status.includes(status)` instead of strict equality.
- Suggested fix: Replace with `t.status === status` and keep validator-driven status constraints.

## Bug 3: Completing a task overrides priority (Not fixed)

- Expected behavior: Marking a task complete should change completion state only, not silently rewrite priority.
- Actual behavior: `PATCH /tasks/:id/complete` sets `priority` to `medium` for every task.
- Discovery method: Code review and route behavior verification.
- Root cause: `completeTask` reconstructs task object with `priority: 'medium'`.
- Suggested fix: Preserve existing priority in `completeTask`.
37 changes: 37 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Quick Deploy Guide

## Option A: Render (recommended)

1. Push this repository to your own GitHub fork.
2. In Render, choose **New +** -> **Blueprint**.
3. Select your forked repository.
4. Render will detect `render.yaml` and create the service automatically.
5. Wait for deploy to finish and copy the generated live URL.

Health check endpoint:
- `/tasks`

## Option B: Railway

1. Push this repository to your own GitHub fork.
2. In Railway, click **New Project** -> **Deploy from GitHub Repo**.
3. Select your forked repository.
4. Railway will use `railway.json` for build/start commands.
5. After deploy, open the generated public domain and verify `/tasks`.

## Verify deployment

Run these checks on your live URL:

1. `GET /tasks` returns 200
2. `GET /tasks/stats` returns 200
3. `POST /tasks` creates a task
4. `PATCH /tasks/:id/assign` works for valid payload

## Suggested live-link smoke test commands

Replace `<LIVE_URL>` with your deployed URL.

- `curl <LIVE_URL>/tasks`
- `curl <LIVE_URL>/tasks/stats`
- `curl -X POST <LIVE_URL>/tasks -H "Content-Type: application/json" -d "{\"title\":\"Live test\"}"`
54 changes: 54 additions & 0 deletions SUBMISSION_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Submission Notes

## What I implemented

1. Added comprehensive tests:
- Unit tests for task service logic (`task-api/tests/taskService.test.js`)
- Integration tests for all task routes using Supertest (`task-api/tests/tasks.routes.test.js`)

2. Fixed one production bug:
- Corrected pagination offset logic so page 1 returns the first set of records.

3. Added new feature endpoint:
- `PATCH /tasks/:id/assign`
- Request body: `{ "assignee": "string" }`
- Returns:
- `200` with updated task on success
- `400` for invalid/empty assignee
- `404` when task does not exist
- `409` when task is already assigned

## Design decisions for assignment feature

- Validation rule: `assignee` must be a non-empty string after trimming whitespace.
- Re-assignment policy: second assignment attempt returns `409 Conflict`.
- Data model change: new tasks now include `assignee: null` by default for consistent shape.

## Coverage summary

Coverage run (`npm run coverage`) reported:

- Statements: 92.4%
- Branches: 82.22%
- Functions: 93.33%
- Lines: 91.66%

This meets the 80%+ target.

## What I would test next with more time

1. Add explicit tests for malformed query params (for example negative/zero page and limit).
2. Add tests for unsupported status filter values at route level.
3. Add tests for race-like behavior in assignment/update flows if persistence or concurrent writes are introduced.

## What surprised me

1. The service logic had subtle behavior bugs (pagination and completion side effects) that are easy to miss without tests.
2. Query and transition behaviors were not uniformly validated, which can lead to inconsistent API contracts.

## Questions I would ask before production

1. Should assignment support re-assignment, and if yes, should audit/history be tracked?
2. Should completing a task be idempotent and preserve all existing fields except status/completedAt?
3. What is the expected behavior for invalid query params (strict validation vs. default fallback)?
4. Are status transitions restricted (for example `done` back to `todo`)?
12 changes: 12 additions & 0 deletions railway.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "NIXPACKS",
"buildCommand": "cd task-api && npm ci"
},
"deploy": {
"startCommand": "cd task-api && npm start",
"healthcheckPath": "/tasks",
"healthcheckTimeout": 120
}
}
10 changes: 10 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
services:
- type: web
name: task-api
runtime: node
rootDir: task-api
plan: free
buildCommand: npm ci
startCommand: npm start
healthCheckPath: /tasks
autoDeploy: true
21 changes: 20 additions & 1 deletion task-api/src/routes/tasks.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -69,4 +69,23 @@ 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);

if (!task) {
return res.status(404).json({ error: 'Task not found' });
}

if (task === 'already_assigned') {
return res.status(409).json({ error: 'Task is already assigned' });
}

res.json(task);
});

module.exports = router;
21 changes: 20 additions & 1 deletion task-api/src/services/taskService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};

Expand All @@ -36,6 +36,7 @@ const create = ({ title, description = '', status = 'todo', priority = 'medium',
status,
priority,
dueDate,
assignee: null,
completedAt: null,
createdAt: new Date().toISOString(),
};
Expand Down Expand Up @@ -76,6 +77,23 @@ const completeTask = (id) => {
return updated;
};

const assignTask = (id, assignee) => {
const index = tasks.findIndex((t) => t.id === id);
if (index === -1) return null;

if (tasks[index].assignee) {
return 'already_assigned';
}

const updated = {
...tasks[index],
assignee: assignee.trim(),
};

tasks[index] = updated;
return updated;
};

const _reset = () => {
tasks = [];
};
Expand All @@ -90,5 +108,6 @@ module.exports = {
update,
remove,
completeTask,
assignTask,
_reset,
};
10 changes: 9 additions & 1 deletion task-api/src/utils/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,12 @@ const validateUpdateTask = (body) => {
return null;
};

module.exports = { validateCreateTask, validateUpdateTask };
const validateAssignTask = (body) => {
if (!body || typeof body.assignee !== 'string' || body.assignee.trim() === '') {
return 'assignee is required and must be a non-empty string';
}

return null;
};

module.exports = { validateCreateTask, validateUpdateTask, validateAssignTask };
133 changes: 133 additions & 0 deletions task-api/tests/taskService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
const taskService = require('../src/services/taskService');

describe('taskService', () => {
beforeEach(() => {
taskService._reset();
});

test('create should build a task with defaults', () => {
const task = taskService.create({ title: 'Write tests' });

expect(task).toMatchObject({
title: 'Write tests',
description: '',
status: 'todo',
priority: 'medium',
dueDate: null,
assignee: null,
completedAt: null,
});
expect(typeof task.id).toBe('string');
expect(Number.isNaN(Date.parse(task.createdAt))).toBe(false);
});

test('getAll should return a copy of in-memory tasks', () => {
taskService.create({ title: 'A' });

const tasks = taskService.getAll();
tasks.push({ id: 'fake' });

expect(taskService.getAll()).toHaveLength(1);
});

test('findById should return the matching task', () => {
const created = taskService.create({ title: 'Find me' });

const found = taskService.findById(created.id);

expect(found).toEqual(created);
});

test('getByStatus should return tasks matching status', () => {
taskService.create({ title: 'Todo one', status: 'todo' });
taskService.create({ title: 'Done one', status: 'done' });

const doneTasks = taskService.getByStatus('done');

expect(doneTasks).toHaveLength(1);
expect(doneTasks[0].title).toBe('Done one');
});

test('getPaginated should return tasks page-wise', () => {
taskService.create({ title: 'Task 1' });
taskService.create({ title: 'Task 2' });
taskService.create({ title: 'Task 3' });

const firstPage = taskService.getPaginated(1, 2);
const secondPage = taskService.getPaginated(2, 2);

expect(firstPage.map((t) => t.title)).toEqual(['Task 1', 'Task 2']);
expect(secondPage.map((t) => t.title)).toEqual(['Task 3']);
});

test('update should update existing task and return null for missing id', () => {
const created = taskService.create({ title: 'Old title' });

const updated = taskService.update(created.id, { title: 'New title' });
const missing = taskService.update('missing-id', { title: 'Nope' });

expect(updated.title).toBe('New title');
expect(missing).toBeNull();
});

test('remove should delete existing task and return false for missing id', () => {
const created = taskService.create({ title: 'Delete me' });

const removed = taskService.remove(created.id);
const removedAgain = taskService.remove(created.id);

expect(removed).toBe(true);
expect(removedAgain).toBe(false);
});

test('completeTask should mark task as done and set completedAt', () => {
const created = taskService.create({ title: 'Complete me', status: 'todo' });

const completed = taskService.completeTask(created.id);

expect(completed.status).toBe('done');
expect(Number.isNaN(Date.parse(completed.completedAt))).toBe(false);
});

test('getStats should include status counts and overdue tasks', () => {
const pastDate = new Date(Date.now() - 86400000).toISOString();
const futureDate = new Date(Date.now() + 86400000).toISOString();

taskService.create({ title: 'Overdue todo', status: 'todo', dueDate: pastDate });
taskService.create({ title: 'Future in progress', status: 'in_progress', dueDate: futureDate });
taskService.create({ title: 'Done in past', status: 'done', dueDate: pastDate });

const stats = taskService.getStats();

expect(stats).toEqual({
todo: 1,
in_progress: 1,
done: 1,
overdue: 1,
});
});

test('assignTask should assign and trim assignee name', () => {
const created = taskService.create({ title: 'Assign me' });

const updated = taskService.assignTask(created.id, ' Rohit ');

expect(updated.assignee).toBe('Rohit');
});

test('assignTask should return null when task does not exist', () => {
const result = taskService.assignTask('missing-id', 'Rohit');

expect(result).toBeNull();
});

test('assignTask should block assignment when already assigned', () => {
const created = taskService.create({ title: 'Assigned already' });
taskService.assignTask(created.id, 'Alice');

const secondAttempt = taskService.assignTask(created.id, 'Bob');

expect(secondAttempt).toBe('already_assigned');
expect(taskService.findById(created.id).assignee).toBe('Alice');
});
});
Loading