diff --git a/app/common/livekit_ext.py b/app/common/livekit_ext.py index 3e1df5cc..c44d59ba 100644 --- a/app/common/livekit_ext.py +++ b/app/common/livekit_ext.py @@ -9,6 +9,8 @@ CreateRoomRequest, ListParticipantsRequest, ListRoomsRequest, + UpdateParticipantRequest, + UpdateRoomMetadataRequest, ) @@ -50,23 +52,57 @@ def api(self) -> LiveKitAPI: def room(self) -> RoomService: return self.api.room - def generate_access_token(self, identity: str, name: str, room_name: str) -> str: + def generate_access_token( + self, + room_name: str, + identity: str, + name: str, + metadata: str = "", + ) -> str: return ( AccessToken(self.api_key, self.api_secret) .with_identity(identity=identity) .with_name(name=name) .with_grants(VideoGrants(room_join=True, room=room_name)) + .with_metadata(metadata=metadata) ).to_jwt() async def list_rooms(self, room_names: list[str]) -> Iterator[Room]: response = await self.room.list_rooms(ListRoomsRequest(names=room_names)) return (room for room in response.rooms) - async def find_or_create_room(self, room_name: str) -> Room: - return await self.room.create_room(CreateRoomRequest(name=room_name)) + async def find_or_create_room(self, room_name: str, metadata: str) -> Room: + return await self.room.create_room( + CreateRoomRequest( + name=room_name, + metadata=metadata, + ) + ) + + async def update_room_metadata(self, room_name: str, metadata: str) -> Room: + return await self.room.update_room_metadata( + UpdateRoomMetadataRequest( + room=room_name, + metadata=metadata, + ) + ) async def list_room_participants(self, room_name: str) -> Iterator[ParticipantInfo]: response = await self.room.list_participants( ListParticipantsRequest(room=room_name) ) return (participant for participant in response.participants) + + async def update_participant_metadata( + self, + room_name: str, + identity: str, + metadata: str, + ) -> ParticipantInfo: + return await self.room.update_participant( + UpdateParticipantRequest( + room=room_name, + identity=identity, + metadata=metadata, + ) + ) diff --git a/app/conferences/routes/classroom_conferences_rst.py b/app/conferences/routes/classroom_conferences_rst.py index 325af09a..173d03c2 100644 --- a/app/conferences/routes/classroom_conferences_rst.py +++ b/app/conferences/routes/classroom_conferences_rst.py @@ -5,7 +5,7 @@ from app.common.config_bdg import classrooms_bridge, notifications_bridge from app.common.dependencies.authorization_dep import AuthorizationData -from app.common.fastapi_ext import APIRouterExt +from app.common.fastapi_ext import APIRouterExt, Responses from app.common.schemas.notifications_sch import ( ClassroomNotificationPayloadSchema, NotificationInputSchema, @@ -15,7 +15,11 @@ LivekitRoomByClassroomID, LivekitRoomNameByClassroomID, ) -from app.conferences.schemas.conferences_sch import ConferenceParticipantSchema +from app.conferences.schemas.conferences_sch import ( + ConferenceParticipantSchema, + ParticipantMetadataSchema, + RoomMetadataSchema, +) from app.conferences.services import conferences_svc router = APIRouterExt(tags=["classroom conferences"]) @@ -50,6 +54,21 @@ async def reactivate_classroom_conference( ) +@router.put( + path="/roles/tutor/classrooms/{classroom_id}/conference/metadata/", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update metadata of a conference in a classroom by id", +) +async def update_classroom_conference_metadata( + livekit_room: LivekitRoomByClassroomID, + metadata: RoomMetadataSchema, +) -> None: + await conferences_svc.update_room_metadata( + livekit_room=livekit_room, + metadata=metadata, + ) + + @router.post( path="/roles/tutor/classrooms/{classroom_id}/conference/access-tokens/", summary="Create a tutor access token for a conference in a classroom by id", @@ -82,3 +101,56 @@ async def list_classroom_conference_participants( return await conferences_svc.list_room_participants( livekit_room_name=livekit_room.name ) + + +class ConferenceParticipantResponses(Responses): + CONFERENCE_PARTICIPANT_NOT_FOUND = ( + status.HTTP_404_NOT_FOUND, + "Conference participant not found", + ) + + +@router.put( + path="/roles/tutor/classrooms/{classroom_id}/conference/participants/current/metadata/", + status_code=status.HTTP_204_NO_CONTENT, + responses=ConferenceParticipantResponses.responses(), + summary="Update metadata of the current participant of a conference in a classroom by id", +) +@router.put( # TODO split this if metadata will become different + path="/roles/student/classrooms/{classroom_id}/conference/participants/current/metadata/", + status_code=status.HTTP_204_NO_CONTENT, + responses=ConferenceParticipantResponses.responses(), + summary="Update metadata of the current participant of a conference in a classroom by id", +) +async def update_classroom_conference_current_participant_metadata( + auth_data: AuthorizationData, + livekit_room: LivekitRoomByClassroomID, + metadata: ParticipantMetadataSchema, +) -> None: + participant_info = await conferences_svc.update_participant_metadata( + livekit_room=livekit_room, + user_id=auth_data.user_id, + metadata=metadata, + ) + if participant_info is None: + raise ConferenceParticipantResponses.CONFERENCE_PARTICIPANT_NOT_FOUND + + +@router.put( + path="/roles/tutor/classrooms/{classroom_id}/conference/participants/{participant_user_id}/metadata/", + status_code=status.HTTP_204_NO_CONTENT, + responses=ConferenceParticipantResponses.responses(), + summary="Update metadata of a participant of a conference in a classroom by ids", +) +async def update_classroom_conference_other_participant_metadata( + livekit_room: LivekitRoomByClassroomID, + participant_user_id: int, + metadata: ParticipantMetadataSchema, +) -> None: + participant_info = await conferences_svc.update_participant_metadata( + livekit_room=livekit_room, + user_id=participant_user_id, + metadata=metadata, + ) + if participant_info is None: + raise ConferenceParticipantResponses.CONFERENCE_PARTICIPANT_NOT_FOUND diff --git a/app/conferences/schemas/conferences_sch.py b/app/conferences/schemas/conferences_sch.py index 500a7f67..5f997c5e 100644 --- a/app/conferences/schemas/conferences_sch.py +++ b/app/conferences/schemas/conferences_sch.py @@ -1,6 +1,19 @@ from pydantic import BaseModel +class BaseMetadataSchema(BaseModel): + def model_dump_metadata_json(self) -> str: + return self.model_dump_json(exclude_none=True) + + +class RoomMetadataSchema(BaseMetadataSchema): + active_material_id: int | None = None + + +class ParticipantMetadataSchema(BaseMetadataSchema): + is_hand_raised: bool = False + + class ConferenceParticipantSchema(BaseModel): user_id: int display_name: str diff --git a/app/conferences/services/conferences_svc.py b/app/conferences/services/conferences_svc.py index 9760b3d1..c18543b9 100644 --- a/app/conferences/services/conferences_svc.py +++ b/app/conferences/services/conferences_svc.py @@ -1,12 +1,21 @@ -from livekit.protocol.models import Room +from livekit.api import TwirpError +from livekit.protocol.models import ParticipantInfo, Room +from starlette import status from app.common.config import livekit from app.common.config_bdg import users_internal_bridge -from app.conferences.schemas.conferences_sch import ConferenceParticipantSchema +from app.conferences.schemas.conferences_sch import ( + ConferenceParticipantSchema, + ParticipantMetadataSchema, + RoomMetadataSchema, +) async def reactivate_room(livekit_room_name: str) -> Room: - return await livekit.find_or_create_room(room_name=livekit_room_name) + return await livekit.find_or_create_room( + room_name=livekit_room_name, + metadata=RoomMetadataSchema().model_dump_metadata_json(), + ) async def find_room_by_name(livekit_room_name: str) -> Room | None: @@ -16,13 +25,24 @@ async def find_room_by_name(livekit_room_name: str) -> Room | None: return None +async def update_room_metadata( + livekit_room: Room, + metadata: RoomMetadataSchema, +) -> Room: + return await livekit.update_room_metadata( + room_name=livekit_room.name, + metadata=metadata.model_dump_metadata_json(), + ) + + async def generate_access_token(livekit_room: Room, user_id: int) -> str: current_user_profile = await users_internal_bridge.retrieve_user(user_id=user_id) return livekit.generate_access_token( + room_name=livekit_room.name, identity=str(user_id), name=current_user_profile.display_name, - room_name=livekit_room.name, + metadata=ParticipantMetadataSchema().model_dump_metadata_json(), ) @@ -38,3 +58,20 @@ async def list_room_participants( room_name=livekit_room_name ) ] + + +async def update_participant_metadata( + livekit_room: Room, + user_id: int, + metadata: ParticipantMetadataSchema, +) -> ParticipantInfo | None: + try: + return await livekit.update_participant_metadata( + room_name=livekit_room.name, + identity=str(user_id), + metadata=metadata.model_dump_metadata_json(), + ) + except TwirpError as e: + if e.status == status.HTTP_404_NOT_FOUND: + return None + raise e # pragma: no cover # undocumented exceptions from livekit diff --git a/poetry.lock b/poetry.lock index 17826cec..ab0b99b0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3155,14 +3155,14 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-lazy-fixtures" -version = "1.3.4" +version = "1.4.0" description = "Allows you to use fixtures in @pytest.mark.parametrize." optional = false python-versions = ">=3.8" groups = ["tests"] files = [ - {file = "pytest_lazy_fixtures-1.3.4-py3-none-any.whl", hash = "sha256:3fcb1032f1ffcde367588f4229f7fc6ff64b7a0522c9cd305a08baf39c1c4f5c"}, - {file = "pytest_lazy_fixtures-1.3.4.tar.gz", hash = "sha256:7dd2c110830897b83f041d3a503cbdda10c98ced6dca7602fc43e2f6017c27ed"}, + {file = "pytest_lazy_fixtures-1.4.0-py3-none-any.whl", hash = "sha256:c5db4506fa0ade5887189d1a18857fec4c329b4f49043fef6732c67c9553389a"}, + {file = "pytest_lazy_fixtures-1.4.0.tar.gz", hash = "sha256:f544b60c96b909b307558a62cc1f28f026f11e9f03d7f583a1dc636de3dbcb10"}, ] [package.dependencies] @@ -4256,4 +4256,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "~=3.12,<4.0" -content-hash = "aac06f7746a11c72445f6adb78f2fe9b38416f940765b033c5e82ef868c4f430" +content-hash = "ce2caa8369ff8b651eff2569fa2f3a03b009d4c2bec31e3a5ead53b778af01d1" diff --git a/pyproject.toml b/pyproject.toml index 7af023c6..00ec9785 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ dlint = "0.14.0" [tool.poetry.group.tests.dependencies] pytest = "^8.3.5" pytest-cov = "^6.1.1" -pytest-lazy-fixtures = "^1.3.4" +pytest-lazy-fixtures = "^1.4.0" pydantic-marshals = {extras = ["assert-contains"], version = "0.3.18"} freezegun = "^1.5.1" respx = "^0.22.0" diff --git a/tests/common/livekit_testing.py b/tests/common/livekit_testing.py index a33f7bf0..47e684c7 100644 --- a/tests/common/livekit_testing.py +++ b/tests/common/livekit_testing.py @@ -3,15 +3,20 @@ import pytest from google.protobuf.message import Message -from livekit.api.twirp_client import TwirpClient +from livekit.api.twirp_client import TwirpClient, TwirpError from tests.common.mock_stack import MockStack class LiveKitRouteMock: - def __init__(self, response_data: Message) -> None: + def __init__( + self, + response_data: Message | None = None, + side_effect: TwirpError | None = None, + ) -> None: self.request_data: Message | None = None self.response_data = response_data + self.side_effect = side_effect def request( self, @@ -19,8 +24,10 @@ def request( response_class: type[Message], ) -> Message: assert self.request_data is None, "LiveKit mock has been called before" - assert isinstance(self.response_data, response_class) self.request_data = request_data + if self.side_effect is not None: + raise self.side_effect + assert isinstance(self.response_data, response_class) return self.response_data def assert_requested_once_with(self, expected_data: Message) -> None: @@ -33,9 +40,13 @@ def __init__(self) -> None: self.route_mocks: dict[tuple[str, str], LiveKitRouteMock] = {} def route( - self, service: str, method: str, response_data: Message + self, + service: str, + method: str, + response_data: Message | None = None, + side_effect: TwirpError | None = None, ) -> LiveKitRouteMock: - route_mock = LiveKitRouteMock(response_data) + route_mock = LiveKitRouteMock(response_data, side_effect) self.route_mocks[(service, method)] = route_mock return route_mock diff --git a/tests/conferences/conftest.py b/tests/conferences/conftest.py index 139890fd..a4db40ad 100644 --- a/tests/conferences/conftest.py +++ b/tests/conferences/conftest.py @@ -6,6 +6,7 @@ from starlette.testclient import TestClient from app.common.dependencies.authorization_dep import ProxyAuthData +from app.conferences.schemas.conferences_sch import RoomMetadataSchema from tests.common.types import PytestRequest from tests.factories import ProxyAuthDataFactory @@ -27,6 +28,16 @@ def outsider_user_id(outsider_auth_data: ProxyAuthData) -> int: return outsider_auth_data.user_id +@pytest.fixture() +def other_auth_data() -> ProxyAuthData: + return ProxyAuthDataFactory.build() + + +@pytest.fixture() +def other_user_id(other_auth_data: ProxyAuthData) -> int: + return other_auth_data.user_id + + ClassroomRoleType = Literal["tutor", "student"] @@ -49,4 +60,7 @@ async def classroom_conference_room_name(classroom_id: int) -> str: @pytest.fixture() async def classroom_conference_room(classroom_conference_room_name: str) -> Room: - return Room(name=classroom_conference_room_name) + return Room( + name=classroom_conference_room_name, + metadata=RoomMetadataSchema().model_dump_metadata_json(), + ) diff --git a/tests/conferences/factories.py b/tests/conferences/factories.py index 54fe40cb..20bd681e 100644 --- a/tests/conferences/factories.py +++ b/tests/conferences/factories.py @@ -1,6 +1,18 @@ -from app.conferences.schemas.conferences_sch import ConferenceParticipantSchema +from app.conferences.schemas.conferences_sch import ( + ConferenceParticipantSchema, + ParticipantMetadataSchema, + RoomMetadataSchema, +) from tests.common.polyfactory_ext import BaseModelFactory +class RoomMetadataFactory(BaseModelFactory[RoomMetadataSchema]): + __model__ = RoomMetadataSchema + + +class ParticipantMetadataFactory(BaseModelFactory[ParticipantMetadataSchema]): + __model__ = ParticipantMetadataSchema + + class ConferenceParticipantFactory(BaseModelFactory[ConferenceParticipantSchema]): __model__ = ConferenceParticipantSchema diff --git a/tests/conferences/router/test_classroom_conferences_rst.py b/tests/conferences/router/test_classroom_conferences_rst.py index 95b33f08..415efaec 100644 --- a/tests/conferences/router/test_classroom_conferences_rst.py +++ b/tests/conferences/router/test_classroom_conferences_rst.py @@ -3,7 +3,8 @@ import pytest from faker import Faker -from livekit.protocol.models import Room +from livekit.protocol.models import ParticipantInfo, Room +from pytest_lazy_fixtures import lf, lfc from respx import MockRouter from starlette import status from starlette.testclient import TestClient @@ -14,11 +15,19 @@ NotificationInputSchema, NotificationKind, ) +from app.conferences.schemas.conferences_sch import ( + ParticipantMetadataSchema, + RoomMetadataSchema, +) from tests.common.assert_contains_ext import assert_nodata_response, assert_response from tests.common.mock_stack import MockStack from tests.common.respx_ext import assert_last_httpx_request from tests.conferences.conftest import ClassroomRoleType -from tests.conferences.factories import ConferenceParticipantFactory +from tests.conferences.factories import ( + ConferenceParticipantFactory, + ParticipantMetadataFactory, + RoomMetadataFactory, +) pytestmark = pytest.mark.anyio @@ -102,6 +111,40 @@ async def test_classroom_conference_reactivation_no_students( ) +async def test_classroom_conference_metadata_updating( + mock_stack: MockStack, + outsider_client: TestClient, + classroom_id: int, + classroom_conference_room_name: str, + classroom_conference_room: Room, +) -> None: + new_room_metadata: RoomMetadataSchema = RoomMetadataFactory.build() + + find_room_by_name_mock = mock_stack.enter_async_mock( + "app.conferences.services.conferences_svc.find_room_by_name", + return_value=classroom_conference_room, + ) + update_room_metadata_mock = mock_stack.enter_async_mock( + "app.conferences.services.conferences_svc.update_room_metadata" + ) + + assert_nodata_response( + outsider_client.put( + "/api/protected/conference-service/roles/tutor" + f"/classrooms/{classroom_id}/conference/metadata/", + json=new_room_metadata.model_dump(), + ), + ) + + find_room_by_name_mock.assert_awaited_once_with( + livekit_room_name=classroom_conference_room_name, + ) + update_room_metadata_mock.assert_awaited_once_with( + livekit_room=classroom_conference_room, + metadata=new_room_metadata, + ) + + async def test_classroom_conference_access_token_generation( faker: Faker, mock_stack: MockStack, @@ -135,7 +178,8 @@ async def test_classroom_conference_access_token_generation( livekit_room_name=classroom_conference_room_name ) generate_access_token_mock.assert_awaited_once_with( - livekit_room=classroom_conference_room, user_id=outsider_user_id + livekit_room=classroom_conference_room, + user_id=outsider_user_id, ) @@ -143,7 +187,6 @@ async def test_classroom_conference_participants_listing( faker: Faker, mock_stack: MockStack, outsider_client: TestClient, - outsider_user_id: int, parametrized_classroom_role: ClassroomRoleType, classroom_id: int, classroom_conference_room_name: str, @@ -178,21 +221,173 @@ async def test_classroom_conference_participants_listing( ) +participant_metadata_updating_request_parametrization = pytest.mark.parametrize( + ("participant_user_id", "participant_user_id_in_path", "role"), + [ + pytest.param( + lf("outsider_user_id"), + "current", + "tutor", + id="tutor-current_participant", + ), + pytest.param( + lf("outsider_user_id"), + "current", + "student", + id="student-current_participant", + ), + pytest.param( + lf("other_user_id"), + lf("other_user_id"), + "tutor", + id="tutor-current_participant", + ), + ], +) + + +@participant_metadata_updating_request_parametrization +async def test_classroom_conference_participant_metadata_updating( + faker: Faker, + mock_stack: MockStack, + outsider_client: TestClient, + outsider_user_id: int, + classroom_id: int, + classroom_conference_room_name: str, + classroom_conference_room: Room, + role: ClassroomRoleType, + participant_user_id: int, + participant_user_id_in_path: int | str, +) -> None: + new_participant_metadata: ParticipantMetadataSchema = ( + ParticipantMetadataFactory.build() + ) + new_participant_info = ParticipantInfo( + name=faker.user_name(), + identity=str(participant_user_id), + metadata=new_participant_metadata.model_dump_metadata_json(), + ) + + find_room_by_name_mock = mock_stack.enter_async_mock( + "app.conferences.services.conferences_svc.find_room_by_name", + return_value=classroom_conference_room, + ) + update_participant_metadata_mock = mock_stack.enter_async_mock( + "app.conferences.services.conferences_svc.update_participant_metadata", + return_value=new_participant_info, + ) + + assert_nodata_response( + outsider_client.put( + f"/api/protected/conference-service/roles/{role}" + f"/classrooms/{classroom_id}/conference" + f"/participants/{participant_user_id_in_path}/metadata/", + json=new_participant_metadata.model_dump(), + ), + ) + + find_room_by_name_mock.assert_awaited_once_with( + livekit_room_name=classroom_conference_room_name + ) + update_participant_metadata_mock.assert_awaited_once_with( + livekit_room=classroom_conference_room, + user_id=participant_user_id, + metadata=new_participant_metadata, + ) + + +@participant_metadata_updating_request_parametrization +async def test_classroom_conference_participant_metadata_updating_participant_not_found( + faker: Faker, + mock_stack: MockStack, + outsider_client: TestClient, + outsider_user_id: int, + classroom_id: int, + classroom_conference_room_name: str, + classroom_conference_room: Room, + role: ClassroomRoleType, + participant_user_id: int, + participant_user_id_in_path: int | str, +) -> None: + new_participant_metadata: ParticipantMetadataSchema = ( + ParticipantMetadataFactory.build() + ) + + find_room_by_name_mock = mock_stack.enter_async_mock( + "app.conferences.services.conferences_svc.find_room_by_name", + return_value=classroom_conference_room, + ) + update_participant_metadata_mock = mock_stack.enter_async_mock( + "app.conferences.services.conferences_svc.update_participant_metadata", + ) + + assert_response( + outsider_client.put( + f"/api/protected/conference-service/roles/{role}" + f"/classrooms/{classroom_id}/conference" + f"/participants/{participant_user_id_in_path}/metadata/", + json=new_participant_metadata.model_dump(), + ), + expected_code=status.HTTP_404_NOT_FOUND, + expected_json={"detail": "Conference participant not found"}, + ) + + find_room_by_name_mock.assert_awaited_once_with( + livekit_room_name=classroom_conference_room_name + ) + update_participant_metadata_mock.assert_awaited_once_with( + livekit_room=classroom_conference_room, + user_id=participant_user_id, + metadata=new_participant_metadata, + ) + + @pytest.mark.parametrize( - ("method", "path"), + ("method", "path", "role"), [ - pytest.param("POST", "access-tokens/", id="generate_access_token"), - pytest.param("GET", "participants/", id="list_participants"), + pytest.param("PUT", "metadata/", "tutor", id="update_room_metadata-tutor"), + pytest.param( + "POST", + "access-tokens/", + "tutor", + id="generate_access_token-tutor", + ), + pytest.param( + "POST", + "access-tokens/", + "student", + id="generate_access_token-student", + ), + pytest.param("GET", "participants/", "tutor", id="list_participants-tutor"), + pytest.param("GET", "participants/", "student", id="list_participants-student"), + pytest.param( + "PUT", + "participants/current/metadata/", + "tutor", + id="update_current_participant_metadata-tutor", + ), + pytest.param( + "PUT", + "participants/current/metadata/", + "student", + id="update_current_participant_metadata-student", + ), + pytest.param( + "PUT", + lfc(lambda other_user_id: f"participants/{other_user_id}/metadata/"), + "tutor", + id="update_other_participant_metadata-tutor", + ), ], ) async def test_classroom_conference_requesting_conference_not_active( mock_stack: MockStack, outsider_client: TestClient, - parametrized_classroom_role: str, classroom_id: int, classroom_conference_room_name: str, method: str, path: str, + role: ClassroomRoleType, ) -> None: find_room_by_name_mock = mock_stack.enter_async_mock( "app.conferences.services.conferences_svc.find_room_by_name", @@ -202,7 +397,7 @@ async def test_classroom_conference_requesting_conference_not_active( outsider_client.request( method=method, url=( - f"/api/protected/conference-service/roles/{parametrized_classroom_role}" + f"/api/protected/conference-service/roles/{role}" f"/classrooms/{classroom_id}/conference/{path}" ), ), diff --git a/tests/conferences/service/test_conference_service.py b/tests/conferences/service/test_conference_service.py index 499275f7..ae67b8c6 100644 --- a/tests/conferences/service/test_conference_service.py +++ b/tests/conferences/service/test_conference_service.py @@ -1,6 +1,7 @@ import jwt import pytest from faker import Faker +from livekit.api import TwirpError, TwirpErrorCode from livekit.protocol.models import ParticipantInfo, Room from livekit.protocol.room import ( CreateRoomRequest, @@ -8,41 +9,63 @@ ListParticipantsResponse, ListRoomsRequest, ListRoomsResponse, + UpdateParticipantRequest, + UpdateRoomMetadataRequest, ) from pydantic_marshals.contains import assert_contains from respx import MockRouter +from starlette import status from app.common.config import settings -from app.conferences.schemas.conferences_sch import ConferenceParticipantSchema +from app.conferences.schemas.conferences_sch import ( + ConferenceParticipantSchema, + ParticipantMetadataSchema, + RoomMetadataSchema, +) from app.conferences.services import conferences_svc from tests.common.livekit_testing import LiveKitMock from tests.common.respx_ext import assert_last_httpx_request from tests.common.types import AnyJSON -from tests.conferences.factories import ConferenceParticipantFactory +from tests.conferences.factories import ( + ConferenceParticipantFactory, + ParticipantMetadataFactory, + RoomMetadataFactory, +) from tests.factories import UserProfileFactory pytestmark = pytest.mark.anyio @pytest.fixture() -async def livekit_room_name(faker: Faker) -> str: +def livekit_room_name(faker: Faker) -> str: return faker.user_name() +@pytest.fixture() +def default_livekit_room(livekit_room_name: str) -> Room: + return Room( + name=livekit_room_name, + metadata=RoomMetadataSchema().model_dump_metadata_json(), + ) + + async def test_room_reactivation( livekit_mock: LiveKitMock, - users_internal_respx_mock: MockRouter, livekit_room_name: str, + default_livekit_room: Room, ) -> None: - livekit_room = Room(name=livekit_room_name) - - create_room_mock = livekit_mock.route("RoomService", "CreateRoom", livekit_room) + create_room_mock = livekit_mock.route( + "RoomService", "CreateRoom", default_livekit_room + ) result = await conferences_svc.reactivate_room(livekit_room_name=livekit_room_name) - assert result == livekit_room + assert result == default_livekit_room create_room_mock.assert_requested_once_with( - CreateRoomRequest(name=livekit_room_name) + CreateRoomRequest( + name=livekit_room_name, + metadata=RoomMetadataSchema().model_dump_metadata_json(), + ) ) @@ -55,35 +78,61 @@ async def test_room_reactivation( ) async def test_room_finding_by_name( livekit_mock: LiveKitMock, - users_internal_respx_mock: MockRouter, - livekit_room_name: str, + default_livekit_room: Room, is_room_found: bool, ) -> None: - livekit_room = Room(name=livekit_room_name) if is_room_found else None - list_rooms_mock = livekit_mock.route( "RoomService", "ListRooms", - ListRoomsResponse(rooms=[] if livekit_room is None else [livekit_room]), + ListRoomsResponse(rooms=[default_livekit_room] if is_room_found else []), ) result = await conferences_svc.find_room_by_name( - livekit_room_name=livekit_room_name + livekit_room_name=default_livekit_room.name ) - assert result == livekit_room + if is_room_found: + assert result == default_livekit_room + else: + assert result is None list_rooms_mock.assert_requested_once_with( - ListRoomsRequest(names=[livekit_room_name]) + ListRoomsRequest(names=[default_livekit_room.name]) + ) + + +async def test_room_updating( + livekit_mock: LiveKitMock, + default_livekit_room: Room, +) -> None: + new_room_metadata: RoomMetadataSchema = RoomMetadataFactory.build() + updated_livekit_room = Room( + name=default_livekit_room.name, + metadata=new_room_metadata.model_dump_metadata_json(), + ) + + create_room_mock = livekit_mock.route( + "RoomService", "UpdateRoomMetadata", updated_livekit_room + ) + + result = await conferences_svc.update_room_metadata( + livekit_room=default_livekit_room, + metadata=new_room_metadata, + ) + assert result == updated_livekit_room + + create_room_mock.assert_requested_once_with( + UpdateRoomMetadataRequest( + room=default_livekit_room.name, + metadata=new_room_metadata.model_dump_metadata_json(), + ) ) async def test_conference_access_token_generation( faker: Faker, users_internal_respx_mock: MockRouter, - livekit_room_name: str, + default_livekit_room: Room, ) -> None: - livekit_room = Room(name=livekit_room_name) - user_id: int = faker.random_int() user_profile_data: AnyJSON = UserProfileFactory.build_json() users_internal_bridge_mock = users_internal_respx_mock.get( @@ -91,7 +140,7 @@ async def test_conference_access_token_generation( ).respond(json=user_profile_data) access_token = await conferences_svc.generate_access_token( - livekit_room=livekit_room, + livekit_room=default_livekit_room, user_id=user_id, ) @@ -100,7 +149,7 @@ async def test_conference_access_token_generation( { "sub": str(user_id), "name": user_profile_data["display_name"], - "video": {"room": livekit_room_name}, + "video": {"room": default_livekit_room.name}, }, ) @@ -113,7 +162,6 @@ async def test_conference_access_token_generation( async def test_listing_room_participants( faker: Faker, livekit_mock: LiveKitMock, - users_internal_respx_mock: MockRouter, livekit_room_name: str, ) -> None: participants: list[ConferenceParticipantSchema] = ( @@ -128,6 +176,7 @@ async def test_listing_room_participants( ParticipantInfo( name=participant.display_name, identity=str(participant.user_id), + metadata=ParticipantMetadataSchema().model_dump_metadata_json(), ) for participant in participants ] @@ -144,3 +193,81 @@ async def test_listing_room_participants( list_participants_mock.assert_requested_once_with( ListParticipantsRequest(room=livekit_room_name) ) + + +async def test_participant_metadata_updating( + livekit_mock: LiveKitMock, + default_livekit_room: Room, +) -> None: + conference_participant_data: ConferenceParticipantSchema = ( + ConferenceParticipantFactory.build() + ) + new_participant_metadata: ParticipantMetadataSchema = ( + ParticipantMetadataFactory.build() + ) + new_participant_info = ParticipantInfo( + name=conference_participant_data.display_name, + identity=str(conference_participant_data.user_id), + metadata=new_participant_metadata.model_dump_metadata_json(), + ) + + update_participant_mock = livekit_mock.route( + "RoomService", + "UpdateParticipant", + new_participant_info, + ) + + assert ( + await conferences_svc.update_participant_metadata( + livekit_room=default_livekit_room, + user_id=conference_participant_data.user_id, + metadata=new_participant_metadata, + ) + == new_participant_info + ) + + update_participant_mock.assert_requested_once_with( + UpdateParticipantRequest( + room=default_livekit_room.name, + identity=str(conference_participant_data.user_id), + metadata=new_participant_metadata.model_dump_metadata_json(), + ) + ) + + +async def test_participant_metadata_updating_participant_not_found( + faker: Faker, + livekit_mock: LiveKitMock, + default_livekit_room: Room, +) -> None: + user_id: int = faker.random_int(1, 1000) + new_participant_metadata: ParticipantMetadataSchema = ( + ParticipantMetadataFactory.build() + ) + + update_participant_mock = livekit_mock.route( + "RoomService", + "UpdateParticipant", + side_effect=TwirpError( + code=TwirpErrorCode.NOT_FOUND, + msg="participant not found", + status=status.HTTP_404_NOT_FOUND, + ), + ) + + assert ( + await conferences_svc.update_participant_metadata( + livekit_room=default_livekit_room, + user_id=user_id, + metadata=new_participant_metadata, + ) + is None + ) + + update_participant_mock.assert_requested_once_with( + UpdateParticipantRequest( + room=default_livekit_room.name, + identity=str(user_id), + metadata=new_participant_metadata.model_dump_metadata_json(), + ) + ) diff --git a/tests/materials/functional/test_classroom_materials_student_rst.py b/tests/materials/functional/test_classroom_materials_student_rst.py index ce1ec2a5..440b9885 100644 --- a/tests/materials/functional/test_classroom_materials_student_rst.py +++ b/tests/materials/functional/test_classroom_materials_student_rst.py @@ -2,7 +2,7 @@ import pytest from freezegun import freeze_time -from pytest_lazy_fixtures import lf, lfc +from pytest_lazy_fixtures import lfc from starlette import status from starlette.testclient import TestClient @@ -54,30 +54,26 @@ async def test_material_retrieving( pytest.param( MaterialAccessMode.READ_ONLY, lfc( - lambda access_group_id, user_id: StorageTokenPayloadSchema( - access_group_id=access_group_id, - user_id=user_id, + lambda classroom_material, student_user_id: StorageTokenPayloadSchema( + access_group_id=classroom_material.access_group_id, + user_id=student_user_id, can_upload_files=False, can_read_files=True, ydoc_access_level=YDocAccessLevel.READ_ONLY, ), - lf("classroom_material.access_group_id"), - lf("student_user_id"), ), id=MaterialAccessMode.READ_ONLY.value, ), pytest.param( MaterialAccessMode.READ_WRITE, lfc( - lambda access_group_id, user_id: StorageTokenPayloadSchema( - access_group_id=access_group_id, - user_id=user_id, + lambda classroom_material, student_user_id: StorageTokenPayloadSchema( + access_group_id=classroom_material.access_group_id, + user_id=student_user_id, can_upload_files=True, can_read_files=True, ydoc_access_level=YDocAccessLevel.READ_WRITE, ), - lf("classroom_material.access_group_id"), - lf("student_user_id"), ), id=MaterialAccessMode.READ_WRITE.value, ),