diff --git a/migrations/20250929000000_init_comments.sql b/migrations/20250929000000_init_comments.sql index 66135d3..4623149 100644 --- a/migrations/20250929000000_init_comments.sql +++ b/migrations/20250929000000_init_comments.sql @@ -1,10 +1,13 @@ -- +goose Up -- +goose StatementBegin +CREATE TYPE comment_status AS ENUM ('DELETED', 'HIDDEN', 'ON_MODERATION'); + CREATE TABLE IF NOT EXISTS comments ( id BIGSERIAL PRIMARY KEY, mod_id BIGINT NOT NULL, author_id BIGINT NOT NULL, text TEXT NOT NULL, + status comment_status, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), edited_at TIMESTAMPTZ ); @@ -15,4 +18,5 @@ CREATE INDEX IF NOT EXISTS idx_comments_mod_id ON comments (mod_id); -- +goose Down -- +goose StatementBegin DROP TABLE IF EXISTS comments; +DROP TYPE IF EXISTS comment_status; -- +goose StatementEnd diff --git a/src/commentservice/constants.py b/src/commentservice/constants.py new file mode 100644 index 0000000..6222cac --- /dev/null +++ b/src/commentservice/constants.py @@ -0,0 +1,3 @@ +STATUS_DELETED = "DELETED" +STATUS_HIDDEN = "HIDDEN" +STATUS_ON_MODERATION = "ON_MODERATION" diff --git a/src/commentservice/grpc/comment_pb2.py b/src/commentservice/grpc/comment_pb2.py index 7a0be3d..69ca316 100644 --- a/src/commentservice/grpc/comment_pb2.py +++ b/src/commentservice/grpc/comment_pb2.py @@ -25,13 +25,15 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rcomment.proto\x12\x07\x63omment\x1a\x1fgoogle/protobuf/timestamp.proto\"\x95\x01\n\x07\x43omment\x12\n\n\x02id\x18\x01 \x01(\x03\x12\x11\n\tauthor_id\x18\x02 \x01(\x03\x12\x0c\n\x04text\x18\x03 \x01(\t\x12.\n\ncreated_at\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12-\n\tedited_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"G\n\x14\x43reateCommentRequest\x12\x0e\n\x06mod_id\x18\x01 \x01(\x03\x12\x11\n\tauthor_id\x18\x02 \x01(\x03\x12\x0c\n\x04text\x18\x03 \x01(\t\"+\n\x15\x43reateCommentResponse\x12\x12\n\ncomment_id\x18\x01 \x01(\x03\"$\n\x12GetCommentsRequest\x12\x0e\n\x06mod_id\x18\x01 \x01(\x03\"I\n\x13GetCommentsResponse\x12\x0e\n\x06mod_id\x18\x01 \x01(\x03\x12\"\n\x08\x63omments\x18\x02 \x03(\x0b\x32\x10.comment.Comment\"*\n\x14\x44\x65leteCommentRequest\x12\x12\n\ncomment_id\x18\x01 \x01(\x03\"(\n\x15\x44\x65leteCommentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\"6\n\x12\x45\x64itCommentRequest\x12\x12\n\ncomment_id\x18\x01 \x01(\x03\x12\x0c\n\x04text\x18\x02 \x01(\t\"&\n\x13\x45\x64itCommentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x32\xc4\x02\n\x0e\x43ommentService\x12N\n\rCreateComment\x12\x1d.comment.CreateCommentRequest\x1a\x1e.comment.CreateCommentResponse\x12H\n\x0bGetComments\x12\x1b.comment.GetCommentsRequest\x1a\x1c.comment.GetCommentsResponse\x12N\n\rDeleteComment\x12\x1d.comment.DeleteCommentRequest\x1a\x1e.comment.DeleteCommentResponse\x12H\n\x0b\x45\x64itComment\x12\x1b.comment.EditCommentRequest\x1a\x1c.comment.EditCommentResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rcomment.proto\x12\x07\x63omment\x1a\x1fgoogle/protobuf/timestamp.proto\"\x95\x01\n\x07\x43omment\x12\n\n\x02id\x18\x01 \x01(\x03\x12\x11\n\tauthor_id\x18\x02 \x01(\x03\x12\x0c\n\x04text\x18\x03 \x01(\t\x12.\n\ncreated_at\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12-\n\tedited_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"G\n\x14\x43reateCommentRequest\x12\x0e\n\x06mod_id\x18\x01 \x01(\x03\x12\x11\n\tauthor_id\x18\x02 \x01(\x03\x12\x0c\n\x04text\x18\x03 \x01(\t\"+\n\x15\x43reateCommentResponse\x12\x12\n\ncomment_id\x18\x01 \x01(\x03\"$\n\x12GetCommentsRequest\x12\x0e\n\x06mod_id\x18\x01 \x01(\x03\"I\n\x13GetCommentsResponse\x12\x0e\n\x06mod_id\x18\x01 \x01(\x03\x12\"\n\x08\x63omments\x18\x02 \x03(\x0b\x32\x10.comment.Comment\"N\n\x10SetStatusRequest\x12\x12\n\ncomment_id\x18\x01 \x01(\x03\x12&\n\x06status\x18\x02 \x01(\x0e\x32\x16.comment.CommentStatus\"$\n\x11SetStatusResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\"6\n\x12\x45\x64itCommentRequest\x12\x12\n\ncomment_id\x18\x01 \x01(\x03\x12\x0c\n\x04text\x18\x02 \x01(\t\"&\n\x13\x45\x64itCommentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08*\x88\x01\n\rCommentStatus\x12\x1e\n\x1a\x43OMMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1a\n\x16\x43OMMENT_STATUS_DELETED\x10\x01\x12\x19\n\x15\x43OMMENT_STATUS_HIDDEN\x10\x02\x12 \n\x1c\x43OMMENT_STATUS_ON_MODERATION\x10\x03\x32\xb8\x02\n\x0e\x43ommentService\x12N\n\rCreateComment\x12\x1d.comment.CreateCommentRequest\x1a\x1e.comment.CreateCommentResponse\x12H\n\x0bGetComments\x12\x1b.comment.GetCommentsRequest\x1a\x1c.comment.GetCommentsResponse\x12\x42\n\tSetStatus\x12\x19.comment.SetStatusRequest\x1a\x1a.comment.SetStatusResponse\x12H\n\x0b\x45\x64itComment\x12\x1b.comment.EditCommentRequest\x1a\x1c.comment.EditCommentResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'comment_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None + _globals['_COMMENTSTATUS']._serialized_start=657 + _globals['_COMMENTSTATUS']._serialized_end=793 _globals['_COMMENT']._serialized_start=60 _globals['_COMMENT']._serialized_end=209 _globals['_CREATECOMMENTREQUEST']._serialized_start=211 @@ -42,14 +44,14 @@ _globals['_GETCOMMENTSREQUEST']._serialized_end=365 _globals['_GETCOMMENTSRESPONSE']._serialized_start=367 _globals['_GETCOMMENTSRESPONSE']._serialized_end=440 - _globals['_DELETECOMMENTREQUEST']._serialized_start=442 - _globals['_DELETECOMMENTREQUEST']._serialized_end=484 - _globals['_DELETECOMMENTRESPONSE']._serialized_start=486 - _globals['_DELETECOMMENTRESPONSE']._serialized_end=526 - _globals['_EDITCOMMENTREQUEST']._serialized_start=528 - _globals['_EDITCOMMENTREQUEST']._serialized_end=582 - _globals['_EDITCOMMENTRESPONSE']._serialized_start=584 - _globals['_EDITCOMMENTRESPONSE']._serialized_end=622 - _globals['_COMMENTSERVICE']._serialized_start=625 - _globals['_COMMENTSERVICE']._serialized_end=949 + _globals['_SETSTATUSREQUEST']._serialized_start=442 + _globals['_SETSTATUSREQUEST']._serialized_end=520 + _globals['_SETSTATUSRESPONSE']._serialized_start=522 + _globals['_SETSTATUSRESPONSE']._serialized_end=558 + _globals['_EDITCOMMENTREQUEST']._serialized_start=560 + _globals['_EDITCOMMENTREQUEST']._serialized_end=614 + _globals['_EDITCOMMENTRESPONSE']._serialized_start=616 + _globals['_EDITCOMMENTRESPONSE']._serialized_end=654 + _globals['_COMMENTSERVICE']._serialized_start=796 + _globals['_COMMENTSERVICE']._serialized_end=1108 # @@protoc_insertion_point(module_scope) diff --git a/src/commentservice/grpc/comment_pb2.pyi b/src/commentservice/grpc/comment_pb2.pyi index 7b44b4f..930e8d1 100644 --- a/src/commentservice/grpc/comment_pb2.pyi +++ b/src/commentservice/grpc/comment_pb2.pyi @@ -2,6 +2,7 @@ import datetime from google.protobuf import timestamp_pb2 as _timestamp_pb2 from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message from collections.abc import Iterable as _Iterable, Mapping as _Mapping @@ -9,6 +10,17 @@ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor +class CommentStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + COMMENT_STATUS_UNSPECIFIED: _ClassVar[CommentStatus] + COMMENT_STATUS_DELETED: _ClassVar[CommentStatus] + COMMENT_STATUS_HIDDEN: _ClassVar[CommentStatus] + COMMENT_STATUS_ON_MODERATION: _ClassVar[CommentStatus] +COMMENT_STATUS_UNSPECIFIED: CommentStatus +COMMENT_STATUS_DELETED: CommentStatus +COMMENT_STATUS_HIDDEN: CommentStatus +COMMENT_STATUS_ON_MODERATION: CommentStatus + class Comment(_message.Message): __slots__ = ("id", "author_id", "text", "created_at", "edited_at") ID_FIELD_NUMBER: _ClassVar[int] @@ -53,13 +65,15 @@ class GetCommentsResponse(_message.Message): comments: _containers.RepeatedCompositeFieldContainer[Comment] def __init__(self, mod_id: _Optional[int] = ..., comments: _Optional[_Iterable[_Union[Comment, _Mapping]]] = ...) -> None: ... -class DeleteCommentRequest(_message.Message): - __slots__ = ("comment_id",) +class SetStatusRequest(_message.Message): + __slots__ = ("comment_id", "status") COMMENT_ID_FIELD_NUMBER: _ClassVar[int] + STATUS_FIELD_NUMBER: _ClassVar[int] comment_id: int - def __init__(self, comment_id: _Optional[int] = ...) -> None: ... + status: CommentStatus + def __init__(self, comment_id: _Optional[int] = ..., status: _Optional[_Union[CommentStatus, str]] = ...) -> None: ... -class DeleteCommentResponse(_message.Message): +class SetStatusResponse(_message.Message): __slots__ = ("success",) SUCCESS_FIELD_NUMBER: _ClassVar[int] success: bool diff --git a/src/commentservice/grpc/comment_pb2_grpc.py b/src/commentservice/grpc/comment_pb2_grpc.py index cba8422..1e74b2a 100644 --- a/src/commentservice/grpc/comment_pb2_grpc.py +++ b/src/commentservice/grpc/comment_pb2_grpc.py @@ -26,7 +26,8 @@ class CommentServiceStub(object): - """Missing associated documentation comment in .proto file.""" + """Сервис для работы с комментариями: создание, получение, изменение статуса, редактирование + """ def __init__(self, channel): """Constructor. @@ -44,10 +45,10 @@ def __init__(self, channel): request_serializer=comment__pb2.GetCommentsRequest.SerializeToString, response_deserializer=comment__pb2.GetCommentsResponse.FromString, _registered_method=True) - self.DeleteComment = channel.unary_unary( - '/comment.CommentService/DeleteComment', - request_serializer=comment__pb2.DeleteCommentRequest.SerializeToString, - response_deserializer=comment__pb2.DeleteCommentResponse.FromString, + self.SetStatus = channel.unary_unary( + '/comment.CommentService/SetStatus', + request_serializer=comment__pb2.SetStatusRequest.SerializeToString, + response_deserializer=comment__pb2.SetStatusResponse.FromString, _registered_method=True) self.EditComment = channel.unary_unary( '/comment.CommentService/EditComment', @@ -57,28 +58,33 @@ def __init__(self, channel): class CommentServiceServicer(object): - """Missing associated documentation comment in .proto file.""" + """Сервис для работы с комментариями: создание, получение, изменение статуса, редактирование + """ def CreateComment(self, request, context): - """Missing associated documentation comment in .proto file.""" + """Создание комментария + """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') def GetComments(self, request, context): - """Missing associated documentation comment in .proto file.""" + """Получение комментариев + """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') - def DeleteComment(self, request, context): - """Missing associated documentation comment in .proto file.""" + def SetStatus(self, request, context): + """Изменение статуса комментария + """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') def EditComment(self, request, context): - """Missing associated documentation comment in .proto file.""" + """Редактирование комментария + """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') @@ -96,10 +102,10 @@ def add_CommentServiceServicer_to_server(servicer, server): request_deserializer=comment__pb2.GetCommentsRequest.FromString, response_serializer=comment__pb2.GetCommentsResponse.SerializeToString, ), - 'DeleteComment': grpc.unary_unary_rpc_method_handler( - servicer.DeleteComment, - request_deserializer=comment__pb2.DeleteCommentRequest.FromString, - response_serializer=comment__pb2.DeleteCommentResponse.SerializeToString, + 'SetStatus': grpc.unary_unary_rpc_method_handler( + servicer.SetStatus, + request_deserializer=comment__pb2.SetStatusRequest.FromString, + response_serializer=comment__pb2.SetStatusResponse.SerializeToString, ), 'EditComment': grpc.unary_unary_rpc_method_handler( servicer.EditComment, @@ -115,7 +121,8 @@ def add_CommentServiceServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class CommentService(object): - """Missing associated documentation comment in .proto file.""" + """Сервис для работы с комментариями: создание, получение, изменение статуса, редактирование + """ @staticmethod def CreateComment(request, @@ -172,7 +179,7 @@ def GetComments(request, _registered_method=True) @staticmethod - def DeleteComment(request, + def SetStatus(request, target, options=(), channel_credentials=None, @@ -185,9 +192,9 @@ def DeleteComment(request, return grpc.experimental.unary_unary( request, target, - '/comment.CommentService/DeleteComment', - comment__pb2.DeleteCommentRequest.SerializeToString, - comment__pb2.DeleteCommentResponse.FromString, + '/comment.CommentService/SetStatus', + comment__pb2.SetStatusRequest.SerializeToString, + comment__pb2.SetStatusResponse.FromString, options, channel_credentials, insecure, diff --git a/src/commentservice/handler/delete_comment.py b/src/commentservice/handler/delete_comment.py deleted file mode 100644 index 3da9a4d..0000000 --- a/src/commentservice/handler/delete_comment.py +++ /dev/null @@ -1,13 +0,0 @@ -import grpc - -from commentservice.grpc import comment_pb2 -from commentservice.service.service import CommentService - - -async def DeleteComment( - service: CommentService, - request: comment_pb2.DeleteCommentRequest, - _: grpc.ServicerContext, -) -> comment_pb2.DeleteCommentResponse: - success = await service.delete_comment(request.comment_id) - return comment_pb2.DeleteCommentResponse(success=success) diff --git a/src/commentservice/handler/handler.py b/src/commentservice/handler/handler.py index 85b893c..3a96b14 100644 --- a/src/commentservice/handler/handler.py +++ b/src/commentservice/handler/handler.py @@ -4,11 +4,9 @@ from commentservice.handler.create_comment import ( CreateComment as _create_comment, ) -from commentservice.handler.delete_comment import ( - DeleteComment as _delete_comment, -) from commentservice.handler.edit_comment import EditComment as _edit_comment from commentservice.handler.get_comments import GetComments as _get_comments +from commentservice.handler.set_status import SetStatus as _set_status from commentservice.service.service import CommentService @@ -30,12 +28,12 @@ async def EditComment( ) -> comment_pb2.EditCommentResponse: return await _edit_comment(self._service, request, context) - async def DeleteComment( + async def SetStatus( self, - request: comment_pb2.DeleteCommentRequest, + request: comment_pb2.SetStatusRequest, context: grpc.ServicerContext, - ) -> comment_pb2.DeleteCommentResponse: - return await _delete_comment(self._service, request, context) + ) -> comment_pb2.SetStatusResponse: + return await _set_status(self._service, request, context) async def GetComments( self, diff --git a/src/commentservice/handler/set_status.py b/src/commentservice/handler/set_status.py new file mode 100644 index 0000000..0d8fb62 --- /dev/null +++ b/src/commentservice/handler/set_status.py @@ -0,0 +1,41 @@ +import grpc + +from commentservice.constants import ( + STATUS_DELETED, + STATUS_HIDDEN, + STATUS_ON_MODERATION, +) +from commentservice.grpc import comment_pb2 +from commentservice.grpc.comment_pb2 import CommentStatus +from commentservice.service.service import CommentService + +_ENUM_TO_DB_STATUS_BY_VALUE: dict[int, str] = { + CommentStatus.COMMENT_STATUS_DELETED: STATUS_DELETED, + CommentStatus.COMMENT_STATUS_HIDDEN: STATUS_HIDDEN, + CommentStatus.COMMENT_STATUS_ON_MODERATION: STATUS_ON_MODERATION, +} + + +def _convert_enum_to_status(status_value: int) -> str: + if status_value == CommentStatus.COMMENT_STATUS_UNSPECIFIED: + raise ValueError("Status must be specified") + return _ENUM_TO_DB_STATUS_BY_VALUE[status_value] + + +async def SetStatus( + service: CommentService, + request: comment_pb2.SetStatusRequest, + context: grpc.ServicerContext, # noqa: ARG001 +) -> comment_pb2.SetStatusResponse: + try: + status_str = _convert_enum_to_status(request.status) + success = await service.set_status(request.comment_id, status_str) + return comment_pb2.SetStatusResponse(success=success) + except ValueError as e: + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(str(e)) + return comment_pb2.SetStatusResponse(success=False) + except Exception as e: + context.set_code(grpc.StatusCode.INTERNAL) + context.set_details(f"Failed to set status: {e!s}") + return comment_pb2.SetStatusResponse(success=False) diff --git a/src/commentservice/repository/delete_comment.py b/src/commentservice/repository/delete_comment.py deleted file mode 100644 index 19c65b4..0000000 --- a/src/commentservice/repository/delete_comment.py +++ /dev/null @@ -1,15 +0,0 @@ -from asyncpg import Pool - - -async def delete_comment(db_pool: Pool, comment_id: int) -> bool: - async with db_pool.acquire() as conn: - deleted_id = await conn.fetchval( - """ - DELETE FROM comments - WHERE id = $1 - RETURNING id - """, - comment_id, - ) - - return deleted_id is not None diff --git a/src/commentservice/repository/repository.py b/src/commentservice/repository/repository.py index ad4d54f..0655b76 100644 --- a/src/commentservice/repository/repository.py +++ b/src/commentservice/repository/repository.py @@ -3,9 +3,6 @@ from commentservice.repository.create_comment import ( create_comment as _create_comment, ) -from commentservice.repository.delete_comment import ( - delete_comment as _delete_comment, -) from commentservice.repository.edit_comment import ( edit_comment as _edit_comment, ) @@ -13,6 +10,7 @@ get_comments as _get_comments, ) from commentservice.repository.model import Comment +from commentservice.repository.set_status import set_status as _set_status class CommentRepository: @@ -30,8 +28,8 @@ async def create_comment( async def edit_comment(self, comment_id: int, new_text: str) -> bool: return await _edit_comment(self._db_pool, comment_id, new_text) - async def delete_comment(self, comment_id: int) -> bool: - return await _delete_comment(self._db_pool, comment_id) + async def set_status(self, comment_id: int, status: str) -> bool: + return await _set_status(self._db_pool, comment_id, status) async def get_comments(self, mod_id: int) -> list[Comment]: return await _get_comments(self._db_pool, mod_id) diff --git a/src/commentservice/repository/set_status.py b/src/commentservice/repository/set_status.py new file mode 100644 index 0000000..26374fa --- /dev/null +++ b/src/commentservice/repository/set_status.py @@ -0,0 +1,19 @@ +from asyncpg import Pool + + +async def set_status(db_pool: Pool, comment_id: int, status: str) -> bool: + try: + async with db_pool.acquire() as conn: + result = await conn.execute( + """ + UPDATE comments + SET status = $1 + WHERE id = $2 + """, + status, + comment_id, + ) + rows_affected = int(result.split()[-1]) if result else 0 + return rows_affected > 0 + except Exception: + return False diff --git a/src/commentservice/server.py b/src/commentservice/server.py index 26416a0..7c5a936 100644 --- a/src/commentservice/server.py +++ b/src/commentservice/server.py @@ -30,7 +30,8 @@ async def serve() -> None: server = grpc.aio.server(futures.ThreadPoolExecutor(max_workers=5)) comment_pb2_grpc.add_CommentServiceServicer_to_server( - handler, server + handler, + server, ) # type: ignore[no-untyped-call] SERVICE_NAMES = ( diff --git a/src/commentservice/service/delete_comment.py b/src/commentservice/service/delete_comment.py deleted file mode 100644 index a993edd..0000000 --- a/src/commentservice/service/delete_comment.py +++ /dev/null @@ -1,5 +0,0 @@ -from commentservice.repository.repository import CommentRepository - - -async def delete_comment(repo: CommentRepository, comment_id: int) -> bool: - return await repo.delete_comment(comment_id) diff --git a/src/commentservice/service/service.py b/src/commentservice/service/service.py index 98cd9f9..650ad9c 100644 --- a/src/commentservice/service/service.py +++ b/src/commentservice/service/service.py @@ -3,11 +3,9 @@ from commentservice.service.create_comment import ( create_comment as _create_comment, ) -from commentservice.service.delete_comment import ( - delete_comment as _delete_comment, -) from commentservice.service.edit_comment import edit_comment as _edit_comment from commentservice.service.get_comments import get_comments as _get_comments +from commentservice.service.set_status import set_status as _set_status class CommentService: @@ -22,8 +20,8 @@ async def create_comment( async def edit_comment(self, comment_id: int, new_text: str) -> bool: return await _edit_comment(self._repo, comment_id, new_text) - async def delete_comment(self, comment_id: int) -> bool: - return await _delete_comment(self._repo, comment_id) + async def set_status(self, comment_id: int, status: str) -> bool: + return await _set_status(self._repo, comment_id, status) async def get_comments(self, mod_id: int) -> list[Comment]: return await _get_comments(self._repo, mod_id) diff --git a/src/commentservice/service/set_status.py b/src/commentservice/service/set_status.py new file mode 100644 index 0000000..2ac4d8a --- /dev/null +++ b/src/commentservice/service/set_status.py @@ -0,0 +1,7 @@ +from commentservice.repository.repository import CommentRepository + + +async def set_status( + repo: CommentRepository, comment_id: int, status: str +) -> bool: + return await repo.set_status(comment_id, status) diff --git a/tests/handler/test_delete_comment.py b/tests/handler/test_delete_comment.py deleted file mode 100644 index ee87f94..0000000 --- a/tests/handler/test_delete_comment.py +++ /dev/null @@ -1,47 +0,0 @@ -from unittest.mock import AsyncMock - -import grpc -import pytest -from faker import Faker -from pytest_mock import MockerFixture - -from commentservice.grpc.comment_pb2 import ( - DeleteCommentRequest, - DeleteCommentResponse, -) -from commentservice.handler.delete_comment import DeleteComment -from commentservice.service.service import CommentService - - -@pytest.mark.asyncio -async def test_delete_comment_success( - mocker: MockerFixture, faker: Faker -) -> None: - ctx = mocker.Mock(spec=grpc.ServicerContext) - fake_service = mocker.Mock(spec=CommentService) - fake_service.delete_comment = AsyncMock(return_value=True) - - comment_id = faker.random_int(min=1, max=100000) - request = DeleteCommentRequest(comment_id=comment_id) - - response = await DeleteComment(fake_service, request, ctx) - - assert isinstance(response, DeleteCommentResponse) - assert response.success is True - fake_service.delete_comment.assert_awaited_once_with(comment_id) - - -@pytest.mark.asyncio -async def test_delete_comment_not_found( - mocker: MockerFixture, faker: Faker -) -> None: - ctx = mocker.Mock(spec=grpc.ServicerContext) - fake_service = mocker.Mock(spec=CommentService) - fake_service.delete_comment = AsyncMock(return_value=False) - - comment_id = faker.random_int(min=1, max=100000) - request = DeleteCommentRequest(comment_id=comment_id) - response = await DeleteComment(fake_service, request, ctx) - - assert response.success is False - fake_service.delete_comment.assert_awaited_once_with(comment_id) diff --git a/tests/handler/test_handler.py b/tests/handler/test_handler.py index 442df42..4fe185e 100644 --- a/tests/handler/test_handler.py +++ b/tests/handler/test_handler.py @@ -45,14 +45,15 @@ def _build_edit_pair( return request, response -def _build_delete_pair( +def _build_set_status_pair( faker: Faker, -) -> tuple[ - comment_pb2.DeleteCommentRequest, comment_pb2.DeleteCommentResponse -]: +) -> tuple[comment_pb2.SetStatusRequest, comment_pb2.SetStatusResponse]: comment_id = faker.random_int(min=1, max=100000) - response = comment_pb2.DeleteCommentResponse(success=True) - request = comment_pb2.DeleteCommentRequest(comment_id=comment_id) + response = comment_pb2.SetStatusResponse(success=True) + request = comment_pb2.SetStatusRequest( + comment_id=comment_id, + status=comment_pb2.CommentStatus.COMMENT_STATUS_DELETED, + ) return request, response @@ -89,9 +90,9 @@ class HandlerCase: _build_edit_pair, ), HandlerCase( - "DeleteComment", - "_delete_comment", - _build_delete_pair, + "SetStatus", + "_set_status", + _build_set_status_pair, ), HandlerCase( "GetComments", diff --git a/tests/handler/test_set_status.py b/tests/handler/test_set_status.py new file mode 100644 index 0000000..f024955 --- /dev/null +++ b/tests/handler/test_set_status.py @@ -0,0 +1,76 @@ +from unittest.mock import AsyncMock + +import grpc +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from commentservice.grpc import comment_pb2 +from commentservice.handler.set_status import SetStatus +from commentservice.service.service import CommentService + + +@pytest.mark.asyncio +async def test_set_status_success(mocker: MockerFixture, faker: Faker) -> None: + context = mocker.Mock(spec=grpc.ServicerContext) + service = mocker.Mock(spec=CommentService) + service.set_status = AsyncMock(return_value=True) + + comment_id = faker.random_int(min=1, max=100000) + request = comment_pb2.SetStatusRequest( + comment_id=comment_id, + status=comment_pb2.CommentStatus.COMMENT_STATUS_DELETED, + ) + + response = await SetStatus(service, request, context) + + assert isinstance(response, comment_pb2.SetStatusResponse) + assert response.success is True + service.set_status.assert_awaited_once_with(comment_id, "DELETED") + context.set_code.assert_not_called() + context.set_details.assert_not_called() + + +@pytest.mark.asyncio +async def test_set_status_invalid_enum_sets_error( + mocker: MockerFixture, faker: Faker +) -> None: + context = mocker.Mock(spec=grpc.ServicerContext) + service = mocker.Mock(spec=CommentService) + service.set_status = AsyncMock() + + request = comment_pb2.SetStatusRequest( + comment_id=faker.random_int(min=1, max=100000), + status=comment_pb2.CommentStatus.COMMENT_STATUS_UNSPECIFIED, + ) + + response = await SetStatus(service, request, context) + + assert response.success is False + service.set_status.assert_not_called() + context.set_code.assert_called_once_with(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details.assert_called_once_with("Status must be specified") + + +@pytest.mark.asyncio +async def test_set_status_internal_error_sets_context( + mocker: MockerFixture, faker: Faker +) -> None: + context = mocker.Mock(spec=grpc.ServicerContext) + service = mocker.Mock(spec=CommentService) + error = RuntimeError(faker.sentence()) + service.set_status = AsyncMock(side_effect=error) + + request = comment_pb2.SetStatusRequest( + comment_id=faker.random_int(min=1, max=100000), + status=comment_pb2.CommentStatus.COMMENT_STATUS_HIDDEN, + ) + + response = await SetStatus(service, request, context) + + assert response.success is False + context.set_code.assert_called_once_with(grpc.StatusCode.INTERNAL) + assert ( + context.set_details.call_args.args[0] + == f"Failed to set status: {error!s}" + ) diff --git a/tests/repository/test_delete_comment.py b/tests/repository/test_delete_comment.py deleted file mode 100644 index b5f48c0..0000000 --- a/tests/repository/test_delete_comment.py +++ /dev/null @@ -1,68 +0,0 @@ -import textwrap -from unittest.mock import AsyncMock - -import pytest -from faker import Faker -from pytest_mock import MockerFixture - -from commentservice.repository.repository import CommentRepository - - -@pytest.mark.asyncio -async def test_repo_delete_comment_success( - mocker: MockerFixture, faker: Faker -) -> None: - conn = mocker.Mock() - conn.fetchval = AsyncMock() - pool = mocker.Mock() - pool.acquire = mocker.Mock() - pool.acquire.return_value = AsyncMock() - pool.acquire.return_value.__aenter__.return_value = conn - pool.acquire.return_value.__aexit__.return_value = None - repo = CommentRepository(pool) - - cid = faker.random_int(min=1, max=100000) - conn.fetchval.return_value = cid - - ok = await repo.delete_comment(comment_id=cid) - assert ok is True - expected_sql = """ - DELETE FROM comments - WHERE id = $1 - RETURNING id - """ - actual_sql = conn.fetchval.await_args.args[0] - assert ( - textwrap.dedent(actual_sql).strip() - == textwrap.dedent(expected_sql).strip() - ) - assert conn.fetchval.await_args.args[1:] == (cid,) - - -@pytest.mark.asyncio -async def test_repo_delete_comment_not_found( - mocker: MockerFixture, faker: Faker -) -> None: - conn = mocker.Mock() - conn.fetchval = AsyncMock(return_value=None) - pool = mocker.Mock() - pool.acquire = mocker.Mock() - pool.acquire.return_value = AsyncMock() - pool.acquire.return_value.__aenter__.return_value = conn - pool.acquire.return_value.__aexit__.return_value = None - repo = CommentRepository(pool) - - cid = faker.random_int(min=1, max=100000) - ok = await repo.delete_comment(comment_id=cid) - assert ok is False - expected_sql = """ - DELETE FROM comments - WHERE id = $1 - RETURNING id - """ - actual_sql = conn.fetchval.await_args.args[0] - assert ( - textwrap.dedent(actual_sql).strip() - == textwrap.dedent(expected_sql).strip() - ) - assert conn.fetchval.await_args.args[1:] == (cid,) diff --git a/tests/repository/test_set_status.py b/tests/repository/test_set_status.py new file mode 100644 index 0000000..17774af --- /dev/null +++ b/tests/repository/test_set_status.py @@ -0,0 +1,52 @@ +import textwrap + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from commentservice.repository.set_status import set_status + + +@pytest.mark.asyncio +async def test_set_status_returns_true_on_update( + mocker: MockerFixture, faker: Faker +) -> None: + conn = mocker.Mock() + conn.execute = mocker.AsyncMock(return_value="UPDATE 1") + acquire_cm = mocker.AsyncMock() + acquire_cm.__aenter__.return_value = conn + acquire_cm.__aexit__.return_value = None + pool = mocker.Mock() + pool.acquire.return_value = acquire_cm + + comment_id = faker.random_int(min=1, max=100000) + status = "DELETED" + + result = await set_status(pool, comment_id, status) + + assert result is True + expected_sql = """ + UPDATE comments + SET status = $1 + WHERE id = $2 + """ + actual_sql = conn.execute.await_args.args[0] + assert ( + textwrap.dedent(actual_sql).strip() + == textwrap.dedent(expected_sql).strip() + ) + assert conn.execute.await_args.args[1:] == (status, comment_id) + + +@pytest.mark.asyncio +async def test_set_status_returns_false_on_exception( + mocker: MockerFixture, faker: Faker +) -> None: + pool = mocker.Mock() + pool.acquire.side_effect = RuntimeError("boom") + + result = await set_status( + pool, faker.random_int(min=1, max=100000), "HIDDEN" + ) + + assert result is False diff --git a/tests/service/test_delete_comment.py b/tests/service/test_set_status.py similarity index 50% rename from tests/service/test_delete_comment.py rename to tests/service/test_set_status.py index 5db3a93..340f782 100644 --- a/tests/service/test_delete_comment.py +++ b/tests/service/test_set_status.py @@ -9,16 +9,19 @@ @pytest.mark.asyncio -async def test_service_delete_comment( +async def test_service_set_status_uses_helper( mocker: MockerFixture, faker: Faker ) -> None: - fake_repo = mocker.Mock(spec=CommentRepository) - fake_repo.delete_comment = AsyncMock(return_value=True) + repo = mocker.Mock(spec=CommentRepository) + helper = AsyncMock(return_value=True) + mocker.patch("commentservice.service.service._set_status", helper) + + service = CommentService(repo) comment_id = faker.random_int(min=1, max=100000) + status = "DELETED" - service = CommentService(fake_repo) - result = await service.delete_comment(comment_id=comment_id) + result = await service.set_status(comment_id, status) assert result is True - fake_repo.delete_comment.assert_awaited_once_with(comment_id) + helper.assert_awaited_once_with(repo, comment_id, status)