diff --git a/services/event/app/models/event.py b/services/event/app/models/event.py index 8e170e4..7d1fee2 100644 --- a/services/event/app/models/event.py +++ b/services/event/app/models/event.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from app.models.common import PyObjectId @@ -18,6 +18,18 @@ class EventCreate(BaseModel): place: str = Field(min_length=1, max_length=200) speakers: list[Speaker] = Field(default_factory=list) image_url: str | None = None + tags: list[str] = Field(default_factory=list) + registration_form_url: str | None = None + event_type: str = Field(min_length=1, max_length=100) + + @field_validator("tags") + @classmethod + def validate_tags(cls, v: list[str]) -> list[str]: + for tag in v: + if not tag or len(tag) > 50: + msg = "Each tag must be a non-empty string of at most 50 characters" + raise ValueError(msg) + return v class EventUpdate(BaseModel): @@ -27,6 +39,20 @@ class EventUpdate(BaseModel): place: str | None = Field(default=None, min_length=1, max_length=200) speakers: list[Speaker] | None = None image_url: str | None = None + tags: list[str] = Field(default_factory=list) + registration_form_url: str | None = None + event_type: str = Field(min_length=1, max_length=100) + + @field_validator("tags") + @classmethod + def validate_tags(cls, v: list[str] | None) -> list[str] | None: + if v is None: + return v + for tag in v: + if not tag or len(tag) > 50: + msg = "Each tag must be a non-empty string of at most 50 characters" + raise ValueError(msg) + return v class EventInDB(BaseModel): @@ -37,6 +63,9 @@ class EventInDB(BaseModel): place: str speakers: list[Speaker] = Field(default_factory=list) image_url: str | None = None + tags: list[str] = Field(default_factory=list) + registration_form_url: str | None = None + event_type: str = "general" created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime | None = None @@ -51,9 +80,14 @@ class EventResponse(BaseModel): place: str speakers: list[Speaker] image_url: str | None + tags: list[str] + registration_form_url: str | None + event_type: str = Field(serialization_alias="type") created_at: datetime updated_at: datetime | None + model_config = {"populate_by_name": True} + @classmethod def from_db(cls, event: EventInDB) -> "EventResponse": return cls( @@ -64,6 +98,9 @@ def from_db(cls, event: EventInDB) -> "EventResponse": place=event.place, speakers=event.speakers, image_url=event.image_url, + tags=event.tags, + registration_form_url=event.registration_form_url, + event_type=event.event_type, created_at=event.created_at, updated_at=event.updated_at, ) diff --git a/services/event/app/repositories/event_repository.py b/services/event/app/repositories/event_repository.py index 3c71c77..3a6dd7f 100644 --- a/services/event/app/repositories/event_repository.py +++ b/services/event/app/repositories/event_repository.py @@ -31,6 +31,9 @@ async def create(cls, event: EventCreate) -> str: "place": event.place, "speakers": [speaker.model_dump() for speaker in event.speakers], "image_url": event.image_url, + "tags": event.tags, + "registration_form_url": event.registration_form_url, + "event_type": event.event_type, "created_at": datetime.now(timezone.utc), "updated_at": None, } diff --git a/services/event/tests/conftest.py b/services/event/tests/conftest.py index eab9e9c..ae298f3 100644 --- a/services/event/tests/conftest.py +++ b/services/event/tests/conftest.py @@ -82,6 +82,9 @@ def sample_event_data() -> EventCreate: place="Yaşar University", speakers=[Speaker(name="Jane Doe", title="Engineer", company="Google")], image_url="https://example.com/image.jpg", + tags=["devfest", "gdg"], + registration_form_url="https://forms.example.com/register", + event_type="conference", ) @@ -96,6 +99,9 @@ def sample_event_doc() -> dict[str, Any]: "place": "Yaşar University", "speakers": [{"name": "Jane Doe", "title": "Engineer", "company": "Google"}], "image_url": "https://example.com/image.jpg", + "tags": ["devfest", "gdg"], + "registration_form_url": "https://forms.example.com/register", + "event_type": "conference", "created_at": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), "updated_at": None, } @@ -113,6 +119,9 @@ def sample_event_docs() -> list[dict[str, Any]]: "place": "Yaşar University", "speakers": [], "image_url": None, + "tags": [], + "registration_form_url": None, + "event_type": "conference", "created_at": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), "updated_at": None, }, @@ -126,6 +135,9 @@ def sample_event_docs() -> list[dict[str, Any]]: {"name": "John Smith", "title": "Developer", "company": "Flutter"} ], "image_url": "https://example.com/flutter.jpg", + "tags": ["flutter", "mobile"], + "registration_form_url": "https://forms.example.com/flutter", + "event_type": "workshop", "created_at": datetime(2025, 2, 1, 0, 0, 0, tzinfo=timezone.utc), "updated_at": None, }, diff --git a/services/event/tests/test_api_events.py b/services/event/tests/test_api_events.py index 9c90a38..99d1dd3 100644 --- a/services/event/tests/test_api_events.py +++ b/services/event/tests/test_api_events.py @@ -60,6 +60,7 @@ def test_creates_event_201(self, sync_client, valid_api_token): "description": "Annual developer festival.", "date": "2099-11-15T10:00:00Z", "place": "Yaşar University", + "event_type": "conference", "speakers": [ {"name": "Jane Doe", "title": "Engineer", "company": "Google"} ], @@ -79,6 +80,22 @@ def test_validates_missing_title_422(self, sync_client, valid_api_token): "description": "Some event", "date": "2099-11-15T10:00:00Z", "place": "Somewhere", + "event_type": "meetup", + }, + ) + + assert response.status_code == 422 + + def test_validates_missing_event_type_422(self, sync_client, valid_api_token): + """POST with missing event_type returns 422.""" + response = sync_client.post( + "/events/", + headers={"X-API-Token": valid_api_token}, + json={ + "title": "Test Event", + "description": "Some event", + "date": "2099-11-15T10:00:00Z", + "place": "Somewhere", }, ) @@ -99,6 +116,7 @@ def test_handles_past_date_400(self, sync_client, valid_api_token): "description": "This event is in the past.", "date": "2020-01-01T00:00:00Z", "place": "Nowhere", + "event_type": "meetup", }, ) @@ -119,6 +137,9 @@ def test_returns_event_200(self, sync_client, valid_api_token): place="Yaşar University", speakers=[Speaker(name="Jane Doe", title="Engineer", company="Google")], image_url="https://example.com/image.jpg", + tags=["devfest", "gdg"], + registration_form_url="https://forms.example.com/register", + event_type="conference", created_at=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), updated_at=None, ) @@ -167,6 +188,9 @@ def test_updates_event_200(self, sync_client, valid_api_token): place="Yaşar University", speakers=[], image_url=None, + tags=[], + registration_form_url=None, + event_type="conference", created_at=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), updated_at=datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc), ) @@ -247,6 +271,9 @@ def test_returns_events_200(self, sync_client, valid_api_token): place="Yaşar University", speakers=[], image_url=None, + tags=[], + registration_form_url=None, + event_type="conference", created_at=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), updated_at=None, ), @@ -258,6 +285,9 @@ def test_returns_events_200(self, sync_client, valid_api_token): place="Engineering Building", speakers=[], image_url=None, + tags=["flutter"], + registration_form_url=None, + event_type="workshop", created_at=datetime(2025, 2, 1, 0, 0, 0, tzinfo=timezone.utc), updated_at=None, ), diff --git a/services/event/tests/test_event_repository.py b/services/event/tests/test_event_repository.py index 8df2431..aad8382 100644 --- a/services/event/tests/test_event_repository.py +++ b/services/event/tests/test_event_repository.py @@ -46,6 +46,11 @@ async def test_document_structure(self, mock_mongodb, sample_event_data): assert call_args["image_url"] == "https://example.com/image.jpg" assert len(call_args["speakers"]) == 1 assert call_args["speakers"][0]["name"] == "Jane Doe" + assert call_args["tags"] == ["devfest", "gdg"] + assert ( + call_args["registration_form_url"] == "https://forms.example.com/register" + ) + assert call_args["event_type"] == "conference" async def test_sets_timestamps(self, mock_mongodb, sample_event_data): """Create sets created_at and updated_at=None.""" @@ -88,19 +93,19 @@ async def test_returns_event_when_found(self, mock_mongodb, sample_event_doc): assert event is not None assert str(event.id) == "507f1f77bcf86cd799439011" assert event.title == "GDG DevFest 2025" + assert event.tags == ["devfest", "gdg"] + assert event.registration_form_url == "https://forms.example.com/register" + assert event.event_type == "conference" async def test_returns_none_when_not_found(self, mock_mongodb): """get_by_id returns None when document does not exist.""" mock_mongodb["events"].find_one.return_value = None - event = await EventRepository.get_by_id("507f1f77bcf86cd799439011") - assert event is None async def test_returns_none_for_invalid_id(self, mock_mongodb): """get_by_id returns None for invalid ObjectId string.""" event = await EventRepository.get_by_id("invalid-id") - assert event is None mock_mongodb["events"].find_one.assert_not_called() @@ -171,7 +176,7 @@ async def test_empty_update_returns_current_event( mock_mongodb["events"].update_one.assert_not_called() async def test_empty_update_raises_not_found_for_missing(self, mock_mongodb): - """Update with no fields raises EventNotFoundError if event doesn't exist.""" + """Update with no fields raises EventNotFoundError if missing.""" from app.models.event import EventUpdate mock_mongodb["events"].find_one.return_value = None @@ -201,6 +206,30 @@ async def test_updates_speakers(self, mock_mongodb, sample_event_doc): assert len(result.speakers) == 1 assert result.speakers[0].name == "New Speaker" + async def test_updates_tags(self, mock_mongodb, sample_event_doc): + """Update persists tags correctly.""" + from app.models.event import EventUpdate + + updated_doc = {**sample_event_doc, "tags": ["new-tag"]} + mock_mongodb["events"].update_one.return_value = MagicMock(matched_count=1) + mock_mongodb["events"].find_one.return_value = updated_doc + + update = EventUpdate(tags=["new-tag"]) + result = await EventRepository.update("507f1f77bcf86cd799439011", update) + assert result.tags == ["new-tag"] + + async def test_updates_event_type(self, mock_mongodb, sample_event_doc): + """Update persists event_type correctly.""" + from app.models.event import EventUpdate + + updated_doc = {**sample_event_doc, "event_type": "workshop"} + mock_mongodb["events"].update_one.return_value = MagicMock(matched_count=1) + mock_mongodb["events"].find_one.return_value = updated_doc + + update = EventUpdate(event_type="workshop") + result = await EventRepository.update("507f1f77bcf86cd799439011", update) + assert result.event_type == "workshop" + class TestDelete: """Tests for EventRepository.delete().""" @@ -208,9 +237,7 @@ class TestDelete: async def test_deletes_existing_event(self, mock_mongodb): """Delete removes the document when it exists.""" mock_mongodb["events"].delete_one.return_value = MagicMock(deleted_count=1) - await EventRepository.delete("507f1f77bcf86cd799439011") - mock_mongodb["events"].delete_one.assert_called_once_with( {"_id": ObjectId("507f1f77bcf86cd799439011")} ) @@ -218,7 +245,6 @@ async def test_deletes_existing_event(self, mock_mongodb): async def test_raises_not_found_when_deleted_count_zero(self, mock_mongodb): """Delete raises EventNotFoundError when deleted_count is 0.""" mock_mongodb["events"].delete_one.return_value = MagicMock(deleted_count=0) - with pytest.raises(EventNotFoundError): await EventRepository.delete("507f1f77bcf86cd799439011") @@ -226,7 +252,6 @@ async def test_raises_not_found_for_invalid_id(self, mock_mongodb): """Delete raises EventNotFoundError for invalid ObjectId.""" with pytest.raises(EventNotFoundError): await EventRepository.delete("invalid-id") - mock_mongodb["events"].delete_one.assert_not_called() @@ -238,9 +263,7 @@ async def test_returns_events_list(self, mock_mongodb, sample_event_docs): mock_mongodb["events"].find.return_value = create_async_cursor( sample_event_docs ) - events = await EventRepository.list_events() - assert len(events) == 2 assert events[0].title == "GDG DevFest 2025" assert events[1].title == "Flutter Workshop" @@ -248,9 +271,7 @@ async def test_returns_events_list(self, mock_mongodb, sample_event_docs): async def test_returns_empty_list(self, mock_mongodb): """list_events returns empty list when no events exist.""" mock_mongodb["events"].find.return_value = create_async_cursor([]) - events = await EventRepository.list_events() - assert events == [] async def test_applies_sort_skip_limit(self, mock_mongodb): diff --git a/services/event/tests/test_event_service.py b/services/event/tests/test_event_service.py index a1465df..87e30cc 100644 --- a/services/event/tests/test_event_service.py +++ b/services/event/tests/test_event_service.py @@ -21,6 +21,9 @@ def _make_db_event(**kwargs) -> EventInDB: "place": "Yaşar University", "speakers": [], "image_url": None, + "tags": ["devfest"], + "registration_form_url": None, + "event_type": "conference", "created_at": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), "updated_at": None, } @@ -43,6 +46,7 @@ async def test_creates_event_with_future_date(self, mock_mongodb): description="Annual developer festival.", date=datetime(2099, 11, 15, 10, 0, 0, tzinfo=timezone.utc), place="Yaşar University", + event_type="conference", ) event_id = await EventService.create_event(data) @@ -55,6 +59,7 @@ async def test_raises_value_error_for_past_date(self, mock_mongodb): description="Already happened.", date=datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc), place="Somewhere", + event_type="meetup", ) with pytest.raises(ValueError, match="Event date must be in the future"): @@ -72,6 +77,7 @@ async def test_delegates_to_repository(self, mock_mongodb): description="A workshop.", date=datetime(2099, 6, 1, 10, 0, 0, tzinfo=timezone.utc), place="Lab", + event_type="workshop", ) await EventService.create_event(data) @@ -95,6 +101,7 @@ async def test_returns_event_response(self, mock_mongodb): assert response is not None assert response.id == "507f1f77bcf86cd799439011" assert response.title == "GDG DevFest 2025" + assert response.event_type == "conference" async def test_returns_none_when_not_found(self, mock_mongodb): """get_event_by_id returns None when event doesn't exist.""" @@ -174,6 +181,7 @@ async def test_returns_list_of_event_responses(self, mock_mongodb): _make_db_event( _id=ObjectId("507f1f77bcf86cd799439012"), title="Flutter Workshop", + event_type="workshop", ), ] diff --git a/services/event/tests/test_models.py b/services/event/tests/test_models.py index a75600d..1e62ff9 100644 --- a/services/event/tests/test_models.py +++ b/services/event/tests/test_models.py @@ -64,6 +64,7 @@ def _valid_data(self) -> dict: "description": "Annual developer festival.", "date": datetime(2025, 11, 15, 10, 0, 0, tzinfo=timezone.utc), "place": "Yaşar University", + "event_type": "conference", } def test_valid_event_accepted(self): @@ -74,6 +75,9 @@ def test_valid_event_accepted(self): assert event.place == "Yaşar University" assert event.speakers == [] assert event.image_url is None + assert event.tags == [] + assert event.registration_form_url is None + assert event.event_type == "conference" def test_valid_event_with_all_fields(self): """Valid event with all fields including speakers and image_url is accepted.""" @@ -82,9 +86,13 @@ def test_valid_event_with_all_fields(self): Speaker(name="Jane Doe", title="Engineer", company="Google") ] data["image_url"] = "https://example.com/image.jpg" + data["tags"] = ["devfest", "gdg"] + data["registration_form_url"] = "https://forms.example.com/register" event = EventCreate(**data) assert len(event.speakers) == 1 assert event.image_url == "https://example.com/image.jpg" + assert event.tags == ["devfest", "gdg"] + assert event.registration_form_url == "https://forms.example.com/register" def test_empty_title_rejected(self): """Empty title raises ValidationError.""" @@ -143,6 +151,51 @@ def test_missing_required_field_rejected(self): with pytest.raises(ValidationError): EventCreate(title="GDG DevFest") + def test_tags_defaults_to_empty_list(self): + """tags field defaults to empty list.""" + event = EventCreate(**self._valid_data()) + assert event.tags == [] + + def test_registration_form_url_defaults_to_none(self): + """registration_form_url defaults to None.""" + event = EventCreate(**self._valid_data()) + assert event.registration_form_url is None + + def test_event_type_required(self): + """Missing event_type raises ValidationError.""" + data = self._valid_data() + del data["event_type"] + with pytest.raises(ValidationError): + EventCreate(**data) + + def test_empty_event_type_rejected(self): + """Empty event_type raises ValidationError.""" + data = self._valid_data() + data["event_type"] = "" + with pytest.raises(ValidationError): + EventCreate(**data) + + def test_event_type_max_length_exceeded_rejected(self): + """event_type over 100 characters raises ValidationError.""" + data = self._valid_data() + data["event_type"] = "x" * 101 + with pytest.raises(ValidationError): + EventCreate(**data) + + def test_empty_string_tag_rejected(self): + """Empty string inside tags list raises ValidationError.""" + data = self._valid_data() + data["tags"] = ["valid", ""] + with pytest.raises(ValidationError): + EventCreate(**data) + + def test_tag_exceeding_max_length_rejected(self): + """Tag over 50 characters raises ValidationError.""" + data = self._valid_data() + data["tags"] = ["x" * 51] + with pytest.raises(ValidationError): + EventCreate(**data) + class TestEventUpdate: """Tests for EventUpdate model.""" @@ -156,6 +209,9 @@ def test_all_fields_optional(self): assert update.place is None assert update.speakers is None assert update.image_url is None + assert update.tags is None + assert update.registration_form_url is None + assert update.event_type is None def test_partial_update_single_field(self): """Update with only one field provided is valid.""" @@ -186,6 +242,36 @@ def test_title_max_length_exceeded_rejected(self): with pytest.raises(ValidationError): EventUpdate(title="x" * 201) + def test_partial_update_tags(self): + """Update with tags list is valid.""" + update = EventUpdate(tags=["new-tag"]) + assert update.tags == ["new-tag"] + + def test_partial_update_event_type(self): + """Update with event_type is valid.""" + update = EventUpdate(event_type="workshop") + assert update.event_type == "workshop" + + def test_partial_update_registration_form_url(self): + """Update with registration_form_url is valid.""" + update = EventUpdate(registration_form_url="https://example.com/form") + assert update.registration_form_url == "https://example.com/form" + + def test_empty_event_type_rejected(self): + """Empty event_type violates min_length.""" + with pytest.raises(ValidationError): + EventUpdate(event_type="") + + def test_empty_string_tag_in_update_rejected(self): + """Empty string inside tags list raises ValidationError.""" + with pytest.raises(ValidationError): + EventUpdate(tags=["valid", ""]) + + def test_tag_exceeding_max_length_in_update_rejected(self): + """Tag over 50 characters raises ValidationError.""" + with pytest.raises(ValidationError): + EventUpdate(tags=["x" * 51]) + class TestEventInDB: """Tests for EventInDB model.""" @@ -199,6 +285,9 @@ def _valid_doc(self) -> dict: "place": "Yaşar University", "speakers": [], "image_url": None, + "tags": ["devfest"], + "registration_form_url": "https://forms.example.com/register", + "event_type": "conference", "created_at": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), "updated_at": None, } @@ -210,6 +299,9 @@ def test_valid_document_accepted(self): assert event.title == "GDG DevFest 2025" assert event.speakers == [] assert event.updated_at is None + assert event.tags == ["devfest"] + assert event.registration_form_url == "https://forms.example.com/register" + assert event.event_type == "conference" def test_string_id_converted_to_objectid(self): """String _id is converted to ObjectId.""" @@ -249,6 +341,24 @@ def test_speakers_list_stored(self): assert len(event.speakers) == 1 assert event.speakers[0].name == "Jane Doe" + def test_backward_compat_defaults_without_new_fields(self): + """Old documents without tags/registration_form_url/event_type still validate.""" + doc = { + "_id": ObjectId("507f1f77bcf86cd799439011"), + "title": "Legacy Event", + "description": "An old event.", + "date": datetime(2025, 11, 15, 10, 0, 0, tzinfo=timezone.utc), + "place": "Somewhere", + "speakers": [], + "image_url": None, + "created_at": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "updated_at": None, + } + event = EventInDB(**doc) + assert event.tags == [] + assert event.registration_form_url is None + assert event.event_type == "general" + class TestEventResponse: """Tests for EventResponse model.""" @@ -262,6 +372,9 @@ def _make_db_event(self, **kwargs) -> EventInDB: "place": "Yaşar University", "speakers": [], "image_url": None, + "tags": ["devfest"], + "registration_form_url": "https://forms.example.com/register", + "event_type": "conference", "created_at": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), "updated_at": None, } @@ -280,6 +393,9 @@ def test_from_db_maps_all_fields(self): assert response.place == "Yaşar University" assert response.speakers == [] assert response.image_url is None + assert response.tags == ["devfest"] + assert response.registration_form_url == "https://forms.example.com/register" + assert response.event_type == "conference" assert response.created_at == datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc) assert response.updated_at == datetime(2025, 2, 1, 0, 0, 0, tzinfo=timezone.utc) @@ -311,7 +427,23 @@ def test_json_serialization(self): speakers=[{"name": "Jane Doe", "title": "Engineer", "company": "Google"}] ) response = EventResponse.from_db(db_event) - json_data = response.model_dump_json() + json_data = response.model_dump_json(by_alias=True) assert "507f1f77bcf86cd799439011" in json_data assert "GDG DevFest 2025" in json_data assert "Jane Doe" in json_data + + def test_serialization_alias_type(self): + """event_type serializes as 'type' in JSON output.""" + db_event = self._make_db_event(event_type="workshop") + response = EventResponse.from_db(db_event) + json_data = response.model_dump(by_alias=True) + assert "type" in json_data + assert json_data["type"] == "workshop" + assert "event_type" not in json_data + + def test_from_db_with_default_event_type(self): + """from_db maps default event_type from legacy documents.""" + db_event = self._make_db_event(event_type="general", tags=[]) + response = EventResponse.from_db(db_event) + assert response.event_type == "general" + assert response.tags == []