File size: 18,633 Bytes
8b9e569
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
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