haiku.rag/tests/test_cli.py
2026-07-16 13:11:27 +03:00

181 lines
6.4 KiB
Python

from unittest.mock import patch
import pytest
from click.exceptions import BadParameter
from typer.testing import CliRunner
from haiku.rag.cli import _cli as cli
from haiku.rag.cli import _parse_meta_options
from haiku.rag.cli import cli as cli_wrapper
from haiku.rag.store.exceptions import MigrationRequiredError
runner = CliRunner()
class TestParseMetaOptions:
def test_empty_input(self):
assert _parse_meta_options(None) == {}
assert _parse_meta_options([]) == {}
def test_simple_key_value(self):
result = _parse_meta_options(["author=alice", "topic=notes"])
assert result == {"author": "alice", "topic": "notes"}
def test_missing_equals_raises(self):
with pytest.raises(BadParameter):
_parse_meta_options(["no_equals_here"])
def test_empty_key_raises(self):
with pytest.raises(BadParameter):
_parse_meta_options(["=value"])
def test_json_number(self):
result = _parse_meta_options(["version=3"])
assert result == {"version": 3}
assert isinstance(result["version"], int)
def test_json_float(self):
result = _parse_meta_options(["score=3.14"])
assert result == {"score": 3.14}
assert isinstance(result["score"], float)
def test_json_bool(self):
result = _parse_meta_options(["published=true", "draft=false"])
assert result == {"published": True, "draft": False}
def test_json_null(self):
result = _parse_meta_options(["empty=null"])
assert result == {"empty": None}
def test_json_array(self):
result = _parse_meta_options(['tags=["a","b","c"]'])
assert result == {"tags": ["a", "b", "c"]}
def test_json_object(self):
result = _parse_meta_options(['nested={"x": 1}'])
assert result == {"nested": {"x": 1}}
def test_plain_string_not_json(self):
result = _parse_meta_options(["name=hello world"])
assert result == {"name": "hello world"}
assert isinstance(result["name"], str)
def test_value_with_equals_sign(self):
result = _parse_meta_options(["equation=a=b+c"])
assert result == {"equation": "a=b+c"}
class TestRebuildValidation:
def test_embed_only_and_rechunk_mutually_exclusive(self):
result = runner.invoke(
cli, ["rebuild", "--embed-only", "--rechunk", "--db", "/tmp/fake.lancedb"]
)
assert result.exit_code == 1
assert "mutually exclusive" in result.output
class TestCliMigrationError:
def test_catches_migration_required_error(self):
with patch("haiku.rag.cli._cli") as mock_cli:
mock_cli.side_effect = MigrationRequiredError(
"Database requires migration. Run 'haiku-rag migrate' to upgrade."
)
with pytest.raises(SystemExit) as exc_info:
cli_wrapper()
assert exc_info.value.code == 1
class TestTagCommands:
def test_tag_round_trip(self, temp_db_path):
db = str(temp_db_path)
result = runner.invoke(cli, ["init", "--db", db])
assert result.exit_code == 0
result = runner.invoke(cli, ["tag", "create", "release-1", "--db", db])
assert result.exit_code == 0
assert "release-1" in result.output
result = runner.invoke(cli, ["tag", "list", "--db", db])
assert result.exit_code == 0
assert "release-1" in result.output
assert "partial" not in result.output
result = runner.invoke(cli, ["history", "--db", db, "-t", "documents"])
assert result.exit_code == 0
assert "release-1" in result.output
result = runner.invoke(cli, ["tag", "create", "release-1", "--db", db])
assert result.exit_code == 1
assert "already exists" in result.output
result = runner.invoke(cli, ["tag", "delete", "release-1", "--db", db])
assert result.exit_code == 0
result = runner.invoke(cli, ["tag", "list", "--db", db])
assert result.exit_code == 0
assert "No tags" in result.output
result = runner.invoke(cli, ["tag", "delete", "release-1", "--db", db])
assert result.exit_code == 1
assert "does not exist" in result.output
def test_tag_create_rejected_when_migrations_pending(self, temp_db_path):
"""A writable tag operation must hit the migration gate and must not
mutate a legacy database (e.g. by creating missing tables)."""
import asyncio
import lancedb
from haiku.rag.store.engine import Store
async def _prepare_legacy_db():
async with Store(temp_db_path, create=True) as store:
await store.set_haiku_version("0.19.0")
db = await lancedb.connect_async(temp_db_path.absolute())
await db.drop_table("document_meta")
db.close()
asyncio.run(_prepare_legacy_db())
result = runner.invoke(
cli, ["tag", "create", "release-1", "--db", str(temp_db_path)]
)
assert result.exit_code == 1
assert isinstance(result.exception, MigrationRequiredError)
async def _table_names() -> list[str]:
db = await lancedb.connect_async(temp_db_path.absolute())
tables = (await db.list_tables()).tables
db.close()
return tables
assert "document_meta" not in asyncio.run(_table_names())
def test_tag_write_commands_reject_time_travel(self, temp_db_path):
db = str(temp_db_path)
result = runner.invoke(cli, ["init", "--db", db])
assert result.exit_code == 0
result = runner.invoke(cli, ["--at", "x", "tag", "create", "r1", "--db", db])
assert result.exit_code == 1
assert "--at" in result.output
result = runner.invoke(
cli, ["--before", "2025-01-01", "tag", "delete", "r1", "--db", db]
)
assert result.exit_code == 1
assert "--before" in result.output
def test_tag_create_invalid_name_fails_cleanly(self, temp_db_path):
"""lance restricts ref names to alphanumeric, '.', '-', '_'; the CLI
surfaces that as a clean error instead of a traceback."""
db = str(temp_db_path)
result = runner.invoke(cli, ["init", "--db", db])
assert result.exit_code == 0
result = runner.invoke(cli, ["tag", "create", "[red]release[/red]", "--db", db])
assert result.exit_code == 1
assert "Error:" in result.output
assert "Ref characters" in result.output