Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/example/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
12 changes: 11 additions & 1 deletion src/example/comment/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
27 changes: 21 additions & 6 deletions src/example/comment/use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand All @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions src/example/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
CreateCommentUseCase,
DeleteCommentInput,
DeleteCommentUseCase,
GetCommentInput,
GetCommentUseCase,
ListCommentsInput,
ListCommentsUseCase,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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]
Expand Down
62 changes: 45 additions & 17 deletions tests/example/comment/test_comment_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,56 +11,84 @@ 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
assert body["items"] == []


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
84 changes: 59 additions & 25 deletions tests/example/comment/test_comment_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Loading