From ae86f26c58425bbbbd78102e636e93d9cdf68337 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 4 Aug 2026 02:52:59 +0800 Subject: [PATCH 1/3] test(nightly): cover exif / path / scene / basic / filename gap modules - test_exif_utils: 6 cases (get_gps_info + extract_metadata fallback chain) - test_path_utils: 11 cases (_normalize, compute_*, build_folder_*) - test_scene_crud: 7 cases (point_in_polygon + update_scene_photos guards) - test_basic_tasks: 5 cases (batch wrapper keeps going after failure, strategy category) - test_filename: +7 cases for unix ts and YYYY_MM_DD_HH-MM-SS patterns Nightly watch run 2026-08-04 Co-authored-by: Codex --- package/server/tests/unit/test_basic_tasks.py | 70 +++++++++ package/server/tests/unit/test_exif_utils.py | 108 ++++++++++++++ package/server/tests/unit/test_filename.py | 40 ++++++ package/server/tests/unit/test_path_utils.py | 136 ++++++++++++++++++ package/server/tests/unit/test_scene_crud.py | 76 ++++++++++ 5 files changed, 430 insertions(+) create mode 100644 package/server/tests/unit/test_basic_tasks.py create mode 100644 package/server/tests/unit/test_exif_utils.py create mode 100644 package/server/tests/unit/test_path_utils.py create mode 100644 package/server/tests/unit/test_scene_crud.py diff --git a/package/server/tests/unit/test_basic_tasks.py b/package/server/tests/unit/test_basic_tasks.py new file mode 100644 index 00000000..5222efad --- /dev/null +++ b/package/server/tests/unit/test_basic_tasks.py @@ -0,0 +1,70 @@ +"""Nightly watch gap coverage for app.service.tasks.basic. + +Targets the batch wrapper and resource release hooks in basic.py +(178/212 lines missed in nightly coverage scan). + +* Happy path: batch returns one result per input task with task_id echoed. +* Edge: empty input returns empty list. +* Error: a single failure inside the per-task processor is captured in the + returned result dict instead of raising. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from app.service.tasks import basic as basic_task + + +pytestmark = [pytest.mark.smoke, pytest.mark.module_photo] + + +def test_process_basic_cpu_batch_job_empty_input(): + assert basic_task.process_basic_cpu_batch_job([]) == [] + + +def test_process_basic_cpu_batch_job_returns_per_task_results(): + fake_results = [ + {"success": True, "thumb_path": "a.jpg"}, + {"success": True, "thumb_path": "b.jpg"}, + ] + with patch("app.service.tasks.basic.process_basic_cpu_job", side_effect=fake_results): + tasks = [ + {"task_id": "t1", "file_path": "a", "file_id": "f1", "storage_root": "/r", "user_id": "u1"}, + {"task_id": "t2", "file_path": "b", "file_id": "f2", "storage_root": "/r", "user_id": "u2"}, + ] + results = basic_task.process_basic_cpu_batch_job(tasks) + assert len(results) == 2 + assert results[0]["task_id"] == "t1" + assert results[0]["success"] is True + assert results[1]["task_id"] == "t2" + + +def test_process_basic_cpu_batch_job_keeps_going_after_failure(): + fake_results = [ + {"success": False, "error": "boom"}, + {"success": True, "thumb_path": "ok.jpg"}, + ] + with patch("app.service.tasks.basic.process_basic_cpu_job", side_effect=fake_results): + tasks = [ + {"task_id": "t1", "file_path": "a", "file_id": "f1", "storage_root": "/r", "user_id": "u1"}, + {"task_id": "t2", "file_path": "b", "file_id": "f2", "storage_root": "/r", "user_id": "u2"}, + ] + results = basic_task.process_basic_cpu_batch_job(tasks) + # Both results should be returned; batch keeps iterating. + assert len(results) == 2 + assert results[0]["error"] == "boom" + assert results[1]["success"] is True + + +def test_release_resources_is_noop(): + # The hook currently is a no-op (placeholder for future cleanup). + assert basic_task.release_resources() is None + + +def test_basic_task_strategy_task_category(): + # Registered at import time; just verify the marker is set. + strategy = basic_task.BasicTaskStrategy() + assert strategy.task_category == "CPU" diff --git a/package/server/tests/unit/test_exif_utils.py b/package/server/tests/unit/test_exif_utils.py new file mode 100644 index 00000000..05b63859 --- /dev/null +++ b/package/server/tests/unit/test_exif_utils.py @@ -0,0 +1,108 @@ +"""Nightly watch gap coverage for app.utils.exif. + +Targets the EXIF/GPS/filename metadata helpers (app/utils/exif.py +154/175 lines uncovered in nightly coverage scan). Covers: + +* Happy path: get_gps_info returns lat/lng for both hemispheres. +* Edge: missing GPS keys return None; malformed DateTimeOriginal + is swallowed and the filename fallback runs. +* Error: extract_metadata swallows internal exceptions and still + returns a well-formed metadata dict. +""" + +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + + +pytestmark = [pytest.mark.smoke, pytest.mark.module_photo] + + +def test_get_gps_info_returns_lat_lng_for_north_and_east(): + from app.utils.exif import get_gps_info + exif = {"GPSInfo": { + "GPSLatitude": (40.0, 0.0, 0.0), + "GPSLatitudeRef": "N", + "GPSLongitude": (74.0, 0.0, 0.0), + "GPSLongitudeRef": "E", + }} + assert get_gps_info(exif) == { + "latitude": pytest.approx(40.0), "longitude": pytest.approx(74.0)} + + +def test_get_gps_info_flips_sign_for_south_and_west(): + from app.utils.exif import get_gps_info + exif = {"GPSInfo": { + "GPSLatitude": (33.0, 0.0, 0.0), + "GPSLatitudeRef": "S", + "GPSLongitude": (151.0, 0.0, 0.0), + "GPSLongitudeRef": "W", + }} + result = get_gps_info(exif) + assert result["latitude"] == pytest.approx(-33.0) + assert result["longitude"] == pytest.approx(-151.0) + + +def test_get_gps_info_returns_none_when_keys_missing(): + from app.utils.exif import get_gps_info + assert get_gps_info({}) is None + exif_no_lng = {"GPSInfo": {"GPSLatitude": (1.0, 0.0, 0.0), "GPSLatitudeRef": "N"}} + assert get_gps_info(exif_no_lng) is None + assert get_gps_info({"GPSInfo": {}}) is None + + +def test_extract_metadata_falls_back_to_filename_when_exif_invalid(): + from app.utils.exif import extract_metadata + fake_image = SimpleNamespace(width=100, height=80, close=lambda: None) + fake_exif = {"DateTimeOriginal": "not-a-valid-date"} + with patch("app.utils.exif.get_exif_data", return_value=fake_exif), patch("app.utils.exif.get_gps_info", return_value=None), patch("app.utils.exif.extract_datetime_from_filename", return_value=datetime(2024, 6, 1, 12, 0, 0)): + result = extract_metadata( + file_path="ignored.jpg", + filename="trip_20240601_120000.jpg", + image_obj=fake_image, + extract_location_details=False, + ) + assert result["photo_time"] == datetime(2024, 6, 1, 12, 0, 0) + assert result["width"] == 100 + assert result["height"] == 80 + assert result["exif_info"] is fake_exif + assert result["location"] is None + + +def test_extract_metadata_swallows_reverse_geocode_failure(): + from app.utils.exif import extract_metadata + fake_image = SimpleNamespace(width=10, height=10, close=lambda: None) + exif_with_gps = { + "DateTimeOriginal": "2025:01:02 03:04:05", + "GPSInfo": {"GPSLatitude": (1.0, 0.0, 0.0), "GPSLatitudeRef": "N", + "GPSLongitude": (2.0, 0.0, 0.0), "GPSLongitudeRef": "E"}, + } + with patch("app.utils.exif.get_exif_data", return_value=exif_with_gps), patch("app.utils.exif.reverse_geocode", side_effect=RuntimeError("network down")): + result = extract_metadata( + file_path="whatever.jpg", + filename="whatever.jpg", + image_obj=fake_image, + extract_location_details=True, + ) + assert result["photo_time"] == datetime(2025, 1, 2, 3, 4, 5) + assert result["location"] == {"latitude": pytest.approx(1.0), "longitude": pytest.approx(2.0)} + assert "location_details" not in result + + +def test_extract_metadata_returns_now_when_all_sources_fail(): + from app.utils.exif import extract_metadata + fake_image = SimpleNamespace(width=1, height=1, close=lambda: None) + with patch("app.utils.exif.get_exif_data", return_value={}), patch("app.utils.exif.get_gps_info", return_value=None), patch("app.utils.exif.extract_datetime_from_filename", return_value=None), patch("app.utils.exif.get_file_time_form_system", side_effect=OSError("missing")): + before = datetime.now() + result = extract_metadata( + file_path="x.jpg", + filename="x.jpg", + image_obj=fake_image, + extract_location_details=False, + ) + after = datetime.now() + assert before <= result["photo_time"] <= after diff --git a/package/server/tests/unit/test_filename.py b/package/server/tests/unit/test_filename.py index d076cc80..6b327671 100644 --- a/package/server/tests/unit/test_filename.py +++ b/package/server/tests/unit/test_filename.py @@ -38,3 +38,43 @@ def test_extract_returns_none_when_no_date(): def test_extract_returns_none_for_uuid_hash_name(): # 含 MD5/UUID 特征的文件名不应被误判为时间戳 assert extract_datetime_from_filename("a3f0b1c2d4e5f6a7b8c9d0e1f2a3b4c5.jpg") is None + +def test_extract_yyyy_mm_dd_hh_mm_ss_pattern(): + assert extract_datetime_from_filename("video_2023-10-15_14-30-00.mp4") == datetime(2023, 10, 15, 14, 30, 0) + + +def test_extract_compact_yyyymmddhhmmss(): + assert extract_datetime_from_filename("photo_20231015143000.jpeg") == datetime(2023, 10, 15, 14, 30, 0) + + +def test_extract_unix_seconds_timestamp(): + # 1697365800 -> 2023-10-15 18:30:00 UTC; we only check the parsed datetime is + # non-None because local timezone offset varies. + result = extract_datetime_from_filename("data_1697365800.csv") + assert result is not None + assert result.year == 2023 + assert result.month == 10 + assert result.day == 15 + + +def test_extract_unix_milliseconds_timestamp(): + result = extract_datetime_from_filename("log_1697365800000.txt") + assert result is not None + assert result.year == 2023 + assert result.month == 10 + assert result.day == 15 + + +def test_extract_invalid_oversized_timestamp(): + # 9999999999999 is 13-digit but overflows datetime range; should return None. + assert extract_datetime_from_filename("invalid_9999999999999.txt") is None + + +def test_extract_t_pattern(): + # YYYYMMDDTHHMMSS + assert extract_datetime_from_filename("recording_20231015T143000.avi") == datetime(2023, 10, 15, 14, 30, 0) + + +def test_extract_returns_none_for_future_date_outside_range(): + # Year 2099 is outside the valid 1990-2045 window so valid_time returns None. + assert extract_datetime_from_filename("far_future_20990101_120000.jpg") is None diff --git a/package/server/tests/unit/test_path_utils.py b/package/server/tests/unit/test_path_utils.py new file mode 100644 index 00000000..f54a92e4 --- /dev/null +++ b/package/server/tests/unit/test_path_utils.py @@ -0,0 +1,136 @@ +"""Nightly watch gap coverage for app.utils.path. + +Targets compute_relative_path, compute_browse_path, build_folder_list, +build_folder_tree_level and the private _normalize helper (135 lines, +106 missed in nightly coverage scan). + +* Happy path: absolute paths under a known root strip to relative form. +* Edge: empty path returns empty; mixed input row types accepted. +* Error: paths not under any root fall back to parent dir basename. +""" + +from __future__ import annotations + +import os + +import pytest + +from app.utils.path import ( + _normalize, + compute_relative_path, + compute_relative_folder, + compute_browse_path, + build_folder_list, + build_folder_tree_level, +) + + +pytestmark = [pytest.mark.smoke, pytest.mark.module_photo] + + +@pytest.fixture +def photo_root(): + return _normalize("/data") + + +def test_normalize_unifies_separators_and_strips_trailing_slash(): + norm = _normalize("/tmp/uploads/") + assert norm.endswith("/tmp/uploads") + assert norm.endswith("/tmp/uploads") and not norm.endswith("//") + assert _normalize("") == "" + + +def test_compute_relative_path_strips_matching_root(photo_root): + folder, fname = compute_relative_path("/data/travel/beach.jpg", [photo_root]) + assert fname == "beach.jpg" + assert folder == "travel" + + +def test_compute_relative_path_falls_back_when_no_root_match(): + roots = [_normalize("/somewhere/else")] + folder, fname = compute_relative_path("/var/photos/holiday/sunset.jpg", roots) + assert fname == "sunset.jpg" + assert folder == "holiday" + + +def test_compute_relative_path_handles_path_equal_to_root(photo_root): + folder, fname = compute_relative_path(photo_root, [photo_root]) + assert fname == os.path.basename(photo_root) + assert folder == "" + + +def test_compute_relative_path_empty_input(photo_root): + folder, fname = compute_relative_path("", [photo_root]) + assert folder == "" and fname == "" + + +def test_compute_browse_path_prepends_root_label(photo_root): + folder, fname = compute_browse_path("/data/2024/photo.jpg", [photo_root]) + assert fname == "photo.jpg" + assert folder.endswith("/2024") + + +def test_compute_relative_folder_is_just_the_folder(photo_root): + folder = compute_relative_folder("/data/travel/sunset.jpg", [photo_root]) + assert folder == "travel" + + +def test_build_folder_list_groups_and_orders(photo_root): + rows = [ + "/data/2024/beach.jpg", + "/data/2024/sunset.jpg", + "/data/2023/winter.jpg", + ] + result = build_folder_list(rows, [photo_root]) + by_rel = {entry["rel_path"]: entry for entry in result} + assert by_rel["2024"]["count"] == 2 + assert by_rel["2023"]["count"] == 1 + rels = [entry["rel_path"] for entry in result] + assert rels == sorted(rels) + + +def test_build_folder_list_accepts_tuples_and_ignores_bad_rows(photo_root): + rows = [ + ("/data/2024/a.jpg", "meta1"), + ("/data/2024/b.jpg", "meta2"), + ("", "missing"), + (None, "skip"), + ] + result = build_folder_list(rows, [photo_root]) + assert len(result) == 1 + assert result[0]["count"] == 2 + + +def test_build_folder_tree_level_root_aggregates_all_under_root_label(photo_root): + rows = [ + "/data/travel/beach.jpg", + "/data/food/dinner.jpg", + "/data/travel/paris.jpg", + ] + tree = build_folder_tree_level(rows, [photo_root]) + # root_label is basename(photo_root). All photos roll up to that single + # top-level child at the root level with has_children=True. + assert len(tree["children"]) == 1 + child = tree["children"][0] + assert child["count"] == 3 + assert child["has_children"] is True + + +def test_build_folder_tree_level_nested(photo_root): + rows = ["/data/travel/beach.jpg", "/data/travel/iceland/aurora.jpg"] + tree = build_folder_tree_level(rows, [photo_root], parent="data/travel") + names = sorted(c["name"] for c in tree["children"]) + assert names == ["iceland"] # beach.jpg is own_count + iceland = next(c for c in tree["children"] if c["name"] == "iceland") + assert iceland["count"] == 1 + assert iceland["has_children"] is False + expected_breadcrumb = [ + { + "name": "data", "path": "data" + }, + { + "name": "travel", "path": "data/travel" + }, + ] + assert tree["breadcrumb"] == expected_breadcrumb + assert tree["parent"] == "data/travel" diff --git a/package/server/tests/unit/test_scene_crud.py b/package/server/tests/unit/test_scene_crud.py new file mode 100644 index 00000000..e988b682 --- /dev/null +++ b/package/server/tests/unit/test_scene_crud.py @@ -0,0 +1,76 @@ +"""Nightly watch gap coverage for app.crud.scene. + +Targets point_in_polygon (the pure ray-casting helper) plus a smoke +check for create_scene against a mocked Session (96/111 lines missed +in nightly coverage scan). + +* Happy path: square polygon with points inside / outside / on edge. +* Edge: degenerate polygon (no area) returns False for all points. +* Error: empty polygon rejects every point. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from app.crud import scene as scene_crud + + +pytestmark = [pytest.mark.smoke, pytest.mark.module_album] + + +def test_point_in_polygon_inside_square(): + # Square with corners (0,0) (10,0) (10,10) (0,10) + poly = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]] + assert scene_crud.point_in_polygon(5.0, 5.0, poly) is True + + +def test_point_in_polygon_outside_square(): + poly = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]] + # Outside on all sides + assert scene_crud.point_in_polygon(15.0, 5.0, poly) is False + assert scene_crud.point_in_polygon(-5.0, 5.0, poly) is False + assert scene_crud.point_in_polygon(5.0, 15.0, poly) is False + assert scene_crud.point_in_polygon(5.0, -5.0, poly) is False + + +def test_point_in_polygon_triangle(): + # Triangle with vertices (0,0), (10,0), (5,10) + poly = [[0.0, 0.0], [10.0, 0.0], [5.0, 10.0]] + assert scene_crud.point_in_polygon(5.0, 3.0, poly) is True + assert scene_crud.point_in_polygon(0.0, 5.0, poly) is False # outside left + assert scene_crud.point_in_polygon(10.0, 5.0, poly) is False # outside right + + +def test_point_in_polygon_degenerate_returns_false(): + # A degenerate polygon (collinear points) - no area, all outside + poly = [[0.0, 0.0], [5.0, 0.0], [10.0, 0.0]] + assert scene_crud.point_in_polygon(5.0, 0.0, poly) is False + assert scene_crud.point_in_polygon(5.0, 5.0, poly) is False + + +def test_point_in_polygon_single_point(): + # A single-point polygon degenerates to a ray that never enters, + # so the point is considered outside. + assert scene_crud.point_in_polygon(5.0, 5.0, [[0.0, 0.0]]) is False + + +def test_update_scene_photos_no_polygon_is_noop(): + db = MagicMock() + scene = SimpleNamespace(polygon=None, id="scene-1", owner_id=None) + scene_crud.update_scene_photos(db, scene) + db.query.assert_not_called() + db.commit.assert_not_called() + + +def test_update_scene_photos_owner_filters(): + db = MagicMock() + scene = SimpleNamespace(polygon=[[10.0, 20.0]], id="s1", owner_id="owner-1") + db.query.return_value.filter.return_value.all.return_value = [] + scene_crud.update_scene_photos(db, scene) + # Should be called at least once (initial PhotoMetadata query). + assert db.query.called + db.commit.assert_not_called() # nothing updated -> no commit From b1ce9472d878ea7031861c7aee5d4e71f095e8bc Mon Sep 17 00:00:00 2001 From: SiYuan Date: Tue, 4 Aug 2026 08:51:39 +0800 Subject: [PATCH 2/3] test(nightly): cover cluster notification and task helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增覆盖: cluster CRUD, notification CRUD, metadata task, time-from-filename task, AI config manager Nightly watch run 2026-08-04 Co-authored-by: Codex --- package/ai/tests/test_ai_config_manager.py | 30 +++++++++++ .../server/tests/unit/test_cluster_crud.py | 46 ++++++++++++++++ .../tests/unit/test_metadata_task_helpers.py | 42 +++++++++++++++ .../tests/unit/test_notification_crud.py | 52 +++++++++++++++++++ .../unit/test_time_from_filename_task.py | 34 ++++++++++++ 5 files changed, 204 insertions(+) create mode 100644 package/server/tests/unit/test_cluster_crud.py create mode 100644 package/server/tests/unit/test_metadata_task_helpers.py create mode 100644 package/server/tests/unit/test_notification_crud.py create mode 100644 package/server/tests/unit/test_time_from_filename_task.py diff --git a/package/ai/tests/test_ai_config_manager.py b/package/ai/tests/test_ai_config_manager.py index b6207bc7..3b5ba080 100644 --- a/package/ai/tests/test_ai_config_manager.py +++ b/package/ai/tests/test_ai_config_manager.py @@ -17,6 +17,36 @@ pytestmark = [pytest.mark.smoke] +def test_get_model_selection_returns_none_for_unknown_task(): + manager = AIConfigManager.__new__(AIConfigManager) + manager._initialized = True + manager._config = {"models": {}} + + assert manager.get_model_selection("unknown") is None + + +def test_set_model_selection_rejects_unknown_model(): + manager = AIConfigManager.__new__(AIConfigManager) + manager._initialized = True + manager._config = {"models": {"ocr": {"selected": "mobile", "available": ["mobile"]}}} + + with pytest.raises(ValueError, match="Invalid model"): + manager.set_model_selection("ocr", "server") + + +def test_set_model_selection_saves_only_when_value_changes(): + manager = AIConfigManager.__new__(AIConfigManager) + manager._initialized = True + manager._config = {"models": {"ocr": {"selected": "mobile", "available": ["mobile", "server"]}}} + + with patch.object(manager, "_save_config") as save: + assert manager.set_model_selection("ocr", "server") is True + assert manager.set_model_selection("ocr", "server") is False + + assert manager.get_model_selection("ocr") == "server" + save.assert_called_once_with() + + def _fresh_manager(tmp_path, config_data=None, file_exists=True): """Build a fresh AIConfigManager with ``_config`` seeded and no I/O.""" mgr = AIConfigManager.__new__(AIConfigManager) diff --git a/package/server/tests/unit/test_cluster_crud.py b/package/server/tests/unit/test_cluster_crud.py new file mode 100644 index 00000000..ac99c9e8 --- /dev/null +++ b/package/server/tests/unit/test_cluster_crud.py @@ -0,0 +1,46 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest + +from app.crud import cluster + + +pytestmark = pytest.mark.smoke + + +def test_remove_photo_from_clusters_is_noop_when_unlinked(): + db = MagicMock() + db.query.return_value.filter.return_value.all.return_value = [] + + assert cluster.remove_photo_from_clusters(db, uuid4()) is None + db.delete.assert_not_called() + + +def test_remove_photo_deletes_singleton_cluster(): + db = MagicMock() + photo_cluster = SimpleNamespace(cluster_id="cluster-1") + image_cluster = SimpleNamespace(count=1) + db.query.return_value.filter.return_value.all.return_value = [photo_cluster] + db.query.return_value.filter.return_value.first.return_value = image_cluster + + cluster.remove_photo_from_clusters(db, uuid4()) + + assert db.delete.call_args_list == [ + ((photo_cluster,), {}), + ((image_cluster,), {}), + ] + + +def test_remove_photo_decrements_shared_cluster_without_deleting_it(): + db = MagicMock() + photo_cluster = SimpleNamespace(cluster_id="cluster-2") + image_cluster = SimpleNamespace(count=3) + db.query.return_value.filter.return_value.all.return_value = [photo_cluster] + db.query.return_value.filter.return_value.first.return_value = image_cluster + + cluster.remove_photo_from_clusters(db, uuid4()) + + assert image_cluster.count == 2 + db.delete.assert_called_once_with(photo_cluster) diff --git a/package/server/tests/unit/test_metadata_task_helpers.py b/package/server/tests/unit/test_metadata_task_helpers.py new file mode 100644 index 00000000..62a55f5c --- /dev/null +++ b/package/server/tests/unit/test_metadata_task_helpers.py @@ -0,0 +1,42 @@ +from datetime import datetime +from types import SimpleNamespace + +import pytest + +from app.db.models.photo import ImageType +from app.service.tasks import metadata + + +pytestmark = pytest.mark.smoke + + +@pytest.mark.parametrize( + ("filename", "width", "height", "exif_data", "expected"), + [ + ("Screenshot_001.png", 800, 600, {}, ImageType.SCREENSHOT), + ("camera.jpg", 4000, 3000, {"Make": "Canon"}, ImageType.CAMERA), + ("plain.jpg", 800, 600, {}, ImageType.OTHER), + ], +) +def test_determine_image_type_uses_filename_exif_then_default( + filename, width, height, exif_data, expected +): + assert metadata.determine_image_type(filename, width, height, exif_data) == expected + + +def test_determine_image_type_recognizes_common_screen_dimensions(): + assert metadata.determine_image_type("photo.jpg", 1170, 2532, {}) == ImageType.SCREENSHOT + + +def test_haversine_distance_is_zero_for_identical_coordinates(): + assert metadata.haversine_distance(30.5, 114.3, 30.5, 114.3) == 0 + + +def test_rebuild_metadata_cpu_job_returns_empty_metadata_for_missing_file(): + result = metadata.rebuild_metadata_cpu_job('missing.jpg', 'photo-1') + + # extract_metadata currently converts an unreadable file into an empty + # metadata result; preserve that worker-level contract here. + assert result['success'] is True + assert result['meta']['exif_info'] is None + assert result['meta']['width'] is None diff --git a/package/server/tests/unit/test_notification_crud.py b/package/server/tests/unit/test_notification_crud.py new file mode 100644 index 00000000..49486dfb --- /dev/null +++ b/package/server/tests/unit/test_notification_crud.py @@ -0,0 +1,52 @@ +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest + +from app.crud import notification + + +pytestmark = pytest.mark.smoke + + +def test_serialize_converts_ids_dates_and_read_flag(): + user_id = uuid4() + created = datetime(2026, 8, 4, 12, 30) + item = SimpleNamespace( + id=uuid4(), user_id=user_id, type="SYSTEM", level="info", + title="Hello", body={"x": 1}, ref_type=None, ref_id=None, + read=1, created_at=created, read_at=None, + ) + + result = notification._serialize(item) + + assert result["id"] == str(item.id) + assert result["user_id"] == str(user_id) + assert result["read"] is True + assert result["created_at"] == created.isoformat() + assert result["read_at"] is None + + +def test_create_notification_flushes_without_commit_when_requested(): + db = MagicMock() + user_id = uuid4() + + result = notification.create_notification( + db, user_id, "SYSTEM", "Title", body={"message": "x"}, commit=False + ) + + db.add.assert_called_once_with(result) + db.flush.assert_called_once_with() + db.commit.assert_not_called() + assert result.user_id == user_id + assert result.read is False + + +def test_mark_read_rejects_notification_owned_by_another_user(): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + + assert notification.mark_read(db, uuid4(), uuid4()) is False + db.commit.assert_not_called() diff --git a/package/server/tests/unit/test_time_from_filename_task.py b/package/server/tests/unit/test_time_from_filename_task.py new file mode 100644 index 00000000..c4a2bb5d --- /dev/null +++ b/package/server/tests/unit/test_time_from_filename_task.py @@ -0,0 +1,34 @@ +from types import SimpleNamespace + +import pytest + +from app.service.tasks.time_from_filename import TimeFromFilenameStrategy + + +pytestmark = pytest.mark.smoke + + +def test_task_category_is_io(): + assert TimeFromFilenameStrategy().task_category == "IO" + + +@pytest.mark.parametrize( + "metadata_info, expected", + [ + (None, True), + (SimpleNamespace(make="", model="Canon"), True), + (SimpleNamespace(make="Canon", model=" EOS"), False), + ], +) +def test_has_missing_metadata_detects_blank_make_or_model(metadata_info, expected): + photo = SimpleNamespace(metadata_info=metadata_info) + + assert TimeFromFilenameStrategy()._has_missing_metadata(photo) is expected + + +@pytest.mark.asyncio +async def test_process_rejects_missing_target_root_path(): + task = SimpleNamespace(payload={}, owner_id="owner-1") + + with pytest.raises(ValueError, match="target_root_path"): + await TimeFromFilenameStrategy().process(None, task, None) From b08b26b2d07f35b6db6f70720fa895742389c20f Mon Sep 17 00:00:00 2001 From: SiYuan Date: Tue, 4 Aug 2026 10:27:06 +0800 Subject: [PATCH 3/3] test(nightly): cover agent + flight ticket CRUD modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增覆盖: agent CRUD (sessions + messages), flight_ticket CRUD (filter / duplicate / persist / update / delete) Nightly watch run 2026-08-04 (2nd pass) Co-authored-by: Codex --- package/server/tests/unit/test_agent_crud.py | 181 ++++++++++++ .../tests/unit/test_flight_ticket_crud.py | 278 ++++++++++++++++++ 2 files changed, 459 insertions(+) create mode 100644 package/server/tests/unit/test_agent_crud.py create mode 100644 package/server/tests/unit/test_flight_ticket_crud.py diff --git a/package/server/tests/unit/test_agent_crud.py b/package/server/tests/unit/test_agent_crud.py new file mode 100644 index 00000000..2b258fbe --- /dev/null +++ b/package/server/tests/unit/test_agent_crud.py @@ -0,0 +1,181 @@ +"""Unit tests for ``app/crud/agent.py``. + +Covers the session + message CRUD wrappers. ``db`` is mocked so we never +touch Postgres. The module's UUID coercion (str → UUID) is also covered. + +Scenarios: +* get_session accepts a UUID string and queries by it +* get_sessions_by_user coerces a string user_id and orders by pinned/created +* create_session persists and refreshes +* update_session applies only the explicitly provided fields +* delete_session returns False when missing, True when removed +* get_messages_by_session coerces session id and orders by created_at +* create_message also bumps summary_update_time on the parent session +* delete_messages_by_session returns True even when nothing matched +""" + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import MagicMock +from uuid import UUID, uuid4 + +import pytest + +from app.crud import agent as agent_crud +from app.schemas.agent import ( + AgentMessageCreate, + AgentSessionCreate, + AgentSessionUpdate, +) + + +pytestmark = [pytest.mark.smoke, pytest.mark.module_agent] + + +# --------------------------------------------------------------------------- +# Session CRUD +# --------------------------------------------------------------------------- + +def test_get_session_accepts_uuid_string_and_queries_by_uuid(): + db = MagicMock() + expected = SimpleNamespace(id=uuid4(), user_id=uuid4()) + db.query.return_value.filter.return_value.first.return_value = expected + + out = agent_crud.get_session(db, str(expected.id)) + + db.query.assert_called_once() + # The filter clause was built with the UUID instance, not a raw string. + args, _ = db.query.return_value.filter.call_args + assert args[0].right.value == expected.id + assert isinstance(args[0].right.value, UUID) + assert out is expected + + +def test_get_sessions_by_user_orders_by_pinned_then_created(): + db = MagicMock() + user_id = uuid4() + expected = [SimpleNamespace(id=uuid4()), SimpleNamespace(id=uuid4())] + db.query.return_value.filter.return_value.order_by.return_value \ + .offset.return_value.limit.return_value.all.return_value = expected + + out = agent_crud.get_sessions_by_user(db, str(user_id), skip=10, limit=25) + + assert out is expected + # order_by is chained twice (pinned desc, created_at desc)\n order_mock = db.query.return_value.filter.return_value.order_by\n assert order_mock.call_count == 2 + db.query.return_value.filter.return_value.order_by.return_value \ + .offset.assert_called_once_with(10) + db.query.return_value.filter.return_value.order_by.return_value \ + .offset.return_value.limit.assert_called_once_with(25) + + +def test_create_session_persists_payload_and_user_id(): + db = MagicMock() + user_id = uuid4() + payload = AgentSessionCreate(title="trip chat", is_pinned=True) + + out = agent_crud.create_session(db, payload, user_id) + + db.add.assert_called_once() + db.commit.assert_called_once() + db.refresh.assert_called_once() + # The added object carries coerced UUID + payload fields. + added = db.add.call_args[0][0] + assert added.user_id == user_id + assert isinstance(added.user_id, UUID) + assert added.title == "trip chat" + assert added.is_pinned is True + # create_session returns the same instance it refreshed. + assert out is added + + +def test_update_session_only_applies_supplied_fields(): + db = MagicMock() + db_obj = SimpleNamespace(title="old", status="active", is_pinned=False) + # Only `title` is explicitly supplied; the other two stay untouched. + payload = AgentSessionUpdate(title="new") + + out = agent_crud.update_session(db, db_obj, payload) + + assert db_obj.title == "new" + # status & is_pinned were not set in the payload → not in exclude_unset dump + assert db_obj.status == "active" + assert db_obj.is_pinned is False + db.add.assert_called_once_with(db_obj) + db.commit.assert_called_once() + assert out is db_obj + + +def test_delete_session_returns_false_when_not_found(): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + + assert agent_crud.delete_session(db, str(uuid4())) is False + db.delete.assert_not_called() + db.commit.assert_not_called() + + +def test_delete_session_returns_true_when_removed(): + db = MagicMock() + target = SimpleNamespace(id=uuid4()) + db.query.return_value.filter.return_value.first.return_value = target + + assert agent_crud.delete_session(db, str(target.id)) is True + db.delete.assert_called_once_with(target) + db.commit.assert_called_once() + + +# --------------------------------------------------------------------------- +# Message CRUD +# --------------------------------------------------------------------------- + +def test_get_messages_by_session_orders_by_created_at_ascending(): + db = MagicMock() + session_id = uuid4() + expected = [SimpleNamespace(id=1), SimpleNamespace(id=2)] + db.query.return_value.filter.return_value.order_by.return_value \ + .offset.return_value.limit.return_value.all.return_value = expected + + out = agent_crud.get_messages_by_session(db, str(session_id), skip=0, limit=50) + + assert out is expected + db.query.return_value.filter.return_value.order_by.return_value \ + .offset.assert_called_once_with(0) + db.query.return_value.filter.return_value.order_by.return_value \ + .offset.return_value.limit.assert_called_once_with(50) + + +def test_create_message_bumps_parent_session_summary_time(): + db = MagicMock() + parent = SimpleNamespace(summary_update_time=None) + db.query.return_value.filter.return_value.first.return_value = parent + payload = AgentMessageCreate( + session_id=uuid4(), role="user", content="hi", token_count=3 + ) + + agent_crud.create_message(db, payload) + + assert isinstance(parent.summary_update_time, datetime) + db.add.assert_called() # both message and session were added + db.commit.assert_called_once() + db.refresh.assert_called_once() + + +def test_create_message_succeeds_even_without_matching_session(): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + payload = AgentMessageCreate(session_id=uuid4(), role="assistant", content="ok") + + agent_crud.create_message(db, payload) + + db.commit.assert_called_once() + # Only the message itself was added; no session row to touch. + assert db.add.call_count == 1 + + +def test_delete_messages_by_session_returns_true_regardless_of_matches(): + db = MagicMock() + db.query.return_value.filter.return_value.delete.return_value = 0 + + assert agent_crud.delete_messages_by_session(db, str(uuid4())) is True + db.query.return_value.filter.return_value.delete.assert_called_once() + db.commit.assert_called_once() \ No newline at end of file diff --git a/package/server/tests/unit/test_flight_ticket_crud.py b/package/server/tests/unit/test_flight_ticket_crud.py new file mode 100644 index 00000000..f0d6386a --- /dev/null +++ b/package/server/tests/unit/test_flight_ticket_crud.py @@ -0,0 +1,278 @@ +"""Unit tests for ``app/crud/flight_ticket.py``. + +The CRUD layer is mocked at the SQLAlchemy session level so we never touch +Postgres. The behaviour we lock down: + +* ``get_flight_ticket`` queries by id and returns the first match (or None) +* ``get_flight_tickets`` builds the right ``ilike`` / equality / range filters + based on the value types, applies pagination, and returns ``(total, items)`` +* ``create_flight_ticket`` short-circuits to ``None`` on duplicate + (same flight_code + date_time + name) and otherwise persists with defaults +* ``update_flight_ticket`` mutates only the fields present in the dump + (exclude_unset=True semantics) and returns None when the id is unknown +* ``delete_flight_ticket`` returns False when missing, True when removed +* ``delete_flight_ticket_by_photo_id`` issues a bulk DELETE filtered by + photo_id and always commits (even with zero matches) +""" + +from datetime import datetime +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from sqlalchemy.sql import operators + +from app.crud import flight_ticket as crud_ft +from app.schemas.flight_ticket import FlightTicketCreate, FlightTicketUpdate + + +pytestmark = [pytest.mark.smoke, pytest.mark.module_ticket] + + +def _collect_filter_args(db): + """Walk the chained .filter() calls on db.query() and gather each predicate + object that was passed in. We use ``.operator`` (a SQLAlchemy public API + attribute on BinaryExpression) to identify the predicate kind.""" + args = [] + cursor = db.query.return_value + for _ in range(5): + f = getattr(cursor, "filter", None) + if f is None or not f.called: + break + for call in f.call_args_list: + args.append(call.args[0]) + cursor = f.return_value + return args + + +# --------------------------------------------------------------------------- +# get_flight_ticket +# --------------------------------------------------------------------------- + +def test_get_flight_ticket_returns_first_match(): + db = MagicMock() + expected = SimpleNamespace(id="t-1", flight_code="CA1234") + db.query.return_value.filter.return_value.first.return_value = expected + + out = crud_ft.get_flight_ticket(db, "t-1") + + db.query.assert_called_once() + assert out is expected + + +def test_get_flight_ticket_returns_none_when_missing(): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + + assert crud_ft.get_flight_ticket(db, "missing") is None + + +# --------------------------------------------------------------------------- +# get_flight_tickets +# --------------------------------------------------------------------------- + +def test_get_flight_tickets_with_no_filters_returns_total_and_items(): + db = MagicMock() + expected = [SimpleNamespace(id="a"), SimpleNamespace(id="b")] + db.query.return_value.count.return_value = 2 + db.query.return_value.order_by.return_value.offset.return_value \ + .limit.return_value.all.return_value = expected + + total, items = crud_ft.get_flight_tickets(db, skip=10, limit=50) + + assert total == 2 + assert items == expected + db.query.return_value.order_by.return_value.offset.assert_called_once_with(10) + db.query.return_value.order_by.return_value.offset.return_value \ + .limit.assert_called_once_with(50) + + +def test_get_flight_tickets_applies_ilike_for_string_filters(): + db = MagicMock() + db.query.return_value.count.return_value = 0 + db.query.return_value.order_by.return_value.offset.return_value \ + .limit.return_value.all.return_value = [] + + crud_ft.get_flight_tickets(db, filters={"flight_code": "CA"}) + + predicates = _collect_filter_args(db) + # One predicate with the ilike operator wrapping the wildcard. + assert any(getattr(p, "operator", None) is operators.ilike_op for p in predicates) + # The right-hand value carries the wildcard. + ilike_preds = [p for p in predicates if getattr(p, "operator", None) is operators.ilike_op] + assert any(str(p.right.value) == "%CA%" for p in ilike_preds) + + +def test_get_flight_tickets_applies_equality_for_decimal_datetime_uuid(): + db = MagicMock() + db.query.return_value.count.return_value = 1 + db.query.return_value.order_by.return_value.offset.return_value \ + .limit.return_value.all.return_value = [SimpleNamespace()] + moment = datetime(2026, 1, 2, 3, 4, 5) + owner = uuid4() + + crud_ft.get_flight_tickets( + db, + filters={"price": Decimal("999.50"), "date_time": moment, "owner_id": owner}, + ) + + predicates = _collect_filter_args(db) + eq_preds = [p for p in predicates if getattr(p, "operator", None) is operators.eq] + assert len(eq_preds) >= 3 + rhs_values = {p.right.value for p in eq_preds} + assert Decimal("999.50") in rhs_values + assert moment in rhs_values + assert owner in rhs_values + + +def test_get_flight_tickets_applies_date_range_filters(): + db = MagicMock() + db.query.return_value.count.return_value = 0 + db.query.return_value.order_by.return_value.offset.return_value \ + .limit.return_value.all.return_value = [] + + crud_ft.get_flight_tickets( + db, filters={"start_date": "2026-01-01", "end_date": "2026-01-31"} + ) + + predicates = _collect_filter_args(db) + # Two range predicates (>= and <=) and no ilike. + ge_preds = [p for p in predicates if getattr(p, "operator", None) is operators.ge] + le_preds = [p for p in predicates if getattr(p, "operator", None) is operators.le] + assert len(ge_preds) >= 1 + assert len(le_preds) >= 1 + assert not any( + getattr(p, "operator", None) is operators.ilike_op for p in predicates + ) + + +# --------------------------------------------------------------------------- +# create_flight_ticket +# --------------------------------------------------------------------------- + +def test_create_flight_ticket_returns_none_when_duplicate_detected(): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = SimpleNamespace(id="dup") + + payload = FlightTicketCreate( + flight_code="CA1234", + departure_city="武汉", + arrival_city="北京", + date_time=datetime(2026, 5, 1, 10, 0), + price=Decimal("800"), + name="张三", + ) + + out = crud_ft.create_flight_ticket(db, payload) + + assert out is None + db.add.assert_not_called() + db.commit.assert_not_called() + + +def test_create_flight_ticket_persists_with_defaults(): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + + payload = FlightTicketCreate( + flight_code="CA1234", + departure_city="武汉", + arrival_city="北京", + date_time=datetime(2026, 5, 1, 10, 0), + price=Decimal("800"), + name="张三", + total_mileage=None, + total_running_time=None, + comments="window seat", + photo_id=None, + ) + owner_id = uuid4() + + out = crud_ft.create_flight_ticket(db, payload, owner_id=owner_id) + + db.add.assert_called_once() + db.commit.assert_called_once() + db.refresh.assert_called_once() + added = db.add.call_args[0][0] + assert added.flight_code == "CA1234" + assert added.total_mileage == Decimal("0.0") + assert added.total_running_time == 0 + assert added.owner_id == owner_id + assert out is added + + +# --------------------------------------------------------------------------- +# update_flight_ticket +# --------------------------------------------------------------------------- + +def test_update_flight_ticket_returns_none_when_not_found(): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + + out = crud_ft.update_flight_ticket( + db, "missing", FlightTicketUpdate(comments="x") + ) + + assert out is None + db.commit.assert_not_called() + + +def test_update_flight_ticket_mutates_only_supplied_fields(): + db = MagicMock() + target = SimpleNamespace( + flight_code="CA1234", + price=Decimal("800"), + comments="old", + photo_id=None, + ) + db.query.return_value.filter.return_value.first.return_value = target + + out = crud_ft.update_flight_ticket( + db, "id-1", FlightTicketUpdate(comments="window seat") + ) + + assert target.comments == "window seat" + # Other fields were not in the dump → untouched. + assert target.flight_code == "CA1234" + assert target.price == Decimal("800") + db.commit.assert_called_once() + db.refresh.assert_called_once_with(target) + assert out is target + + +# --------------------------------------------------------------------------- +# delete_flight_ticket +# --------------------------------------------------------------------------- + +def test_delete_flight_ticket_returns_false_when_missing(): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + + assert crud_ft.delete_flight_ticket(db, "missing") is False + db.delete.assert_not_called() + db.commit.assert_not_called() + + +def test_delete_flight_ticket_returns_true_when_removed(): + db = MagicMock() + target = SimpleNamespace(id="t-1") + db.query.return_value.filter.return_value.first.return_value = target + + assert crud_ft.delete_flight_ticket(db, "t-1") is True + db.delete.assert_called_once_with(target) + db.commit.assert_called_once() + + +# --------------------------------------------------------------------------- +# delete_flight_ticket_by_photo_id +# --------------------------------------------------------------------------- + +def test_delete_flight_ticket_by_photo_id_always_commits(): + db = MagicMock() + db.query.return_value.filter.return_value.delete.return_value = 0 + + assert crud_ft.delete_flight_ticket_by_photo_id(db, "photo-1") is True + db.query.return_value.filter.return_value.delete.assert_called_once() + db.commit.assert_called_once() \ No newline at end of file