From dea36e931a2d3f4995cd2e7af03be52d937d0596 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Mon, 9 Mar 2026 03:49:09 +0100 Subject: [PATCH] Add test coverage for CLI commands, Flask routes, and shared DB methods 74 new tests across 3 files: - test_cli.py: CLI help, version, config, report generation, wg/viz/pipeline subcommands - test_web.py: All public pages, admin pages (dev/prod), API JSON endpoints, CSV export, 404s - test_db_shared.py: rated_count, gap_count, false_positive_names, category_counts, source_counts, draft_author_count_map, search_gaps, search_authors Co-Authored-By: Claude Opus 4.6 --- tests/test_cli.py | 157 +++++++++++++++++++++++ tests/test_db_shared.py | 175 ++++++++++++++++++++++++++ tests/test_web.py | 269 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 601 insertions(+) create mode 100644 tests/test_cli.py create mode 100644 tests/test_db_shared.py create mode 100644 tests/test_web.py diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..20ae680 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,157 @@ +"""Tests for CLI commands using Click's CliRunner.""" + +from __future__ import annotations + +from unittest.mock import patch, MagicMock + +import pytest +from click.testing import CliRunner + +from ietf_analyzer.cli import main + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def mock_config(tmp_path): + """Patch Config.load to return a config pointing at a temp DB.""" + from ietf_analyzer.config import Config + + cfg = Config( + data_dir=str(tmp_path), + db_path=str(tmp_path / "test.db"), + ) + with patch("ietf_analyzer.cli.Config.load", return_value=cfg): + yield cfg + + +# ── Help & version ──────────────────────────────────────────────────── + + +def test_help(runner, mock_config): + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "IETF Draft Analyzer" in result.output + + +def test_version(runner, mock_config): + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert "0.3.0" in result.output + + +def test_unknown_command(runner, mock_config): + result = runner.invoke(main, ["nonexistent-command"]) + assert result.exit_code != 0 + + +# ── config command ──────────────────────────────────────────────────── + + +def test_config_show(runner, mock_config): + result = runner.invoke(main, ["config", "--show"]) + assert result.exit_code == 0 + assert "data_dir" in result.output + + +def test_config_show_default(runner, mock_config): + """config without --show or --set also prints config.""" + result = runner.invoke(main, ["config"]) + assert result.exit_code == 0 + assert "data_dir" in result.output + + +# ── report subcommands ──────────────────────────────────────────────── + + +def test_report_help(runner, mock_config): + result = runner.invoke(main, ["report", "--help"]) + assert result.exit_code == 0 + assert "overview" in result.output + + +def test_report_overview(runner, mock_config, seeded_db): + """report overview should run and produce a file.""" + mock_config.db_path = seeded_db.config.db_path + mock_config.data_dir = seeded_db.config.data_dir + result = runner.invoke(main, ["report", "overview"]) + assert result.exit_code == 0 + assert "Report saved" in result.output + + +def test_report_landscape(runner, mock_config, seeded_db): + mock_config.db_path = seeded_db.config.db_path + mock_config.data_dir = seeded_db.config.data_dir + result = runner.invoke(main, ["report", "landscape"]) + assert result.exit_code == 0 + assert "Report saved" in result.output + + +def test_report_timeline(runner, mock_config, seeded_db): + mock_config.db_path = seeded_db.config.db_path + mock_config.data_dir = seeded_db.config.data_dir + result = runner.invoke(main, ["report", "timeline"]) + assert result.exit_code == 0 + assert "Report saved" in result.output + + +def test_report_ideas(runner, mock_config, seeded_db): + mock_config.db_path = seeded_db.config.db_path + mock_config.data_dir = seeded_db.config.data_dir + result = runner.invoke(main, ["report", "ideas"]) + assert result.exit_code == 0 + assert "Report saved" in result.output + + +def test_report_authors(runner, mock_config, seeded_db): + mock_config.db_path = seeded_db.config.db_path + mock_config.data_dir = seeded_db.config.data_dir + result = runner.invoke(main, ["report", "authors"]) + assert result.exit_code == 0 + assert "Report saved" in result.output + + +# ── export command ──────────────────────────────────────────────────── + + +def test_export_help(runner, mock_config): + result = runner.invoke(main, ["export", "--help"]) + assert result.exit_code == 0 + assert "json" in result.output + + +# ── wg commands ─────────────────────────────────────────────────────── + + +def test_wg_help(runner, mock_config): + result = runner.invoke(main, ["wg", "--help"]) + assert result.exit_code == 0 + assert "list" in result.output + + +def test_wg_list(runner, mock_config, seeded_db): + mock_config.db_path = seeded_db.config.db_path + mock_config.data_dir = seeded_db.config.data_dir + result = runner.invoke(main, ["wg", "list"]) + assert result.exit_code == 0 + + +# ── viz commands ────────────────────────────────────────────────────── + + +def test_viz_help(runner, mock_config): + result = runner.invoke(main, ["viz", "--help"]) + assert result.exit_code == 0 + assert "landscape" in result.output + + +# ── pipeline commands ───────────────────────────────────────────────── + + +def test_pipeline_help(runner, mock_config): + result = runner.invoke(main, ["pipeline", "--help"]) + assert result.exit_code == 0 + assert "generate" in result.output diff --git a/tests/test_db_shared.py b/tests/test_db_shared.py new file mode 100644 index 0000000..4cee01c --- /dev/null +++ b/tests/test_db_shared.py @@ -0,0 +1,175 @@ +"""Tests for shared Database query methods added in the DB refactor.""" + +from __future__ import annotations + + +# ── rated_count ─────────────────────────────────────────────────────── + + +def test_rated_count_returns_int(seeded_db): + result = seeded_db.rated_count() + assert isinstance(result, int) + assert result == 5 # seeded_db has 5 ratings + + +def test_rated_count_empty(tmp_db): + result = tmp_db.rated_count() + assert result == 0 + + +# ── gap_count ───────────────────────────────────────────────────────── + + +def test_gap_count_empty(tmp_db): + result = tmp_db.gap_count() + assert isinstance(result, int) + assert result == 0 + + +def test_gap_count_after_insert(seeded_db): + seeded_db.conn.execute( + "INSERT INTO gaps (topic, description, category, severity) VALUES (?, ?, ?, ?)", + ("Test Gap", "A test gap", "A2A protocols", "high"), + ) + seeded_db.conn.commit() + assert seeded_db.gap_count() == 1 + + +# ── false_positive_names ────────────────────────────────────────────── + + +def test_false_positive_names_empty(seeded_db): + result = seeded_db.false_positive_names() + assert isinstance(result, set) + assert len(result) == 0 + + +def test_false_positive_names_after_flag(seeded_db): + seeded_db.conn.execute( + "UPDATE ratings SET false_positive = 1 WHERE draft_name = 'draft-alpha-agent-comm'" + ) + seeded_db.conn.commit() + result = seeded_db.false_positive_names() + assert "draft-alpha-agent-comm" in result + assert len(result) == 1 + + +# ── category_counts ────────────────────────────────────────────────── + + +def test_category_counts_returns_dict(seeded_db): + result = seeded_db.category_counts() + assert isinstance(result, dict) + assert len(result) > 0 + + +def test_category_counts_values_are_ints(seeded_db): + result = seeded_db.category_counts() + for k, v in result.items(): + assert isinstance(k, str) + assert isinstance(v, int) + assert v > 0 + + +# ── source_counts ──────────────────────────────────────────────────── + + +def test_source_counts_returns_list(seeded_db): + result = seeded_db.source_counts() + assert isinstance(result, list) + + +def test_source_counts_tuples(seeded_db): + result = seeded_db.source_counts() + for item in result: + assert isinstance(item, tuple) + assert len(item) == 2 + + +# ── draft_author_count_map ─────────────────────────────────────────── + + +def test_draft_author_count_map_returns_dict(seeded_db): + result = seeded_db.draft_author_count_map() + assert isinstance(result, dict) + # seeded_db has 4 drafts with authors + assert len(result) >= 3 + + +def test_draft_author_count_map_values(seeded_db): + result = seeded_db.draft_author_count_map() + # draft-alpha-agent-comm has 2 authors + assert result.get("draft-alpha-agent-comm") == 2 + # draft-beta-ml-traffic has 1 author + assert result.get("draft-beta-ml-traffic") == 1 + + +def test_draft_author_count_map_empty(tmp_db): + result = tmp_db.draft_author_count_map() + assert result == {} + + +# ── search_gaps ────────────────────────────────────────────────────── + + +def test_search_gaps_empty_db(tmp_db): + result = tmp_db.search_gaps("anything") + assert isinstance(result, list) + assert len(result) == 0 + + +def test_search_gaps_finds_match(seeded_db): + seeded_db.conn.execute( + "INSERT INTO gaps (topic, description, category, severity) VALUES (?, ?, ?, ?)", + ("Agent Discovery Gap", "No standard for agent discovery", "Agent discovery/reg", "high"), + ) + seeded_db.conn.commit() + result = seeded_db.search_gaps("discovery") + assert len(result) == 1 + assert result[0]["topic"] == "Agent Discovery Gap" + + +def test_search_gaps_no_match(seeded_db): + seeded_db.conn.execute( + "INSERT INTO gaps (topic, description, category, severity) VALUES (?, ?, ?, ?)", + ("Agent Discovery Gap", "No standard", "Agent discovery/reg", "high"), + ) + seeded_db.conn.commit() + result = seeded_db.search_gaps("zzz-nonexistent-zzz") + assert len(result) == 0 + + +# ── search_authors ─────────────────────────────────────────────────── + + +def test_search_authors_by_name(seeded_db): + result = seeded_db.search_authors("Alice") + assert len(result) >= 1 + assert any(a["name"] == "Alice Researcher" for a in result) + + +def test_search_authors_by_affiliation(seeded_db): + result = seeded_db.search_authors("ExampleCorp") + assert len(result) >= 2 # Alice and Carol + + +def test_search_authors_no_match(seeded_db): + result = seeded_db.search_authors("zzz-nonexistent-zzz") + assert len(result) == 0 + + +def test_search_authors_empty_db(tmp_db): + result = tmp_db.search_authors("anyone") + assert isinstance(result, list) + assert len(result) == 0 + + +# ── search_authors dict shape ──────────────────────────────────────── + + +def test_search_authors_result_shape(seeded_db): + result = seeded_db.search_authors("Alice") + for item in result: + assert "person_id" in item + assert "name" in item + assert "affiliation" in item diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..3e6b142 --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,269 @@ +"""Tests for Flask web routes using the test client. + +Uses the real data/drafts.db so templates can render with actual data. +""" + +from __future__ import annotations + +import sys +import os + +import pytest + +# Ensure src is on path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from webui.app import create_app + + +@pytest.fixture +def client(): + """Create a Flask test client in dev mode (admin enabled).""" + # Reset auth module state so create_app can re-initialize + import webui.auth as auth_mod + auth_mod._initialized = False + auth_mod._dev_mode = False + + app = create_app(dev=True) + app.config["TESTING"] = True + with app.test_client() as c: + yield c + + +@pytest.fixture +def prod_client(): + """Create a Flask test client in production mode (admin disabled).""" + import webui.auth as auth_mod + auth_mod._initialized = False + auth_mod._dev_mode = False + + app = create_app(dev=False) + app.config["TESTING"] = True + with app.test_client() as c: + yield c + + +# ── Public pages ────────────────────────────────────────────────────── + + +def test_overview_page(client): + resp = client.get("/") + assert resp.status_code == 200 + + +def test_drafts_page(client): + resp = client.get("/drafts") + assert resp.status_code == 200 + + +def test_authors_page(client): + resp = client.get("/authors") + assert resp.status_code == 200 + + +def test_timeline_page(client): + resp = client.get("/timeline") + assert resp.status_code == 200 + + +def test_search_page_empty(client): + resp = client.get("/search") + assert resp.status_code == 200 + + +def test_search_page_with_query(client): + resp = client.get("/search?q=agent") + assert resp.status_code == 200 + + +def test_about_page(client): + resp = client.get("/about") + assert resp.status_code == 200 + + +def test_ratings_page(client): + resp = client.get("/ratings") + assert resp.status_code == 200 + + +def test_citations_page(client): + resp = client.get("/citations") + assert resp.status_code == 200 + + +def test_ideas_page(client): + resp = client.get("/ideas") + assert resp.status_code == 200 + + +def test_idea_clusters_page(client): + resp = client.get("/idea-clusters") + assert resp.status_code == 200 + + +def test_architecture_page(client): + resp = client.get("/architecture") + assert resp.status_code == 200 + + +def test_impressum_page(client): + resp = client.get("/impressum") + assert resp.status_code == 200 + + +def test_datenschutz_page(client): + resp = client.get("/datenschutz") + assert resp.status_code == 200 + + +# ── 404 handling ────────────────────────────────────────────────────── + + +def test_404_for_nonexistent_page(client): + resp = client.get("/this-page-does-not-exist") + assert resp.status_code == 404 + + +def test_404_for_nonexistent_draft(client): + resp = client.get("/drafts/draft-nonexistent-foobar-xyz") + assert resp.status_code == 404 + + +# ── Admin pages (dev mode) ─────────────────────────────────────────── + + +def test_gaps_page_dev(client): + resp = client.get("/gaps") + assert resp.status_code == 200 + + +def test_monitor_page_dev(client): + resp = client.get("/monitor") + assert resp.status_code == 200 + + +def test_proposals_page_dev(client): + resp = client.get("/proposals") + assert resp.status_code == 200 + + +# ── Admin pages (production = 404) ─────────────────────────────────── + + +def test_gaps_page_prod_hidden(prod_client): + resp = prod_client.get("/gaps") + assert resp.status_code == 404 + + +def test_monitor_page_prod_hidden(prod_client): + resp = prod_client.get("/monitor") + assert resp.status_code == 404 + + +def test_proposals_page_prod_hidden(prod_client): + resp = prod_client.get("/proposals") + assert resp.status_code == 404 + + +# ── API endpoints ───────────────────────────────────────────────────── + + +def test_api_stats(client): + resp = client.get("/api/stats") + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, dict) + assert "total_drafts" in data or "drafts" in data or len(data) > 0 + + +def test_api_drafts(client): + resp = client.get("/api/drafts") + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, dict) + + +def test_api_ratings(client): + resp = client.get("/api/ratings") + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, dict) + + +def test_api_timeline(client): + resp = client.get("/api/timeline") + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, (dict, list)) + + +def test_api_categories(client): + resp = client.get("/api/categories") + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, dict) + + +def test_api_ideas(client): + resp = client.get("/api/ideas") + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, dict) + + +def test_api_search_empty(client): + resp = client.get("/api/search") + assert resp.status_code == 200 + data = resp.get_json() + assert "drafts" in data + + +def test_api_search_with_query(client): + resp = client.get("/api/search?q=agent") + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, dict) + + +def test_api_draft_detail_not_found(client): + resp = client.get("/api/drafts/nonexistent-draft-xyz") + assert resp.status_code == 404 + data = resp.get_json() + assert "error" in data + + +def test_api_architecture(client): + resp = client.get("/api/architecture") + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, dict) + + +def test_api_idea_clusters(client): + resp = client.get("/api/idea-clusters") + assert resp.status_code == 200 + + +def test_api_citations(client): + resp = client.get("/api/citations") + assert resp.status_code == 200 + + +def test_api_author_network(client): + resp = client.get("/api/authors/network") + assert resp.status_code == 200 + + +# ── CSV export format ───────────────────────────────────────────────── + + +def test_api_drafts_csv(client): + resp = client.get("/api/drafts?format=csv") + assert resp.status_code == 200 + assert "text/csv" in resp.content_type + + +def test_api_categories_csv(client): + resp = client.get("/api/categories?format=csv") + assert resp.status_code == 200 + assert "text/csv" in resp.content_type