from __future__ import annotations from datetime import datetime, timezone from io import StringIO from pathlib import Path from tempfile import TemporaryDirectory from django.core.management import call_command from django.test import TestCase from pobsync.util import write_yaml_atomic from pobsync_backend.models import GlobalConfig, HostConfig, SnapshotRecord from pobsync_backend.snapshot_discovery import ( discover_snapshots, inspect_snapshot_discovery, parse_snapshot_datetime, resolve_base_links, upsert_snapshot_record, ) class SnapshotDiscoveryTests(TestCase): def test_parse_snapshot_datetime_prefers_metadata(self) -> None: parsed = parse_snapshot_datetime( "20260519-021500Z__ABCDEFGH", {"started_at": "2026-05-20T03:16:00Z"}, "started_at", ) self.assertEqual(parsed, datetime(2026, 5, 20, 3, 16, tzinfo=timezone.utc)) def test_parse_snapshot_datetime_falls_back_to_dirname(self) -> None: parsed = parse_snapshot_datetime("20260519-021500Z__ABCDEFGH", {}, "started_at") self.assertEqual(parsed, datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc)) def test_discovery_upserts_snapshot_records_idempotently(self) -> None: with TemporaryDirectory() as tmp: backup_root = Path(tmp) / "backups" GlobalConfig.objects.create(name="default", backup_root=str(backup_root)) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") snapshot_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH" meta_dir = snapshot_dir / "meta" meta_dir.mkdir(parents=True) write_yaml_atomic( meta_dir / "meta.yaml", { "status": "success", "started_at": "2026-05-19T02:15:00Z", "ended_at": "2026-05-19T02:16:00Z", }, ) first = discover_snapshots(host=host) second = discover_snapshots(host=host) self.assertEqual(first["created"], 1) self.assertEqual(first["updated"], 0) self.assertEqual(second["created"], 0) self.assertEqual(second["updated"], 1) self.assertEqual(SnapshotRecord.objects.count(), 1) record = SnapshotRecord.objects.get() self.assertEqual(record.status, "success") self.assertEqual(record.kind, "scheduled") self.assertEqual(record.started_at, datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc)) def test_inspect_snapshot_discovery_reports_missing_global_config(self) -> None: host = HostConfig.objects.create(host="web-01", address="web-01.example.test") result = inspect_snapshot_discovery(host=host) self.assertFalse(result["ok"]) self.assertEqual(result["reason"], "missing_global_config") self.assertEqual(result["total_candidates"], 0) def test_inspect_snapshot_discovery_counts_snapshot_directories(self) -> None: with TemporaryDirectory() as tmp: backup_root = Path(tmp) / "backups" GlobalConfig.objects.create(name="default", backup_root=str(backup_root)) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") (backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH").mkdir(parents=True) (backup_root / host.host / "manual" / "20260519-031500Z__MANUAL01").mkdir(parents=True) result = inspect_snapshot_discovery(host=host) self.assertTrue(result["ok"]) self.assertEqual(result["reason"], "ready") self.assertEqual(result["total_candidates"], 2) self.assertEqual(result["kind_counts"], {"scheduled": 1, "manual": 1, "incomplete": 0}) def test_discovery_links_snapshot_to_base_record(self) -> None: with TemporaryDirectory() as tmp: backup_root = Path(tmp) / "backups" GlobalConfig.objects.create(name="default", backup_root=str(backup_root)) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") base_dir = backup_root / host.host / "scheduled" / "20260518-021500Z__BASESNAP" child_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__CHILDSNP" (base_dir / "meta").mkdir(parents=True) (child_dir / "meta").mkdir(parents=True) write_yaml_atomic( base_dir / "meta" / "meta.yaml", { "id": "base-id", "status": "success", "started_at": "2026-05-18T02:15:00Z", "base": None, }, ) write_yaml_atomic( child_dir / "meta" / "meta.yaml", { "id": "child-id", "status": "success", "started_at": "2026-05-19T02:15:00Z", "base": { "kind": "scheduled", "dirname": base_dir.name, "id": "base-id", "path": str(base_dir / "data"), }, }, ) result = discover_snapshots(host=host) self.assertEqual(result["created"], 2) child = SnapshotRecord.objects.get(dirname=child_dir.name) base = SnapshotRecord.objects.get(dirname=base_dir.name) self.assertEqual(child.base, base) self.assertEqual(child.base_kind, "scheduled") self.assertEqual(child.base_dirname, base_dir.name) self.assertEqual(child.base_snapshot_id, "base-id") self.assertEqual(child.base_path, str(base_dir / "data")) def test_base_link_can_be_resolved_after_base_record_exists(self) -> None: with TemporaryDirectory() as tmp: backup_root = Path(tmp) / "backups" host = HostConfig.objects.create(host="web-01", address="web-01.example.test") base_dir = backup_root / host.host / "scheduled" / "20260518-021500Z__BASESNAP" child_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__CHILDSNP" (base_dir / "meta").mkdir(parents=True) (child_dir / "meta").mkdir(parents=True) write_yaml_atomic(base_dir / "meta" / "meta.yaml", {"status": "success"}) write_yaml_atomic( child_dir / "meta" / "meta.yaml", { "status": "success", "base": { "kind": "scheduled", "dirname": base_dir.name, "id": "base-id", "path": str(base_dir / "data"), }, }, ) child, _created = upsert_snapshot_record(host=host, kind="scheduled", snapshot_dir=child_dir) upsert_snapshot_record(host=host, kind="scheduled", snapshot_dir=base_dir) linked = resolve_base_links(host=host) child.refresh_from_db() self.assertEqual(linked, 1) self.assertIsNotNone(child.base) self.assertEqual(child.base.dirname, base_dir.name) self.assertEqual(child.base_dirname, base_dir.name) def test_command_discovers_snapshots_for_host(self) -> None: with TemporaryDirectory() as tmp: backup_root = Path(tmp) / "backups" GlobalConfig.objects.create(name="default", backup_root=str(backup_root)) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") snapshot_dir = backup_root / host.host / ".incomplete" / "20260519-021500Z__ABCDEFGH" (snapshot_dir / "meta").mkdir(parents=True) call_command("discover_pobsync_snapshots", host=host.host, kind="incomplete", stdout=StringIO()) self.assertEqual(SnapshotRecord.objects.count(), 1) self.assertEqual(SnapshotRecord.objects.get().kind, "incomplete")