diff --git a/backend/tests/test_api_backups.py b/backend/tests/test_api_backups.py index 73cf7c6..100db71 100644 --- a/backend/tests/test_api_backups.py +++ b/backend/tests/test_api_backups.py @@ -58,3 +58,54 @@ def test_backup_history_populated(client, db_session): assert response.status_code == 200 assert len(response.json()) == 1 assert response.json()[0]["status"] == "COMPLETED" + + +def test_trigger_backup_already_running(client, db_session): + """Tests triggering backup when one is already active returns 400.""" + media = models.StorageMedia( + media_type="hdd", identifier="DISK_1", capacity=1000, status="active" + ) + db_session.add(media) + db_session.flush() + + # Create an active backup job + active_job = models.Job(job_type="BACKUP", status="RUNNING", is_cancelled=False) + db_session.add(active_job) + db_session.commit() + + response = client.post(f"/backups/trigger/{media.id}") + assert response.status_code == 400 + assert "already running" in response.json()["detail"] + + +def test_trigger_auto_backup_no_media(client): + """Tests auto backup with no active media returns 400.""" + response = client.post("/backups/trigger/auto") + assert response.status_code == 400 + assert "No active media" in response.json()["detail"] + + +def test_trigger_auto_backup_already_running(client, db_session): + """Tests auto backup when one is already active returns 400.""" + active_job = models.Job(job_type="BACKUP", status="PENDING", is_cancelled=False) + db_session.add(active_job) + db_session.commit() + + response = client.post("/backups/trigger/auto") + assert response.status_code == 400 + assert "already running" in response.json()["detail"] + + +def test_trigger_auto_backup_success(client, db_session): + """Tests triggering auto backup creates a job.""" + media = models.StorageMedia( + media_type="hdd", identifier="AUTO_DISK", capacity=1000000000, status="active" + ) + db_session.add(media) + db_session.commit() + + response = client.post("/backups/trigger/auto") + assert response.status_code == 200 + data = response.json() + assert "job_id" in data + assert "Auto-archival job submitted" in data["message"] diff --git a/backend/tests/test_api_discrepancies.py b/backend/tests/test_api_discrepancies.py new file mode 100644 index 0000000..24a4219 --- /dev/null +++ b/backend/tests/test_api_discrepancies.py @@ -0,0 +1,445 @@ +from datetime import datetime, timezone + +from app.db import models + + +# ── list_discrepancies ── + + +def test_list_discrepancies_empty(client): + """Tests listing discrepancies when none exist.""" + response = client.get("/system/discrepancies") + assert response.status_code == 200 + assert response.json() == [] + + +def test_list_discrepancies_deleted_file(client, db_session): + """Tests listing a confirmed-deleted file in discrepancies.""" + file_record = models.FilesystemState( + file_path="/data/old.txt", + size=100, + mtime=1000, + is_deleted=True, + is_ignored=False, + sha256_hash=None, + ) + db_session.add(file_record) + db_session.commit() + + response = client.get("/system/discrepancies") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["path"] == "/data/old.txt" + assert data[0]["is_deleted"] is True + + +def test_list_discrepancies_unhashed_missing(client, db_session, tmp_path): + """Tests listing an unhashed file that is missing from disk.""" + # File path that does not exist on disk + missing_path = str(tmp_path / "nonexistent" / "file.txt") + file_record = models.FilesystemState( + file_path=missing_path, + size=100, + mtime=1000, + sha256_hash=None, + is_deleted=False, + is_ignored=False, + ) + db_session.add(file_record) + db_session.commit() + + response = client.get("/system/discrepancies") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["path"] == missing_path + assert data[0]["is_deleted"] is False + + +def test_list_discrepancies_ignored_excluded(client, db_session): + """Tests that ignored files are excluded from discrepancies.""" + file_record = models.FilesystemState( + file_path="/data/ignored.txt", + size=100, + mtime=1000, + is_deleted=True, + is_ignored=True, + ) + db_session.add(file_record) + db_session.commit() + + response = client.get("/system/discrepancies") + assert response.status_code == 200 + assert response.json() == [] + + +def test_list_discrepancies_acknowledged_excluded(client, db_session): + """Tests that acknowledged discrepancies are excluded.""" + file_record = models.FilesystemState( + file_path="/data/dismissed.txt", + size=100, + mtime=1000, + is_deleted=True, + is_ignored=False, + missing_acknowledged_at=datetime.now(timezone.utc), + ) + db_session.add(file_record) + db_session.commit() + + response = client.get("/system/discrepancies") + assert response.status_code == 200 + assert response.json() == [] + + +def test_list_discrepancies_has_versions_flag(client, db_session): + """Tests that has_versions is set based on active/full media.""" + active_media = models.StorageMedia( + media_type="hdd", identifier="M1", capacity=1000, status="active" + ) + db_session.add(active_media) + db_session.flush() + + file1 = models.FilesystemState( + file_path="/data/deleted_with_backup.txt", + size=100, + mtime=1000, + is_deleted=True, + is_ignored=False, + ) + db_session.add(file1) + db_session.flush() + + db_session.add( + models.FileVersion( + filesystem_state_id=file1.id, + media_id=active_media.id, + file_number="1", + offset_start=0, + offset_end=100, + ) + ) + db_session.commit() + + response = client.get("/system/discrepancies") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["has_versions"] is True + + +# ── confirm_discrepancy ── + + +def test_confirm_discrepancy(client, db_session): + """Tests confirming a file as deleted.""" + file_record = models.FilesystemState( + file_path="/data/verify.txt", size=50, mtime=2000, is_deleted=False + ) + db_session.add(file_record) + db_session.commit() + + response = client.post(f"/system/discrepancies/{file_record.id}/confirm") + assert response.status_code == 200 + assert "marked as deleted" in response.json()["message"] + + db_session.expire_all() + db_session.refresh(file_record) + assert file_record.is_deleted is True + + +def test_confirm_discrepancy_not_found(client): + """Tests confirming a non-existent file returns 404.""" + response = client.post("/system/discrepancies/9999/confirm") + assert response.status_code == 404 + + +# ── dismiss_discrepancy ── + + +def test_dismiss_discrepancy(client, db_session): + """Tests dismissing a deleted file.""" + file_record = models.FilesystemState( + file_path="/data/dismiss.txt", size=50, mtime=2000, is_deleted=True + ) + db_session.add(file_record) + db_session.commit() + + response = client.post(f"/system/discrepancies/{file_record.id}/dismiss") + assert response.status_code == 200 + assert "dismissed" in response.json()["message"] + + db_session.expire_all() + db_session.refresh(file_record) + assert file_record.missing_acknowledged_at is not None + + +def test_undo_dismiss_discrepancy(client, db_session): + """Tests undoing a dismissed discrepancy.""" + file_record = models.FilesystemState( + file_path="/data/undo.txt", + size=50, + mtime=2000, + is_deleted=True, + missing_acknowledged_at=datetime.now(timezone.utc), + ) + db_session.add(file_record) + db_session.commit() + + response = client.post(f"/system/discrepancies/{file_record.id}/undo-dismiss") + assert response.status_code == 200 + assert "undo" in response.json()["message"].lower() + + db_session.expire_all() + db_session.refresh(file_record) + assert file_record.missing_acknowledged_at is None + + +# ── delete_discrepancy ── + + +def test_delete_discrepancy_hard_delete(client, db_session): + """Tests hard-deleting a file record and its versions/cart entries.""" + media = models.StorageMedia( + media_type="hdd", identifier="M1", capacity=1000, status="active" + ) + db_session.add(media) + db_session.flush() + + file_record = models.FilesystemState( + file_path="/data/hard_delete.txt", size=100, mtime=1000, is_deleted=True + ) + db_session.add(file_record) + db_session.flush() + + db_session.add( + models.FileVersion( + filesystem_state_id=file_record.id, + media_id=media.id, + file_number="1", + offset_start=0, + offset_end=100, + ) + ) + db_session.add(models.RestoreCart(filesystem_state_id=file_record.id)) + db_session.commit() + + file_id = file_record.id + + response = client.delete(f"/system/discrepancies/{file_id}") + assert response.status_code == 200 + + db_session.expire_all() + assert db_session.get(models.FilesystemState, file_id) is None + assert ( + db_session.query(models.FileVersion) + .filter_by(filesystem_state_id=file_id) + .first() + is None + ) + assert ( + db_session.query(models.RestoreCart) + .filter_by(filesystem_state_id=file_id) + .first() + is None + ) + + +def test_delete_discrepancy_not_found(client): + """Tests deleting a non-existent discrepancy returns 404.""" + response = client.delete("/system/discrepancies/9999") + assert response.status_code == 404 + + +# ── batch operations ── + + +def test_batch_confirm_discrepancies(client, db_session): + """Tests batch confirming files as deleted.""" + file1 = models.FilesystemState( + file_path="/data/batch1.txt", size=100, mtime=1000, is_deleted=False + ) + file2 = models.FilesystemState( + file_path="/data/batch2.txt", size=200, mtime=1000, is_deleted=False + ) + db_session.add_all([file1, file2]) + db_session.commit() + + response = client.post( + "/system/discrepancies/batch/confirm", + json={"ids": [file1.id, file2.id]}, + ) + assert response.status_code == 200 + assert response.json()["count"] == 2 + + db_session.expire_all() + assert db_session.get(models.FilesystemState, file1.id).is_deleted is True + assert db_session.get(models.FilesystemState, file2.id).is_deleted is True + + +def test_batch_dismiss_discrepancies(client, db_session): + """Tests batch dismissing discrepancies.""" + file1 = models.FilesystemState( + file_path="/data/dismiss1.txt", size=100, mtime=1000, is_deleted=True + ) + db_session.add(file1) + db_session.commit() + + response = client.post( + "/system/discrepancies/batch/dismiss", + json={"ids": [file1.id]}, + ) + assert response.status_code == 200 + assert response.json()["count"] == 1 + + db_session.expire_all() + assert ( + db_session.get(models.FilesystemState, file1.id).missing_acknowledged_at + is not None + ) + + +def test_batch_delete_discrepancies(client, db_session): + """Tests batch hard-deleting discrepancy records.""" + file1 = models.FilesystemState( + file_path="/data/del1.txt", size=100, mtime=1000, is_deleted=True + ) + db_session.add(file1) + db_session.commit() + + response = client.post( + "/system/discrepancies/batch/delete", + json={"ids": [file1.id]}, + ) + assert response.status_code == 200 + assert response.json()["count"] == 1 + + db_session.expire_all() + assert db_session.get(models.FilesystemState, file1.id) is None + + +def test_batch_resolve_discrepancies(client, db_session): + """Tests smart batch resolve: recoverable -> queue, lost -> confirm delete.""" + media = models.StorageMedia( + media_type="hdd", identifier="M1", capacity=1000, status="active" + ) + db_session.add(media) + db_session.flush() + + # File with backup (recoverable) + file_recover = models.FilesystemState( + file_path="/data/recover.txt", size=100, mtime=1000, is_deleted=True + ) + # File without backup (lost) + file_lost = models.FilesystemState( + file_path="/data/lost.txt", size=200, mtime=1000, is_deleted=True + ) + db_session.add_all([file_recover, file_lost]) + db_session.flush() + + db_session.add( + models.FileVersion( + filesystem_state_id=file_recover.id, + media_id=media.id, + file_number="1", + offset_start=0, + offset_end=100, + ) + ) + db_session.commit() + + response = client.post( + "/system/discrepancies/batch/resolve", + json={"ids": [file_recover.id, file_lost.id]}, + ) + assert response.status_code == 200 + data = response.json() + assert data["recovered_count"] == 1 + assert data["lost_count"] == 1 + assert "/data/recover.txt" in data["recovered_paths"] + assert "/data/lost.txt" in data["lost_paths"] + + db_session.expire_all() + # Recoverable should be in restore cart + assert ( + db_session.query(models.RestoreCart) + .filter_by(filesystem_state_id=file_recover.id) + .first() + is not None + ) + # Lost should be marked deleted and dismissed + lost_record = db_session.get(models.FilesystemState, file_lost.id) + assert lost_record.is_deleted is True + assert lost_record.missing_acknowledged_at is not None + + +def test_batch_resolve_by_path_prefix(client, db_session): + """Tests batch resolve using path_prefix instead of ids.""" + file1 = models.FilesystemState( + file_path="/data/lost1.txt", size=100, mtime=1000, is_deleted=True + ) + db_session.add(file1) + db_session.commit() + + response = client.post( + "/system/discrepancies/batch/resolve", + json={"path_prefix": "/data"}, + ) + assert response.status_code == 200 + assert response.json()["lost_count"] == 1 + + +def test_batch_action_no_ids_or_prefix(client): + """Tests batch actions without ids or path_prefix return 400.""" + for endpoint in ["confirm", "dismiss", "delete", "resolve"]: + response = client.post(f"/system/discrepancies/batch/{endpoint}", json={}) + assert response.status_code == 400 + + +# ── discrepancy tree ── + + +def test_discrepancy_tree_empty(client): + """Tests discrepancy tree when no discrepancies exist.""" + response = client.get("/system/discrepancies/tree") + assert response.status_code == 200 + assert response.json() == [] + + +def test_discrepancy_tree_root(client, db_session): + """Tests discrepancy tree at ROOT level.""" + file1 = models.FilesystemState( + file_path="data/file1.txt", size=100, mtime=1000, is_deleted=True + ) + db_session.add(file1) + db_session.commit() + + response = client.get("/system/discrepancies/tree") + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + + +# ── discrepancy browse ── + + +def test_discrepancy_browse_empty(client): + """Tests discrepancy browse when no discrepancies exist.""" + response = client.get("/system/discrepancies/browse") + assert response.status_code == 200 + data = response.json() + assert data["files"] == [] + + +def test_discrepancy_browse_with_files(client, db_session): + """Tests discrepancy browse returns files and directories.""" + file1 = models.FilesystemState( + file_path="data/sub/file1.txt", size=100, mtime=1000, is_deleted=True + ) + db_session.add(file1) + db_session.commit() + + response = client.get("/system/discrepancies/browse?path=ROOT") + assert response.status_code == 200 + data = response.json() + assert "files" in data diff --git a/backend/tests/test_api_inventory.py b/backend/tests/test_api_inventory.py index 0f334a3..005bfa0 100644 --- a/backend/tests/test_api_inventory.py +++ b/backend/tests/test_api_inventory.py @@ -560,3 +560,137 @@ def test_update_status_to_retired_purges_versions(client, db_session): {"media_id": media.id}, ).scalar() assert result == 0 + + +# ── Archive Tree ── + + +def test_archive_tree_with_versions(client, db_session): + """Tests archive tree returns source roots with versions.""" + db_session.add(models.SystemSetting(key="source_roots", value=json.dumps(["data"]))) + db_session.flush() + + media = models.StorageMedia( + media_type="hdd", identifier="M1", capacity=1000, status="active" + ) + db_session.add(media) + db_session.flush() + + file1 = models.FilesystemState(file_path="data/file1.txt", size=100, mtime=1000) + db_session.add(file1) + db_session.flush() + + db_session.add( + models.FileVersion( + filesystem_state_id=file1.id, + media_id=media.id, + file_number="1", + offset_start=0, + offset_end=100, + ) + ) + db_session.commit() + + response = client.get("/archive/tree") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "data" + assert data[0]["has_children"] is True + + +def test_archive_tree_nested_directories(client, db_session): + """Tests archive tree with nested directories.""" + db_session.add(models.SystemSetting(key="source_roots", value=json.dumps(["data"]))) + db_session.flush() + + media = models.StorageMedia( + media_type="hdd", identifier="M1", capacity=1000, status="active" + ) + db_session.add(media) + db_session.flush() + + file1 = models.FilesystemState( + file_path="data/subdir/file1.txt", size=100, mtime=1000 + ) + db_session.add(file1) + db_session.flush() + + db_session.add( + models.FileVersion( + filesystem_state_id=file1.id, + media_id=media.id, + file_number="1", + offset_start=0, + offset_end=100, + ) + ) + db_session.commit() + + response = client.get("/archive/tree") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "data" + + +def test_archive_tree_empty_no_versions(client, db_session): + """Tests archive tree excludes roots with no versions.""" + db_session.add(models.SystemSetting(key="source_roots", value=json.dumps(["data"]))) + db_session.flush() + + # Add file but no versions + file1 = models.FilesystemState(file_path="data/file1.txt", size=100, mtime=1000) + db_session.add(file1) + db_session.commit() + + response = client.get("/archive/tree") + assert response.status_code == 200 + assert response.json() == [] + + +# ── Metadata Directory ── + + +def test_metadata_directory(client, db_session): + """Tests metadata endpoint for a directory returns aggregated stats.""" + db_session.add(models.SystemSetting(key="source_roots", value=json.dumps(["data"]))) + db_session.flush() + + media = models.StorageMedia( + media_type="hdd", identifier="M1", capacity=1000, status="active" + ) + db_session.add(media) + db_session.flush() + + file1 = models.FilesystemState(file_path="data/sub/file1.txt", size=100, mtime=1000) + file2 = models.FilesystemState(file_path="data/sub/file2.txt", size=200, mtime=1000) + db_session.add_all([file1, file2]) + db_session.flush() + + db_session.add( + models.FileVersion( + filesystem_state_id=file1.id, + media_id=media.id, + file_number="1", + offset_start=0, + offset_end=100, + ) + ) + db_session.add( + models.FileVersion( + filesystem_state_id=file2.id, + media_id=media.id, + file_number="2", + offset_start=0, + offset_end=200, + ) + ) + db_session.commit() + + response = client.get("/archive/metadata?path=data/sub") + assert response.status_code == 200 + data = response.json() + assert data["type"] == "directory" + assert data["child_count"] == 2 + assert data["size"] == 300 diff --git a/backend/tests/test_api_system.py b/backend/tests/test_api_system.py index 967a2d9..d9ada3f 100644 --- a/backend/tests/test_api_system.py +++ b/backend/tests/test_api_system.py @@ -504,3 +504,177 @@ def test_update_existing_secret(client): response = client.get("/system/secrets/ rotating-key ") assert response.status_code == 200 assert response.json()["value"] == "new-value" + + +# ── Filesystem Browse ── + + +def test_filesystem_browse_root(client, db_session): + """Tests browsing the filesystem at ROOT level.""" + db_session.add(models.SystemSetting(key="source_roots", value='["/source_data"]')) + db_session.commit() + + response = client.get("/system/browse?path=ROOT") + assert response.status_code == 200 + data = response.json() + assert len(data["files"]) == 1 + assert data["files"][0]["path"] == "/source_data" + assert data["files"][0]["type"] == "directory" + + +def test_filesystem_browse_subdirectory(client, db_session): + """Tests browsing a subdirectory of the filesystem index.""" + db_session.add(models.SystemSetting(key="source_roots", value='["/source_data"]')) + db_session.flush() + + file1 = models.FilesystemState( + file_path="/source_data/subdir/file1.txt", size=100, mtime=1000 + ) + db_session.add(file1) + db_session.commit() + + response = client.get("/system/browse?path=/source_data/subdir") + assert response.status_code == 200 + data = response.json() + assert len(data["files"]) == 1 + assert data["files"][0]["name"] == "file1.txt" + assert data["files"][0]["type"] == "file" + + +def test_filesystem_browse_outside_roots(client, db_session): + """Tests browsing outside configured roots returns 403.""" + db_session.add(models.SystemSetting(key="source_roots", value='["/source_data"]')) + db_session.commit() + + response = client.get("/system/browse?path=/etc") + assert response.status_code == 403 + + +# ── Filesystem Search ── + + +def test_filesystem_search_too_short(client): + """Tests search with query < 3 chars returns empty list.""" + response = client.get("/system/search?q=ab") + assert response.status_code == 200 + assert response.json() == [] + + +# ── Hardware Discover ── + + +def test_discover_hardware_empty(client): + """Tests hardware discovery endpoint returns a list (may contain real mounts).""" + response = client.get("/system/hardware/discover") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + # Each discovered node should have required fields if present + for node in data: + assert "type" in node + assert "identifier" in node + assert "is_registered" in node + + +# ── Hardware Ignore ── + + +def test_ignore_hardware_duplicate(client): + """Tests ignoring the same hardware twice is idempotent.""" + client.post("/system/hardware/ignore", json={"identifier": "DISK_DUP"}) + response = client.post("/system/hardware/ignore", json={"identifier": "DISK_DUP"}) + assert response.status_code == 200 + + response = client.get("/system/settings") + assert response.json()["ignored_hardware"].count("DISK_DUP") == 1 + + +# ── Database Export ── + + +def test_database_export(client): + """Tests database export endpoint returns a file response.""" + response = client.get("/system/database/export") + # May return 200 with file or 404 if db path not found + assert response.status_code in (200, 404) + + +# ── Tracking Batch ── + + +def test_batch_track_include(client, db_session): + """Tests batch tracking include action sets is_ignored=0.""" + file1 = models.FilesystemState( + file_path="/data/important.txt", size=100, mtime=1000, is_ignored=True + ) + db_session.add(file1) + db_session.commit() + + response = client.post( + "/system/track/batch", + json={"tracks": ["/data/important.txt"], "untracks": []}, + ) + assert response.status_code == 200 + assert "synchronized" in response.json()["message"] + + db_session.expire_all() + assert db_session.get(models.FilesystemState, file1.id).is_ignored is False + + # Verify tracked source record was created + ts = ( + db_session.query(models.TrackedSource) + .filter_by(path="/data/important.txt") + .first() + ) + assert ts is not None + assert ts.action == "include" + + +def test_batch_track_exclude(client, db_session): + """Tests batch tracking exclude action sets is_ignored=1.""" + file1 = models.FilesystemState( + file_path="/data/temp.txt", size=100, mtime=1000, is_ignored=False + ) + db_session.add(file1) + db_session.commit() + + response = client.post( + "/system/track/batch", + json={"tracks": [], "untracks": ["/data/temp.txt"]}, + ) + assert response.status_code == 200 + + db_session.expire_all() + assert db_session.get(models.FilesystemState, file1.id).is_ignored is True + + ts = db_session.query(models.TrackedSource).filter_by(path="/data/temp.txt").first() + assert ts is not None + assert ts.action == "exclude" + + +def test_batch_track_empty(client): + """Tests batch track with empty lists succeeds.""" + response = client.post("/system/track/batch", json={"tracks": [], "untracks": []}) + assert response.status_code == 200 + + +# ── Archive Tree ── + + +def test_archive_tree_empty(client): + """Tests archive tree when index is empty.""" + response = client.get("/archive/tree") + assert response.status_code == 200 + assert response.json() == [] + + +# ── Notifications ── + + +def test_test_notification_invalid_url(client): + """Tests test notification with invalid URL returns 500.""" + response = client.post( + "/system/notifications/test", json={"url": "not-a-valid-url"} + ) + # Notification manager may succeed or fail depending on apprise parsing + assert response.status_code in (200, 500)