TASK-001: implement todo backend
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
DATABASE_URL="file:./dev.db"
|
||||
PORT=3000
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.env
|
||||
prisma/dev.db
|
||||
prisma/dev.db-journal
|
||||
coverage
|
||||
62
README.md
Normal file
62
README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Todo App
|
||||
|
||||
Node.js, Express, Prisma, SQLite로 만든 간단한 할 일 관리 API야.
|
||||
|
||||
## 기능 범위
|
||||
|
||||
- Todo 생성, 조회, 수정, 삭제
|
||||
- done 상태 수정
|
||||
- SQLite + Prisma 연동
|
||||
|
||||
## 실행 방법
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run prisma:generate
|
||||
npm run prisma:migrate
|
||||
npm test
|
||||
npm run dev
|
||||
```
|
||||
|
||||
기본 서버 주소:
|
||||
|
||||
- `http://localhost:3000`
|
||||
|
||||
## 환경 변수
|
||||
|
||||
- `DATABASE_URL`: SQLite 연결 문자열
|
||||
- `PORT`: 서버 포트
|
||||
|
||||
## API
|
||||
|
||||
### Health Check
|
||||
|
||||
- `GET /health`
|
||||
|
||||
### Todo API
|
||||
|
||||
- `GET /todos` - 전체 목록 조회
|
||||
- `GET /todos/:id` - 단건 조회
|
||||
- `POST /todos` - Todo 생성
|
||||
- `PUT /todos/:id` - 제목/설명/done 수정
|
||||
- `PATCH /todos/:id/done` - done 상태만 수정
|
||||
- `DELETE /todos/:id` - Todo 삭제
|
||||
|
||||
## 요청 예시
|
||||
|
||||
### Todo 생성
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/todos \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"장보기","description":"우유 사기"}'
|
||||
```
|
||||
|
||||
### done 상태 수정
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://localhost:3000/todos/1/done \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"done":true}'
|
||||
```
|
||||
1499
package-lock.json
generated
Normal file
1499
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "todo-app",
|
||||
"version": "1.0.0",
|
||||
"description": "Simple todo management API with Express, Prisma, and SQLite",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/server.js",
|
||||
"start": "node src/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev --name init",
|
||||
"test": "node --test"
|
||||
},
|
||||
"keywords": [
|
||||
"todo",
|
||||
"express",
|
||||
"prisma",
|
||||
"sqlite"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.6.0",
|
||||
"express": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.6.0",
|
||||
"supertest": "^7.1.0"
|
||||
}
|
||||
}
|
||||
9
prisma/migrations/20260403064607_init/migration.sql
Normal file
9
prisma/migrations/20260403064607_init/migration.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Todo" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"done" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
17
prisma/schema.prisma
Normal file
17
prisma/schema.prisma
Normal file
@@ -0,0 +1,17 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Todo {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
description String?
|
||||
done Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
23
src/app.js
Normal file
23
src/app.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const express = require('express');
|
||||
const todosRouter = require('./routes/todos');
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.use('/todos', todosRouter);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ message: `Route not found: ${req.method} ${req.originalUrl}` });
|
||||
});
|
||||
|
||||
app.use((error, _req, res, _next) => {
|
||||
console.error(error);
|
||||
res.status(500).json({ message: 'Internal server error.' });
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
185
src/controllers/todosController.js
Normal file
185
src/controllers/todosController.js
Normal file
@@ -0,0 +1,185 @@
|
||||
const prisma = require('../lib/prisma');
|
||||
|
||||
function parseId(value) {
|
||||
const id = Number.parseInt(value, 10);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
function parseDone(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePayload(body = {}, { partial = false } = {}) {
|
||||
const data = {};
|
||||
|
||||
if (!partial || Object.prototype.hasOwnProperty.call(body, 'title')) {
|
||||
if (typeof body.title !== 'string' || body.title.trim() === '') {
|
||||
return { error: 'title is required and must be a non-empty string.' };
|
||||
}
|
||||
|
||||
data.title = body.title.trim();
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'description')) {
|
||||
if (body.description !== null && typeof body.description !== 'string') {
|
||||
return { error: 'description must be a string or null.' };
|
||||
}
|
||||
|
||||
data.description = body.description === null ? null : body.description.trim();
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'done')) {
|
||||
const done = parseDone(body.done);
|
||||
|
||||
if (done === null) {
|
||||
return { error: 'done must be a boolean.' };
|
||||
}
|
||||
|
||||
data.done = done;
|
||||
}
|
||||
|
||||
if (partial && Object.keys(data).length === 0) {
|
||||
return { error: 'At least one field is required.' };
|
||||
}
|
||||
|
||||
return { data };
|
||||
}
|
||||
|
||||
async function listTodos(_req, res, next) {
|
||||
try {
|
||||
const todos = await prisma.todo.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
|
||||
res.json(todos);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function getTodo(req, res, next) {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ message: 'Invalid todo id.' });
|
||||
}
|
||||
|
||||
const todo = await prisma.todo.findUnique({ where: { id } });
|
||||
|
||||
if (!todo) {
|
||||
return res.status(404).json({ message: 'Todo not found.' });
|
||||
}
|
||||
|
||||
return res.json(todo);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createTodo(req, res, next) {
|
||||
try {
|
||||
const { data, error } = normalizePayload(req.body);
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({ message: error });
|
||||
}
|
||||
|
||||
const todo = await prisma.todo.create({ data });
|
||||
|
||||
return res.status(201).json(todo);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTodo(req, res, next) {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ message: 'Invalid todo id.' });
|
||||
}
|
||||
|
||||
const { data, error } = normalizePayload(req.body, { partial: true });
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({ message: error });
|
||||
}
|
||||
|
||||
const todo = await prisma.todo.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
return res.json(todo);
|
||||
} catch (error) {
|
||||
if (error.code === 'P2025') {
|
||||
return res.status(404).json({ message: 'Todo not found.' });
|
||||
}
|
||||
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTodoDone(req, res, next) {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ message: 'Invalid todo id.' });
|
||||
}
|
||||
|
||||
const done = parseDone(req.body?.done);
|
||||
|
||||
if (done === null) {
|
||||
return res.status(400).json({ message: 'done must be a boolean.' });
|
||||
}
|
||||
|
||||
const todo = await prisma.todo.update({
|
||||
where: { id },
|
||||
data: { done },
|
||||
});
|
||||
|
||||
return res.json(todo);
|
||||
} catch (error) {
|
||||
if (error.code === 'P2025') {
|
||||
return res.status(404).json({ message: 'Todo not found.' });
|
||||
}
|
||||
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTodo(req, res, next) {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ message: 'Invalid todo id.' });
|
||||
}
|
||||
|
||||
await prisma.todo.delete({ where: { id } });
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error.code === 'P2025') {
|
||||
return res.status(404).json({ message: 'Todo not found.' });
|
||||
}
|
||||
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listTodos,
|
||||
getTodo,
|
||||
createTodo,
|
||||
updateTodo,
|
||||
updateTodoDone,
|
||||
deleteTodo,
|
||||
};
|
||||
5
src/lib/prisma.js
Normal file
5
src/lib/prisma.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
module.exports = prisma;
|
||||
20
src/routes/todos.js
Normal file
20
src/routes/todos.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const express = require('express');
|
||||
const {
|
||||
listTodos,
|
||||
getTodo,
|
||||
createTodo,
|
||||
updateTodo,
|
||||
updateTodoDone,
|
||||
deleteTodo,
|
||||
} = require('../controllers/todosController');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', listTodos);
|
||||
router.get('/:id', getTodo);
|
||||
router.post('/', createTodo);
|
||||
router.put('/:id', updateTodo);
|
||||
router.patch('/:id/done', updateTodoDone);
|
||||
router.delete('/:id', deleteTodo);
|
||||
|
||||
module.exports = router;
|
||||
7
src/server.js
Normal file
7
src/server.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const app = require('./app');
|
||||
|
||||
const PORT = Number.parseInt(process.env.PORT, 10) || 3000;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Todo API server listening on port ${PORT}`);
|
||||
});
|
||||
209
tests/todos.test.js
Normal file
209
tests/todos.test.js
Normal file
@@ -0,0 +1,209 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const todoStore = [];
|
||||
let nextId = 1;
|
||||
|
||||
const prismaMock = {
|
||||
todo: {
|
||||
findMany: async () => [...todoStore],
|
||||
findUnique: async ({ where: { id } }) => todoStore.find((todo) => todo.id === id) ?? null,
|
||||
create: async ({ data }) => {
|
||||
const todo = {
|
||||
id: nextId++,
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
done: data.done ?? false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
todoStore.push(todo);
|
||||
return todo;
|
||||
},
|
||||
update: async ({ where: { id }, data }) => {
|
||||
const index = todoStore.findIndex((todo) => todo.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
const error = new Error('Record to update not found.');
|
||||
error.code = 'P2025';
|
||||
throw error;
|
||||
}
|
||||
|
||||
todoStore[index] = {
|
||||
...todoStore[index],
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return todoStore[index];
|
||||
},
|
||||
delete: async ({ where: { id } }) => {
|
||||
const index = todoStore.findIndex((todo) => todo.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
const error = new Error('Record to delete not found.');
|
||||
error.code = 'P2025';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [removed] = todoStore.splice(index, 1);
|
||||
return removed;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
require.cache[require.resolve('../src/lib/prisma')] = {
|
||||
exports: prismaMock,
|
||||
};
|
||||
|
||||
const app = require('../src/app');
|
||||
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
async function api(path, { method = 'GET', body } = {}) {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const json = text ? JSON.parse(text) : null;
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
body: json,
|
||||
};
|
||||
}
|
||||
|
||||
test.before(async () => {
|
||||
server = app.listen(0);
|
||||
await new Promise((resolve) => server.once('listening', resolve));
|
||||
const { port } = server.address();
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
if (server) {
|
||||
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
todoStore.length = 0;
|
||||
nextId = 1;
|
||||
});
|
||||
|
||||
test('POST /todos creates todo', async () => {
|
||||
const response = await api('/todos', {
|
||||
method: 'POST',
|
||||
body: { title: '테스트', description: '설명' },
|
||||
});
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(response.body.title, '테스트');
|
||||
assert.equal(response.body.done, false);
|
||||
assert.equal(todoStore.length, 1);
|
||||
});
|
||||
|
||||
test('GET /todos returns todos', async () => {
|
||||
await prismaMock.todo.create({ data: { title: '할 일 1', description: null } });
|
||||
await prismaMock.todo.create({ data: { title: '할 일 2', description: '설명', done: true } });
|
||||
|
||||
const response = await api('/todos');
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.length, 2);
|
||||
assert.equal(response.body[0].id, 1);
|
||||
assert.equal(response.body[1].done, true);
|
||||
});
|
||||
|
||||
test('GET /todos/:id returns a todo', async () => {
|
||||
const created = await prismaMock.todo.create({ data: { title: '조회 대상', description: '상세' } });
|
||||
|
||||
const response = await api(`/todos/${created.id}`);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.id, created.id);
|
||||
assert.equal(response.body.title, '조회 대상');
|
||||
});
|
||||
|
||||
test('PUT /todos/:id updates todo fields', async () => {
|
||||
const created = await prismaMock.todo.create({ data: { title: '원본', description: null } });
|
||||
|
||||
const response = await api(`/todos/${created.id}`, {
|
||||
method: 'PUT',
|
||||
body: { title: '수정됨', description: '업데이트', done: true },
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.title, '수정됨');
|
||||
assert.equal(response.body.done, true);
|
||||
});
|
||||
|
||||
test('PATCH /todos/:id/done updates done only', async () => {
|
||||
const created = await prismaMock.todo.create({ data: { title: '원본', description: null } });
|
||||
|
||||
const response = await api(`/todos/${created.id}/done`, {
|
||||
method: 'PATCH',
|
||||
body: { done: true },
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.done, true);
|
||||
assert.equal(response.body.title, '원본');
|
||||
});
|
||||
|
||||
test('DELETE /todos/:id removes todo', async () => {
|
||||
const created = await prismaMock.todo.create({ data: { title: '삭제 대상', description: null } });
|
||||
|
||||
const response = await api(`/todos/${created.id}`, { method: 'DELETE' });
|
||||
|
||||
assert.equal(response.status, 204);
|
||||
assert.equal(todoStore.length, 0);
|
||||
});
|
||||
|
||||
test('returns 400 for invalid done payload', async () => {
|
||||
const created = await prismaMock.todo.create({ data: { title: '원본', description: null } });
|
||||
|
||||
const response = await api(`/todos/${created.id}/done`, {
|
||||
method: 'PATCH',
|
||||
body: { done: 'yes' },
|
||||
});
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(response.body.message, /boolean/);
|
||||
});
|
||||
|
||||
test('returns 400 for empty title on create', async () => {
|
||||
const response = await api('/todos', {
|
||||
method: 'POST',
|
||||
body: { title: ' ' },
|
||||
});
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(response.body.message, /title is required/i);
|
||||
});
|
||||
|
||||
test('returns 400 for invalid todo id', async () => {
|
||||
const response = await api('/todos/abc');
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(response.body.message, /invalid todo id/i);
|
||||
});
|
||||
|
||||
test('returns 404 when todo is missing', async () => {
|
||||
const response = await api('/todos/999');
|
||||
|
||||
assert.equal(response.status, 404);
|
||||
assert.match(response.body.message, /todo not found/i);
|
||||
});
|
||||
|
||||
test('returns 404 for unknown route', async () => {
|
||||
const response = await api('/unknown');
|
||||
|
||||
assert.equal(response.status, 404);
|
||||
assert.match(response.body.message, /route not found/i);
|
||||
});
|
||||
Reference in New Issue
Block a user