agnes-the-ai-analyst/tests/test_cli_sync_quiet.py
ZdenekSrotyr d0b7e122d6 feat(cli): smart local sync — Claude Code SessionStart/SessionEnd hooks + da sync --quiet
The analyst flow becomes a closed loop with the server-curated table catalog:

  - `da analyst setup` writes `<workspace>/.claude/settings.json` with two hooks:
      SessionStart → `da sync --quiet || true`        — pulls fresh RBAC-filtered parquets at session start
      SessionEnd   → `da sync --upload-only --quiet || true` — uploads session jsonl + CLAUDE.local.md
  - `|| true` keeps Claude Code unblocked when the server is down.
  - Workspace-level (not user-home) so the hooks fire only when Claude Code opens this analyst workspace.
  - `da sync --quiet` rewrites the CLI output for hook consumption — 0 stdout on success, single-line error on failure.
  - Existing settings.json is patched (deep-merged), not overwritten; malformed JSON is reported, not silently overwritten.

Tests cover: workspace bootstrap, hook insertion, malformed-json safety, quiet-mode output shape.
2026-05-01 20:25:27 +02:00

138 lines
5.4 KiB
Python

"""`da sync --quiet` truly suppresses stdout chatter, including the download
loop and final summary.
Without --quiet, the same fixture prints "Downloading", "Downloaded:", etc.;
with --quiet, stdout stays empty and the terse one-liner lands on stderr.
The first test forces the download loop to run so the contrast between
noisy/quiet stdout is observable (mutation-tests the flag — see PR #145
for the original empty-manifest test that passed even without --quiet).
"""
import json
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
from cli.main import app
def _fake_manifest_one_table():
resp = MagicMock()
resp.json.return_value = {
"tables": {
"orders": {
"hash": "abc123",
"rows": 5,
"size_bytes": 100,
"query_mode": "local",
"source_type": "keboola",
}
},
"assets": {},
"server_time": "2026-04-30T00:00:00Z",
}
resp.raise_for_status = MagicMock()
return resp
def _stub_download(_url, target_path):
from pathlib import Path
Path(target_path).write_bytes(b"PAR1" + b"\x00" * 16 + b"PAR1")
def test_quiet_suppresses_stdout_when_downloading(tmp_path, monkeypatch):
"""Manifest has tables that actually trigger downloads. Without --quiet
stdout would contain 'Downloading' / 'Downloaded:'. With --quiet stdout
stays empty and the terse summary lands on stderr."""
monkeypatch.setenv("DA_LOCAL_DIR", str(tmp_path))
monkeypatch.setenv("DA_CONFIG_DIR", str(tmp_path / "_cfg"))
runner = CliRunner()
with patch("cli.commands.sync.api_get", return_value=_fake_manifest_one_table()), \
patch("cli.commands.sync.stream_download", side_effect=_stub_download), \
patch("cli.commands.sync._md5_file", return_value="abc123"), \
patch("cli.commands.sync._rebuild_duckdb_views"), \
patch("cli.commands.sync._fetch_and_write_rules"):
result = runner.invoke(app, ["sync", "--quiet"])
assert result.exit_code == 0, result.stdout
assert result.stdout == "", f"expected empty stdout, got: {result.stdout!r}"
assert "sync: 1 tables" in result.stderr
def test_noisy_mode_prints_to_stdout(tmp_path, monkeypatch):
"""Anchor: the noisy path DOES print download chatter to stdout, so the
contrast in the quiet test above is meaningful."""
monkeypatch.setenv("DA_LOCAL_DIR", str(tmp_path))
monkeypatch.setenv("DA_CONFIG_DIR", str(tmp_path / "_cfg"))
runner = CliRunner()
with patch("cli.commands.sync.api_get", return_value=_fake_manifest_one_table()), \
patch("cli.commands.sync.stream_download", side_effect=_stub_download), \
patch("cli.commands.sync._md5_file", return_value="abc123"), \
patch("cli.commands.sync._rebuild_duckdb_views"), \
patch("cli.commands.sync._fetch_and_write_rules"):
result = runner.invoke(app, ["sync"])
assert result.exit_code == 0, result.stdout
assert "Downloaded:" in result.stdout
def test_quiet_manifest_failure_exits_nonzero(tmp_path, monkeypatch):
"""SessionStart hook contract: server unreachable → non-zero exit (so
`|| true` swallows it cleanly), error message on stderr."""
monkeypatch.setenv("DA_LOCAL_DIR", str(tmp_path))
monkeypatch.setenv("DA_CONFIG_DIR", str(tmp_path / "_cfg"))
runner = CliRunner()
fake_resp = MagicMock()
fake_resp.raise_for_status.side_effect = RuntimeError("boom")
with patch("cli.commands.sync.api_get", return_value=fake_resp):
result = runner.invoke(app, ["sync", "--quiet"])
assert result.exit_code == 1
assert "manifest fetch failed" in result.stderr
def test_quiet_skips_remote_mode_tables(tmp_path, monkeypatch):
"""Materialized rows go through the download path; remote rows do not.
Locks in the contract that --quiet honors the same skipped_remote
filter as the noisy path."""
monkeypatch.setenv("DA_LOCAL_DIR", str(tmp_path))
monkeypatch.setenv("DA_CONFIG_DIR", str(tmp_path / "_cfg"))
resp = MagicMock()
resp.json.return_value = {
"tables": {
"live_orders": {
"hash": "x", "rows": 0, "size_bytes": 0,
"query_mode": "remote", "source_type": "bigquery",
},
"agg_90d": {
"hash": "abc", "rows": 5, "size_bytes": 100,
"query_mode": "materialized", "source_type": "bigquery",
},
},
"assets": {},
"server_time": "2026-04-30T00:00:00Z",
}
resp.raise_for_status = MagicMock()
runner = CliRunner()
download_calls = []
def _spy_download(url, target):
download_calls.append(url)
from pathlib import Path
Path(target).write_bytes(b"PAR1" + b"\x00" * 16 + b"PAR1")
with patch("cli.commands.sync.api_get", return_value=resp), \
patch("cli.commands.sync.stream_download", side_effect=_spy_download), \
patch("cli.commands.sync._md5_file", return_value="abc"), \
patch("cli.commands.sync._rebuild_duckdb_views"), \
patch("cli.commands.sync._fetch_and_write_rules"):
result = runner.invoke(app, ["sync", "--quiet"])
assert result.exit_code == 0, result.stdout
# Remote table never downloaded; materialized table downloaded.
assert any("agg_90d" in u for u in download_calls)
assert not any("live_orders" in u for u in download_calls)