"""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"], )