| import pytest
|
| from fastapi.testclient import TestClient
|
| from unittest.mock import patch
|
| from constants import MAX_FILE_NAME_LENGTH
|
| from main import app
|
|
|
| client = TestClient(app)
|
|
|
|
|
| def delete_with_body(url: str, json_data: dict):
|
| """Helper to send DELETE request with JSON body"""
|
| return client.request("DELETE", url, json=json_data)
|
|
|
|
|
| class TestDeleteFileEndpoint:
|
| """Test the DELETE /file endpoint"""
|
|
|
| @pytest.fixture
|
| def base_required_fields(self):
|
| """Base fields required by IdentifierBase and ProfileBase"""
|
| return {
|
| "user_id": "test-user-123",
|
| "participant_id": "participant-456",
|
| "session_id": "test-session-123",
|
| "consent": True,
|
| "age_group": "25-34",
|
| "gender": "M",
|
| "roles": ["patient"],
|
| }
|
|
|
| @pytest.fixture
|
| def valid_payload(self, base_required_fields):
|
| return {**base_required_fields, "file_name": "document.txt"}
|
|
|
| @pytest.fixture
|
| def mock_dependencies(self):
|
| """Mock external dependencies"""
|
| with (
|
| patch("main.session_document_store") as mock_store,
|
| patch("main.replace_spaces_in_filename") as mock_replace,
|
| ):
|
|
|
| mock_replace.side_effect = lambda x: (
|
| x
|
| )
|
| mock_store.delete_document.return_value = True
|
|
|
| yield {"store": mock_store, "replace_spaces": mock_replace}
|
|
|
|
|
|
|
| def test_successful_file_deletion(self, valid_payload, mock_dependencies):
|
| """Test successful file deletion with valid inputs"""
|
| response = delete_with_body("/file", valid_payload)
|
|
|
| assert response.status_code == 200
|
|
|
|
|
| mock_dependencies["replace_spaces"].assert_called_once_with("document.txt")
|
| mock_dependencies["store"].delete_document.assert_called_once_with(
|
| "test-session-123", "document.txt"
|
| )
|
|
|
| def test_delete_file_with_spaces_in_filename(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test that spaces in filename are replaced"""
|
| payload = {**base_required_fields, "file_name": "my document.txt"}
|
|
|
|
|
| mock_dependencies["replace_spaces"].side_effect = None
|
| mock_dependencies["replace_spaces"].return_value = "my_document.txt"
|
|
|
| response = delete_with_body("/file", payload)
|
|
|
| assert response.status_code == 200
|
| mock_dependencies["replace_spaces"].assert_called_once_with("my document.txt")
|
| mock_dependencies["store"].delete_document.assert_called_once_with(
|
| "test-session-123", "my_document.txt"
|
| )
|
|
|
| def test_delete_file_different_filenames(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test deleting files with various filename formats"""
|
| filenames = [
|
| "document.txt",
|
| "report.pdf",
|
| "data.csv",
|
| "file_with_underscores.docx",
|
| "file-with-dashes.xlsx",
|
| "file.multiple.dots.txt",
|
| ]
|
|
|
| for filename in filenames:
|
| payload = {**base_required_fields, "file_name": filename}
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 200
|
|
|
| def test_delete_file_different_session_ids(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test deleting files from different sessions"""
|
| session_ids = [
|
| "session-1",
|
| "session-2",
|
| "session_abc_123",
|
| "a1b2c3",
|
| ]
|
|
|
| for session_id in session_ids:
|
| payload = {
|
| **base_required_fields,
|
| "session_id": session_id,
|
| "file_name": "document.txt",
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 200
|
|
|
|
|
|
|
| def test_delete_file_missing_session_id(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test that missing session_id returns validation error"""
|
| payload = {**base_required_fields, "file_name": "document.txt"}
|
| del payload["session_id"]
|
|
|
| response = delete_with_body("/file", payload)
|
|
|
| assert response.status_code == 422
|
|
|
| assert not mock_dependencies["store"].delete_document.called
|
|
|
| def test_delete_file_missing_file_name(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test that missing file_name returns validation error"""
|
| payload = {**base_required_fields}
|
|
|
| response = delete_with_body("/file", payload)
|
|
|
| assert response.status_code == 422
|
| assert not mock_dependencies["store"].delete_document.called
|
|
|
| def test_delete_file_missing_both_fields(self, mock_dependencies):
|
| """Test that missing session_id and file_name returns validation error"""
|
| payload = {
|
| "user_id": "test-user",
|
| "participant_id": "participant-123",
|
| "consent": True,
|
| "age_group": "25-34",
|
| "gender": "M",
|
| "roles": ["patient"],
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
|
|
| assert response.status_code == 422
|
| assert not mock_dependencies["store"].delete_document.called
|
|
|
| def test_delete_file_empty_session_id(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test handling of empty session_id"""
|
| payload = {
|
| **base_required_fields,
|
| "session_id": "",
|
| "file_name": "document.txt",
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
|
|
|
|
| assert response.status_code == 422
|
|
|
| def test_delete_file_empty_file_name(self, base_required_fields, mock_dependencies):
|
| """Test handling of empty file_name"""
|
| payload = {**base_required_fields, "file_name": ""}
|
|
|
| response = delete_with_body("/file", payload)
|
|
|
|
|
| assert response.status_code == 422
|
|
|
| def test_delete_file_extra_fields_ignored(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test that extra fields in payload are ignored"""
|
| payload = {
|
| **base_required_fields,
|
| "file_name": "document.txt",
|
| "extra_field": "should be ignored",
|
| "another_field": 123,
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
|
|
| assert response.status_code == 200
|
| mock_dependencies["store"].delete_document.assert_called_once()
|
|
|
|
|
|
|
| def test_delete_file_store_returns_true(self, valid_payload, mock_dependencies):
|
| """Test when store successfully deletes (returns True)"""
|
| mock_dependencies["store"].delete_document.return_value = True
|
|
|
| response = delete_with_body("/file", valid_payload)
|
|
|
| assert response.status_code == 200
|
|
|
| def test_delete_file_store_returns_false(self, valid_payload, mock_dependencies):
|
| """Test when store deletion fails (returns False)"""
|
| mock_dependencies["store"].delete_document.return_value = False
|
|
|
| response = delete_with_body("/file", valid_payload)
|
|
|
|
|
| assert response.status_code == 200
|
|
|
| def test_delete_file_nonexistent_file(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test deleting a file that doesn't exist"""
|
| payload = {**base_required_fields, "file_name": "nonexistent.txt"}
|
|
|
|
|
| mock_dependencies["store"].delete_document.return_value = False
|
|
|
| response = delete_with_body("/file", payload)
|
|
|
|
|
| assert response.status_code == 200
|
|
|
| def test_delete_file_nonexistent_session(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test deleting from a session that doesn't exist"""
|
| payload = {
|
| **base_required_fields,
|
| "session_id": "nonexistent-session",
|
| "file_name": "document.txt",
|
| }
|
|
|
| mock_dependencies["store"].delete_document.return_value = False
|
|
|
| response = delete_with_body("/file", payload)
|
|
|
| assert response.status_code == 200
|
|
|
|
|
|
|
| def test_replace_spaces_called_with_correct_argument(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test that replace_spaces_in_filename is called with the right argument"""
|
| payload = {**base_required_fields, "file_name": "my file.txt"}
|
|
|
| delete_with_body("/file", payload)
|
|
|
| mock_dependencies["replace_spaces"].assert_called_once_with("my file.txt")
|
|
|
|
|
|
|
| def test_invalid_filename_pattern_double_dots(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test that filenames with double dots are rejected"""
|
| payload = {**base_required_fields, "file_name": "file..txt"}
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 422
|
|
|
| def test_invalid_filename_pattern_starting_dot(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test that filenames starting with dot are rejected"""
|
| payload = {**base_required_fields, "file_name": ".hidden.txt"}
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 422
|
|
|
| def test_invalid_filename_pattern_starting_space(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test that filenames starting with space are rejected"""
|
| payload = {**base_required_fields, "file_name": " file.txt"}
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 422
|
|
|
| def test_valid_filename_with_parentheses(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test that filenames with parentheses are accepted"""
|
| payload = {**base_required_fields, "file_name": "file(1).txt"}
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 200
|
|
|
| def test_invalid_session_id_with_special_chars(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test that session IDs with invalid characters are rejected"""
|
| invalid_ids = ["session@123", "session.123", "session/123", "session 123"]
|
|
|
| for invalid_id in invalid_ids:
|
| payload = {
|
| **base_required_fields,
|
| "session_id": invalid_id,
|
| "file_name": "document.txt",
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 422
|
|
|
| def test_invalid_age_group(self, base_required_fields, mock_dependencies):
|
| """Test that invalid age groups are rejected"""
|
| payload = {
|
| **base_required_fields,
|
| "age_group": "99-100",
|
| "file_name": "document.txt",
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 422
|
|
|
| def test_invalid_gender(self, base_required_fields, mock_dependencies):
|
| """Test that invalid gender values are rejected"""
|
| payload = {
|
| **base_required_fields,
|
| "gender": "X",
|
| "file_name": "document.txt",
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 422
|
|
|
| def test_missing_consent(self, base_required_fields, mock_dependencies):
|
| """Test that missing consent field is rejected"""
|
| payload = {**base_required_fields, "file_name": "document.txt"}
|
| del payload["consent"]
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 422
|
|
|
| def test_invalid_roles_empty_set(self, base_required_fields, mock_dependencies):
|
| """Test that empty roles set is rejected"""
|
| payload = {
|
| **base_required_fields,
|
| "roles": [],
|
| "file_name": "document.txt",
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 422
|
|
|
| def test_invalid_roles_too_many(self, base_required_fields, mock_dependencies):
|
| """Test that more than 5 roles is rejected"""
|
| payload = {
|
| **base_required_fields,
|
| "roles": [
|
| "patient",
|
| "clinician",
|
| "computer-scientist",
|
| "researcher",
|
| "other",
|
| "extra",
|
| ],
|
| "file_name": "document.txt",
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 422
|
|
|
| def test_invalid_role_value(self, base_required_fields, mock_dependencies):
|
| """Test that invalid role values are rejected"""
|
| payload = {
|
| **base_required_fields,
|
| "roles": ["invalid-role"],
|
| "file_name": "document.txt",
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 422
|
|
|
| def test_valid_multiple_roles(self, base_required_fields, mock_dependencies):
|
| """Test that multiple valid roles are accepted"""
|
| payload = {
|
| **base_required_fields,
|
| "roles": ["patient", "clinician", "researcher"],
|
| "file_name": "document.txt",
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 200
|
|
|
|
|
|
|
| @pytest.mark.enable_rate_limit
|
| def test_rate_limiting(self, valid_payload, mock_dependencies):
|
| """Test that rate limiting works (20 requests per minute)"""
|
| from fastapi.testclient import TestClient
|
| from main import app
|
|
|
|
|
| rate_limit_client = TestClient(app)
|
|
|
|
|
| responses = []
|
| for i in range(21):
|
| response = rate_limit_client.request("DELETE", "/file", json=valid_payload)
|
| responses.append(response)
|
|
|
|
|
|
|
| assert responses[-1].status_code == 429
|
|
|
|
|
|
|
| def test_delete_same_file_twice_idempotent(self, valid_payload, mock_dependencies):
|
| """Test that deleting the same file twice is idempotent"""
|
|
|
| response1 = delete_with_body("/file", valid_payload)
|
| assert response1.status_code == 200
|
|
|
|
|
| mock_dependencies["store"].delete_document.return_value = False
|
| response2 = delete_with_body("/file", valid_payload)
|
| assert response2.status_code == 200
|
|
|
| def test_delete_multiple_files_same_session(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test deleting multiple files from the same session"""
|
| session_id = "test-session"
|
| files = ["file1.txt", "file2.txt", "file3.txt"]
|
|
|
| for filename in files:
|
| payload = {
|
| **base_required_fields,
|
| "session_id": session_id,
|
| "file_name": filename,
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 200
|
|
|
| def test_delete_files_from_multiple_sessions(
|
| self, base_required_fields, mock_dependencies
|
| ):
|
| """Test deleting files from different sessions"""
|
| sessions_and_files = [
|
| ("session-1", "file1.txt"),
|
| ("session-2", "file2.txt"),
|
| ("session-3", "file3.txt"),
|
| ]
|
|
|
| for session_id, filename in sessions_and_files:
|
| payload = {
|
| **base_required_fields,
|
| "session_id": session_id,
|
| "file_name": filename,
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
| assert response.status_code == 200
|
|
|
| def test_workflow_order(self, valid_payload, mock_dependencies):
|
| """Test that operations happen in correct order"""
|
| call_order = []
|
|
|
| def track_replace(filename):
|
| call_order.append("replace")
|
| return filename
|
|
|
| def track_delete(session_id, filename):
|
| call_order.append("delete")
|
| return True
|
|
|
| mock_dependencies["replace_spaces"].side_effect = track_replace
|
| mock_dependencies["store"].delete_document.side_effect = track_delete
|
|
|
| delete_with_body("/file", valid_payload)
|
|
|
|
|
| assert call_order == ["replace", "delete"]
|
|
|
| def test_very_long_filename(self, base_required_fields, mock_dependencies):
|
| """Test handling of very long filenames"""
|
| long_filename = "a" * MAX_FILE_NAME_LENGTH + ".txt"
|
| payload = {**base_required_fields, "file_name": long_filename}
|
|
|
| response = delete_with_body("/file", payload)
|
|
|
| assert response.status_code == 422
|
|
|
| def test_very_long_session_id(self, base_required_fields, mock_dependencies):
|
| """Test handling of very long session IDs"""
|
| long_session_id = "s" * 51
|
| payload = {
|
| **base_required_fields,
|
| "session_id": long_session_id,
|
| "file_name": "document.txt",
|
| }
|
|
|
| response = delete_with_body("/file", payload)
|
|
|
| assert response.status_code == 422
|
|
|