champ-chatbot / tests /api /test_file_delete.py
qyle's picture
deployment
8b9e569 verified
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch
from constants import MAX_FILE_NAME_LENGTH
from main import app # Adjust import based on your structure
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,
):
# Setup default behavior
mock_replace.side_effect = lambda x: (
x
) # Return filename unchanged by default
mock_store.delete_document.return_value = True
yield {"store": mock_store, "replace_spaces": mock_replace}
# ==================== Successful Deletion Tests ====================
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
# Verify workflow
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 replace_spaces to return expected result
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
# ==================== Request Validation Tests ====================
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
# Store should not be called
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)
# Empty string violates pattern and min_length
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)
# Empty string violates min_length=1
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()
# ==================== Store Behavior Tests ====================
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)
# Endpoint doesn't check return value, so still 200
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"}
# Store returns False for nonexistent file
mock_dependencies["store"].delete_document.return_value = False
response = delete_with_body("/file", payload)
# Endpoint still returns 200 (idempotent DELETE)
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
# ==================== Filename Replacement Tests ====================
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")
# ==================== Rate Limiting Tests ====================
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", # Invalid
"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", # Invalid - must be M or F
"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": [], # Empty - violates min_length=1
"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
# ==================== Rate Limiting Tests ====================
@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
# Create fresh client with rate limiting enabled
rate_limit_client = TestClient(app)
# Make 21 rapid requests
responses = []
for i in range(21):
response = rate_limit_client.request("DELETE", "/file", json=valid_payload)
responses.append(response)
# First 20 should succeed
# 21st should be rate limited
assert responses[-1].status_code == 429
# ==================== Integration Tests ====================
def test_delete_same_file_twice_idempotent(self, valid_payload, mock_dependencies):
"""Test that deleting the same file twice is idempotent"""
# First delete
response1 = delete_with_body("/file", valid_payload)
assert response1.status_code == 200
# Second delete (file already gone)
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)
# replace_spaces should be called before delete_document
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