MorphGuard / tests /test_feedback_loop.py
juanquy's picture
feat(learning): verify active learning feedback loop and async retraining daemon (Story 4.3)
210db8d
Raw History Blame Contribute Delete
3.31 kB
import pytest
import os
import json
from unittest.mock import patch, MagicMock
def test_api_feedback_unauthorized(client):
"""Test feedback endpoint requires session authentication or API key."""
response = client.post('/api/feedback', json={'image_path': 'test.jpg', 'label': 'morph'})
assert response.status_code == 401
def test_api_feedback_missing_payload(client):
"""Test feedback endpoint rejects missing parameters."""
with client.session_transaction() as sess:
sess['logged_in'] = True
sess['role'] = 'admin'
response = client.post('/api/feedback', json={})
assert response.status_code == 400
assert 'Missing image_path or label' in response.get_json()['error']
def test_api_feedback_persistence_and_threshold_trigger(client, tmp_path):
"""Test feedback persists entries and triggers background training when reaching threshold."""
with client.session_transaction() as sess:
sess['logged_in'] = True
sess['role'] = 'admin'
test_feedback_file = str(tmp_path / "test_feedback.json")
# Mock FEEDBACK_FILE in training route
with patch('src.routes.training.log_model_interaction'), \
patch('src.routes.training.Thread') as mock_thread, \
patch('os.path.exists', return_value=False), \
patch('builtins.open', create=True) as mock_open:
# Test single feedback submission
response = client.post(
'/api/feedback',
json={
'image_path': 'static/uploads/sample_morph.jpg',
'label': 'morph',
'prediction': 'real'
}
)
assert response.status_code == 200
json_data = response.get_json()
assert json_data['status'] == 'flagged'
assert json_data['feedback_status'] == 'feedback_received'
assert json_data['count'] == 1
def test_api_feedback_triggers_retrain_at_threshold(client):
"""Test background training thread is spawned when count reaches RETRAIN_FLAG_THRESHOLD (10)."""
with client.session_transaction() as sess:
sess['logged_in'] = True
sess['role'] = 'admin'
# Pre-populate 9 feedbacks
existing_feedbacks = [
{'timestamp': '2026-08-30T00:00:00', 'image_path': f'img_{i}.jpg', 'user_label': 'morph'}
for i in range(9)
]
with patch('src.routes.training.log_model_interaction'), \
patch('os.path.exists', return_value=True), \
patch('builtins.open') as mock_file, \
patch('json.load', return_value=existing_feedbacks), \
patch('json.dump'), \
patch('src.routes.training.Thread') as mock_thread:
mock_instance = MagicMock()
mock_thread.return_value = mock_instance
# 10th feedback submission reaches threshold
response = client.post(
'/api/feedback',
json={
'image_path': 'img_9.jpg',
'label': 'morph'
}
)
assert response.status_code == 200
json_data = response.get_json()
assert json_data['count'] == 10
# Verify background daemon thread was started
mock_thread.assert_called_once()
assert mock_thread.call_args.kwargs['daemon'] is True
mock_instance.start.assert_called_once()