TASK-001: implement todo backend

This commit is contained in:
2026-04-03 07:51:53 +00:00
commit 027abfe603
14 changed files with 2074 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
DATABASE_URL="file:./dev.db"
PORT=3000

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.env
prisma/dev.db
prisma/dev.db-journal
coverage

62
README.md Normal file
View 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

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View 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"
}
}

View 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
);

View 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
View 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
View 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;

View 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
View File

@@ -0,0 +1,5 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
module.exports = prisma;

20
src/routes/todos.js Normal file
View 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
View 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
View 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);
});