from datetime import datetime from pve_backup_report.models import ( BackupCoverage, BackupJob, Guest, LastBackupResult, PbsAccessUser, PbsBackupSnapshotSummary, PbsDatastoreUsage, PbsRetentionPolicy, PbsStorage, ReportData, ) from pve_backup_report.report_pdf import coverage_sort_key from pve_backup_report.report_pdf import coverage_row from pve_backup_report.report_pdf import backup_retention_row from pve_backup_report.report_pdf import build_table from pve_backup_report.report_pdf import build_backup_retention_rows from pve_backup_report.report_pdf import build_styles from pve_backup_report.report_pdf import display_retention_delta from pve_backup_report.report_pdf import expected_retention_versions from pve_backup_report.report_pdf import format_duration from pve_backup_report.report_pdf import find_snapshot_summary from pve_backup_report.report_pdf import format_last_backup from pve_backup_report.report_pdf import format_size from pve_backup_report.report_pdf import pbs_datastore_usage_row from pve_backup_report.report_pdf import pbs_access_user_row from pve_backup_report.report_pdf import retention_policy_row from pve_backup_report.report_pdf import add_table_of_contents from pve_backup_report.report_pdf import unique_report_path from pve_backup_report.report_pdf import format_pbs_server def test_unique_report_path_uses_timestamp(tmp_path) -> None: generated_at = datetime(2026, 5, 7, 2, 0, 0) path = unique_report_path(tmp_path, "rapport", generated_at) assert path.name == "rapport-2026-05-07-020000.pdf" def test_unique_report_path_does_not_overwrite(tmp_path) -> None: generated_at = datetime(2026, 5, 7, 2, 0, 0) existing = tmp_path / "rapport-2026-05-07-020000.pdf" existing.write_text("existing", encoding="utf-8") path = unique_report_path(tmp_path, "rapport", generated_at) assert path.name == "rapport-2026-05-07-020000-1.pdf" def test_format_pbs_server_with_hostname_mapping() -> None: assert ( format_pbs_server("192.0.2.10", {"192.0.2.10": "backup-display"}) == "192.0.2.10 (backup-display)" ) def test_format_pbs_server_without_mapping() -> None: assert format_pbs_server("192.0.2.10", {}) == "192.0.2.10" def test_format_duration() -> None: assert format_duration(3723) == "01:02:03" def test_format_size() -> None: assert format_size(1536) == "1.5 Kio" assert format_size(2 * 1024 * 1024 * 1024) == "2.0 Gio" def test_pbs_datastore_usage_row() -> None: usage = PbsDatastoreUsage( server_name="PBS01", datastore="RAID5", total_bytes=10 * 1024 * 1024 * 1024, used_bytes=4 * 1024 * 1024 * 1024, available_bytes=6 * 1024 * 1024 * 1024, ) assert pbs_datastore_usage_row(usage) == [ "PBS01", "RAID5", "10.0 Gio", "4.0 Gio", "6.0 Gio", ] def test_pbs_access_user_row_displays_permissions() -> None: user = PbsAccessUser( server_name="PBS01", auth_id="backup@pbs!pve", user_id="backup@pbs", storage_id="BACKUP-PROD", datastore="RAID5", namespace="serveurs", enabled=True, expire=0, email="admin@example.invalid", permissions={"Datastore.Backup": True, "Datastore.Modify": False}, comment="Compte PVE", ) assert pbs_access_user_row(user) == [ "PBS01", "backup@pbs!pve", "BACKUP-PROD", "RAID5", "serveurs", "oui", "aucune", "admin@example.invalid", "Datastore.Backup", "Compte PVE", ] def test_build_table_centers_table() -> None: table = build_table([["Colonne"], ["Valeur"]], [4]) assert table.hAlign == "CENTER" def test_add_table_of_contents_adds_page_break() -> None: story: list[object] = [] add_table_of_contents(story, build_styles()) assert len(story) == 3 def test_format_last_backup_includes_duration() -> None: result = LastBackupResult( vmid=100, status="succes", finished_at=datetime(2026, 5, 7, 2, 14), duration_seconds=222, ) assert format_last_backup(result) == "Succes - 2026-05-07 02:14 - duree 00:03:42" def test_retention_policy_row_splits_columns() -> None: policy = PbsRetentionPolicy( policy_id="prune-prod", server_name="PBS01", datastore="RAID5", namespace="serveurs-internes", schedule="daily", enabled=True, keep_last=1, keep_hourly=2, keep_daily=14, keep_weekly=8, keep_monthly=3, keep_yearly=1, max_depth=0, ) assert retention_policy_row(policy) == [ "PBS01", "RAID5", "serveurs-internes", "daily", "oui", 1, 2, 14, 8, 3, 1, 0, ] def test_find_snapshot_summary_matches_storage_namespace_and_guest() -> None: guest = Guest(vmid=100, name="srv", guest_type="qemu") item = BackupCoverage( guest=guest, status="sauvegarde_pbs_planifiee", storages=["BACKUP-PRODR5"], ) summary = PbsBackupSnapshotSummary( server_name="PBS01", vmid=100, guest_type="qemu", datastore="RAID5", namespace="serveurs-internes", snapshot_count=13, ) assert ( find_snapshot_summary( item, {"BACKUP-PRODR5": "RAID5"}, {"BACKUP-PRODR5": "serveurs-internes"}, {("PBS01", "RAID5", "serveurs-internes", "qemu", 100): summary}, "PBS01", ) == summary ) def test_find_snapshot_summary_matches_pbs02_without_pve_storage_datastore() -> None: guest = Guest(vmid=100, name="srv", guest_type="qemu") item = BackupCoverage( guest=guest, status="sauvegarde_pbs_planifiee", storages=["BACKUP-PRODR5"], ) summary = PbsBackupSnapshotSummary( server_name="PBS02", vmid=100, guest_type="qemu", datastore="PBS2RAID5", namespace="serveurs-internes", snapshot_count=13, ) assert ( find_snapshot_summary( item, {"BACKUP-PRODR5": "RAID5"}, {"BACKUP-PRODR5": "serveurs-internes"}, {("PBS02", "PBS2RAID5", "serveurs-internes", "qemu", 100): summary}, "PBS02", ) == summary ) def test_coverage_sort_key_uses_namespace_then_schedule_then_vmid() -> None: first = BackupCoverage( guest=Guest(vmid=200, name="b", guest_type="qemu"), status="sauvegarde_pbs_planifiee", jobs=[BackupJob(job_id="backup-b", schedule="22:00")], storages=["STORAGE-B"], ) second = BackupCoverage( guest=Guest(vmid=100, name="a", guest_type="qemu"), status="sauvegarde_pbs_planifiee", jobs=[BackupJob(job_id="backup-a", schedule="21:00")], storages=["STORAGE-A"], ) third = BackupCoverage( guest=Guest(vmid=50, name="c", guest_type="qemu"), status="non_sauvegardee", ) sorted_items = sorted( [first, second, third], key=lambda item: coverage_sort_key( item, { "STORAGE-A": "serveurs-internes", "STORAGE-B": "Serveurs-PVELAB", }, ), ) assert [item.guest.vmid for item in sorted_items] == [200, 100, 50] def test_coverage_row_includes_notes_after_name() -> None: row = coverage_row( BackupCoverage( guest=Guest(vmid=100, name="srv", guest_type="qemu", notes="note applicative"), status="sauvegarde_pbs_planifiee", ), include_storage=True, ) assert row[:3] == [100, "srv", "note applicative"] def test_coverage_row_uses_french_missing_notes_label() -> None: row = coverage_row( BackupCoverage( guest=Guest(vmid=100, name="srv", guest_type="qemu"), status="sauvegarde_pbs_planifiee", ), include_storage=True, ) assert row[2] == "non renseigné" def test_backup_retention_row_uses_snapshot_summary() -> None: summary = PbsBackupSnapshotSummary( server_name="PBS01", vmid=100, guest_type="qemu", datastore="RAID5", namespace="serveurs-internes", snapshot_count=13, oldest_backup_at=datetime(2026, 3, 27, 21, 30), newest_backup_at=datetime(2026, 5, 7, 21, 30), newest_backup_size_bytes=2 * 1024 * 1024 * 1024, ) assert backup_retention_row( summary, Guest(vmid=100, name="srv", guest_type="qemu"), ) == [ 100, "srv", "serveurs-internes", "RAID5", "Active sur PVE", "13", "non renseigne", "non renseigne", "2026-03-27 21:30", "2026-05-07 21:30", "2.0 Gio", ] def test_display_retention_delta_formats_sign() -> None: assert display_retention_delta(13, 12) == "+1" assert display_retention_delta(11, 12) == "-1" assert display_retention_delta(12, 12) == "0" assert display_retention_delta(12, None) == "non renseigne" def test_expected_retention_versions_uses_active_policy_for_snapshot_namespace() -> None: summary = PbsBackupSnapshotSummary( server_name="PBS01", vmid=100, guest_type="qemu", datastore="RAID5", namespace="serveurs-internes", ) report_data = ReportData( pbs_retention_policies=[ PbsRetentionPolicy( policy_id="disabled", server_name="PBS01", datastore="RAID5", namespace="serveurs-internes", enabled=False, keep_daily=99, ), PbsRetentionPolicy( policy_id="active", server_name="PBS01", datastore="RAID5", namespace="serveurs-internes", keep_last=1, keep_daily=7, keep_weekly=4, ), ] ) assert expected_retention_versions(report_data, summary) == 12 def test_build_backup_retention_rows_includes_inactive_guest() -> None: report_data = ReportData( guests=[ Guest(vmid=100, name="srv", guest_type="qemu"), ], pbs_storages=[ PbsStorage(storage_id="backup-storage", namespace="serveurs-internes"), PbsStorage(storage_id="pbs-root", namespace=None), ], pbs_retention_policies=[ PbsRetentionPolicy( policy_id="prune-root", server_name="PBS01", datastore="RAID5", namespace="/", keep_last=5, ), PbsRetentionPolicy( policy_id="prune-serveurs", server_name="PBS01", datastore="RAID5", namespace="serveurs-internes", keep_last=1, keep_daily=7, keep_weekly=4, ), ], pbs_snapshot_summaries={ ("PBS01", "RAID5", "serveurs-internes", "qemu", 100): PbsBackupSnapshotSummary( server_name="PBS01", vmid=100, guest_type="qemu", datastore="RAID5", namespace="serveurs-internes", snapshot_count=13, oldest_backup_at=datetime(2026, 3, 27, 21, 30), newest_backup_at=datetime(2026, 5, 7, 21, 30), newest_backup_size_bytes=10 * 1024 * 1024, ), ("PBS01", "RAID5", "serveurs-internes", "qemu", 200): PbsBackupSnapshotSummary( server_name="PBS01", vmid=200, guest_type="qemu", datastore="RAID5", namespace="serveurs-internes", snapshot_count=4, oldest_backup_at=datetime(2026, 4, 1, 21, 30), newest_backup_at=datetime(2026, 5, 8, 21, 30), newest_backup_size_bytes=3 * 1024 * 1024 * 1024, ), ("PBS01", "RAID5", "hors-pve", "qemu", 300): PbsBackupSnapshotSummary( server_name="PBS01", vmid=300, guest_type="qemu", datastore="RAID5", namespace="hors-pve", snapshot_count=2, oldest_backup_at=datetime(2026, 4, 2, 21, 30), newest_backup_at=datetime(2026, 5, 8, 21, 45), ), ("PBS01", "RAID5", "/", "qemu", 400): PbsBackupSnapshotSummary( server_name="PBS01", vmid=400, guest_type="qemu", datastore="RAID5", namespace="/", snapshot_count=5, oldest_backup_at=datetime(2026, 4, 3, 21, 30), newest_backup_at=datetime(2026, 5, 8, 22, 0), newest_backup_size_bytes=512, raw={"name": "srv-racine"}, ), }, ) rows = build_backup_retention_rows(report_data, "PBS01") assert rows[0] == [ "VMID", "Nom VM/CT", "Namespace", "Datastore", "Etat PVE", "Nombre de versions", "Nombre attendu de versions", "Delta", "Plus ancienne", "Plus recente", "Taille", ] assert rows[1] == [ 400, "srv-racine", "/", "RAID5", "Non-active sur PVE", "5", "5", "0", "2026-04-03 21:30", "2026-05-08 22:00", "512 o", ] assert rows[2] == [ 100, "srv", "serveurs-internes", "RAID5", "Active sur PVE", "13", "12", "+1", "2026-03-27 21:30", "2026-05-07 21:30", "10.0 Mio", ] assert rows[3] == [ 200, "non renseigne", "serveurs-internes", "RAID5", "Non-active sur PVE", "4", "12", "-8", "2026-04-01 21:30", "2026-05-08 21:30", "3.0 Gio", ]