Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion services/form/app/routers/submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
10 changes: 9 additions & 1 deletion services/form/app/services/submission_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
98 changes: 98 additions & 0 deletions services/form/tests/test_api_submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
64 changes: 64 additions & 0 deletions services/form/tests/test_submission_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down