"""N4 — Scope-Deklarationen der restlichen Module (Phase N, letzter Task). Plugins declare workspace_scopes() for: tasks (only_mine), kommunikation (conversation_ids), wiki (category_ids subtree), reports (template_ids), agents (agent_ids), tags (tag_ids), search (entity_types — dynamic from the provider registry). Core contributions add navigation (default_route) and dashboard (widget_app_ids — the workspace limits the offered widget TYPES, never the personal layout, Phase M boundary). """ from __future__ import annotations import pytest from app.schemas.workspace import WorkspaceModuleScopes N4_PLUGINS = ( "tasks", "kommunikation", "wiki", "report_generator", "automation", "tags", "unified_search", ) N4_MODULES = { "tasks", "communication", "wiki", "reports", "agents", "tags", "search", "navigation", "dashboard", } # ─── Unit: Contract-Deklarationen der N4-Plugins ────────────── @pytest.mark.parametrize("plugin_name", N4_PLUGINS) def test_contract_declares_valid_scopes(plugin_name: str): """Every N4 plugin declares workspace_scopes() with valid contributions.""" from app.plugins.builtins.contracts import get_contract contract = get_contract(plugin_name) assert contract is not None, f"Contract für {plugin_name} fehlt" fn = getattr(contract, "workspace_scopes", None) assert callable(fn), f"{plugin_name} deklariert workspace_scopes() nicht" contributions = fn() or [] assert contributions, f"{plugin_name}: mindestens eine Contribution" for contribution in contributions: WorkspaceModuleScopes.model_validate(contribution) def _dims_for(plugin_name: str) -> dict[str, dict]: from app.plugins.builtins.contracts import get_contract return { d["key"]: d for c in get_contract(plugin_name).workspace_scopes() for d in c["dimensions"] } def test_tasks_declares_only_mine_toggle(): """Roadmap N4 tasks: „nur meine" — reiner Toggle ohne Wertequelle.""" dims = _dims_for("tasks") assert "only_mine" in dims assert dims["only_mine"]["control"] == "toggle" assert dims["only_mine"]["value_source"] is None # module_key tasks (Menüpfad /tasks) from app.plugins.builtins.contracts import get_contract keys = [c["module_key"] for c in get_contract("tasks").workspace_scopes()] assert "tasks" in keys def test_kommunikation_declares_conversation_ids(): """Roadmap N4 Kommunikation: Räume-Teilmengen.""" dims = _dims_for("kommunikation") assert "conversation_ids" in dims assert dims["conversation_ids"]["control"] == "multiselect" assert dims["conversation_ids"]["value_source"]["endpoint"] == "/api/v1/comm/conversations" from app.plugins.builtins.contracts import get_contract keys = [c["module_key"] for c in get_contract("kommunikation").workspace_scopes()] assert "communication" in keys # Menüpfad /communication def test_wiki_declares_category_ids(): """Roadmap N4 Wiki: Kategorien-Teilmengen (Subtree wie contacts/dms).""" dims = _dims_for("wiki") assert "category_ids" in dims assert dims["category_ids"]["value_source"]["endpoint"] == "/api/v1/wiki/categories" assert dims["category_ids"]["value_source"]["items_path"] == "items" def test_reports_declares_template_ids(): """Roadmap N4 Reports/Dokumente: Vorlagen-Teilmengen.""" dims = _dims_for("report_generator") assert "template_ids" in dims assert dims["template_ids"]["value_source"]["endpoint"] == "/api/v1/reports/print-templates" assert dims["template_ids"]["value_source"]["items_path"] == "items" from app.plugins.builtins.contracts import get_contract keys = [c["module_key"] for c in get_contract("report_generator").workspace_scopes()] assert "reports" in keys # Menüpfad /reports def test_automation_declares_agent_ids(): """Roadmap N4 Automation: Agenten-Teilmengen (module_key agents).""" dims = _dims_for("automation") assert "agent_ids" in dims assert dims["agent_ids"]["value_source"]["endpoint"] == "/api/v1/agents" assert dims["agent_ids"]["value_source"]["items_path"] == "items" from app.plugins.builtins.contracts import get_contract keys = [c["module_key"] for c in get_contract("automation").workspace_scopes()] assert "agents" in keys # page route /agents (kein Menüeintrag) def test_tags_declares_tag_ids(): """Roadmap N4 Tags: Tag-Teilmengen.""" dims = _dims_for("tags") assert "tag_ids" in dims assert dims["tag_ids"]["value_source"]["endpoint"] == "/api/v1/tags" assert dims["tag_ids"]["value_source"]["value_key"] == "id" def test_search_declares_entity_types_with_current_providers(): """Roadmap N4 Suche: Provider-Teilmengen — Optionen dynamisch aus der Provider-Registry (Contract liefert sie zur Aufrufzeit).""" from app.plugins.builtins.contracts import get_contract contributions = get_contract("unified_search").workspace_scopes() keys = [c["module_key"] for c in contributions] assert "search" in keys dims = {d["key"]: d for c in contributions for d in c["dimensions"]} assert "entity_types" in dims assert dims["entity_types"]["control"] == "multiselect" # Dynamic options: reflect the currently registered providers values = {o["value"] for o in dims["entity_types"]["options"]} assert {"contact", "task", "mail"} <= values, ( f"entity_types-Optionen enthalten nicht Core-Provider: {values}" ) # ─── Unit: Core-Beiträge (navigation, dashboard) ─────────────── def test_core_contributions_navigation_and_dashboard(): """Core-Module navigation + dashboard contribute scope dimensions via the aggregator (they are core-owned, not plugin-owned).""" from app.services.workspace_scope_service import get_scope_definitions modules = get_scope_definitions() assert "navigation" in modules, "navigation-Beitrag fehlt" nav_dims = {d["key"]: d for d in modules["navigation"]} assert nav_dims["default_route"]["control"] == "select" route_values = {o["value"] for o in nav_dims["default_route"]["options"]} assert "/" in route_values assert "/contacts" in route_values assert "dashboard" in modules, "dashboard-Beitrag fehlt" dash_dims = {d["key"]: d for d in modules["dashboard"]} assert dash_dims["widget_app_ids"]["control"] == "multiselect" source = dash_dims["widget_app_ids"]["value_source"] assert source["endpoint"] == "/api/v1/miniapps?host=dashboard" assert source["value_key"] == "app_id" def test_aggregator_covers_all_n4_modules(): """The aggregated registry covers every N4 module (plugin + core).""" from app.services.workspace_scope_service import get_scope_definitions modules = get_scope_definitions() for key in N4_MODULES: assert key in modules, f"Modul {key} fehlt in den Scope-Definitionen" assert modules[key], f"{key}: Dimensionen leer" # ─── Unit: deklarierte Value-Endpoints existieren (OpenAPI) ──── async def test_n4_value_endpoints_exist(app): """Every N4 value_source endpoint must exist as a GET route — checked via OpenAPI (app.routes carries only _IncludedRouter wrappers, N1 lesson).""" from app.plugins.builtins.contracts import get_contract paths = app.openapi().get("paths", {}) get_paths = {p for p, ops in paths.items() if "get" in ops} for plugin_name in N4_PLUGINS: contract = get_contract(plugin_name) for contribution in contract.workspace_scopes() or []: for dim in contribution.get("dimensions", []): source = dim.get("value_source") if not source: continue endpoint_path = source["endpoint"].split("?")[0] assert endpoint_path in get_paths, ( f"{plugin_name}: deklarierter Value-Endpoint {endpoint_path} " "existiert nicht als GET-Route" ) def test_navigation_routes_are_valid_paths(): """Navigation default_route options must be real frontend paths.""" from app.services.workspace_scope_service import get_scope_definitions nav = {d["key"]: d for d in get_scope_definitions()["navigation"]} for option in nav["default_route"]["options"]: assert option["value"].startswith("/"), ( f"Route {option['value']} muss mit / beginnen" )