Spaces:
Sleeping
Sleeping
| import json | |
| import tempfile | |
| import unittest | |
| from pathlib import Path | |
| from unittest.mock import patch | |
| import numpy as np | |
| import cv2 | |
| from fastapi.testclient import TestClient | |
| from app import main | |
| from monitoring import feedback as feedback_mod | |
| from monitoring import metrics as metrics_mod | |
| class TestFeedbackRecording(unittest.TestCase): | |
| def setUp(self): | |
| self._tmpdir = tempfile.TemporaryDirectory() | |
| self.tmp_path = Path(self._tmpdir.name) | |
| self.feedback_path = self.tmp_path / "feedback.jsonl" | |
| def tearDown(self): | |
| self._tmpdir.cleanup() | |
| def test_record_feedback_appends_entry(self): | |
| entry = feedback_mod.record_feedback( | |
| "abc.jpg", {"prix": 10000, "volume": 14.28}, | |
| corrected_by="pompiste-7", feedback_path=self.feedback_path, | |
| ) | |
| self.assertEqual(entry["photo_reference"], "abc.jpg") | |
| self.assertFalse(entry["converted"]) | |
| entries = feedback_mod.load_feedback_entries(self.feedback_path) | |
| self.assertEqual(len(entries), 1) | |
| self.assertEqual(entries[0]["corrected_fields"]["prix"], 10000) | |
| def test_record_feedback_rejects_unknown_field(self): | |
| with self.assertRaises(ValueError): | |
| feedback_mod.record_feedback( | |
| "abc.jpg", {"montant_total": 10000}, feedback_path=self.feedback_path, | |
| ) | |
| def test_record_feedback_rejects_empty_corrections(self): | |
| with self.assertRaises(ValueError): | |
| feedback_mod.record_feedback("abc.jpg", {}, feedback_path=self.feedback_path) | |
| def test_load_feedback_entries_missing_file_returns_empty(self): | |
| entries = feedback_mod.load_feedback_entries(self.tmp_path / "nope.jsonl") | |
| self.assertEqual(entries, []) | |
| class TestFeedbackConversion(unittest.TestCase): | |
| def setUp(self): | |
| self._tmpdir = tempfile.TemporaryDirectory() | |
| self.tmp_path = Path(self._tmpdir.name) | |
| self.photos_dir = self.tmp_path / "photos" | |
| self.photos_dir.mkdir() | |
| self.feedback_path = self.tmp_path / "feedback.jsonl" | |
| self.annotations_path = self.tmp_path / "annotations.json" | |
| self.crops_dir = self.tmp_path / "crops" | |
| img = np.full((200, 400, 3), 30, dtype=np.uint8) | |
| cv2.imwrite(str(self.photos_dir / "photo1.jpg"), img) | |
| def tearDown(self): | |
| self._tmpdir.cleanup() | |
| def test_convert_creates_pending_review_annotation(self): | |
| feedback_mod.record_feedback( | |
| "photo1.jpg", {"prix": 10000, "volume": 14.28, "prix_litre": 700}, | |
| corrected_by="pompiste-7", feedback_path=self.feedback_path, | |
| ) | |
| count = feedback_mod.convert_feedback_to_annotations( | |
| photos_dir=self.photos_dir, feedback_path=self.feedback_path, | |
| annotations_path=self.annotations_path, crops_dir=self.crops_dir, | |
| ) | |
| self.assertEqual(count, 1) | |
| with open(self.annotations_path, "r", encoding="utf-8") as f: | |
| annotations = json.load(f) | |
| self.assertIn("photo1.jpg", annotations) | |
| entry = annotations["photo1.jpg"] | |
| self.assertEqual(entry["status"], "pending_review") | |
| self.assertEqual(entry["fields"]["prix"], "10000") | |
| self.assertEqual(entry["fields"]["volume"], "14.28") | |
| entries = feedback_mod.load_feedback_entries(self.feedback_path) | |
| self.assertTrue(entries[0]["converted"]) | |
| def test_convert_is_idempotent_for_already_converted_entries(self): | |
| feedback_mod.record_feedback( | |
| "photo1.jpg", {"prix": 10000}, feedback_path=self.feedback_path, | |
| ) | |
| feedback_mod.convert_feedback_to_annotations( | |
| photos_dir=self.photos_dir, feedback_path=self.feedback_path, | |
| annotations_path=self.annotations_path, crops_dir=self.crops_dir, | |
| ) | |
| second_count = feedback_mod.convert_feedback_to_annotations( | |
| photos_dir=self.photos_dir, feedback_path=self.feedback_path, | |
| annotations_path=self.annotations_path, crops_dir=self.crops_dir, | |
| ) | |
| self.assertEqual(second_count, 0) | |
| def test_convert_skips_missing_photo(self): | |
| feedback_mod.record_feedback( | |
| "ghost.jpg", {"prix": 10000}, feedback_path=self.feedback_path, | |
| ) | |
| count = feedback_mod.convert_feedback_to_annotations( | |
| photos_dir=self.photos_dir, feedback_path=self.feedback_path, | |
| annotations_path=self.annotations_path, crops_dir=self.crops_dir, | |
| ) | |
| self.assertEqual(count, 0) | |
| class TestMetrics(unittest.TestCase): | |
| def setUp(self): | |
| self._tmpdir = tempfile.TemporaryDirectory() | |
| self.log_path = Path(self._tmpdir.name) / "api.log" | |
| def tearDown(self): | |
| self._tmpdir.cleanup() | |
| def _write_log_line(self, payload): | |
| with open(self.log_path, "a", encoding="utf-8") as f: | |
| f.write(f"2026-07-07 10:00:00,000 | INFO | {json.dumps(payload, ensure_ascii=False)}\n") | |
| def test_compute_metrics_on_missing_log_returns_zero(self): | |
| m = metrics_mod.compute_metrics(self.log_path) | |
| self.assertEqual(m["total_requests"], 0) | |
| self.assertIsNone(m["success_rate"]) | |
| def test_compute_metrics_aggregates_success_and_blocking(self): | |
| self._write_log_line({ | |
| "model_version": "v2", | |
| "response": {"success": True, "confidence_score": 0.9, "image_quality": "valid", "message": "ok"}, | |
| }) | |
| self._write_log_line({ | |
| "model_version": "v2", | |
| "response": {"success": False, "confidence_score": 0.2, "image_quality": "blurry", "message": "Photo floue, veuillez reprendre la photo."}, | |
| }) | |
| with open(self.log_path, "a", encoding="utf-8") as f: | |
| f.write("2026-07-07 10:00:01,000 | INFO | Modèle CRNN chargé sur cpu.\n") | |
| m = metrics_mod.compute_metrics(self.log_path) | |
| self.assertEqual(m["total_requests"], 2) | |
| self.assertEqual(m["success_rate"], 0.5) | |
| self.assertAlmostEqual(m["avg_confidence_score"], 0.55) | |
| self.assertEqual(m["blocking_causes"]["Photo floue, veuillez reprendre la photo."], 1) | |
| self.assertEqual(m["image_quality_distribution"]["valid"], 1) | |
| def test_list_failed_requests_returns_only_failures_most_recent_first(self): | |
| self._write_log_line({ | |
| "response": {"success": True, "photo_reference": "ok.jpg", "message": "ok"}, | |
| }) | |
| self._write_log_line({ | |
| "response": {"success": False, "photo_reference": "first-fail.jpg", | |
| "message": "Photo floue, veuillez reprendre la photo.", | |
| "image_quality": "blurry", "confidence_score": 0.1}, | |
| }) | |
| self._write_log_line({ | |
| "response": {"success": False, "photo_reference": "second-fail.jpg", | |
| "message": "Incohérence détectée entre montant, litres et prix.", | |
| "image_quality": "valid", "confidence_score": 0.4}, | |
| }) | |
| failures = metrics_mod.list_failed_requests(self.log_path) | |
| self.assertEqual(len(failures), 2) | |
| self.assertEqual(failures[0]["photo_reference"], "second-fail.jpg") | |
| self.assertEqual(failures[1]["photo_reference"], "first-fail.jpg") | |
| self.assertNotIn("filename", failures[0]) | |
| def test_list_failed_requests_respects_limit(self): | |
| for i in range(5): | |
| self._write_log_line({ | |
| "response": {"success": False, "photo_reference": f"fail{i}.jpg", "message": "x"}, | |
| }) | |
| failures = metrics_mod.list_failed_requests(self.log_path, limit=2) | |
| self.assertEqual(len(failures), 2) | |
| class TestFeedbackAndMetricsEndpoints(unittest.TestCase): | |
| def setUp(self): | |
| self.client = TestClient(main.app) | |
| self._tmpdir = tempfile.TemporaryDirectory() | |
| self.fake_photos_dir = Path(self._tmpdir.name) | |
| (self.fake_photos_dir / "photo.jpg").write_bytes(b"fake") | |
| self._patch_photos_dir = patch.object(main, "PHOTOS_DIR", self.fake_photos_dir) | |
| self._patch_photos_dir.start() | |
| def tearDown(self): | |
| self._patch_photos_dir.stop() | |
| self._tmpdir.cleanup() | |
| def test_feedback_rejects_unknown_photo_reference(self): | |
| resp = self.client.post( | |
| "/feedback", | |
| data={"photo_reference": "does-not-exist.jpg", "corrected_prix": "10000"}, | |
| ) | |
| self.assertEqual(resp.status_code, 404) | |
| def test_feedback_rejects_no_corrections(self): | |
| resp = self.client.post("/feedback", data={"photo_reference": "photo.jpg"}) | |
| self.assertEqual(resp.status_code, 400) | |
| def test_feedback_success_records_entry(self): | |
| fake_entry = {"feedback_id": "fake-id"} | |
| with patch.object(main, "record_feedback", return_value=fake_entry) as mock_record: | |
| resp = self.client.post( | |
| "/feedback", | |
| data={"photo_reference": "photo.jpg", "corrected_prix": "10000", "corrected_by": "pompiste-7"}, | |
| ) | |
| self.assertEqual(resp.status_code, 200) | |
| body = resp.json() | |
| self.assertTrue(body["success"]) | |
| self.assertEqual(body["feedback_id"], "fake-id") | |
| mock_record.assert_called_once() | |
| args, kwargs = mock_record.call_args | |
| self.assertEqual(args[0], "photo.jpg") | |
| self.assertEqual(args[1], {"prix": 10000.0}) | |
| def test_metrics_endpoint_returns_aggregate(self): | |
| fake_metrics = {"total_requests": 0, "success_rate": None, | |
| "avg_confidence_score": None, "blocking_causes": {}, | |
| "image_quality_distribution": {}} | |
| with patch.object(main, "compute_metrics", return_value=fake_metrics): | |
| resp = self.client.get("/metrics") | |
| self.assertEqual(resp.status_code, 200) | |
| self.assertEqual(resp.json(), fake_metrics) | |
| def test_failures_endpoint_returns_list(self): | |
| fake_failures = [{"photo_reference": "photo.jpg", "message": "Photo floue."}] | |
| with patch.object(main, "list_failed_requests", return_value=fake_failures): | |
| resp = self.client.get("/failures") | |
| self.assertEqual(resp.status_code, 200) | |
| self.assertEqual(resp.json(), fake_failures) | |
| def test_get_photo_returns_file(self): | |
| resp = self.client.get("/photos/photo.jpg") | |
| self.assertEqual(resp.status_code, 200) | |
| self.assertEqual(resp.content, b"fake") | |
| def test_get_photo_unknown_reference_returns_404(self): | |
| resp = self.client.get("/photos/does-not-exist.jpg") | |
| self.assertEqual(resp.status_code, 404) | |
| def test_get_photo_rejects_path_traversal(self): | |
| outside_file = Path(self._tmpdir.name).parent / "secret.txt" | |
| outside_file.write_text("should not be servable") | |
| try: | |
| resp = self.client.get("/photos/..%2Fsecret.txt") | |
| self.assertEqual(resp.status_code, 404) | |
| finally: | |
| outside_file.unlink(missing_ok=True) | |
| if __name__ == "__main__": | |
| unittest.main() | |