from fastapi import status from fastapi.testclient import TestClient from sqlalchemy.orm import Session from datetime import datetime, timedelta, timezone # Add timezone from pytest_mock import MockerFixture # Import MockerFixture from tests.helpers import generators from modules.calendar.models import CalendarEvent # Assuming model exists from tests.conftest import fake # Helper function to create an event payload def create_event_payload(start_offset_days=0, end_offset_days=1): # Ensure datetimes are timezone-aware (UTC) start_time = datetime.now(timezone.utc) + timedelta(days=start_offset_days) end_time = datetime.now(timezone.utc) + timedelta(days=end_offset_days) return { "title": fake.sentence(nb_words=3), "description": fake.text(), "start": start_time.isoformat().replace("+00:00", "Z"), # Ensure Z suffix "end": end_time.isoformat().replace("+00:00", "Z"), # Ensure Z suffix "all_day": fake.boolean(), } # --- Test Create Event --- def test_create_event_unauthorized(client: TestClient) -> None: """Test creating an event without authentication.""" payload = create_event_payload() response = client.post("/api/calendar/events", json=payload) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_create_event_success( db: Session, client: TestClient, mocker: MockerFixture ) -> None: """Test creating a calendar event successfully.""" user, password = generators.create_user(db) login_rsp = generators.login(db, user.username, password) access_token = login_rsp["access_token"] payload = create_event_payload() # Mock the celery task sending mock_send_task = mocker.patch( "core.celery_app.celery_app.send_task" ) # Corrected patch target response = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload, ) assert ( response.status_code == status.HTTP_201_CREATED ) # Change expected status to 201 data = response.json() assert data["title"] == payload["title"] assert data["description"] == payload["description"] # Assert with Z suffix assert data["start"] == payload["start"] assert data["end"] == payload["end"] assert data["all_day"] == payload["all_day"] assert "id" in data assert data["user_id"] == user.id # Verify in DB event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == data["id"]).first() assert event_in_db is not None assert event_in_db.user_id == user.id assert event_in_db.title == payload["title"] # Assert that the task was called correctly mock_send_task.assert_called_once_with( "modules.calendar.tasks.schedule_event_notifications", args=[data["id"]] ) # --- Test Get Events --- def test_get_events_unauthorized(client: TestClient) -> None: """Test getting events without authentication.""" response = client.get("/api/calendar/events") assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_get_events_success( db: Session, client: TestClient, mocker: MockerFixture ) -> None: # Add mocker """Test getting all calendar events for a user.""" user, password = generators.create_user( db, username="testuser_get_events" ) # Unique username login_rsp = generators.login(db, user.username, password) access_token = login_rsp["access_token"] # Mock celery task for creation mocker.patch("core.celery_app.celery_app.send_task") # Create a couple of events for the user payload1 = create_event_payload(0, 1) create_rsp1 = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload1, ) assert create_rsp1.status_code == status.HTTP_201_CREATED payload2 = create_event_payload(2, 3) create_rsp2 = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload2, ) assert create_rsp2.status_code == status.HTTP_201_CREATED # Create an event for another user (should not be returned) other_user, other_password = generators.create_user( db, username="otheruser_get_events" ) # Unique username other_login_rsp = generators.login(db, other_user.username, other_password) other_access_token = other_login_rsp["access_token"] other_payload = create_event_payload(4, 5) create_rsp_other = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {other_access_token}"}, json=other_payload, ) assert create_rsp_other.status_code == status.HTTP_201_CREATED response = client.get( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"} ) assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data) == 2 assert data[0]["title"] == payload1["title"] assert data[1]["title"] == payload2["title"] assert data[0]["user_id"] == user.id assert data[1]["user_id"] == user.id def test_get_events_filtered( db: Session, client: TestClient, mocker: MockerFixture ) -> None: # Add mocker """Test getting filtered calendar events for a user.""" user, password = generators.create_user( db, username="testuser_filter_events" ) # Unique username login_rsp = generators.login(db, user.username, password) access_token = login_rsp["access_token"] # Mock celery task for creation mocker.patch("core.celery_app.celery_app.send_task") # Create events payload1 = create_event_payload(0, 1) # Today -> Tomorrow create_rsp1 = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload1, ) assert create_rsp1.status_code == status.HTTP_201_CREATED payload2 = create_event_payload(5, 6) # In 5 days -> In 6 days create_rsp2 = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload2, ) assert create_rsp2.status_code == status.HTTP_201_CREATED payload3 = create_event_payload(10, 11) # In 10 days -> In 11 days create_rsp3 = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload3, ) assert create_rsp3.status_code == status.HTTP_201_CREATED # Filter for events starting within the next week start_filter = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") end_filter = ( (datetime.now(timezone.utc) + timedelta(days=7)) .isoformat() .replace("+00:00", "Z") ) response = client.get( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, params={"start": start_filter, "end": end_filter}, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data) == 2 # Should get event 1 and 2 assert data[0]["title"] == payload1["title"] assert data[1]["title"] == payload2["title"] # Filter for events starting after 8 days start_filter_late = ( (datetime.now(timezone.utc) + timedelta(days=8)) .isoformat() .replace("+00:00", "Z") ) response = client.get( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, params={"start": start_filter_late}, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data) == 1 # Should get event 3 assert data[0]["title"] == payload3["title"] # --- Test Get Event By ID --- def test_get_event_by_id_unauthorized( db: Session, client: TestClient, mocker: MockerFixture ) -> None: # Add mocker """Test getting a specific event without authentication.""" user, password = generators.create_user(db) login_rsp = generators.login(db, user.username, password) access_token = login_rsp["access_token"] payload = create_event_payload() # Mock celery task for creation mocker.patch("core.celery_app.celery_app.send_task") create_response = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload, ) assert create_response.status_code == status.HTTP_201_CREATED event_id = create_response.json()["id"] response = client.get(f"/api/calendar/events/{event_id}") assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_get_event_by_id_success( db: Session, client: TestClient, mocker: MockerFixture ) -> None: # Add mocker """Test getting a specific event successfully.""" user, password = generators.create_user(db) login_rsp = generators.login(db, user.username, password) access_token = login_rsp["access_token"] payload = create_event_payload() # Mock celery task for creation mocker.patch("core.celery_app.celery_app.send_task") create_response = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload, ) assert create_response.status_code == status.HTTP_201_CREATED event_id = create_response.json()["id"] response = client.get( f"/api/calendar/events/{event_id}", headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["id"] == event_id assert data["title"] == payload["title"] # Assert datetime with Z suffix assert data["start"] == payload["start"] assert data["end"] == payload["end"] assert data["user_id"] == user.id def test_get_event_by_id_not_found(db: Session, client: TestClient) -> None: """Test getting a non-existent event.""" user, password = generators.create_user(db) login_rsp = generators.login(db, user.username, password) access_token = login_rsp["access_token"] non_existent_id = 99999 response = client.get( f"/api/calendar/events/{non_existent_id}", headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == status.HTTP_404_NOT_FOUND def test_get_event_by_id_forbidden( db: Session, client: TestClient, mocker: MockerFixture ) -> None: # Add mocker """Test getting another user's event.""" user1, password_user1 = generators.create_user( db, username="user1_forbidden_get" ) # Unique username user2, password_user2 = generators.create_user( db, username="user2_forbidden_get" ) # Unique username # Log in as user1 and create an event login_rsp1 = generators.login(db, user1.username, password_user1) access_token1 = login_rsp1["access_token"] payload = create_event_payload() # Mock celery task for creation mocker.patch("core.celery_app.celery_app.send_task") create_response = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token1}"}, json=payload, ) assert create_response.status_code == status.HTTP_201_CREATED event_id = create_response.json()["id"] # Log in as user2 and try to get user1's event login_rsp2 = generators.login(db, user2.username, password_user2) access_token2 = login_rsp2["access_token"] response = client.get( f"/api/calendar/events/{event_id}", headers={"Authorization": f"Bearer {access_token2}"}, ) assert ( response.status_code == status.HTTP_404_NOT_FOUND ) # Service layer returns 404 if user_id doesn't match # --- Test Update Event --- def test_update_event_unauthorized( db: Session, client: TestClient, mocker: MockerFixture ) -> None: # Add mocker """Test updating an event without authentication.""" user, password = generators.create_user(db) login_rsp = generators.login(db, user.username, password) access_token = login_rsp["access_token"] payload = create_event_payload() # Mock celery task for creation mocker.patch("core.celery_app.celery_app.send_task") create_response = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload, ) assert create_response.status_code == status.HTTP_201_CREATED event_id = create_response.json()["id"] update_payload = {"title": "Updated Title"} response = client.patch(f"/api/calendar/events/{event_id}", json=update_payload) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_update_event_success( db: Session, client: TestClient, mocker: MockerFixture ) -> None: # Add mocker """Test updating an event successfully.""" user, password = generators.create_user(db) login_rsp = generators.login(db, user.username, password) access_token = login_rsp["access_token"] payload = create_event_payload() # Mock celery task for creation mocker.patch( "core.celery_app.celery_app.send_task", return_value=None ) # Mock for creation create_response = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload, ) assert ( create_response.status_code == status.HTTP_201_CREATED ) # Ensure creation check uses 201 event_id = create_response.json()["id"] update_payload = { "title": "Updated Title", "description": "Updated description.", "all_day": not payload["all_day"], # Toggle all_day } # Mock celery task for update (needs separate mock) mock_send_task_update = mocker.patch( "modules.calendar.service.celery_app.send_task" ) # Mock cancel notifications as well, as it's called synchronously in the service mocker.patch("modules.calendar.tasks.cancel_event_notifications") response = client.patch( f"/api/calendar/events/{event_id}", headers={"Authorization": f"Bearer {access_token}"}, json=update_payload, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["id"] == event_id assert data["title"] == update_payload["title"] assert data["description"] == update_payload["description"] assert data["all_day"] == update_payload["all_day"] # Assert datetime with Z suffix assert data["start"] == payload["start"] assert data["user_id"] == user.id # Verify in DB event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first() assert event_in_db is not None assert event_in_db.title == update_payload["title"] assert event_in_db.description == update_payload["description"] assert event_in_db.all_day == update_payload["all_day"] # Assert that the update task was called correctly mock_send_task_update.assert_called_once_with( "modules.calendar.tasks.schedule_event_notifications", args=[event_id] ) # Assert cancel was NOT called because update doesn't cancel # mock_cancel_notifications.assert_not_called() # Update: cancel IS called in update path via re-schedule # Actually, schedule_event_notifications calls cancel_event_notifications first. # So we need to mock cancel_event_notifications called *within* schedule_event_notifications # OR mock schedule_event_notifications itself. Let's stick to mocking send_task. # The cancel mock added earlier handles the direct call in the service layer if any. def test_update_event_not_found(db: Session, client: TestClient) -> None: """Test updating a non-existent event.""" user, password = generators.create_user(db) login_rsp = generators.login(db, user.username, password) access_token = login_rsp["access_token"] non_existent_id = 99999 update_payload = {"title": "Updated Title"} response = client.patch( f"/api/calendar/events/{non_existent_id}", headers={"Authorization": f"Bearer {access_token}"}, json=update_payload, ) assert response.status_code == status.HTTP_404_NOT_FOUND def test_update_event_forbidden( db: Session, client: TestClient, mocker: MockerFixture ) -> None: # Add mocker """Test updating another user's event.""" user1, password_user1 = generators.create_user( db, username="user1_forbidden_update" ) # Unique username user2, password_user2 = generators.create_user( db, username="user2_forbidden_update" ) # Unique username # Log in as user1 and create an event login_rsp1 = generators.login(db, user1.username, password_user1) access_token1 = login_rsp1["access_token"] payload = create_event_payload() # Mock celery task for creation mocker.patch("core.celery_app.celery_app.send_task") create_response = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token1}"}, json=payload, ) assert create_response.status_code == status.HTTP_201_CREATED event_id = create_response.json()["id"] # Log in as user2 and try to update user1's event login_rsp2 = generators.login(db, user2.username, password_user2) access_token2 = login_rsp2["access_token"] update_payload = {"title": "Updated by User 2"} response = client.patch( f"/api/calendar/events/{event_id}", headers={"Authorization": f"Bearer {access_token2}"}, json=update_payload, ) assert ( response.status_code == status.HTTP_404_NOT_FOUND ) # Service layer returns 404 if user_id doesn't match # --- Test Delete Event --- def test_delete_event_unauthorized( db: Session, client: TestClient, mocker: MockerFixture ) -> None: # Add mocker """Test deleting an event without authentication.""" user, password = generators.create_user(db) login_rsp = generators.login(db, user.username, password) access_token = login_rsp["access_token"] payload = create_event_payload() # Mock celery task for creation mocker.patch("core.celery_app.celery_app.send_task") create_response = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload, ) assert create_response.status_code == status.HTTP_201_CREATED event_id = create_response.json()["id"] response = client.delete(f"/api/calendar/events/{event_id}") assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_delete_event_success( db: Session, client: TestClient, mocker: MockerFixture ) -> None: """Test deleting an event successfully.""" user, password = generators.create_user(db) login_rsp = generators.login(db, user.username, password) access_token = login_rsp["access_token"] payload = create_event_payload() # Mock the celery task sending for creation mocker.patch("core.celery_app.celery_app.send_task") create_response = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload, ) assert ( create_response.status_code == status.HTTP_201_CREATED ) # Ensure creation check uses 201 event_id = create_response.json()["id"] # Verify event exists before delete event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first() assert event_in_db is not None # Mock the cancel_event_notifications function to prevent Redis call mock_cancel_notifications = mocker.patch( "modules.calendar.service.cancel_event_notifications" # Target the function as used in service.py ) response = client.delete( f"/api/calendar/events/{event_id}", headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == status.HTTP_204_NO_CONTENT # Assert that cancel_event_notifications was called mock_cancel_notifications.assert_called_once_with(event_id) # Verify event is deleted from DB event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first() assert event_in_db is None # Try getting the deleted event (should be 404) get_response = client.get( f"/api/calendar/events/{event_id}", headers={"Authorization": f"Bearer {access_token}"}, ) assert get_response.status_code == status.HTTP_404_NOT_FOUND def test_delete_event_not_found(db: Session, client: TestClient) -> None: """Test deleting a non-existent event.""" user, password = generators.create_user(db) login_rsp = generators.login(db, user.username, password) access_token = login_rsp["access_token"] non_existent_id = 99999 response = client.delete( f"/api/calendar/events/{non_existent_id}", headers={"Authorization": f"Bearer {access_token}"}, ) # The service layer raises NotFound, which should result in 404 assert response.status_code == status.HTTP_404_NOT_FOUND def test_delete_event_forbidden( db: Session, client: TestClient, mocker: MockerFixture ) -> None: # Add mocker """Test deleting another user's event.""" user1, password_user1 = generators.create_user( db, username="user1_forbidden_delete" ) # Unique username user2, password_user2 = generators.create_user( db, username="user2_forbidden_delete" ) # Unique username # Log in as user1 and create an event login_rsp1 = generators.login(db, user1.username, password_user1) access_token1 = login_rsp1["access_token"] payload = create_event_payload() # Mock celery task for creation mocker.patch("core.celery_app.celery_app.send_task") create_response = client.post( "/api/calendar/events", headers={"Authorization": f"Bearer {access_token1}"}, json=payload, ) assert create_response.status_code == status.HTTP_201_CREATED event_id = create_response.json()["id"] # Log in as user2 and try to delete user1's event login_rsp2 = generators.login(db, user2.username, password_user2) access_token2 = login_rsp2["access_token"] response = client.delete( f"/api/calendar/events/{event_id}", headers={"Authorization": f"Bearer {access_token2}"}, ) # The service layer raises NotFound if user_id doesn't match, resulting in 404 assert response.status_code == status.HTTP_404_NOT_FOUND # Verify event still exists for user1 event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first() assert event_in_db is not None assert event_in_db.user_id == user1.id