Files
Orchestrator/shared/tools/monorepo-cli/tests/test_knowledge_cli.py
T

507 lines
20 KiB
Python

"""Unit-Tests: CLI-Argument-Parsing für Knowledge Management.
Testet alle Subcommands mit verschiedenen Flag-Kombinationen,
Kontext-Ableitung aus CWD und Hilfe-Ausgabe.
Requirements: 15.2, 15.3, 15.7
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from monorepo.knowledge.cli import (
VALID_CONTEXTS,
_derive_context_from_cwd,
build_knowledge_parser,
knowledge_main,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def parser() -> argparse.ArgumentParser:
"""Build a standalone knowledge parser for testing."""
return build_knowledge_parser()
def _parse(parser: argparse.ArgumentParser, args: list[str]) -> argparse.Namespace:
"""Parse args without triggering SystemExit on error."""
return parser.parse_args(args)
# ---------------------------------------------------------------------------
# Tests: capture subcommand
# ---------------------------------------------------------------------------
class TestCaptureCommand:
"""Tests for 'knowledge capture' argument parsing."""
def test_capture_plain_text(self, parser: argparse.ArgumentParser) -> None:
"""Capture with plain text input."""
ns = _parse(parser, ["capture", "Eine schnelle Notiz"])
assert ns.knowledge_command == "capture"
assert ns.input == "Eine schnelle Notiz"
assert ns.context is None
assert ns.tags is None
def test_capture_url_input(self, parser: argparse.ArgumentParser) -> None:
"""Capture with URL input."""
ns = _parse(parser, ["capture", "https://example.com/docs"])
assert ns.knowledge_command == "capture"
assert ns.input == "https://example.com/docs"
def test_capture_file_path_input(self, parser: argparse.ArgumentParser) -> None:
"""Capture with file path input."""
ns = _parse(parser, ["capture", "/tmp/notes/meeting.md"])
assert ns.input == "/tmp/notes/meeting.md"
def test_capture_with_context(self, parser: argparse.ArgumentParser) -> None:
"""Capture with explicit --context flag."""
ns = _parse(parser, ["capture", "Notiz", "--context", "bahn"])
assert ns.context == "bahn"
def test_capture_with_context_short(self, parser: argparse.ArgumentParser) -> None:
"""Capture with -c short flag."""
ns = _parse(parser, ["capture", "Notiz", "-c", "dhive"])
assert ns.context == "dhive"
def test_capture_with_tags(self, parser: argparse.ArgumentParser) -> None:
"""Capture with --tags flag."""
ns = _parse(parser, ["capture", "Notiz", "--tags", "tag1,tag2"])
assert ns.tags == "tag1,tag2"
def test_capture_with_tags_short(self, parser: argparse.ArgumentParser) -> None:
"""Capture with -t short flag."""
ns = _parse(parser, ["capture", "Notiz", "-t", "meeting,acv2"])
assert ns.tags == "meeting,acv2"
def test_capture_with_context_and_tags(self, parser: argparse.ArgumentParser) -> None:
"""Capture with both --context and --tags."""
ns = _parse(parser, [
"capture", "Sprint Retro Notiz",
"--context", "bahn",
"--tags", "tag1,tag2",
])
assert ns.context == "bahn"
assert ns.tags == "tag1,tag2"
assert ns.input == "Sprint Retro Notiz"
def test_capture_invalid_context_rejected(self, parser: argparse.ArgumentParser) -> None:
"""Capture with invalid context raises error."""
with pytest.raises(SystemExit):
_parse(parser, ["capture", "Notiz", "--context", "invalid"])
# ---------------------------------------------------------------------------
# Tests: ingest subcommand
# ---------------------------------------------------------------------------
class TestIngestCommand:
"""Tests for 'knowledge ingest' argument parsing."""
def test_ingest_defaults(self, parser: argparse.ArgumentParser) -> None:
"""Ingest with no flags uses defaults."""
ns = _parse(parser, ["ingest"])
assert ns.knowledge_command == "ingest"
assert ns.context is None
assert ns.dry_run is False
assert ns.verbose is False
assert ns.no_commit is False
def test_ingest_with_context(self, parser: argparse.ArgumentParser) -> None:
"""Ingest with --context flag."""
ns = _parse(parser, ["ingest", "--context", "privat"])
assert ns.context == "privat"
def test_ingest_with_dry_run(self, parser: argparse.ArgumentParser) -> None:
"""Ingest with --dry-run flag."""
ns = _parse(parser, ["ingest", "--dry-run"])
assert ns.dry_run is True
def test_ingest_with_verbose(self, parser: argparse.ArgumentParser) -> None:
"""Ingest with --verbose flag."""
ns = _parse(parser, ["ingest", "--verbose"])
assert ns.verbose is True
def test_ingest_with_verbose_short(self, parser: argparse.ArgumentParser) -> None:
"""Ingest with -v short flag."""
ns = _parse(parser, ["ingest", "-v"])
assert ns.verbose is True
def test_ingest_with_no_commit(self, parser: argparse.ArgumentParser) -> None:
"""Ingest with --no-commit flag."""
ns = _parse(parser, ["ingest", "--no-commit"])
assert ns.no_commit is True
def test_ingest_all_flags(self, parser: argparse.ArgumentParser) -> None:
"""Ingest with all flags combined."""
ns = _parse(parser, [
"ingest",
"--context", "privat",
"--dry-run",
"--verbose",
"--no-commit",
])
assert ns.context == "privat"
assert ns.dry_run is True
assert ns.verbose is True
assert ns.no_commit is True
def test_ingest_invalid_context_rejected(self, parser: argparse.ArgumentParser) -> None:
"""Ingest with invalid context raises error."""
with pytest.raises(SystemExit):
_parse(parser, ["ingest", "--context", "ungueltig"])
# ---------------------------------------------------------------------------
# Tests: search subcommand
# ---------------------------------------------------------------------------
class TestSearchCommand:
"""Tests for 'knowledge search' argument parsing."""
def test_search_basic(self, parser: argparse.ArgumentParser) -> None:
"""Search with query string."""
ns = _parse(parser, ["search", "API Design"])
assert ns.knowledge_command == "search"
assert ns.query == "API Design"
assert ns.context is None
assert ns.limit is None
def test_search_with_context(self, parser: argparse.ArgumentParser) -> None:
"""Search with --context flag."""
ns = _parse(parser, ["search", "query", "--context", "dhive"])
assert ns.context == "dhive"
def test_search_with_limit(self, parser: argparse.ArgumentParser) -> None:
"""Search with --limit flag."""
ns = _parse(parser, ["search", "query", "--limit", "5"])
assert ns.limit == 5
def test_search_with_limit_short(self, parser: argparse.ArgumentParser) -> None:
"""Search with -l short flag."""
ns = _parse(parser, ["search", "query", "-l", "10"])
assert ns.limit == 10
def test_search_all_flags(self, parser: argparse.ArgumentParser) -> None:
"""Search with --context and --limit combined."""
ns = _parse(parser, [
"search", "Confluence Migration",
"--context", "bahn",
"--limit", "5",
])
assert ns.query == "Confluence Migration"
assert ns.context == "bahn"
assert ns.limit == 5
def test_search_missing_query_rejected(self, parser: argparse.ArgumentParser) -> None:
"""Search without query raises error."""
with pytest.raises(SystemExit):
_parse(parser, ["search"])
# ---------------------------------------------------------------------------
# Tests: status subcommand
# ---------------------------------------------------------------------------
class TestStatusCommand:
"""Tests for 'knowledge status' argument parsing."""
def test_status_defaults(self, parser: argparse.ArgumentParser) -> None:
"""Status with no flags."""
ns = _parse(parser, ["status"])
assert ns.knowledge_command == "status"
assert ns.context is None
def test_status_with_context(self, parser: argparse.ArgumentParser) -> None:
"""Status with --context flag."""
ns = _parse(parser, ["status", "--context", "bahn"])
assert ns.context == "bahn"
def test_status_with_context_short(self, parser: argparse.ArgumentParser) -> None:
"""Status with -c short flag."""
ns = _parse(parser, ["status", "-c", "privat"])
assert ns.context == "privat"
# ---------------------------------------------------------------------------
# Tests: link subcommand
# ---------------------------------------------------------------------------
class TestLinkCommand:
"""Tests for 'knowledge link' argument parsing."""
def test_link_basic(self, parser: argparse.ArgumentParser) -> None:
"""Link with URL only."""
ns = _parse(parser, ["link", "https://example.com"])
assert ns.knowledge_command == "link"
assert ns.url == "https://example.com"
assert ns.title is None
assert ns.tags is None
assert ns.context is None
def test_link_with_title(self, parser: argparse.ArgumentParser) -> None:
"""Link with --title flag."""
ns = _parse(parser, ["link", "https://example.com", "--title", "Example Page"])
assert ns.title == "Example Page"
def test_link_with_title_short(self, parser: argparse.ArgumentParser) -> None:
"""Link with -T short flag."""
ns = _parse(parser, ["link", "https://example.com", "-T", "Example"])
assert ns.title == "Example"
def test_link_with_tags(self, parser: argparse.ArgumentParser) -> None:
"""Link with --tags flag."""
ns = _parse(parser, ["link", "https://example.com", "--tags", "ref"])
assert ns.tags == "ref"
def test_link_with_context(self, parser: argparse.ArgumentParser) -> None:
"""Link with --context flag."""
ns = _parse(parser, ["link", "https://example.com", "--context", "bahn"])
assert ns.context == "bahn"
def test_link_all_flags(self, parser: argparse.ArgumentParser) -> None:
"""Link with all flags combined."""
ns = _parse(parser, [
"link", "https://example.com/docs",
"--title", "Documentation",
"--tags", "ref,docs",
"--context", "dhive",
])
assert ns.url == "https://example.com/docs"
assert ns.title == "Documentation"
assert ns.tags == "ref,docs"
assert ns.context == "dhive"
def test_link_missing_url_rejected(self, parser: argparse.ArgumentParser) -> None:
"""Link without URL raises error."""
with pytest.raises(SystemExit):
_parse(parser, ["link"])
# ---------------------------------------------------------------------------
# Tests: Context derivation from CWD
# ---------------------------------------------------------------------------
class TestContextDerivation:
"""Tests for _derive_context_from_cwd."""
def test_derive_bahn_context(self) -> None:
"""CWD in bahn/ subtree returns 'bahn'."""
with patch("monorepo.knowledge.cli.Path") as mock_path:
mock_path.cwd.return_value = Path("/repo/bahn/aisupport/src")
result = _derive_context_from_cwd()
assert result == "bahn"
def test_derive_dhive_context(self) -> None:
"""CWD in dhive/ subtree returns 'dhive'."""
with patch("monorepo.knowledge.cli.Path") as mock_path:
mock_path.cwd.return_value = Path("/repo/dhive/project/src")
result = _derive_context_from_cwd()
assert result == "dhive"
def test_derive_privat_context(self) -> None:
"""CWD in privat/ subtree returns 'privat'."""
with patch("monorepo.knowledge.cli.Path") as mock_path:
mock_path.cwd.return_value = Path("/repo/privat/notes")
result = _derive_context_from_cwd()
assert result == "privat"
def test_derive_no_context(self) -> None:
"""CWD outside context folders returns None."""
with patch("monorepo.knowledge.cli.Path") as mock_path:
mock_path.cwd.return_value = Path("/repo/shared/tools")
result = _derive_context_from_cwd()
assert result is None
def test_derive_bahn_at_end(self) -> None:
"""CWD ending with /bahn returns 'bahn'."""
with patch("monorepo.knowledge.cli.Path") as mock_path:
mock_path.cwd.return_value = Path("/repo/bahn")
result = _derive_context_from_cwd()
assert result == "bahn"
def test_derive_windows_path(self) -> None:
"""CWD as Windows path with backslashes works."""
with patch("monorepo.knowledge.cli.Path") as mock_path:
mock_path.cwd.return_value = Path("C:\\Users\\user\\repo\\bahn\\project")
result = _derive_context_from_cwd()
assert result == "bahn"
# ---------------------------------------------------------------------------
# Tests: Help output (no arguments)
# ---------------------------------------------------------------------------
class TestHelpOutput:
"""Tests for help behavior when called without arguments."""
def test_no_command_sets_none(self, parser: argparse.ArgumentParser) -> None:
"""No subcommand results in knowledge_command=None."""
ns = _parse(parser, [])
assert ns.knowledge_command is None
def test_knowledge_main_no_command_shows_help(self) -> None:
"""knowledge_main with no command triggers help (SystemExit)."""
parser = build_knowledge_parser()
ns = parser.parse_args([])
# knowledge_main should call sys.exit when no command given
with pytest.raises(SystemExit):
knowledge_main(ns)
def test_parser_has_all_subcommands(self, parser: argparse.ArgumentParser) -> None:
"""Parser registers all expected subcommands."""
# Verify by parsing each command without error
commands = {
"capture": ["capture", "test input"],
"ingest": ["ingest"],
"search": ["search", "query"],
"status": ["status"],
"link": ["link", "https://example.com"],
}
for cmd_name, args in commands.items():
ns = _parse(parser, args)
assert ns.knowledge_command == cmd_name
# ---------------------------------------------------------------------------
# Tests: Handler dispatch with mocks
# ---------------------------------------------------------------------------
class TestHandlerDispatch:
"""Tests that handlers are correctly dispatched and call underlying components."""
def test_capture_handler_calls_quick_capture(self, tmp_path: Path) -> None:
"""capture handler invokes QuickCapture.capture."""
parser = build_knowledge_parser()
ns = parser.parse_args(["capture", "Test note", "--context", "bahn"])
with patch("monorepo.knowledge.cli._find_monorepo_root", return_value=tmp_path), \
patch("monorepo.knowledge.ingestion.capture.QuickCapture") as mock_qc_cls:
mock_qc = MagicMock()
mock_qc.capture.return_value = tmp_path / "bahn/knowledge/inbox/note.md"
mock_qc_cls.return_value = mock_qc
from monorepo.knowledge.cli import _cmd_capture
result = _cmd_capture(ns)
assert result == 0
mock_qc.capture.assert_called_once_with(
input_text="Test note",
context="bahn",
tags=[],
)
def test_capture_handler_splits_tags(self, tmp_path: Path) -> None:
"""capture handler correctly splits comma-separated tags."""
parser = build_knowledge_parser()
ns = parser.parse_args(["capture", "Notiz", "-c", "dhive", "-t", "a,b,c"])
with patch("monorepo.knowledge.cli._find_monorepo_root", return_value=tmp_path), \
patch("monorepo.knowledge.ingestion.capture.QuickCapture") as mock_qc_cls:
mock_qc = MagicMock()
mock_qc.capture.return_value = tmp_path / "dhive/knowledge/inbox/note.md"
mock_qc_cls.return_value = mock_qc
from monorepo.knowledge.cli import _cmd_capture
_cmd_capture(ns)
mock_qc.capture.assert_called_once_with(
input_text="Notiz",
context="dhive",
tags=["a", "b", "c"],
)
def test_ingest_handler_calls_pipeline(self, tmp_path: Path) -> None:
"""ingest handler invokes IngestionPipeline.run."""
parser = build_knowledge_parser()
ns = parser.parse_args(["ingest", "--context", "privat", "--dry-run", "--no-commit"])
mock_result = MagicMock()
mock_result.context = "privat"
mock_result.processed = 5
mock_result.updated = 0
mock_result.skipped = 2
mock_result.errors = []
with patch("monorepo.knowledge.cli._find_monorepo_root", return_value=tmp_path), \
patch("monorepo.knowledge.ingestion.pipeline.IngestionPipeline") as mock_pipeline_cls:
mock_pipeline = MagicMock()
mock_pipeline.run.return_value = mock_result
mock_pipeline_cls.return_value = mock_pipeline
from monorepo.knowledge.cli import _cmd_ingest
result = _cmd_ingest(ns)
assert result == 0
mock_pipeline_cls.assert_called_once_with(
monorepo_root=tmp_path,
context="privat",
dry_run=True,
no_commit=True,
)
mock_pipeline.run.assert_called_once()
def test_status_handler_counts_inbox(self, tmp_path: Path) -> None:
"""status handler counts .md files in inbox (excluding index.md)."""
# Create inbox with some files
inbox = tmp_path / "bahn" / "knowledge" / "inbox"
inbox.mkdir(parents=True)
(inbox / "index.md").write_text("# Inbox")
(inbox / "note1.md").write_text("note 1")
(inbox / "note2.md").write_text("note 2")
parser = build_knowledge_parser()
ns = parser.parse_args(["status", "--context", "bahn"])
with patch("monorepo.knowledge.cli._find_monorepo_root", return_value=tmp_path):
from monorepo.knowledge.cli import _cmd_status
result = _cmd_status(ns)
assert result == 0
def test_link_handler_calls_registry(self, tmp_path: Path) -> None:
"""link handler invokes LinkRegistry.save_link."""
parser = build_knowledge_parser()
ns = parser.parse_args([
"link", "https://example.com",
"--title", "Example",
"--tags", "ref",
"--context", "bahn",
])
with patch("monorepo.knowledge.cli._find_monorepo_root", return_value=tmp_path), \
patch("monorepo.knowledge.sources.link.LinkRegistry") as mock_reg_cls:
mock_reg = MagicMock()
mock_reg.save_link.return_value = tmp_path / "bahn/knowledge/links/example.md"
mock_reg_cls.return_value = mock_reg
from monorepo.knowledge.cli import _cmd_link
result = _cmd_link(ns)
assert result == 0
mock_reg_cls.assert_called_once_with(tmp_path / "bahn" / "knowledge")
mock_reg.save_link.assert_called_once_with(
url="https://example.com",
title="Example",
tags=["ref"],
)