diff --git a/src/example/app.py b/src/example/app.py index d59d01b..31e7420 100644 --- a/src/example/app.py +++ b/src/example/app.py @@ -183,7 +183,7 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: make_comment_router( ListCommentsUseCase(comment_repo), GetCommentUseCase(comment_repo), - CreateCommentUseCase(comment_repo), + CreateCommentUseCase(comment_repo, note_repo), UpdateCommentUseCase(comment_repo), DeleteCommentUseCase(comment_repo), ) diff --git a/src/example/comment/handler.py b/src/example/comment/handler.py index ccdfbcd..40fa9cb 100644 --- a/src/example/comment/handler.py +++ b/src/example/comment/handler.py @@ -11,11 +11,13 @@ from nene2.validation.exceptions import ValidationError, ValidationException from .entity import Comment +from .exceptions import CommentNotFoundException from .use_case import ( CreateCommentInput, CreateCommentUseCase, DeleteCommentInput, DeleteCommentUseCase, + GetCommentInput, GetCommentUseCase, ListCommentsInput, ListCommentsUseCase, @@ -62,7 +64,9 @@ async def list_comments(note_id: int, request: Request) -> JSONResponse: @router.get("/{comment_id}") async def get_comment(note_id: int, comment_id: int) -> JSONResponse: - comment = get_use_case.execute(comment_id) + comment = get_use_case.execute(GetCommentInput(comment_id=comment_id)) + if comment.note_id != note_id: + raise CommentNotFoundException(comment_id) return JSONResponse(_comment_dict(comment)) @router.post("", status_code=201) @@ -84,11 +88,17 @@ async def update_comment( errors.append(ValidationError("body", "Body must not be empty.", "required")) if errors: raise ValidationException(errors) + existing = get_use_case.execute(GetCommentInput(comment_id=comment_id)) + if existing.note_id != note_id: + raise CommentNotFoundException(comment_id) comment = update_use_case.execute(UpdateCommentInput(comment_id=comment_id, body=body.body)) return JSONResponse(_comment_dict(comment)) @router.delete("/{comment_id}", status_code=204) async def delete_comment(note_id: int, comment_id: int) -> None: + existing = get_use_case.execute(GetCommentInput(comment_id=comment_id)) + if existing.note_id != note_id: + raise CommentNotFoundException(comment_id) delete_use_case.execute(DeleteCommentInput(comment_id=comment_id)) return router diff --git a/src/example/comment/use_case.py b/src/example/comment/use_case.py index 362dfb3..6055593 100644 --- a/src/example/comment/use_case.py +++ b/src/example/comment/use_case.py @@ -2,6 +2,9 @@ from dataclasses import dataclass +from example.note.exceptions import NoteNotFoundException +from example.note.repository import NoteRepositoryInterface + from .entity import Comment from .exceptions import CommentNotFoundException from .repository import CommentRepositoryInterface @@ -39,14 +42,19 @@ def execute(self, input_: ListCommentsInput) -> ListCommentsOutput: ) +@dataclass(frozen=True, slots=True) +class GetCommentInput: + comment_id: int + + class GetCommentUseCase: def __init__(self, repository: CommentRepositoryInterface) -> None: self._repository = repository - def execute(self, comment_id: int) -> Comment: - comment = self._repository.find_by_id(comment_id) + def execute(self, input_: GetCommentInput) -> Comment: + comment = self._repository.find_by_id(input_.comment_id) if comment is None: - raise CommentNotFoundException(comment_id) + raise CommentNotFoundException(input_.comment_id) return comment @@ -57,11 +65,18 @@ class CreateCommentInput: class CreateCommentUseCase: - def __init__(self, repository: CommentRepositoryInterface) -> None: - self._repository = repository + def __init__( + self, + comment_repository: CommentRepositoryInterface, + note_repository: NoteRepositoryInterface, + ) -> None: + self._comment_repository = comment_repository + self._note_repository = note_repository def execute(self, input_: CreateCommentInput) -> Comment: - return self._repository.save(input_.note_id, input_.body) + if self._note_repository.find_by_id(input_.note_id) is None: + raise NoteNotFoundException(input_.note_id) + return self._comment_repository.save(input_.note_id, input_.body) @dataclass(frozen=True, slots=True) diff --git a/src/example/mcp.py b/src/example/mcp.py index 322a9a3..d3b99d8 100644 --- a/src/example/mcp.py +++ b/src/example/mcp.py @@ -15,6 +15,7 @@ CreateCommentUseCase, DeleteCommentInput, DeleteCommentUseCase, + GetCommentInput, GetCommentUseCase, ListCommentsInput, ListCommentsUseCase, @@ -64,7 +65,7 @@ def create_mcp_server(settings: AppSettings | None = None) -> LocalMcpServer: comment_list = ListCommentsUseCase(comment_repo) comment_get = GetCommentUseCase(comment_repo) - comment_create = CreateCommentUseCase(comment_repo) + comment_create = CreateCommentUseCase(comment_repo, note_repo) comment_update = UpdateCommentUseCase(comment_repo) comment_delete = DeleteCommentUseCase(comment_repo) @@ -126,7 +127,7 @@ def list_comments(note_id: int, limit: int = 20, offset: int = 0) -> list[dict]: @server.tool("Get a single comment by ID.") def get_comment(comment_id: int) -> dict: # type: ignore[type-arg] - return asdict(comment_get.execute(comment_id)) + return asdict(comment_get.execute(GetCommentInput(comment_id=comment_id))) @server.tool("Create a new comment on a note.") def create_comment(note_id: int, body: str) -> dict: # type: ignore[type-arg] diff --git a/tests/example/comment/test_comment_http.py b/tests/example/comment/test_comment_http.py index f81e5d9..9616473 100644 --- a/tests/example/comment/test_comment_http.py +++ b/tests/example/comment/test_comment_http.py @@ -11,8 +11,16 @@ def _client() -> TestClient: return TestClient(create_app(cfg)) +def _client_with_note() -> tuple[TestClient, int]: + """Return a client and an existing note_id.""" + client = _client() + note = client.post("/notes", json={"title": "Test Note", "body": "body"}).json() + return client, note["id"] + + def test_list_comments_empty() -> None: - response = _client().get("/notes/1/comments") + client, note_id = _client_with_note() + response = client.get(f"/notes/{note_id}/comments") assert response.status_code == 200 body = response.json() assert body["total"] == 0 @@ -20,47 +28,67 @@ def test_list_comments_empty() -> None: def test_create_and_list_comments() -> None: - client = _client() - create_response = client.post("/notes/1/comments", json={"body": "first comment"}) + client, note_id = _client_with_note() + create_response = client.post(f"/notes/{note_id}/comments", json={"body": "first comment"}) assert create_response.status_code == 201 data = create_response.json() - assert data["note_id"] == 1 + assert data["note_id"] == note_id assert data["body"] == "first comment" - list_response = client.get("/notes/1/comments") + list_response = client.get(f"/notes/{note_id}/comments") assert list_response.json()["total"] == 1 def test_get_comment() -> None: - client = _client() - created = client.post("/notes/1/comments", json={"body": "get me"}).json() - response = client.get(f"/notes/1/comments/{created['id']}") + client, note_id = _client_with_note() + created = client.post(f"/notes/{note_id}/comments", json={"body": "get me"}).json() + response = client.get(f"/notes/{note_id}/comments/{created['id']}") assert response.status_code == 200 assert response.json()["body"] == "get me" def test_get_comment_not_found() -> None: - response = _client().get("/notes/1/comments/9999") + client, note_id = _client_with_note() + response = client.get(f"/notes/{note_id}/comments/9999") assert response.status_code == 404 def test_update_comment() -> None: - client = _client() - created = client.post("/notes/1/comments", json={"body": "original"}).json() - response = client.put(f"/notes/1/comments/{created['id']}", json={"body": "updated"}) + client, note_id = _client_with_note() + created = client.post(f"/notes/{note_id}/comments", json={"body": "original"}).json() + response = client.put( + f"/notes/{note_id}/comments/{created['id']}", json={"body": "updated"} + ) assert response.status_code == 200 assert response.json()["body"] == "updated" def test_delete_comment() -> None: - client = _client() - created = client.post("/notes/1/comments", json={"body": "to delete"}).json() - delete_response = client.delete(f"/notes/1/comments/{created['id']}") + client, note_id = _client_with_note() + created = client.post(f"/notes/{note_id}/comments", json={"body": "to delete"}).json() + delete_response = client.delete(f"/notes/{note_id}/comments/{created['id']}") assert delete_response.status_code == 204 - get_response = client.get(f"/notes/1/comments/{created['id']}") + get_response = client.get(f"/notes/{note_id}/comments/{created['id']}") assert get_response.status_code == 404 def test_create_comment_empty_body_returns_422() -> None: - response = _client().post("/notes/1/comments", json={"body": " "}) + client, note_id = _client_with_note() + response = client.post(f"/notes/{note_id}/comments", json={"body": " "}) assert response.status_code == 422 + + +def test_create_comment_for_nonexistent_note_returns_404() -> None: + response = _client().post("/notes/9999/comments", json={"body": "orphan"}) + assert response.status_code == 404 + + +def test_get_comment_from_wrong_note_returns_404() -> None: + client = _client() + note1 = client.post("/notes", json={"title": "Note 1", "body": "b"}).json() + note2 = client.post("/notes", json={"title": "Note 2", "body": "b"}).json() + comment = client.post( + f"/notes/{note1['id']}/comments", json={"body": "belongs to note1"} + ).json() + response = client.get(f"/notes/{note2['id']}/comments/{comment['id']}") + assert response.status_code == 404 diff --git a/tests/example/comment/test_comment_use_case.py b/tests/example/comment/test_comment_use_case.py index d8959c6..c12507a 100644 --- a/tests/example/comment/test_comment_use_case.py +++ b/tests/example/comment/test_comment_use_case.py @@ -9,72 +9,106 @@ CreateCommentUseCase, DeleteCommentInput, DeleteCommentUseCase, + GetCommentInput, GetCommentUseCase, ListCommentsInput, ListCommentsUseCase, UpdateCommentInput, UpdateCommentUseCase, ) +from example.note.exceptions import NoteNotFoundException +from example.note.repository import InMemoryNoteRepository -def _repo() -> InMemoryCommentRepository: - return InMemoryCommentRepository() +def _repos() -> tuple[InMemoryCommentRepository, InMemoryNoteRepository]: + return InMemoryCommentRepository(), InMemoryNoteRepository() + + +def _create_use_case( + comment_repo: InMemoryCommentRepository, note_repo: InMemoryNoteRepository +) -> CreateCommentUseCase: + return CreateCommentUseCase(comment_repo, note_repo) def test_create_and_list() -> None: - repo = _repo() - CreateCommentUseCase(repo).execute(CreateCommentInput(note_id=1, body="hello")) - result = ListCommentsUseCase(repo).execute(ListCommentsInput(note_id=1, limit=10, offset=0)) + comment_repo, note_repo = _repos() + note_repo.save("title", "body") + _create_use_case(comment_repo, note_repo).execute(CreateCommentInput(note_id=1, body="hello")) + result = ListCommentsUseCase(comment_repo).execute( + ListCommentsInput(note_id=1, limit=10, offset=0) + ) assert result.total == 1 assert result.items[0].body == "hello" def test_list_filters_by_note_id() -> None: - repo = _repo() - CreateCommentUseCase(repo).execute(CreateCommentInput(note_id=1, body="note1 comment")) - CreateCommentUseCase(repo).execute(CreateCommentInput(note_id=2, body="note2 comment")) - result = ListCommentsUseCase(repo).execute(ListCommentsInput(note_id=1, limit=10, offset=0)) + comment_repo, note_repo = _repos() + note_repo.save("n1", "b1") + note_repo.save("n2", "b2") + uc = _create_use_case(comment_repo, note_repo) + uc.execute(CreateCommentInput(note_id=1, body="note1 comment")) + uc.execute(CreateCommentInput(note_id=2, body="note2 comment")) + result = ListCommentsUseCase(comment_repo).execute( + ListCommentsInput(note_id=1, limit=10, offset=0) + ) assert result.total == 1 assert result.items[0].body == "note1 comment" +def test_create_raises_when_note_not_found() -> None: + comment_repo, note_repo = _repos() + with pytest.raises(NoteNotFoundException): + _create_use_case(comment_repo, note_repo).execute( + CreateCommentInput(note_id=9999, body="orphan") + ) + + def test_get_returns_comment() -> None: - repo = _repo() - comment = CreateCommentUseCase(repo).execute(CreateCommentInput(note_id=1, body="body")) - fetched = GetCommentUseCase(repo).execute(comment.id) + comment_repo, note_repo = _repos() + note_repo.save("title", "body") + comment = _create_use_case(comment_repo, note_repo).execute( + CreateCommentInput(note_id=1, body="body") + ) + fetched = GetCommentUseCase(comment_repo).execute(GetCommentInput(comment_id=comment.id)) assert fetched == comment def test_get_raises_when_not_found() -> None: - repo = _repo() + comment_repo, _ = _repos() with pytest.raises(CommentNotFoundException): - GetCommentUseCase(repo).execute(9999) + GetCommentUseCase(comment_repo).execute(GetCommentInput(comment_id=9999)) def test_update_changes_body() -> None: - repo = _repo() - comment = CreateCommentUseCase(repo).execute(CreateCommentInput(note_id=1, body="old")) - updated = UpdateCommentUseCase(repo).execute( + comment_repo, note_repo = _repos() + note_repo.save("title", "body") + comment = _create_use_case(comment_repo, note_repo).execute( + CreateCommentInput(note_id=1, body="old") + ) + updated = UpdateCommentUseCase(comment_repo).execute( UpdateCommentInput(comment_id=comment.id, body="new") ) assert updated.body == "new" def test_update_raises_when_not_found() -> None: - repo = _repo() + comment_repo, _ = _repos() with pytest.raises(CommentNotFoundException): - UpdateCommentUseCase(repo).execute(UpdateCommentInput(comment_id=9999, body="x")) + UpdateCommentUseCase(comment_repo).execute(UpdateCommentInput(comment_id=9999, body="x")) def test_delete_removes_comment() -> None: - repo = _repo() - comment = CreateCommentUseCase(repo).execute(CreateCommentInput(note_id=1, body="bye")) - DeleteCommentUseCase(repo).execute(DeleteCommentInput(comment_id=comment.id)) + comment_repo, note_repo = _repos() + note_repo.save("title", "body") + comment = _create_use_case(comment_repo, note_repo).execute( + CreateCommentInput(note_id=1, body="bye") + ) + DeleteCommentUseCase(comment_repo).execute(DeleteCommentInput(comment_id=comment.id)) with pytest.raises(CommentNotFoundException): - GetCommentUseCase(repo).execute(comment.id) + GetCommentUseCase(comment_repo).execute(GetCommentInput(comment_id=comment.id)) def test_delete_raises_when_not_found() -> None: - repo = _repo() + comment_repo, _ = _repos() with pytest.raises(CommentNotFoundException): - DeleteCommentUseCase(repo).execute(DeleteCommentInput(comment_id=9999)) + DeleteCommentUseCase(comment_repo).execute(DeleteCommentInput(comment_id=9999))