"""Tests for `agnes snapshot list/refresh/drop/prune` (spec §4.2). NOTE: Tests construct a local Typer app so cli/main.py is NOT imported. The snapshot_app is registered under the "snapshot" sub-app. """ import json import pytest import typer from typer.testing import CliRunner from unittest.mock import patch, MagicMock import pyarrow as pa from cli.snapshot_meta import SnapshotMeta, write_meta # --------------------------------------------------------------------------- # Shared fixtures # --------------------------------------------------------------------------- @pytest.fixture def cli_env(tmp_path, monkeypatch): monkeypatch.setenv("AGNES_LOCAL_DIR", str(tmp_path)) snap_dir = tmp_path / "user" / "snapshots" snap_dir.mkdir(parents=True) yield tmp_path @pytest.fixture def test_app(): """Local Typer app with snapshot sub-commands (no cli.main dependency).""" from cli.commands.snapshot import snapshot_app app = typer.Typer() app.add_typer(snapshot_app, name="snapshot") return app def _seed_meta(tmp_path, name="cz_recent", rows=100): snap_dir = tmp_path / "user" / "snapshots" parquet = snap_dir / f"{name}.parquet" parquet.write_bytes(b"PAR1\x00\x00PAR1") write_meta(snap_dir, SnapshotMeta( name=name, table_id="bq_view", select=None, where=None, limit=None, order_by=None, fetched_at="2026-04-27T10:00:00+00:00", effective_as_of="2026-04-27T10:00:00+00:00", rows=rows, bytes_local=parquet.stat().st_size, estimated_scan_bytes_at_fetch=0, result_hash_md5="abc", )) # --------------------------------------------------------------------------- # list # --------------------------------------------------------------------------- class TestSnapshotList: def test_list_empty(self, cli_env, test_app): runner = CliRunner() result = runner.invoke(test_app, ["snapshot", "list"]) assert result.exit_code == 0 assert "no snapshots" in result.output def test_list_shows_snapshot(self, cli_env, test_app): _seed_meta(cli_env, "cz_recent", rows=42) runner = CliRunner() result = runner.invoke(test_app, ["snapshot", "list"]) assert result.exit_code == 0 assert "cz_recent" in result.output def test_list_json(self, cli_env, test_app): _seed_meta(cli_env, "cz_recent", rows=42) runner = CliRunner() result = runner.invoke(test_app, ["snapshot", "list", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert isinstance(data, list) assert data[0]["name"] == "cz_recent" # --------------------------------------------------------------------------- # drop # --------------------------------------------------------------------------- class TestSnapshotDrop: def test_drop_removes_files(self, cli_env, test_app): _seed_meta(cli_env, "cz_recent") snap_dir = cli_env / "user" / "snapshots" assert (snap_dir / "cz_recent.parquet").exists() runner = CliRunner() result = runner.invoke(test_app, ["snapshot", "drop", "cz_recent"]) assert result.exit_code == 0 assert not (snap_dir / "cz_recent.parquet").exists() assert not (snap_dir / "cz_recent.meta.json").exists() def test_drop_missing_returns_2(self, cli_env, test_app): runner = CliRunner() result = runner.invoke(test_app, ["snapshot", "drop", "nonexistent"]) assert result.exit_code == 2 def test_drop_message(self, cli_env, test_app): _seed_meta(cli_env, "my_snap") runner = CliRunner() result = runner.invoke(test_app, ["snapshot", "drop", "my_snap"]) assert result.exit_code == 0 assert "my_snap" in result.output # --------------------------------------------------------------------------- # refresh # --------------------------------------------------------------------------- class TestSnapshotRefresh: def test_refresh_missing_returns_2(self, cli_env, test_app): runner = CliRunner() result = runner.invoke(test_app, ["snapshot", "refresh", "nosuchsnap"]) assert result.exit_code == 2 def test_refresh_success(self, cli_env, test_app): _seed_meta(cli_env, "cz_recent", rows=100) snap_dir = cli_env / "user" / "snapshots" # Build a minimal Arrow table to return from the mocked API call arrow_table = pa.table({"col": [1, 2, 3]}) with patch("cli.commands.snapshot.api_post_arrow", return_value=arrow_table): runner = CliRunner() result = runner.invoke(test_app, ["snapshot", "refresh", "cz_recent"]) assert result.exit_code == 0, result.output assert "Refreshed" in result.output # Meta file should be updated from cli.snapshot_meta import read_meta new_meta = read_meta(snap_dir, "cz_recent") assert new_meta is not None assert new_meta.rows == 3 def test_refresh_server_error_returns_5(self, cli_env, test_app): _seed_meta(cli_env, "cz_recent") from cli.v2_client import V2ClientError with patch("cli.commands.snapshot.api_post_arrow", side_effect=V2ClientError(status_code=500, body="internal error")): runner = CliRunner() result = runner.invoke(test_app, ["snapshot", "refresh", "cz_recent"]) assert result.exit_code == 5 def test_refresh_with_where_override(self, cli_env, test_app): _seed_meta(cli_env, "cz_recent") arrow_table = pa.table({"col": [1]}) captured = {} def fake_post(path, payload): captured["where"] = payload.get("where") return arrow_table with patch("cli.commands.snapshot.api_post_arrow", side_effect=fake_post): runner = CliRunner() result = runner.invoke( test_app, ["snapshot", "refresh", "cz_recent", "--where", "country = 'US'"] ) assert result.exit_code == 0, result.output assert captured["where"] == "country = 'US'" # --------------------------------------------------------------------------- # prune # --------------------------------------------------------------------------- class TestSnapshotPrune: def test_prune_empty(self, cli_env, test_app): runner = CliRunner() result = runner.invoke(test_app, ["snapshot", "prune", "--older-than", "7d"]) assert result.exit_code == 0 assert "no matches" in result.output def test_prune_dry_run(self, cli_env, test_app): _seed_meta(cli_env, "old_snap", rows=10) runner = CliRunner() snap_dir = cli_env / "user" / "snapshots" # With --older-than 0d everything qualifies (age is > 0) result = runner.invoke(test_app, ["snapshot", "prune", "--older-than", "0d", "--dry-run"]) assert result.exit_code == 0 assert "would drop" in result.output # Dry run must NOT delete files assert (snap_dir / "old_snap.parquet").exists() def test_prune_deletes_matching(self, cli_env, test_app): _seed_meta(cli_env, "old_snap") snap_dir = cli_env / "user" / "snapshots" result = CliRunner().invoke(test_app, ["snapshot", "prune", "--older-than", "0d"]) assert result.exit_code == 0 assert "dropped" in result.output assert not (snap_dir / "old_snap.parquet").exists() def test_prune_larger_than(self, cli_env, test_app): # Seed a tiny snapshot (~8 bytes); --larger-than 1m should not match _seed_meta(cli_env, "tiny_snap") result = CliRunner().invoke(test_app, ["snapshot", "prune", "--larger-than", "1m"]) assert result.exit_code == 0 assert "no matches" in result.output def test_prune_no_flags_drops_all(self, cli_env, test_app): _seed_meta(cli_env, "snap1") snap_dir = cli_env / "user" / "snapshots" result = CliRunner().invoke(test_app, ["snapshot", "prune"]) assert result.exit_code == 0 # No predicates → all snapshots match → all are dropped assert "dropped" in result.output assert not (snap_dir / "snap1.parquet").exists()