diff --git a/services/form/app/routers/submissions.py b/services/form/app/routers/submissions.py index d9ce203..bea813b 100644 --- a/services/form/app/routers/submissions.py +++ b/services/form/app/routers/submissions.py @@ -24,14 +24,20 @@ @router.post("/", response_model=SubmissionResponse, status_code=201) async def create_submission( submission_data: SubmissionCreate, + dry_run: Annotated[bool, Query(alias="dry_run")] = False, ) -> SubmissionResponse: """ Create a new form submission. Validates that the form exists, is active, has started, and deadline has not passed. + + When `dry_run=true`, the payload is fully validated but nothing is + persisted to the database. """ try: - submission = await SubmissionService.create_submission(submission_data) + submission = await SubmissionService.create_submission( + submission_data, dry_run=dry_run + ) return SubmissionResponse.from_db(submission) except FormNotFoundError: raise HTTPException(status_code=404, detail={"code": "form_not_found"}) diff --git a/services/form/app/services/submission_service.py b/services/form/app/services/submission_service.py index 22e7fc8..80ef4b1 100644 --- a/services/form/app/services/submission_service.py +++ b/services/form/app/services/submission_service.py @@ -221,13 +221,14 @@ def _validate_required_answers( @classmethod async def create_submission( - cls, submission_data: SubmissionCreate + cls, submission_data: SubmissionCreate, *, dry_run: bool = False ) -> SubmissionInDB: """ Create a new submission. Args: submission_data: Submission data to create + dry_run: If True, validate only — skip DB insert and submission_count increment. Returns: Created SubmissionInDB instance @@ -247,6 +248,13 @@ async def create_submission( # Ensure form_id is stored as ObjectId, not string submission_doc["form_id"] = cls._to_object_id(submission_data.form_id) + if dry_run: + logger.info( + "Dry-run submission validated (not persisted)", + form_id=str(submission_data.form_id), + ) + return cls._document_to_submission(submission_doc) + object_id = cls._to_object_id(submission_data.form_id) submissions_collection = cls._get_submissions_collection() forms_collection = cls._get_forms_collection() diff --git a/services/form/tests/test_api_submissions.py b/services/form/tests/test_api_submissions.py index c7e8ed7..dbcd624 100644 --- a/services/form/tests/test_api_submissions.py +++ b/services/form/tests/test_api_submissions.py @@ -165,6 +165,104 @@ def test_create_submission_missing_visible_conditional_field( assert response.json()["detail"]["code"] == "required_answer_incomplete" +class TestCreateSubmissionDryRunAPI: + """Test POST /submissions/?dry_run=true endpoint.""" + + def test_dry_run_returns_201_without_persisting(self, sync_client, mock_mongodb): + """POST /submissions/?dry_run=true returns 201 but does not write to DB.""" + form_doc = _make_active_form_doc() + mock_mongodb["forms"].find_one = AsyncMock(return_value=form_doc) + mock_mongodb["submissions"].insert_one = AsyncMock() + mock_mongodb["forms"].update_one = AsyncMock() + + with patch( + "app.services.submission_service.MongoDB.get_db", + return_value=mock_mongodb["db"], + ): + response = sync_client.post( + "/submissions/?dry_run=true", + json={ + "form_id": str(SAMPLE_FORM_ID), + "answers": {"q1": "answer"}, + "respondent_email": "test@example.com", + }, + ) + + assert response.status_code == 201 + data = response.json() + assert data["respondent_email"] == "test@example.com" + mock_mongodb["submissions"].insert_one.assert_not_awaited() + mock_mongodb["forms"].update_one.assert_not_awaited() + + def test_dry_run_still_returns_404_for_missing_form( + self, sync_client, mock_mongodb + ): + """POST /submissions/?dry_run=true returns 404 when form does not exist.""" + mock_mongodb["forms"].find_one = AsyncMock(return_value=None) + + with patch( + "app.services.submission_service.MongoDB.get_db", + return_value=mock_mongodb["db"], + ): + response = sync_client.post( + "/submissions/?dry_run=true", + json={ + "form_id": str(SAMPLE_FORM_ID), + "answers": {"q1": "answer"}, + "respondent_email": "test@example.com", + }, + ) + + assert response.status_code == 404 + + def test_dry_run_still_returns_400_for_missing_required_field( + self, sync_client, mock_mongodb + ): + """POST /submissions/?dry_run=true returns 400 when required answer is empty.""" + form_doc = _make_active_form_doc() + mock_mongodb["forms"].find_one = AsyncMock(return_value=form_doc) + + with patch( + "app.services.submission_service.MongoDB.get_db", + return_value=mock_mongodb["db"], + ): + response = sync_client.post( + "/submissions/?dry_run=true", + json={ + "form_id": str(SAMPLE_FORM_ID), + "answers": {"q1": ""}, + "respondent_email": "test@example.com", + }, + ) + + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "required_answer_incomplete" + + def test_dry_run_false_still_persists(self, sync_client, mock_mongodb): + """POST /submissions/?dry_run=false behaves like default (persists to DB).""" + form_doc = _make_active_form_doc() + mock_mongodb["forms"].find_one = AsyncMock(return_value=form_doc) + mock_mongodb["submissions"].insert_one = AsyncMock() + mock_mongodb["forms"].update_one = AsyncMock() + + with patch( + "app.services.submission_service.MongoDB.get_db", + return_value=mock_mongodb["db"], + ): + response = sync_client.post( + "/submissions/?dry_run=false", + json={ + "form_id": str(SAMPLE_FORM_ID), + "answers": {"q1": "answer"}, + "respondent_email": "test@example.com", + }, + ) + + assert response.status_code == 201 + mock_mongodb["submissions"].insert_one.assert_awaited_once() + mock_mongodb["forms"].update_one.assert_awaited_once() + + class TestGetSubmissionAPI: """Test GET /submissions/{submission_id} endpoint.""" diff --git a/services/form/tests/test_submission_service.py b/services/form/tests/test_submission_service.py index 86b5dbe..3fabcfe 100644 --- a/services/form/tests/test_submission_service.py +++ b/services/form/tests/test_submission_service.py @@ -215,6 +215,70 @@ async def test_create_submission_skips_conditional_field_when_hidden( assert result.answers["is_yasar_student"] == "Evet" +class TestCreateSubmissionDryRun: + """Test SubmissionService.create_submission with dry_run=True.""" + + async def test_dry_run_returns_submission_without_persisting( + self, mock_submission_collections, sample_submission_data + ): + """Returns a valid SubmissionInDB without calling insert_one or incrementing submission_count.""" + form_doc = _make_active_form_doc() + mock_submission_collections["forms"].find_one = AsyncMock(return_value=form_doc) + mock_submission_collections["submissions"].insert_one = AsyncMock() + mock_submission_collections["forms"].update_one = AsyncMock() + + result = await SubmissionService.create_submission( + sample_submission_data, dry_run=True + ) + + assert result is not None + assert result.respondent_email == "john@example.com" + assert result.form_id == sample_submission_data.form_id + mock_submission_collections["submissions"].insert_one.assert_not_awaited() + mock_submission_collections["forms"].update_one.assert_not_awaited() + + async def test_dry_run_still_rejects_missing_form( + self, mock_submission_collections, sample_submission_data + ): + """Raises FormNotFoundError even when dry_run=True.""" + mock_submission_collections["forms"].find_one = AsyncMock(return_value=None) + + with pytest.raises(FormNotFoundError): + await SubmissionService.create_submission( + sample_submission_data, dry_run=True + ) + + async def test_dry_run_still_rejects_inactive_form( + self, mock_submission_collections, sample_submission_data + ): + """Raises FormValidationError for inactive form even when dry_run=True.""" + form_doc = _make_active_form_doc(is_active=False) + mock_submission_collections["forms"].find_one = AsyncMock(return_value=form_doc) + + with pytest.raises(FormValidationError) as exc_info: + await SubmissionService.create_submission( + sample_submission_data, dry_run=True + ) + assert exc_info.value.code == "form_not_active" + + async def test_dry_run_still_validates_required_answers( + self, mock_submission_collections + ): + """Raises FormValidationError for missing required field even when dry_run=True.""" + form_doc = _make_active_form_doc() + mock_submission_collections["forms"].find_one = AsyncMock(return_value=form_doc) + + submission_data = SubmissionCreate( + form_id=SAMPLE_FORM_ID, + answers={"name": ""}, + respondent_email="test@example.com", + ) + + with pytest.raises(FormValidationError) as exc_info: + await SubmissionService.create_submission(submission_data, dry_run=True) + assert exc_info.value.code == "required_answer_incomplete" + + class TestGetSubmissionById: """Test SubmissionService.get_submission_by_id."""