From 9180c68f7aa96ff6d3b886f10fef1e46944f38af Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Tue, 2 May 2023 23:59:45 -0300 Subject: [PATCH 01/15] TACS-44 feat(events): add scheduler events --- .../app/domain/events/scheduler_service.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 api-gateway/app/domain/events/scheduler_service.py diff --git a/api-gateway/app/domain/events/scheduler_service.py b/api-gateway/app/domain/events/scheduler_service.py new file mode 100644 index 0000000..fab3b20 --- /dev/null +++ b/api-gateway/app/domain/events/scheduler_service.py @@ -0,0 +1,33 @@ +""" +Events from scheduler service. +""" +import datetime + +from pydantic import Field + +from app.domain.schemas import CamelCaseModel + + +class OptionVoted(CamelCaseModel): + """ + Event emitted when an option is voted. + """ + date: datetime.datetime = Field(description="A tentative date for a meeting", example="2021-01-01T09:30:00") + votes: list[str] = Field(description="A list of usernames that voted for the option.", example=["johndoe"]) + + +class MeetingScheduled(CamelCaseModel): + """ + Event emitted when a meeting is scheduled. + """ + + id: str = Field(description="The meeting's unique identifier.", example="6442ee3291a1304d4c88ffc9") + organizer: str = Field(description="Username of who scheduled the meeting.", example="johndoe") + voting: bool = Field(description="Whether mode voting is enabled.", example=True) + title: str = Field(description="The meeting's title.", example="Sprint Planning") + description: str | None = Field(description="The meeting's description.", example="Planning the next sprint.") + location: str | None = Field(description="The meeting's location.", example="Room 1") + date: datetime.datetime | None = Field(description="The meeting's date.", example="2021-01-01T09:30:00") + guests: list[str] = Field(description="A list of guests.", example=["johndoe", "clarasmith"], default_factory=list) + options: list[OptionVoted] = Field(description="A list of options.", + example=["2021-01-01T09:30:00", "2021-01-01T10:30:00"]) From 1f7f3a72857230e256b97af711bfb78b550df75e Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Wed, 3 May 2023 00:44:53 -0300 Subject: [PATCH 02/15] TACS-44 feat(gateway): map queries to scheduler --- .../app/domain/events/scheduler_service.py | 7 +- .../app/entrypoints/v1/scheduler_service.py | 60 +++++++++ api-gateway/app/router.py | 3 +- .../tests/e2e/v1/test_scheduler_service.py | 121 ++++++++++++++++++ 4 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 api-gateway/app/entrypoints/v1/scheduler_service.py create mode 100644 api-gateway/tests/e2e/v1/test_scheduler_service.py diff --git a/api-gateway/app/domain/events/scheduler_service.py b/api-gateway/app/domain/events/scheduler_service.py index fab3b20..0cd8292 100644 --- a/api-gateway/app/domain/events/scheduler_service.py +++ b/api-gateway/app/domain/events/scheduler_service.py @@ -12,7 +12,7 @@ class OptionVoted(CamelCaseModel): """ Event emitted when an option is voted. """ - date: datetime.datetime = Field(description="A tentative date for a meeting", example="2021-01-01T09:30:00") + date: datetime.datetime = Field(description="A tentative date for a meeting") votes: list[str] = Field(description="A list of usernames that voted for the option.", example=["johndoe"]) @@ -27,7 +27,6 @@ class MeetingScheduled(CamelCaseModel): title: str = Field(description="The meeting's title.", example="Sprint Planning") description: str | None = Field(description="The meeting's description.", example="Planning the next sprint.") location: str | None = Field(description="The meeting's location.", example="Room 1") - date: datetime.datetime | None = Field(description="The meeting's date.", example="2021-01-01T09:30:00") + date: datetime.datetime | None = Field(description="The meeting's date.", example="2021-01-01T09:30:00Z") guests: list[str] = Field(description="A list of guests.", example=["johndoe", "clarasmith"], default_factory=list) - options: list[OptionVoted] = Field(description="A list of options.", - example=["2021-01-01T09:30:00", "2021-01-01T10:30:00"]) + options: list[OptionVoted] = Field(description="A list of options.") diff --git a/api-gateway/app/entrypoints/v1/scheduler_service.py b/api-gateway/app/entrypoints/v1/scheduler_service.py new file mode 100644 index 0000000..397f824 --- /dev/null +++ b/api-gateway/app/entrypoints/v1/scheduler_service.py @@ -0,0 +1,60 @@ +""" +Scheduler Service Gateway +""" +from fastapi import APIRouter +from starlette.status import HTTP_200_OK + +from app.adapters.network import gateway +from app.dependencies import AsyncHttpClientDependency, ServiceProvider +from app.domain.events.scheduler_service import MeetingScheduled +from app.domain.schemas import ResponseModel, ResponseModels +from app.service_layer.gateway import api_v1_url, get_service, verify_status + +router = APIRouter(prefix="/scheduler-service", tags=["Scheduler"]) + + +@router.get("/schedules", + status_code=HTTP_200_OK, + summary="Finds all schedules", + tags=["Queries"] + ) +async def query_schedules( + services: ServiceProvider, + client: AsyncHttpClientDependency, +) -> ResponseModels[MeetingScheduled]: + """ + Retrieves schedules from the Database. + """ + + service = await get_service(service_name="scheduler", services=services) + + response, code = await gateway(service_url=service.base_url, path=f"{api_v1_url}/schedules", + client=client, method="GET") + + verify_status(response=response, status_code=code) + + return ResponseModels[MeetingScheduled](**response) + + +@router.get("/schedules/{schedule_id}", + status_code=HTTP_200_OK, + summary="Finds schedule by id", + tags=["Queries"] + ) +async def query_schedule_by_id( + schedule_id: str, + services: ServiceProvider, + client: AsyncHttpClientDependency, +) -> ResponseModel[MeetingScheduled]: + """ + Retrieves a specific schedule from the Database. + """ + + service = await get_service(service_name="scheduler", services=services) + + response, code = await gateway(service_url=service.base_url, path=f"{api_v1_url}/schedules/{schedule_id}", + client=client, method="GET") + + verify_status(response=response, status_code=code) + + return ResponseModel[MeetingScheduled](**response) diff --git a/api-gateway/app/router.py b/api-gateway/app/router.py index 161b455..ddd41dc 100644 --- a/api-gateway/app/router.py +++ b/api-gateway/app/router.py @@ -7,7 +7,7 @@ from fastapi import APIRouter from app.entrypoints import actuator -from app.entrypoints.v1 import auth_service +from app.entrypoints.v1 import auth_service, scheduler_service root_router = APIRouter() api_router_v1 = APIRouter(prefix="/api/v1") @@ -17,3 +17,4 @@ # API Routers api_router_v1.include_router(auth_service.router) +api_router_v1.include_router(scheduler_service.router) diff --git a/api-gateway/tests/e2e/v1/test_scheduler_service.py b/api-gateway/tests/e2e/v1/test_scheduler_service.py new file mode 100644 index 0000000..04002e4 --- /dev/null +++ b/api-gateway/tests/e2e/v1/test_scheduler_service.py @@ -0,0 +1,121 @@ +""" +Tests for the scheduler service gateway. +""" +from typing import Any, Callable +import uuid + +from aioresponses import aioresponses +import pytest +from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_503_SERVICE_UNAVAILABLE + +from app.dependencies import get_async_http_client, get_services +from app.domain.models import Service +from tests.conftest import DependencyOverrider + +FAKE_SCHEDULER_URL = "http://fake-scheduler-service:8001" + + +def fake_schedule_response(meeting_id: str | None = None) -> dict[str, Any]: + """ + Fake schedule response. + + Returns: + dict[str, Any]: The fake schedule response. + """ + return {"data": { + "id": meeting_id or str(uuid.uuid4()), + "organizer": "johndoe", + "voting": False, + "title": "Meeting with the team", + "description": "We will discuss the new project", + "location": "Zoom", + "guests": [], + "options": [ + { + "date": "2023-05-03T23:59:00", + "votes": [] + } + ] + }} + + +class TestSchedulerServiceGateway: + """ + Tests for the scheduler service gateway. + """ + + @pytest.fixture + def fake_web(self): + with aioresponses() as mock: + yield mock + + overrides: dict[Callable, Callable] = { + get_services: lambda: [Service(name="scheduler", base_url=FAKE_SCHEDULER_URL)], + } + + +class TestSchedulerQueries(TestSchedulerServiceGateway): + + def test_get_schedules(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to get all schedules, and a working service + WHEN the request is made + THEN it should return all schedules. + """ + fake_web.get(f"{FAKE_SCHEDULER_URL}/api/v1/schedules", payload={"data": []}, status=HTTP_200_OK) + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.get("/api/v1/scheduler-service/schedules") + # then + assert response.status_code == HTTP_200_OK + + def test_service_unavailable(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to get all schedules, and a service that is not available + WHEN the request is made + THEN it should return a service unavailable error. + """ + fake_web.get(f"{FAKE_SCHEDULER_URL}/api/v1/schedules", payload={}, status=HTTP_503_SERVICE_UNAVAILABLE) + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.get("/api/v1/scheduler-service/schedules") + # then + assert response.status_code == HTTP_503_SERVICE_UNAVAILABLE + + def test_get_schedule_by_id(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to get a schedule by id, and a working service + WHEN the request is made + THEN it should return the schedule. + """ + fake_web.get(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/1", + payload=fake_schedule_response(meeting_id="1"), + status=HTTP_200_OK) + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.get("/api/v1/scheduler-service/schedules/1") + # then + assert response.status_code == HTTP_200_OK + + def test_get_schedule_by_id_not_found(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to get a schedule by id, and a working service + WHEN the request is made + THEN it should return the schedule. + """ + fake_web.get(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/1", + payload={"detail": "Not found."}, + status=HTTP_404_NOT_FOUND) + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.get("/api/v1/scheduler-service/schedules/1") + # then + assert response.status_code == HTTP_404_NOT_FOUND From 2d487a472d96aaa265718ff56637fd78b73c826a Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Wed, 3 May 2023 00:45:34 -0300 Subject: [PATCH 03/15] TACS-44 refactor(scheduler): update for connectivity - Remove MongoDB - Change default port --- pom.xml | 6 +----- scheduler/src/main/resources/application.yml | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pom.xml b/pom.xml index 6a42c1b..a90714f 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ schedutn pom 0.2.0-SNAPSHOT - + scheduler @@ -84,10 +84,6 @@ org.springframework.boot spring-boot-starter-actuator - - spring-boot-starter-data-mongodb - org.springframework.boot - org.springframework.boot spring-boot-starter-security diff --git a/scheduler/src/main/resources/application.yml b/scheduler/src/main/resources/application.yml index 1b31e76..ca26101 100644 --- a/scheduler/src/main/resources/application.yml +++ b/scheduler/src/main/resources/application.yml @@ -1,5 +1,5 @@ server: - port: 8080 + port: 8001 spring: application: From 88b61e8adc035f93a92c620897ec94f721e62982 Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Thu, 4 May 2023 21:35:18 -0300 Subject: [PATCH 04/15] TACS-44 feat(commands): add Schedule Meeting command - Add domain commands - User validation function --- .../app/domain/commands/scheduler_service.py | 33 ++++ api-gateway/app/domain/schemas.py | 1 + .../app/entrypoints/v1/scheduler_service.py | 49 ++++- api-gateway/app/service_layer/gateway.py | 45 ++++- api-gateway/app/utils/formatter.py | 18 +- .../tests/e2e/v1/test_scheduler_service.py | 144 +++++++++++++- .../integration/service_layer/__init__.py | 0 .../integration/service_layer/test_gateway.py | 178 ++++++++++++++++++ api-gateway/tests/mocks.py | 59 ++++++ 9 files changed, 516 insertions(+), 11 deletions(-) create mode 100644 api-gateway/app/domain/commands/scheduler_service.py create mode 100644 api-gateway/tests/integration/service_layer/__init__.py create mode 100644 api-gateway/tests/integration/service_layer/test_gateway.py create mode 100644 api-gateway/tests/mocks.py diff --git a/api-gateway/app/domain/commands/scheduler_service.py b/api-gateway/app/domain/commands/scheduler_service.py new file mode 100644 index 0000000..9e74840 --- /dev/null +++ b/api-gateway/app/domain/commands/scheduler_service.py @@ -0,0 +1,33 @@ +""" +Scheduler Commands +""" +import datetime + +from pydantic import Field + +from app.domain.schemas import CamelCaseModel + + +class ProposeOption(CamelCaseModel): + """ + Command to propose a meeting option. + """ + + date: datetime.date = Field(description="The date of the meeting.", example="2021-01-01") + hour: int = Field(description="The hour of the meeting.", example="12", max=23, min=0) + minute: int = Field(description="The minute of the meeting.", example="30", max=59, min=0) + + +class ScheduleMeeting(CamelCaseModel): + """ + Command to schedule a meeting. + """ + + organizer: str = Field(description="Responsible for the meeting's username.", example="johndoe") + title: str | None = Field(description="The meeting's title.", example="Coffee with John Doe") + description: str | None = Field(description="The meeting's description.", + example="A meeting to discuss the project.") + location: str | None = Field(description="The meeting's location.", example="Floor 3, Cafeteria") + options: list[ProposeOption] = Field(description="A list of options to schedule the meeting.", min_items=1) + guests: set[str] = Field(description="A list of guests to invite to the meeting.", + example=["frank", "jane"], default_factory=set) diff --git a/api-gateway/app/domain/schemas.py b/api-gateway/app/domain/schemas.py index e7e8e58..9b9b052 100644 --- a/api-gateway/app/domain/schemas.py +++ b/api-gateway/app/domain/schemas.py @@ -35,6 +35,7 @@ class Config(BaseConfig): alias_generator = to_camel allow_population_by_field_name = True allow_arbitrary_types = True + use_enum_values = True anystr_strip_whitespace = True diff --git a/api-gateway/app/entrypoints/v1/scheduler_service.py b/api-gateway/app/entrypoints/v1/scheduler_service.py index 397f824..274d4bf 100644 --- a/api-gateway/app/entrypoints/v1/scheduler_service.py +++ b/api-gateway/app/entrypoints/v1/scheduler_service.py @@ -1,14 +1,17 @@ """ Scheduler Service Gateway """ -from fastapi import APIRouter -from starlette.status import HTTP_200_OK +from typing import Annotated + +from fastapi import APIRouter, Path, Request, Response +from starlette.status import HTTP_200_OK, HTTP_201_CREATED from app.adapters.network import gateway from app.dependencies import AsyncHttpClientDependency, ServiceProvider +from app.domain.commands.scheduler_service import ScheduleMeeting from app.domain.events.scheduler_service import MeetingScheduled from app.domain.schemas import ResponseModel, ResponseModels -from app.service_layer.gateway import api_v1_url, get_service, verify_status +from app.service_layer.gateway import api_v1_url, get_service, verify_scheduling_meeting, verify_status router = APIRouter(prefix="/scheduler-service", tags=["Scheduler"]) @@ -42,7 +45,7 @@ async def query_schedules( tags=["Queries"] ) async def query_schedule_by_id( - schedule_id: str, + schedule_id: Annotated[str, Path(description="The schedule's id.", example="b455f6t63t7")], services: ServiceProvider, client: AsyncHttpClientDependency, ) -> ResponseModel[MeetingScheduled]: @@ -58,3 +61,41 @@ async def query_schedule_by_id( verify_status(response=response, status_code=code) return ResponseModel[MeetingScheduled](**response) + + +@router.post("/schedules", + status_code=HTTP_201_CREATED, + summary="Creates a schedule", + tags=["Commands"], + ) +async def schedule( + command: ScheduleMeeting, + services: ServiceProvider, + client: AsyncHttpClientDependency, + request: Request, + response: Response, +) -> ResponseModel[MeetingScheduled]: + """ + Schedules a meeting. + """ + + updated_command = await verify_scheduling_meeting(command=command, + service=await get_service(service_name="auth", + services=services), + client=client) + + service_response, status_code = await gateway( + service_url=(await get_service(service_name="scheduler", services=services)).base_url, + path=f"{api_v1_url}/schedules", + client=client, + method="POST", + request_body=updated_command.json() + ) + + verify_status(response=service_response, status_code=status_code, status_codes=[HTTP_201_CREATED]) + + response_body = ResponseModel[MeetingScheduled](**service_response) + + response.headers["Location"] = f"{request.base_url}api/v1/scheduler-service/schedules/{response_body.data.id}" + + return response_body diff --git a/api-gateway/app/service_layer/gateway.py b/api-gateway/app/service_layer/gateway.py index 9bb76ef..6b1bcf5 100644 --- a/api-gateway/app/service_layer/gateway.py +++ b/api-gateway/app/service_layer/gateway.py @@ -4,11 +4,14 @@ from typing import Any from fastapi import HTTPException -from starlette.status import HTTP_200_OK, HTTP_503_SERVICE_UNAVAILABLE +from starlette.status import HTTP_200_OK, HTTP_409_CONFLICT, HTTP_503_SERVICE_UNAVAILABLE from app.adapters.http_client import AsyncHttpClient from app.adapters.network import gateway +from app.domain.commands.scheduler_service import ScheduleMeeting +from app.domain.events.auth_service import UserRegistered from app.domain.models import Service +from app.domain.schemas import ResponseModels api_v1_url = "/api/v1" @@ -76,3 +79,43 @@ async def get_users(users: str, service: Service, client: AsyncHttpClient) -> tu return await gateway(service_url=service.base_url, path=f"{api_v1_url}/users", query_params=params, client=client, method="GET") + + +async def verify_scheduling_meeting(command: ScheduleMeeting, + service: Service, + client: AsyncHttpClient) -> ScheduleMeeting: + """ + Verify scheduling meeting command by checking if the users exists. + + Updates the command with the verified guests: all non-existing guests are removed. + + Args: + command (ScheduleMeeting): The schedule command. + service (Service): The service. + client (AsyncHttpClient): The Async HTTP Client. + + Returns: + ScheduleMeeting: The schedule command with guests verified. + + Raises: + HTTPException: If the organizer does not exist. + """ + user_set = command.guests.copy() + user_set.add(command.organizer) + + comma_separated_usernames = ", ".join(user_set) + + response, code = await get_users(users=comma_separated_usernames, service=service, client=client) + + verify_status(response=response, status_code=code) + + response_event = ResponseModels[UserRegistered](**response) + + usernames = [user.username for user in response_event.data] + + if command.organizer not in usernames: + raise HTTPException(status_code=HTTP_409_CONFLICT, detail="Organizer does not exists.") + + usernames.remove(command.organizer) + + return command.copy(update={"guests": set(usernames)}) diff --git a/api-gateway/app/utils/formatter.py b/api-gateway/app/utils/formatter.py index a8225cf..f210989 100644 --- a/api-gateway/app/utils/formatter.py +++ b/api-gateway/app/utils/formatter.py @@ -1,8 +1,11 @@ """ Text formatter """ - +import json from re import sub +from typing import Any + +from pydantic import BaseModel def to_camel(s: str) -> str: @@ -14,3 +17,16 @@ def to_camel(s: str) -> str: """ s = sub(r"(_|-)+", " ", s).title().replace(" ", "") return "".join([s[0].lower(), s[1:]]) + + +def to_jsonable_dict(model: BaseModel) -> dict[str, Any]: + """ + Converts a pydantic model to a json-encode-able dictionary. + + Args: + model (BaseModel): The model to convert. + + Returns: + dict[str, Any]: The converted model. + """ + return json.loads(model.json(by_alias=True)) diff --git a/api-gateway/tests/e2e/v1/test_scheduler_service.py b/api-gateway/tests/e2e/v1/test_scheduler_service.py index 04002e4..6c425f4 100644 --- a/api-gateway/tests/e2e/v1/test_scheduler_service.py +++ b/api-gateway/tests/e2e/v1/test_scheduler_service.py @@ -6,16 +6,24 @@ from aioresponses import aioresponses import pytest -from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_503_SERVICE_UNAVAILABLE +from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, \ + HTTP_503_SERVICE_UNAVAILABLE from app.dependencies import get_async_http_client, get_services +from app.domain.events.scheduler_service import MeetingScheduled from app.domain.models import Service +from app.domain.schemas import ResponseModel +from app.utils.formatter import to_jsonable_dict from tests.conftest import DependencyOverrider +from tests.mocks import schedule_command_factory, user_registered_factory FAKE_SCHEDULER_URL = "http://fake-scheduler-service:8001" +FAKE_AUTH_URL = "http://fake-auth-service:8002" -def fake_schedule_response(meeting_id: str | None = None) -> dict[str, Any]: +def fake_schedule_response(meeting_id: str | None = None, + organizer: str = "johndoe", + guests: list[str] | None = None) -> dict[str, Any]: """ Fake schedule response. @@ -24,12 +32,12 @@ def fake_schedule_response(meeting_id: str | None = None) -> dict[str, Any]: """ return {"data": { "id": meeting_id or str(uuid.uuid4()), - "organizer": "johndoe", + "organizer": organizer, "voting": False, "title": "Meeting with the team", "description": "We will discuss the new project", "location": "Zoom", - "guests": [], + "guests": guests or list(), "options": [ { "date": "2023-05-03T23:59:00", @@ -50,7 +58,9 @@ def fake_web(self): yield mock overrides: dict[Callable, Callable] = { - get_services: lambda: [Service(name="scheduler", base_url=FAKE_SCHEDULER_URL)], + get_services: lambda: [Service(name="scheduler", base_url=FAKE_SCHEDULER_URL), + Service(name="auth", base_url=FAKE_AUTH_URL), + ], } @@ -119,3 +129,127 @@ def test_get_schedule_by_id_not_found(self, test_client, fake_web, aio_http_clie response = test_client.get("/api/v1/scheduler-service/schedules/1") # then assert response.status_code == HTTP_404_NOT_FOUND + + +class TestSchedulerCommands(TestSchedulerServiceGateway): + + def test_schedule_meeting(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to schedule a meeting, with valid organizer + WHEN the request is made + THEN it should return the schedule. + """ + command = schedule_command_factory(organizer="johndoe") + + fake_web.post(f"{FAKE_SCHEDULER_URL}/api/v1/schedules", + payload=fake_schedule_response(meeting_id="1"), + status=HTTP_201_CREATED) + + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?users=johndoe", + payload={"data": [to_jsonable_dict(user_registered_factory(username="johndoe"))]}, + status=HTTP_200_OK, + ) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + json = to_jsonable_dict(command) + + with DependencyOverrider(self.overrides): + # when + response = test_client.post("/api/v1/scheduler-service/schedules", json=json) + # then + assert response.status_code == HTTP_201_CREATED + + def test_scheduling_meeting_with_guests(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to schedule a meeting, with valid organizer and guests + WHEN the request is made + THEN it should return the schedule. + """ + guest_1, guest_2, organizer = "carl", "jane", "johndoe" + command = schedule_command_factory(organizer=organizer, guests={guest_1, guest_2}) + + guests = {guest_1, guest_2, organizer} + query = ", ".join(guests) + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?users={query}", + payload={"data": [to_jsonable_dict(user_registered_factory(username="johndoe")), + to_jsonable_dict(user_registered_factory(username="carl")), + to_jsonable_dict(user_registered_factory(username="jane")) + ] + }, + status=HTTP_200_OK, + ) + + fake_web.post(f"{FAKE_SCHEDULER_URL}/api/v1/schedules", + payload=fake_schedule_response(guests=[guest_1, guest_2]), + status=HTTP_201_CREATED) + + json = to_jsonable_dict(command) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.post("/api/v1/scheduler-service/schedules", json=json) + + response_body = ResponseModel[MeetingScheduled](**response.json()) + # then + assert response.status_code == HTTP_201_CREATED + assert guest_1, guest_2 in response_body.data.guests + + def test_scheduling_meeting_with_guests_not_found(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to schedule a meeting, with valid organizer and not valid guests + WHEN the request is made + THEN it schedules without guests + """ + guest_1, guest_2, organizer = "carl", "jane", "johndoe" + command = schedule_command_factory(organizer=organizer, guests={guest_1, guest_2}) + guests = {guest_1, guest_2, organizer} + + query = ", ".join(guests) + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?users={query}", + payload={"data": [to_jsonable_dict(user_registered_factory(username="johndoe")), + ] + }, + status=HTTP_200_OK, + ) + fake_web.post(f"{FAKE_SCHEDULER_URL}/api/v1/schedules", + payload=fake_schedule_response(), + status=HTTP_201_CREATED) + + json = to_jsonable_dict(command) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.post("/api/v1/scheduler-service/schedules", json=json) + + response_body = ResponseModel[MeetingScheduled](**response.json()) + # then + assert response.status_code == HTTP_201_CREATED + assert guest_1, guest_2 not in response_body.data.guests + + def test_scheduling_meeting_with_organizer_not_found(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to schedule a meeting, with invalid organizer + WHEN the request is made + THEN it is a conflict + """ + command = schedule_command_factory(organizer="johndoe") + + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?users=johndoe", + payload={"data": []}, + status=HTTP_200_OK, + ) + + json = to_jsonable_dict(command) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.post("/api/v1/scheduler-service/schedules", json=json) + # then + assert response.status_code == HTTP_409_CONFLICT diff --git a/api-gateway/tests/integration/service_layer/__init__.py b/api-gateway/tests/integration/service_layer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api-gateway/tests/integration/service_layer/test_gateway.py b/api-gateway/tests/integration/service_layer/test_gateway.py new file mode 100644 index 0000000..cc94ef4 --- /dev/null +++ b/api-gateway/tests/integration/service_layer/test_gateway.py @@ -0,0 +1,178 @@ +""" +Test for the gateway service layer +""" +import json +from typing import Any + +from aioresponses import aioresponses +from fastapi import HTTPException +import pytest +from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_202_ACCEPTED, HTTP_404_NOT_FOUND, \ + HTTP_503_SERVICE_UNAVAILABLE + +from app.domain.models import Service +from app.service_layer.gateway import get_users, verify_scheduling_meeting, verify_status +from tests.mocks import schedule_command_factory, user_registered_factory + + +class TestGateway: + + @pytest.fixture + def auth_service(self) -> Service: + return Service(name="auth", base_url="http://fake-auth") + + @pytest.fixture + def fake_web(self): + with aioresponses() as mock: + yield mock + + @pytest.mark.parametrize( + "status_code, payload", + [ + (HTTP_200_OK, {"data": []}), + (HTTP_503_SERVICE_UNAVAILABLE, {"detail": "Service unavailable"}), + ], + ) + @pytest.mark.asyncio + async def test_get_users(self, fake_web, auth_service, aio_http_client, + status_code, payload + ): + """ + GIVEN a request to get all users + WHEN the request is made, and service is in a specific status + THEN it should return corresponding data. + """ + # given + fake_web.get(f"{auth_service.base_url}/api/v1/users", payload=payload, status=status_code) + + # when + response, status_code = await get_users(users="", service=auth_service, client=aio_http_client) + + # then + assert status_code == status_code + assert response == payload + + @pytest.mark.parametrize( + "response_status, valid_values, payload", + [ + (HTTP_503_SERVICE_UNAVAILABLE, [HTTP_200_OK, HTTP_201_CREATED, HTTP_202_ACCEPTED], + {"detail": "Service unavailable"}), + (HTTP_404_NOT_FOUND, [HTTP_200_OK], {"detail": "object not found"}), + ], + ) + @pytest.mark.asyncio + async def test_raises_invalid_status(self, fake_web, auth_service, + valid_values: list[int], response_status: int, payload: dict[str, Any]): + """ + GIVEN a request to get all users + WHEN the request is made, and service is in a specific status + THEN it should return corresponding data. + """ + + # when / then + with pytest.raises(HTTPException): + await verify_status(response=payload, status_codes=valid_values, status_code=response_status) + + @pytest.mark.asyncio + async def test_schedule_command_is_ok(self, fake_web, auth_service, aio_http_client): + """ + GIVEN a schedule command with a valid organizer + WHEN verification is made + THEN it should return the same command. + """ + # given + organizer = "johndoe" + command = schedule_command_factory(organizer=organizer) + json_dict = json.loads(user_registered_factory(username=organizer).json(by_alias=True)) + payload = {"data": [json_dict]} + fake_web.get(f"{auth_service.base_url}/api/v1/users?users=johndoe", payload=payload, status=HTTP_200_OK) + + # when + updated_command = await verify_scheduling_meeting(command=command, service=auth_service, client=aio_http_client) + + # then + assert command == updated_command + + @pytest.mark.asyncio + async def test_schedule_command_is_ok_with_guests(self, fake_web, auth_service, aio_http_client): + """ + GIVEN a schedule command with a valid organizer and guests + WHEN verification is made + THEN it should return the same command. + """ + # given + organizer = "johndoe" + guest_1 = "janedoe" + guest_2 = "mark" + guests = {guest_1, guest_2} + + command = schedule_command_factory(organizer=organizer, guests=guests) + users = guests.copy() + users.add(organizer) + query = f"users={', '.join(users)}" + + json_dict = json.loads(user_registered_factory(username=organizer).json(by_alias=True)) + json_dict_2 = json.loads(user_registered_factory(username=guest_1).json(by_alias=True)) + json_dict_3 = json.loads(user_registered_factory(username=guest_2).json(by_alias=True)) + payload = {"data": [json_dict, json_dict_2, json_dict_3]} + + fake_web.get(f"{auth_service.base_url}/api/v1/users?{query}", payload=payload, status=HTTP_200_OK) + + # when + updated_command = await verify_scheduling_meeting(command=command, service=auth_service, client=aio_http_client) + + # then + assert command == updated_command + + @pytest.mark.asyncio + async def test_schedule_command_with_missing_guests(self, fake_web, auth_service, aio_http_client): + """ + GIVEN a schedule command with a valid organizer but invalid guests + WHEN verification is made + THEN only valid guests should be returned. + """ + # given + organizer = "johndoe" + guest_1 = "janedoe" + guest_2 = "mark" + guests = {guest_1, guest_2} + + command = schedule_command_factory(organizer=organizer, guests=guests) + users = guests.copy() + users.add(organizer) + query = f"users={', '.join(users)}" + + json_dict = json.loads(user_registered_factory(username=organizer).json(by_alias=True)) + json_dict_2 = json.loads(user_registered_factory(username=guest_1).json(by_alias=True)) + payload = {"data": [json_dict, json_dict_2]} + + fake_web.get(f"{auth_service.base_url}/api/v1/users?{query}", payload=payload, status=HTTP_200_OK) + + # when + updated_command = await verify_scheduling_meeting(command=command, service=auth_service, client=aio_http_client) + + # then + assert command != updated_command + assert guest_2 not in updated_command.guests + + @pytest.mark.asyncio + async def test_schedule_command_with_missing_organizer(self, fake_web, auth_service, aio_http_client): + """ + GIVEN a schedule command an invalid organizer + WHEN verification is made + THEN should raise an exception. + """ + # given + organizer = "johndoe" + + command = schedule_command_factory(organizer=organizer) + + fake_web.get(f"{auth_service.base_url}/api/v1/users?users={organizer}", + payload={"data": []}, + status=HTTP_200_OK) + + # when + with pytest.raises(HTTPException): + updated_command = await verify_scheduling_meeting(command=command, + service=auth_service, + client=aio_http_client) diff --git a/api-gateway/tests/mocks.py b/api-gateway/tests/mocks.py new file mode 100644 index 0000000..de05267 --- /dev/null +++ b/api-gateway/tests/mocks.py @@ -0,0 +1,59 @@ +""" +Tests Mocks +""" +import datetime +from typing import Any +from uuid import uuid4 + +from pydantic import EmailStr + +from app.domain.commands.scheduler_service import ProposeOption, ScheduleMeeting +from app.domain.events.auth_service import UserRegistered + + +def user_registered_factory(username: str, user_id: str | None = None) -> UserRegistered: + """ + Creates a UserRegistered event. + + Args: + username (str): The username. + user_id (str, optional): The user id. Defaults to uuid4. + + Returns: + UserRegistered: The UserRegistered with the given username and user id, and a fake email. + """ + return UserRegistered(username=username, id=user_id or str(uuid4()), email=EmailStr(f"{username}@e.mail")) + + +def user_registered_response_factory(username: str, user_id: str | None = None) -> dict[str, Any]: + """ + Creates a UserRegistered response. + + Args: + username (str): The username. + user_id (str, optional): The user id. Defaults to uuid4. + + Returns: + dict[str, Any]: The UserRegistered response with the given username and user id, and a fake email. + """ + + return { + "data": user_registered_factory(username, user_id).dict(by_alias=True) + } + + +def schedule_command_factory(organizer: str, + title: str = "Test Meeting", guests: set[str] | None = None) -> ScheduleMeeting: + """ + Creates a schedule command. + + Args: + organizer (str): The organizer. + title (str, optional): The title. Defaults to "Test Meeting". + guests (set[str], optional): The guests. Defaults to None. + + Returns: + dict[str, Any]: The schedule command. + """ + fake_options = [ProposeOption(date=datetime.date.today(), hour=12, minute=30)] + return ScheduleMeeting(organizer=organizer, title=title, guests=guests or set(), options=fake_options) From 61a240caa10f776e68b90b219acb266ac1ebc3cd Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Fri, 5 May 2023 10:01:24 -0300 Subject: [PATCH 05/15] TACS-44 fix(Scheduler): add body - Change to Patch - Add OpenAPI definition --- .../scheduler/SchedulerApplication.java | 16 ++++++++++++++++ .../scheduler/api/v1/SchedulesEntryPoint.kt | 15 +++++++-------- .../scheduler/domain/commands/JoinMeeting.kt | 19 +++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 scheduler/src/main/java/com/schedutn/scheduler/domain/commands/JoinMeeting.kt diff --git a/scheduler/src/main/java/com/schedutn/scheduler/SchedulerApplication.java b/scheduler/src/main/java/com/schedutn/scheduler/SchedulerApplication.java index 257686f..1284b55 100644 --- a/scheduler/src/main/java/com/schedutn/scheduler/SchedulerApplication.java +++ b/scheduler/src/main/java/com/schedutn/scheduler/SchedulerApplication.java @@ -1,8 +1,24 @@ package com.schedutn.scheduler; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +@OpenAPIDefinition( + info = @io.swagger.v3.oas.annotations.info.Info( + title = "Scheduler Service", + version = "0.1.1", + description = "Scheduler manages the workflow of meetings scheduling", + license = @io.swagger.v3.oas.annotations.info.License( + name = "MIT", + url = "https://mit-license.org/" + ), + contact = @io.swagger.v3.oas.annotations.info.Contact( + name = "Tomas Sanchez", + url = "https://tomasanchez.github.io", + email = "tosanchez@frba.utn.edu.ar") + ) +) @SpringBootApplication public class SchedulerApplication { diff --git a/scheduler/src/main/java/com/schedutn/scheduler/api/v1/SchedulesEntryPoint.kt b/scheduler/src/main/java/com/schedutn/scheduler/api/v1/SchedulesEntryPoint.kt index 81c6603..1b4b881 100644 --- a/scheduler/src/main/java/com/schedutn/scheduler/api/v1/SchedulesEntryPoint.kt +++ b/scheduler/src/main/java/com/schedutn/scheduler/api/v1/SchedulesEntryPoint.kt @@ -3,6 +3,7 @@ package com.schedutn.scheduler.api.v1 import com.schedutn.scheduler.api.DataWrapper import com.schedutn.scheduler.api.v1.SchedulesEntryPoint.Companion.SCHEDULES_ENTRY_POINT_URL +import com.schedutn.scheduler.domain.commands.JoinMeeting import com.schedutn.scheduler.domain.commands.ScheduleMeeting import com.schedutn.scheduler.domain.commands.ToggleVoting import com.schedutn.scheduler.domain.commands.VoteForOption @@ -14,7 +15,6 @@ import jakarta.validation.Valid import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType import org.springframework.http.ResponseEntity -import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.bind.annotation.* import org.springframework.web.servlet.support.ServletUriComponentsBuilder import java.net.URI @@ -93,7 +93,7 @@ class SchedulesEntryPoint { tags = ["Commands"] ) fun toggleVoting(@PathVariable id: String, - @Valid @RequestBody command: ToggleVoting + @Valid @RequestBody command: ToggleVoting ): DataWrapper { log.info("Toggling voting for schedule with id: $id") @@ -110,7 +110,7 @@ class SchedulesEntryPoint { tags = ["Commands"] ) fun voteForOption(@PathVariable id: String, - @Valid @RequestBody command: VoteForOption + @Valid @RequestBody command: VoteForOption ): DataWrapper { log.info("Voting for option for schedule with id: $id") @@ -118,21 +118,20 @@ class SchedulesEntryPoint { return DataWrapper(data = schedule) } - @PostMapping("/{id}/relationships/guests") + @PatchMapping("/{id}/relationships/guests") @ResponseStatus(org.springframework.http.HttpStatus.OK) @Operation( summary = "Commands to Join a Meeting", description = "Adds a guest to a meeting", tags = ["Commands"] ) - fun joinMeeting(@PathVariable id: String): DataWrapper { + fun joinMeeting(@PathVariable id: String, @Valid @RequestBody command: JoinMeeting): DataWrapper { log.info("Joining meeting for schedule with id: $id") - val auth = SecurityContextHolder.getContext().authentication.principal.toString() - val joined = service.joinAMeeting(id = id, username = auth) + val joined = service.joinAMeeting(id = id, username = command.username) - log.info("$auth joined meeting for schedule with id: $id") + log.info("${command.username} joined meeting for schedule with id: $id") return DataWrapper(data = joined) } diff --git a/scheduler/src/main/java/com/schedutn/scheduler/domain/commands/JoinMeeting.kt b/scheduler/src/main/java/com/schedutn/scheduler/domain/commands/JoinMeeting.kt new file mode 100644 index 0000000..3675429 --- /dev/null +++ b/scheduler/src/main/java/com/schedutn/scheduler/domain/commands/JoinMeeting.kt @@ -0,0 +1,19 @@ +package com.schedutn.scheduler.domain.commands + +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +/** + * Join meeting command. + * + * @property username + */ +@Schema(description = "Command to Join a meeting") +data class JoinMeeting( + + @JsonProperty("username") + @Schema(description = "User's username", example = "johndoe", required = true) + @field:NotBlank + val username: String, +) : Command From 8c51879e4df91a4150867e111836f588a184180b Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Fri, 5 May 2023 11:19:28 -0300 Subject: [PATCH 06/15] TACS-44 refactor(Scheduler): add error wrapper --- .../api/GlobalControllerExceptionHandler.kt | 27 ++++++++++--------- .../scheduler/api/errors/ErrorWrapper.kt | 18 +++++++++++++ .../scheduler/service/MeetingScheduler.kt | 5 ++-- 3 files changed, 35 insertions(+), 15 deletions(-) create mode 100644 scheduler/src/main/java/com/schedutn/scheduler/api/errors/ErrorWrapper.kt diff --git a/scheduler/src/main/java/com/schedutn/scheduler/api/GlobalControllerExceptionHandler.kt b/scheduler/src/main/java/com/schedutn/scheduler/api/GlobalControllerExceptionHandler.kt index 6108f55..ec50e0d 100644 --- a/scheduler/src/main/java/com/schedutn/scheduler/api/GlobalControllerExceptionHandler.kt +++ b/scheduler/src/main/java/com/schedutn/scheduler/api/GlobalControllerExceptionHandler.kt @@ -1,5 +1,6 @@ package com.schedutn.scheduler.api +import com.schedutn.scheduler.api.errors.ErrorWrapper import com.schedutn.scheduler.api.errors.InvalidSchedule import com.schedutn.scheduler.api.errors.UnAuthorizedScheduleOperation import com.schedutn.scheduler.service.ScheduleAuthorizationException @@ -29,28 +30,30 @@ class GlobalControllerExceptionHandler : ResponseEntityExceptionHandler() { @ResponseStatus(HttpStatus.NOT_FOUND) @ExceptionHandler(ScheduleNotFoundException::class) fun handleScheduleNotFound( - ex: ScheduleNotFoundException): ResponseEntity { + ex: ScheduleNotFoundException): ResponseEntity> { val details = InvalidSchedule( - code = "404", + code = "${HttpStatus.NOT_FOUND}", message = ex.message ?: "Not found" ) - log.error("404: $details") - return ResponseEntity(details, HttpStatus.NOT_FOUND) + log.error("${HttpStatus.NOT_FOUND}: $details") + return ResponseEntity(ErrorWrapper(detail = details), HttpStatus.NOT_FOUND) } @ResponseStatus(HttpStatus.FORBIDDEN) @ExceptionHandler(ScheduleAuthorizationException::class) fun handleScheduleAuthorization( - ex: ScheduleAuthorizationException): ResponseEntity { + ex: ScheduleAuthorizationException): ResponseEntity> { - val bodyOfResponse = UnAuthorizedScheduleOperation( - code = "403", + val error = UnAuthorizedScheduleOperation( + code = "${HttpStatus.FORBIDDEN}", message = ex.message ?: "Forbidden" ) - - return ResponseEntity(bodyOfResponse, HttpStatus.FORBIDDEN) + + return ResponseEntity(ErrorWrapper( + detail = error, + ), HttpStatus.FORBIDDEN) } /** @@ -60,9 +63,9 @@ class GlobalControllerExceptionHandler : ResponseEntityExceptionHandler() { * @return a response entity with the occurred errors and an unprocessable entity status */ override fun handleMethodArgumentNotValid(ex: MethodArgumentNotValidException, - headers: HttpHeaders, - status: HttpStatusCode, - request: WebRequest): ResponseEntity { + headers: HttpHeaders, + status: HttpStatusCode, + request: WebRequest): ResponseEntity { val errors: MutableMap = HashMap() diff --git a/scheduler/src/main/java/com/schedutn/scheduler/api/errors/ErrorWrapper.kt b/scheduler/src/main/java/com/schedutn/scheduler/api/errors/ErrorWrapper.kt new file mode 100644 index 0000000..6d736da --- /dev/null +++ b/scheduler/src/main/java/com/schedutn/scheduler/api/errors/ErrorWrapper.kt @@ -0,0 +1,18 @@ +package com.schedutn.scheduler.api.errors + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "Error wrapper") +@JsonInclude(JsonInclude.Include.NON_NULL) +class ErrorWrapper( + @Schema(description = "Error details") + @JsonProperty("detail") + val detail: T + +) { + @Schema(description = "Response metadata") + @JsonProperty("meta") + val meta: Map? = null +} \ No newline at end of file diff --git a/scheduler/src/main/java/com/schedutn/scheduler/service/MeetingScheduler.kt b/scheduler/src/main/java/com/schedutn/scheduler/service/MeetingScheduler.kt index fc63c20..96e6d21 100644 --- a/scheduler/src/main/java/com/schedutn/scheduler/service/MeetingScheduler.kt +++ b/scheduler/src/main/java/com/schedutn/scheduler/service/MeetingScheduler.kt @@ -49,8 +49,7 @@ class MeetingScheduler : ScheduleService { } override fun toggleVoting(id: String, command: ToggleVoting): MeetingScheduled { - val schedule = schedules.findById(id) ?: throw IllegalArgumentException( - "Schedule not found") + val schedule = schedules.findById(id) ?: throw ScheduleNotFoundException(id) try { val toggled = schedule.toggleVoting(username = command.username, @@ -72,7 +71,7 @@ class MeetingScheduler : ScheduleService { hour = command.option.hour, minute = command.option.minute, ) - + try { val voted: Schedule = schedule.vote(option = option, username = command.username) From 64859a71a56afb7a6fad38b43e74bd480584308b Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Fri, 5 May 2023 12:03:48 -0300 Subject: [PATCH 07/15] TACS-44 feat(commands): add toggle voting --- .../app/domain/commands/scheduler_service.py | 8 +++ .../app/entrypoints/v1/scheduler_service.py | 29 ++++++++- .../tests/e2e/v1/test_scheduler_service.py | 63 ++++++++++++++++++- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/api-gateway/app/domain/commands/scheduler_service.py b/api-gateway/app/domain/commands/scheduler_service.py index 9e74840..85d9a8a 100644 --- a/api-gateway/app/domain/commands/scheduler_service.py +++ b/api-gateway/app/domain/commands/scheduler_service.py @@ -31,3 +31,11 @@ class ScheduleMeeting(CamelCaseModel): options: list[ProposeOption] = Field(description="A list of options to schedule the meeting.", min_items=1) guests: set[str] = Field(description="A list of guests to invite to the meeting.", example=["frank", "jane"], default_factory=set) + + +class ToggleVoting(CamelCaseModel): + """ + Command to toggle voting on a meeting. + """ + username: str = Field(description="Responsible for the meeting's username.", example="johndoe") + voting: bool = Field(description="Whether voting is enabled", example=True, default=True) diff --git a/api-gateway/app/entrypoints/v1/scheduler_service.py b/api-gateway/app/entrypoints/v1/scheduler_service.py index 274d4bf..a4e8c21 100644 --- a/api-gateway/app/entrypoints/v1/scheduler_service.py +++ b/api-gateway/app/entrypoints/v1/scheduler_service.py @@ -8,7 +8,7 @@ from app.adapters.network import gateway from app.dependencies import AsyncHttpClientDependency, ServiceProvider -from app.domain.commands.scheduler_service import ScheduleMeeting +from app.domain.commands.scheduler_service import ScheduleMeeting, ToggleVoting from app.domain.events.scheduler_service import MeetingScheduled from app.domain.schemas import ResponseModel, ResponseModels from app.service_layer.gateway import api_v1_url, get_service, verify_scheduling_meeting, verify_status @@ -99,3 +99,30 @@ async def schedule( response.headers["Location"] = f"{request.base_url}api/v1/scheduler-service/schedules/{response_body.data.id}" return response_body + + +@router.patch("/schedules/{schedule_id}/voting", + status_code=HTTP_200_OK, + summary="Toggles voting on a schedule", + tags=["Commands"], + ) +async def toggle_voting( + schedule_id: Annotated[str, Path(description="The schedule's id.", example="b455f6t63t7")], + command: ToggleVoting, + services: ServiceProvider, + client: AsyncHttpClientDependency, +) -> ResponseModel[MeetingScheduled]: + """ + Toggles voting on a schedule. + """ + service_response, status_code = await gateway( + service_url=(await get_service(service_name="scheduler", services=services)).base_url, + path=f"{api_v1_url}/schedules/{schedule_id}/voting", + client=client, + method="PATCH", + request_body=command.json() + ) + + verify_status(response=service_response, status_code=status_code, status_codes=[HTTP_200_OK]) + + return ResponseModel[MeetingScheduled](**service_response) diff --git a/api-gateway/tests/e2e/v1/test_scheduler_service.py b/api-gateway/tests/e2e/v1/test_scheduler_service.py index 6c425f4..a9a858c 100644 --- a/api-gateway/tests/e2e/v1/test_scheduler_service.py +++ b/api-gateway/tests/e2e/v1/test_scheduler_service.py @@ -6,10 +6,11 @@ from aioresponses import aioresponses import pytest -from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, \ +from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, \ HTTP_503_SERVICE_UNAVAILABLE from app.dependencies import get_async_http_client, get_services +from app.domain.commands.scheduler_service import ToggleVoting from app.domain.events.scheduler_service import MeetingScheduled from app.domain.models import Service from app.domain.schemas import ResponseModel @@ -253,3 +254,63 @@ def test_scheduling_meeting_with_organizer_not_found(self, test_client, fake_web response = test_client.post("/api/v1/scheduler-service/schedules", json=json) # then assert response.status_code == HTTP_409_CONFLICT + + def test_toggle_voting(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to toggle voting + WHEN the request is made + THEN it should return the schedule. + """ + command = ToggleVoting(username="johndoe") + fake_web.patch(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/1/voting", + payload=fake_schedule_response(meeting_id="1"), + status=HTTP_200_OK) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.patch("/api/v1/scheduler-service/schedules/1/voting", + json=to_jsonable_dict(command)) + # then + assert response.status_code == HTTP_200_OK + + def test_toggle_voting_not_found(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to toggle voting for an invalid schedule + WHEN the request is made + THEN it should return NOT FOUND. + """ + command = ToggleVoting(username="johndoe") + fake_web.patch(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/non-found/voting", + payload={"detail": "Not found"}, + status=HTTP_404_NOT_FOUND) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.patch("/api/v1/scheduler-service/schedules/non-found/voting", + json=to_jsonable_dict(command)) + # then + assert response.status_code == HTTP_404_NOT_FOUND + + def test_toggle_voting_non_organizer(self, test_client, fake_web, aio_http_client): + """ + Given a request to toggle voting for a schedule that the user is not the organizer + WHEN the request is made + THEN it should return FORBIDDEN. + """ + command = ToggleVoting(username="johndoe") + fake_web.patch(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/1/voting", + payload={"detail": "Forbidden"}, + status=HTTP_403_FORBIDDEN) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.patch("/api/v1/scheduler-service/schedules/1/voting", + json=to_jsonable_dict(command)) + # then + assert response.status_code == HTTP_403_FORBIDDEN From 91e4afa2580384291c3c736e91db76d6ca1aea49 Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Fri, 5 May 2023 12:59:25 -0300 Subject: [PATCH 08/15] TACS-44 fix(gateway): user verification - Wrong query param --- api-gateway/app/service_layer/gateway.py | 37 ++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/api-gateway/app/service_layer/gateway.py b/api-gateway/app/service_layer/gateway.py index 6b1bcf5..4181e84 100644 --- a/api-gateway/app/service_layer/gateway.py +++ b/api-gateway/app/service_layer/gateway.py @@ -11,7 +11,7 @@ from app.domain.commands.scheduler_service import ScheduleMeeting from app.domain.events.auth_service import UserRegistered from app.domain.models import Service -from app.domain.schemas import ResponseModels +from app.domain.schemas import ResponseModel, ResponseModels api_v1_url = "/api/v1" @@ -75,7 +75,7 @@ async def get_users(users: str, service: Service, client: AsyncHttpClient) -> tu Returns: tuple[dict[str, Any], int]: The response and the status code. """ - params = {"users": users.strip()} if users else None + params = {"usernames": users.strip()} if users else None return await gateway(service_url=service.base_url, path=f"{api_v1_url}/users", query_params=params, client=client, method="GET") @@ -111,7 +111,7 @@ async def verify_scheduling_meeting(command: ScheduleMeeting, response_event = ResponseModels[UserRegistered](**response) - usernames = [user.username for user in response_event.data] + usernames = [user.username for user in response_event.data if user.username in user_set] if command.organizer not in usernames: raise HTTPException(status_code=HTTP_409_CONFLICT, detail="Organizer does not exists.") @@ -119,3 +119,34 @@ async def verify_scheduling_meeting(command: ScheduleMeeting, usernames.remove(command.organizer) return command.copy(update={"guests": set(usernames)}) + + +async def verify_user_existence(username: str, + service: Service, + client: AsyncHttpClient) -> UserRegistered: + """ + Verifies if a user exists + + Args: + username: to verify + service: service which validates users + client: HTTP client to make requests + + Returns: + UserRegistered: the user if it exists + + Raises: + HTTPException: If the user does not exist, or service error. + """ + response, code = await gateway( + service_url=service.base_url, + path=f"{api_v1_url}/users/{username}", + client=client, + method="GET" + ) + + verify_status(response=response, status_code=code) + + response_event = ResponseModel[UserRegistered](**response) + + return response_event.data From c23b1dac037fe3c2e1296e1c973ab03050a94b5b Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Fri, 5 May 2023 13:01:15 -0300 Subject: [PATCH 09/15] TACS-44 feat(command): add join meeting --- .../app/domain/commands/scheduler_service.py | 7 ++++ .../app/entrypoints/v1/scheduler_service.py | 37 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/api-gateway/app/domain/commands/scheduler_service.py b/api-gateway/app/domain/commands/scheduler_service.py index 85d9a8a..517e92d 100644 --- a/api-gateway/app/domain/commands/scheduler_service.py +++ b/api-gateway/app/domain/commands/scheduler_service.py @@ -39,3 +39,10 @@ class ToggleVoting(CamelCaseModel): """ username: str = Field(description="Responsible for the meeting's username.", example="johndoe") voting: bool = Field(description="Whether voting is enabled", example=True, default=True) + + +class JoinMeeting(CamelCaseModel): + """ + Command to join a meeting. + """ + username: str = Field(description="Username of who wants to join a meeting", example="johndoe") diff --git a/api-gateway/app/entrypoints/v1/scheduler_service.py b/api-gateway/app/entrypoints/v1/scheduler_service.py index a4e8c21..1599650 100644 --- a/api-gateway/app/entrypoints/v1/scheduler_service.py +++ b/api-gateway/app/entrypoints/v1/scheduler_service.py @@ -8,10 +8,11 @@ from app.adapters.network import gateway from app.dependencies import AsyncHttpClientDependency, ServiceProvider -from app.domain.commands.scheduler_service import ScheduleMeeting, ToggleVoting +from app.domain.commands.scheduler_service import JoinMeeting, ScheduleMeeting, ToggleVoting from app.domain.events.scheduler_service import MeetingScheduled from app.domain.schemas import ResponseModel, ResponseModels -from app.service_layer.gateway import api_v1_url, get_service, verify_scheduling_meeting, verify_status +from app.service_layer.gateway import api_v1_url, get_service, verify_scheduling_meeting, verify_status, \ + verify_user_existence router = APIRouter(prefix="/scheduler-service", tags=["Scheduler"]) @@ -126,3 +127,35 @@ async def toggle_voting( verify_status(response=service_response, status_code=status_code, status_codes=[HTTP_200_OK]) return ResponseModel[MeetingScheduled](**service_response) + + +@router.patch("/schedules/{schedule_id}/relationships/guests", + status_code=HTTP_200_OK, + summary="Adds a guest to a schedule", + tags=["Commands"], + ) +async def join_meeting( + schedule_id: Annotated[str, Path(description="The schedule's id.", example="b455f6t63t7")], + command: JoinMeeting, + services: ServiceProvider, + client: AsyncHttpClientDependency, +) -> ResponseModel[MeetingScheduled]: + """ + Allows a valid user to join a meeting. + """ + await verify_user_existence(username=command.username, + client=client, + service=await get_service(service_name="auth", services=services), + ) + + service_response, status_code = await gateway( + service_url=(await get_service(service_name="scheduler", services=services)).base_url, + client=client, + path=f"{api_v1_url}/schedules/{schedule_id}/relationships/guests", + method="PATCH", + request_body=command.json() + ) + + verify_status(response=service_response, status_code=status_code, status_codes=[HTTP_200_OK]) + + return ResponseModel[MeetingScheduled](**service_response) From 02228d34986f1c64eb759f33f142a01bc648a43e Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Fri, 5 May 2023 13:03:16 -0300 Subject: [PATCH 10/15] TACS-44 tests(gateway) update query param --- api-gateway/tests/e2e/v1/test_scheduler_service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api-gateway/tests/e2e/v1/test_scheduler_service.py b/api-gateway/tests/e2e/v1/test_scheduler_service.py index a9a858c..fa4a463 100644 --- a/api-gateway/tests/e2e/v1/test_scheduler_service.py +++ b/api-gateway/tests/e2e/v1/test_scheduler_service.py @@ -146,7 +146,7 @@ def test_schedule_meeting(self, test_client, fake_web, aio_http_client): payload=fake_schedule_response(meeting_id="1"), status=HTTP_201_CREATED) - fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?users=johndoe", + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?usernames=johndoe", payload={"data": [to_jsonable_dict(user_registered_factory(username="johndoe"))]}, status=HTTP_200_OK, ) @@ -172,7 +172,7 @@ def test_scheduling_meeting_with_guests(self, test_client, fake_web, aio_http_cl guests = {guest_1, guest_2, organizer} query = ", ".join(guests) - fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?users={query}", + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?usernames={query}", payload={"data": [to_jsonable_dict(user_registered_factory(username="johndoe")), to_jsonable_dict(user_registered_factory(username="carl")), to_jsonable_dict(user_registered_factory(username="jane")) @@ -209,7 +209,7 @@ def test_scheduling_meeting_with_guests_not_found(self, test_client, fake_web, a guests = {guest_1, guest_2, organizer} query = ", ".join(guests) - fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?users={query}", + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?usernames={query}", payload={"data": [to_jsonable_dict(user_registered_factory(username="johndoe")), ] }, @@ -240,7 +240,7 @@ def test_scheduling_meeting_with_organizer_not_found(self, test_client, fake_web """ command = schedule_command_factory(organizer="johndoe") - fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?users=johndoe", + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?usernames=johndoe", payload={"data": []}, status=HTTP_200_OK, ) From a22a843898aad68eb6d8736b786d8aa281c19058 Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Fri, 5 May 2023 13:08:30 -0300 Subject: [PATCH 11/15] TACS-44 tests(e2e) join meeting --- .../tests/e2e/v1/test_scheduler_service.py | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/api-gateway/tests/e2e/v1/test_scheduler_service.py b/api-gateway/tests/e2e/v1/test_scheduler_service.py index fa4a463..bbac7eb 100644 --- a/api-gateway/tests/e2e/v1/test_scheduler_service.py +++ b/api-gateway/tests/e2e/v1/test_scheduler_service.py @@ -10,7 +10,7 @@ HTTP_503_SERVICE_UNAVAILABLE from app.dependencies import get_async_http_client, get_services -from app.domain.commands.scheduler_service import ToggleVoting +from app.domain.commands.scheduler_service import JoinMeeting, ToggleVoting from app.domain.events.scheduler_service import MeetingScheduled from app.domain.models import Service from app.domain.schemas import ResponseModel @@ -314,3 +314,53 @@ def test_toggle_voting_non_organizer(self, test_client, fake_web, aio_http_clien json=to_jsonable_dict(command)) # then assert response.status_code == HTTP_403_FORBIDDEN + + def test_user_joins_meeting(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to join a meeting + WHEN the request is made + THEN it should return the schedule. + """ + joiner = "clarahill" + meeting_id = "1" + command = JoinMeeting(username=joiner) + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users/{joiner}", + payload={"data": to_jsonable_dict(user_registered_factory(username=joiner))}, + status=HTTP_200_OK, + ) + + fake_web.patch(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/{meeting_id}/relationships/guests", + payload=fake_schedule_response(meeting_id=meeting_id, guests=[joiner]), + status=HTTP_200_OK) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.patch(f"/api/v1/scheduler-service/schedules/{meeting_id}/relationships/guests", + json=to_jsonable_dict(command)) + # then + assert response.status_code == HTTP_200_OK + + def test_invalid_user_cant_join_meeting(self, test_client, fake_web, aio_http_client): + """ + Given a request to join a meeting with an invalid user + WHEN the request is made + THEN it should return NOT FOUND. + """ + joiner = "clarahill" + meeting_id = "1" + command = JoinMeeting(username=joiner) + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users/{joiner}", + payload={"detail": "Not found"}, + status=HTTP_404_NOT_FOUND, + ) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.patch(f"/api/v1/scheduler-service/schedules/{meeting_id}/relationships/guests", + json=to_jsonable_dict(command)) + # then + assert response.status_code == HTTP_404_NOT_FOUND From e544a8c8b80a23fcbf2ff220a5415850fa78cead Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Fri, 5 May 2023 13:11:52 -0300 Subject: [PATCH 12/15] TACS-44 test(integration): update query param --- .../tests/integration/service_layer/test_gateway.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api-gateway/tests/integration/service_layer/test_gateway.py b/api-gateway/tests/integration/service_layer/test_gateway.py index cc94ef4..402c883 100644 --- a/api-gateway/tests/integration/service_layer/test_gateway.py +++ b/api-gateway/tests/integration/service_layer/test_gateway.py @@ -85,7 +85,7 @@ async def test_schedule_command_is_ok(self, fake_web, auth_service, aio_http_cli command = schedule_command_factory(organizer=organizer) json_dict = json.loads(user_registered_factory(username=organizer).json(by_alias=True)) payload = {"data": [json_dict]} - fake_web.get(f"{auth_service.base_url}/api/v1/users?users=johndoe", payload=payload, status=HTTP_200_OK) + fake_web.get(f"{auth_service.base_url}/api/v1/users?usernames=johndoe", payload=payload, status=HTTP_200_OK) # when updated_command = await verify_scheduling_meeting(command=command, service=auth_service, client=aio_http_client) @@ -109,7 +109,7 @@ async def test_schedule_command_is_ok_with_guests(self, fake_web, auth_service, command = schedule_command_factory(organizer=organizer, guests=guests) users = guests.copy() users.add(organizer) - query = f"users={', '.join(users)}" + query = f"usernames={', '.join(users)}" json_dict = json.loads(user_registered_factory(username=organizer).json(by_alias=True)) json_dict_2 = json.loads(user_registered_factory(username=guest_1).json(by_alias=True)) @@ -140,7 +140,7 @@ async def test_schedule_command_with_missing_guests(self, fake_web, auth_service command = schedule_command_factory(organizer=organizer, guests=guests) users = guests.copy() users.add(organizer) - query = f"users={', '.join(users)}" + query = f"usernames={', '.join(users)}" json_dict = json.loads(user_registered_factory(username=organizer).json(by_alias=True)) json_dict_2 = json.loads(user_registered_factory(username=guest_1).json(by_alias=True)) @@ -167,7 +167,7 @@ async def test_schedule_command_with_missing_organizer(self, fake_web, auth_serv command = schedule_command_factory(organizer=organizer) - fake_web.get(f"{auth_service.base_url}/api/v1/users?users={organizer}", + fake_web.get(f"{auth_service.base_url}/api/v1/users?usernames={organizer}", payload={"data": []}, status=HTTP_200_OK) From 1a99fd294ce1be0fc3d0868dc9c9e78c8aa55db9 Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Fri, 5 May 2023 13:59:12 -0300 Subject: [PATCH 13/15] TACS-44 feat(commands): adds vote option --- .../app/domain/commands/scheduler_service.py | 8 +++ .../app/entrypoints/v1/scheduler_service.py | 49 +++++++++++++++++-- .../tests/e2e/v1/test_scheduler_service.py | 49 ++++++++++++++++++- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/api-gateway/app/domain/commands/scheduler_service.py b/api-gateway/app/domain/commands/scheduler_service.py index 517e92d..82ff54d 100644 --- a/api-gateway/app/domain/commands/scheduler_service.py +++ b/api-gateway/app/domain/commands/scheduler_service.py @@ -46,3 +46,11 @@ class JoinMeeting(CamelCaseModel): Command to join a meeting. """ username: str = Field(description="Username of who wants to join a meeting", example="johndoe") + + +class VoteOption(CamelCaseModel): + """ + Command to vote on a meeting option. + """ + username: str = Field(description="Username of who wants to vote on a meeting option", example="johndoe") + option: ProposeOption = Field(description="The option to vote on.") diff --git a/api-gateway/app/entrypoints/v1/scheduler_service.py b/api-gateway/app/entrypoints/v1/scheduler_service.py index 1599650..3011f23 100644 --- a/api-gateway/app/entrypoints/v1/scheduler_service.py +++ b/api-gateway/app/entrypoints/v1/scheduler_service.py @@ -6,10 +6,12 @@ from fastapi import APIRouter, Path, Request, Response from starlette.status import HTTP_200_OK, HTTP_201_CREATED +from app.adapters.http_client import AsyncHttpClient from app.adapters.network import gateway from app.dependencies import AsyncHttpClientDependency, ServiceProvider -from app.domain.commands.scheduler_service import JoinMeeting, ScheduleMeeting, ToggleVoting +from app.domain.commands.scheduler_service import JoinMeeting, ScheduleMeeting, ToggleVoting, VoteOption from app.domain.events.scheduler_service import MeetingScheduled +from app.domain.models import Service from app.domain.schemas import ResponseModel, ResponseModels from app.service_layer.gateway import api_v1_url, get_service, verify_scheduling_meeting, verify_status, \ verify_user_existence @@ -143,6 +145,47 @@ async def join_meeting( """ Allows a valid user to join a meeting. """ + return await command_with_user_validation( + path=f"{schedule_id}/relationships/guests", + command=command, + services=services, + client=client, + ) + + +@router.patch("/schedules/{schedule_id}/options", + status_code=HTTP_200_OK, + summary="Votes for an option", + tags=["Commands"], + ) +async def vote_for_option( + schedule_id: Annotated[str, Path(description="The schedule's id.", example="b455f6t63t7")], + command: VoteOption, + services: ServiceProvider, + client: AsyncHttpClientDependency, +) -> ResponseModel[MeetingScheduled]: + return await command_with_user_validation( + path=f"{schedule_id}/options", + command=command, + services=services, + client=client, + ) + + +######################################################################################################################## +# Helper functions +######################################################################################################################## + +async def command_with_user_validation( + path: str, + command: JoinMeeting | VoteOption, + services: list[Service], + client: AsyncHttpClient, + method: str = "PATCH", +) -> ResponseModel[MeetingScheduled]: + """ + Validates the user and then sends the command to the scheduler service. + """ await verify_user_existence(username=command.username, client=client, service=await get_service(service_name="auth", services=services), @@ -151,8 +194,8 @@ async def join_meeting( service_response, status_code = await gateway( service_url=(await get_service(service_name="scheduler", services=services)).base_url, client=client, - path=f"{api_v1_url}/schedules/{schedule_id}/relationships/guests", - method="PATCH", + path=f"{api_v1_url}/schedules/{path}", + method=method, request_body=command.json() ) diff --git a/api-gateway/tests/e2e/v1/test_scheduler_service.py b/api-gateway/tests/e2e/v1/test_scheduler_service.py index bbac7eb..17d54b0 100644 --- a/api-gateway/tests/e2e/v1/test_scheduler_service.py +++ b/api-gateway/tests/e2e/v1/test_scheduler_service.py @@ -1,16 +1,18 @@ """ Tests for the scheduler service gateway. """ +import datetime from typing import Any, Callable import uuid from aioresponses import aioresponses import pytest -from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, \ +from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_403_FORBIDDEN, \ + HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, \ HTTP_503_SERVICE_UNAVAILABLE from app.dependencies import get_async_http_client, get_services -from app.domain.commands.scheduler_service import JoinMeeting, ToggleVoting +from app.domain.commands.scheduler_service import JoinMeeting, ProposeOption, ToggleVoting, VoteOption from app.domain.events.scheduler_service import MeetingScheduled from app.domain.models import Service from app.domain.schemas import ResponseModel @@ -364,3 +366,46 @@ def test_invalid_user_cant_join_meeting(self, test_client, fake_web, aio_http_cl json=to_jsonable_dict(command)) # then assert response.status_code == HTTP_404_NOT_FOUND + + @pytest.mark.parametrize( + "auth_status, scheduler_status, expected_status", + [ + (HTTP_404_NOT_FOUND, HTTP_404_NOT_FOUND, HTTP_404_NOT_FOUND), + (HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_404_NOT_FOUND), + (HTTP_200_OK, HTTP_200_OK, HTTP_200_OK), + (HTTP_200_OK, HTTP_403_FORBIDDEN, HTTP_403_FORBIDDEN), + ], + ) + def test_user_votes_option(self, test_client, fake_web, aio_http_client, + auth_status, scheduler_status, expected_status): + """ + GIVEN a request to vote for an option + WHEN the request is made + THEN it should return the corresponding data. + """ + username = "mark" + option = ProposeOption( + date=datetime.date.today(), + hour=12, + minute=30 + ) + + command = VoteOption(username=username, option=option) + + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users/{username}", + payload={"data": to_jsonable_dict(user_registered_factory(username=username))}, + status=auth_status, + ) + + fake_web.patch(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/1/options", + payload=fake_schedule_response(meeting_id="1"), + status=scheduler_status) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.patch(f"/api/v1/scheduler-service/schedules/1/options", + json=to_jsonable_dict(command)) + # then + assert response.status_code == expected_status From afd5ec65038e0bb33bffc1c735817480f8c377bb Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Fri, 5 May 2023 14:06:40 -0300 Subject: [PATCH 14/15] TACS-44 docs(tags): adds OPENAPI tags description --- api-gateway/app/asgi.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/api-gateway/app/asgi.py b/api-gateway/app/asgi.py index a0a3629..553384c 100644 --- a/api-gateway/app/asgi.py +++ b/api-gateway/app/asgi.py @@ -74,6 +74,29 @@ def get_application() -> FastAPI: "url": "https://mit-license.org/", } + tags_metadata = [ + { + "name": "Actuator", + "description": "Verifies application's liveliness and readiness.", + }, + { + "name": "Queries", + "description": "A request for data from the system.", + }, + { + "name": "Commands", + "description": "A request to change the state of the system.", + }, + { + "name": "Scheduler", + "description": "Manages meeting's schedule workflows.", + }, + { + "name": "Auth", + "description": "Manages user's validation workflows.", + } + ] + app = FastAPI( title=settings.PROJECT_NAME, description=settings.PROJECT_DESCRIPTION, @@ -83,6 +106,7 @@ def get_application() -> FastAPI: lifespan=lifespan, license_info=license_info, contact=contact, + openapi_tags=tags_metadata, ) log.debug("Add application routes.") From e224a573da99c8b6df0c3bc6970a361450c3bf23 Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Fri, 5 May 2023 14:07:02 -0300 Subject: [PATCH 15/15] TACS-44 chore(release): API Gateway v0.3.0 --- api-gateway/app/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-gateway/app/version.py b/api-gateway/app/version.py index e5eee81..47d5635 100644 --- a/api-gateway/app/version.py +++ b/api-gateway/app/version.py @@ -1,4 +1,4 @@ """ Application Version """ -__version__ = "0.2.0" +__version__ = "0.3.0"