feat(N4): Restliche Module — Tasks/Kommunikation/Wiki/Reports/Agents/Tags/Search + Navigation + Dashboard-Schnittstelle (#368)
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- Scope-Deklarationen: tasks only_mine, kommunikation conversation_ids, wiki category_ids (NEUE contracts.py), report_generator template_ids, automation agent_ids (module_key agents), tags tag_ids, unified_search entity_types dynamisch aus Provider-Registry - Core-Beiträge: navigation default_route (Startseite) + dashboard widget_app_ids (Widget-TYP-Angebot, Layout bleibt Phase M) - Backend-Filter (additive UND): /tasks (only_mine), /comm/conversations, /wiki/articles+/categories (Subtree), /reports/print-templates, /agents, /tags, /search GET+POST (entity_types-Schnitt), /miniapps?host=dashboard - apply_entity_type_scope-Helper (requested ∧ scope) - Frontend: WorkspaceSwitcher default_route-Navigation, Sidebar workspace-menu_order-Sortierung, workspaceStore moduleMenuOrder() - Tests: 18/18 Deklarationen + 11/11 Filter (TDD), Frontend 2/2 + Store 18/18, tsc clean, Build OK - Regression 64 passed (4 Kombi-Failures = Suite-Isolation, solo-bewiesen); Checker 0; Ruff = Vorbestand (Stash-bewiesen)
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
"""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"
|
||||
)
|
||||
@@ -0,0 +1,476 @@
|
||||
"""N4 — Workspace-Scopes in den restlichen Backend-Listen (Phase N).
|
||||
|
||||
X-Workspace-ID filtering for: tasks (only_mine), communication
|
||||
(conversation_ids), wiki (category_ids incl. subtree), reports
|
||||
(template_ids), agents (agent_ids), tags (tag_ids), search (entity_types
|
||||
intersection) and the dashboard widget-type boundary (miniapps listing,
|
||||
host=dashboard — the personal layout stays untouched, Phase M split).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
# ─── Helpers (shared with N3 pattern) ─────────────────────────
|
||||
|
||||
|
||||
async def _make_member(db: AsyncSession, seed: dict, email: str):
|
||||
from app.core.auth import hash_password
|
||||
from app.models.role import Role
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
name=email.split("@")[0].title(),
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
role = Role(
|
||||
tenant_id=seed["tenant_a"].id,
|
||||
name=f"n4-{uuid.uuid4().hex[:8]}",
|
||||
permissions={
|
||||
"tasks": {"read": True},
|
||||
"comm": {"read": True},
|
||||
"wiki": {"read": True},
|
||||
"reports": {"read": True},
|
||||
"agents": {"read": True},
|
||||
"tags": {"read": True},
|
||||
"search": {"read": True},
|
||||
"workspaces": {"read": True},
|
||||
},
|
||||
denied_permissions=[],
|
||||
field_permissions={},
|
||||
)
|
||||
db.add(role)
|
||||
await db.flush()
|
||||
db.add(
|
||||
UserTenant(
|
||||
user_id=user.id,
|
||||
tenant_id=seed["tenant_a"].id,
|
||||
is_default=True,
|
||||
role="viewer",
|
||||
role_id=role.id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def _make_workspace(db, seed, member, configs: dict[str, dict]) -> uuid.UUID:
|
||||
from app.services import workspace_service
|
||||
|
||||
tenant = seed["tenant_a"].id
|
||||
ws = await workspace_service.create_workspace(
|
||||
db, tenant, seed["admin_a"].id, f"N4WS-{uuid.uuid4().hex[:6]}"
|
||||
)
|
||||
ws_id = uuid.UUID(ws["id"])
|
||||
await workspace_service.assign_user(db, tenant, ws_id, member.id, role="member")
|
||||
modules = [
|
||||
{"module_key": k, "is_visible": True, "menu_order": i, "config": c}
|
||||
for i, (k, c) in enumerate(configs.items())
|
||||
]
|
||||
if modules:
|
||||
await workspace_service.set_workspace_modules(db, tenant, ws_id, modules)
|
||||
await db.commit()
|
||||
return ws_id
|
||||
|
||||
|
||||
# ─── Fixture: all N4 plugins active ──────────────────────────
|
||||
|
||||
|
||||
N4_PLUGINS = (
|
||||
"permissions",
|
||||
"unified_search",
|
||||
"dms",
|
||||
"kommunikation",
|
||||
"mail",
|
||||
"tasks",
|
||||
"wiki",
|
||||
"report_generator",
|
||||
"automation",
|
||||
"tags",
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_app(engine: AsyncEngine, redis_client):
|
||||
"""App with every N4 plugin (and their dependencies) active."""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.core.db import close_engine, reset_engine_for_testing
|
||||
from app.core.permission_registry import (
|
||||
init_permission_registry,
|
||||
register_plugin_permissions,
|
||||
)
|
||||
from app.core.service_container import get_container
|
||||
from app.main import create_app
|
||||
from app.plugins.builtins.automation.plugin import AutomationPlugin
|
||||
from app.plugins.builtins.dms.plugin import DmsPlugin
|
||||
from app.plugins.builtins.kommunikation.plugin import KommunikationPlugin
|
||||
from app.plugins.builtins.mail.plugin import MailPlugin
|
||||
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
|
||||
from app.plugins.builtins.report_generator.plugin import ReportGeneratorPlugin
|
||||
from app.plugins.builtins.tags.plugin import TagsPlugin
|
||||
from app.plugins.builtins.tasks.plugin import TasksPlugin
|
||||
from app.plugins.builtins.unified_search.plugin import UnifiedSearchPlugin
|
||||
from app.plugins.builtins.wiki.plugin import WikiPlugin
|
||||
from app.plugins.registry import reset_registry_for_testing
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||
|
||||
reset_engine_for_testing(engine)
|
||||
app = create_app()
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, app)
|
||||
init_permission_registry(active_plugin_names=set(N4_PLUGINS))
|
||||
container = get_container()
|
||||
await container.initialize()
|
||||
for plugin in (
|
||||
PermissionsPlugin(),
|
||||
UnifiedSearchPlugin(),
|
||||
DmsPlugin(),
|
||||
KommunikationPlugin(),
|
||||
MailPlugin(),
|
||||
TasksPlugin(),
|
||||
WikiPlugin(),
|
||||
ReportGeneratorPlugin(),
|
||||
AutomationPlugin(),
|
||||
TagsPlugin(),
|
||||
):
|
||||
registry.register_plugin(plugin)
|
||||
if plugin.manifest.permissions:
|
||||
register_plugin_permissions(plugin.name, plugin.manifest.permissions)
|
||||
reset_plugin_service_for_testing(registry)
|
||||
sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||||
async with sf() as session:
|
||||
for name in N4_PLUGINS:
|
||||
await registry.install(session, name)
|
||||
await registry.activate(session, name)
|
||||
await session.commit()
|
||||
yield app
|
||||
await close_engine()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_client(n4_app) -> AsyncClient:
|
||||
transport = ASGITransport(app=n4_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ─── tasks: only_mine ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_seed_tasks(n4_app, db_session: AsyncSession):
|
||||
from app.plugins.builtins.tasks.models import Task
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-tasks@example.com")
|
||||
tenant = seed["tenant_a"].id
|
||||
mine = Task(tenant_id=tenant, title="Mein Task", assigned_to=member.id, created_by=member.id)
|
||||
other = Task(tenant_id=tenant, title="Fremder Task", assigned_to=seed["admin_a"].id, created_by=seed["admin_a"].id)
|
||||
db_session.add_all([mine, other])
|
||||
await db_session.flush()
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"tasks": {"only_mine": True}})
|
||||
return member, ws_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tasks_only_mine(n4_client: AsyncClient, n4_seed_tasks):
|
||||
member, ws_id = n4_seed_tasks
|
||||
await login_client(n4_client, "n4-tasks@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/tasks", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
titles = {i["title"] for i in resp.json()["items"]}
|
||||
assert "Mein Task" in titles
|
||||
assert "Fremder Task" not in titles, "only_mine muss fremde Tasks ausblenden"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tasks_no_header_no_filter(n4_client: AsyncClient, n4_seed_tasks):
|
||||
member, ws_id = n4_seed_tasks
|
||||
await login_client(n4_client, "n4-tasks@example.com")
|
||||
|
||||
resp = await n4_client.get("/api/v1/tasks", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200, resp.text
|
||||
titles = {i["title"] for i in resp.json()["items"]}
|
||||
assert {"Mein Task", "Fremder Task"} <= titles
|
||||
|
||||
|
||||
# ─── communication: conversation_ids ─────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_seed_comm(n4_app, db_session: AsyncSession):
|
||||
from app.plugins.builtins.kommunikation.models import (
|
||||
CommConversation,
|
||||
CommParticipant,
|
||||
)
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-comm@example.com")
|
||||
tenant = seed["tenant_a"].id
|
||||
conv1 = CommConversation(tenant_id=tenant, title="Vertrieb-Raum")
|
||||
conv2 = CommConversation(tenant_id=tenant, title="Kaffeeklatsch")
|
||||
db_session.add_all([conv1, conv2])
|
||||
await db_session.flush()
|
||||
for conv in (conv1, conv2):
|
||||
db_session.add(
|
||||
CommParticipant(
|
||||
tenant_id=tenant,
|
||||
conversation_id=conv.id,
|
||||
participant_id=member.id,
|
||||
participant_type="user",
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"communication": {"conversation_ids": [str(conv1.id)]}})
|
||||
return member, ws_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_comm_conversations_scoped(n4_client: AsyncClient, n4_seed_comm):
|
||||
member, ws_id = n4_seed_comm
|
||||
await login_client(n4_client, "n4-comm@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/comm/conversations", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
titles = {c["title"] for c in resp.json()["items"]}
|
||||
assert "Vertrieb-Raum" in titles
|
||||
assert "Kaffeeklatsch" not in titles, "Räume-Scope muss ausgeblendet werden"
|
||||
|
||||
|
||||
# ─── wiki: category_ids (subtree) ────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_seed_wiki(n4_app, db_session: AsyncSession):
|
||||
from app.plugins.builtins.wiki.models import WikiArticle, WikiCategory
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-wiki@example.com")
|
||||
tenant = seed["tenant_a"].id
|
||||
cat1 = WikiCategory(tenant_id=tenant, name="Vertrieb", slug="vertrieb")
|
||||
db_session.add(cat1)
|
||||
await db_session.flush()
|
||||
cat1b = WikiCategory(tenant_id=tenant, name="Angebote", slug="angebote", parent_id=cat1.id)
|
||||
db_session.add(cat1b)
|
||||
await db_session.flush()
|
||||
cat2 = WikiCategory(tenant_id=tenant, name="Intern", slug="intern")
|
||||
db_session.add(cat2)
|
||||
await db_session.flush()
|
||||
a1 = WikiArticle(tenant_id=tenant, title="Playbook", slug="playbook", content="x", category_id=cat1.id)
|
||||
a2 = WikiArticle(tenant_id=tenant, title="Preise", slug="preise", content="x", category_id=cat1b.id)
|
||||
a3 = WikiArticle(tenant_id=tenant, title="Onboarding", slug="onboarding", content="x", category_id=cat2.id)
|
||||
db_session.add_all([a1, a2, a3])
|
||||
await db_session.flush()
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"wiki": {"category_ids": [str(cat1.id)]}})
|
||||
return member, ws_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_articles_scoped_subtree(n4_client: AsyncClient, n4_seed_wiki):
|
||||
member, ws_id = n4_seed_wiki
|
||||
await login_client(n4_client, "n4-wiki@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/wiki/articles", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
titles = {a["title"] for a in resp.json()["items"]}
|
||||
assert titles == {"Playbook", "Preise"}, "Scope-Kategorie + Subtree"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_categories_scoped(n4_client: AsyncClient, n4_seed_wiki):
|
||||
member, ws_id = n4_seed_wiki
|
||||
await login_client(n4_client, "n4-wiki@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/wiki/categories", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {c["name"] for c in resp.json()["items"]}
|
||||
assert names == {"Vertrieb", "Angebote"}
|
||||
|
||||
|
||||
# ─── reports: template_ids ───────────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_seed_reports(n4_app, db_session: AsyncSession):
|
||||
from app.plugins.builtins.report_generator.models import PrintTemplate
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-reports@example.com")
|
||||
tenant = seed["tenant_a"].id
|
||||
t1 = PrintTemplate(tenant_id=tenant, name="Angebot", created_by=member.id, blocks=[])
|
||||
t2 = PrintTemplate(tenant_id=tenant, name="Rechnung", created_by=member.id, blocks=[])
|
||||
db_session.add_all([t1, t2])
|
||||
await db_session.flush()
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"reports": {"template_ids": [str(t1.id)]}})
|
||||
return member, ws_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reports_templates_scoped(n4_client: AsyncClient, n4_seed_reports):
|
||||
member, ws_id = n4_seed_reports
|
||||
await login_client(n4_client, "n4-reports@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/reports/print-templates", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {t["name"] for t in resp.json()["items"]}
|
||||
assert names == {"Angebot"}
|
||||
|
||||
|
||||
# ─── agents: agent_ids ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_seed_agents(n4_app, db_session: AsyncSession):
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-agents@example.com")
|
||||
tenant = seed["tenant_a"].id
|
||||
g1 = AgentDefinition(tenant_id=tenant, name="Vertriebs-Assistent", llm_model="gpt", mode="agent", owner_id=member.id)
|
||||
g2 = AgentDefinition(tenant_id=tenant, name="Support-Bot", llm_model="gpt", mode="agent", owner_id=member.id)
|
||||
db_session.add_all([g1, g2])
|
||||
await db_session.flush()
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"agents": {"agent_ids": [str(g1.id)]}})
|
||||
return member, ws_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agents_scoped(n4_client: AsyncClient, n4_seed_agents):
|
||||
member, ws_id = n4_seed_agents
|
||||
await login_client(n4_client, "n4-agents@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/agents", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {a["name"] for a in resp.json()["items"]}
|
||||
assert names == {"Vertriebs-Assistent"}
|
||||
|
||||
|
||||
# ─── tags: tag_ids ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_seed_tags(n4_app, db_session: AsyncSession):
|
||||
from app.plugins.builtins.tags.models import Tag
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-tags@example.com")
|
||||
tenant = seed["tenant_a"].id
|
||||
tag1 = Tag(tenant_id=tenant, name="VIP", color="#ff0000")
|
||||
tag2 = Tag(tenant_id=tenant, name="Lead", color="#00ff00")
|
||||
db_session.add_all([tag1, tag2])
|
||||
await db_session.flush()
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"tags": {"tag_ids": [str(tag1.id)]}})
|
||||
return member, ws_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tags_scoped(n4_client: AsyncClient, n4_seed_tags):
|
||||
member, ws_id = n4_seed_tags
|
||||
await login_client(n4_client, "n4-tags@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/tags", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {t["name"] for t in resp.json()}
|
||||
assert names == {"VIP"}
|
||||
|
||||
|
||||
# ─── search: entity_types intersection ───────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_entity_type_scope_unit():
|
||||
"""The entity-type scope is a pure intersection (AND) — request ∧ scope."""
|
||||
from app.services.workspace_scope_service import apply_entity_type_scope
|
||||
|
||||
assert apply_entity_type_scope(None, ["contact", "mail"]) == ["contact", "mail"]
|
||||
assert apply_entity_type_scope(["contact", "task"], None) == ["contact", "task"]
|
||||
assert apply_entity_type_scope(["contact", "mail"], ["contact", "wiki_article"]) == ["contact"]
|
||||
assert apply_entity_type_scope(["task"], ["contact"]) == []
|
||||
assert apply_entity_type_scope(None, None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_route_with_workspace_header_ok(n4_client: AsyncClient, db_session: AsyncSession):
|
||||
"""Search with X-Workspace-ID answers 200 (scope intersection applied)."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-search@example.com")
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"search": {"entity_types": ["contact"]}})
|
||||
await login_client(n4_client, "n4-search@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get(
|
||||
"/api/v1/search",
|
||||
params={"q": "Alpha", "use_ai": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
# ─── dashboard boundary: miniapps listing ────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
def _clean_miniapp_registry():
|
||||
from app.plugins.miniapp_registry import reset_miniapp_registry
|
||||
|
||||
reset_miniapp_registry()
|
||||
yield
|
||||
reset_miniapp_registry()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_miniapps_dashboard_scope(n4_client: AsyncClient, db_session: AsyncSession):
|
||||
"""workspace widget_app_ids limits the OFFERED widget types on
|
||||
/miniapps?host=dashboard — personal layouts stay untouched (Phase M)."""
|
||||
from app.plugins.miniapp_registry import get_miniapp_registry
|
||||
|
||||
reg = get_miniapp_registry()
|
||||
reg.register(app_id="w1", name="Widget 1", plugin_name="test", component="@/x", hosts=["dashboard"])
|
||||
reg.register(app_id="w2", name="Widget 2", plugin_name="test", component="@/x", hosts=["dashboard"])
|
||||
reg.register(app_id="c1", name="Chat App", plugin_name="test", hosts=["chat"])
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-dash@example.com")
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"dashboard": {"widget_app_ids": ["w1"]}})
|
||||
await login_client(n4_client, "n4-dash@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
scoped = await n4_client.get("/api/v1/miniapps", params={"host": "dashboard"}, headers=headers)
|
||||
assert scoped.status_code == 200, scoped.text
|
||||
ids = {a["app_id"] for a in scoped.json()["items"]}
|
||||
assert ids == {"w1"}, "widget_app_ids muss das Widget-Angebot begrenzen"
|
||||
|
||||
unscoped = await n4_client.get("/api/v1/miniapps", params={"host": "dashboard"}, headers=ORIGIN_HEADER)
|
||||
assert unscoped.status_code == 200
|
||||
unscoped_ids = {a["app_id"] for a in unscoped.json()["items"]}
|
||||
# Plugin activation registers the plugins' own miniapps too — the test
|
||||
# apps must be a SUBSET of the unscoped offer (superset check).
|
||||
assert {"w1", "w2"} <= unscoped_ids, "Ohne Workspace: volles Angebot"
|
||||
|
||||
chat = await n4_client.get("/api/v1/miniapps", params={"host": "chat"}, headers=headers)
|
||||
assert chat.status_code == 200
|
||||
chat_ids = {a["app_id"] for a in chat.json()["items"]}
|
||||
# Plugin chat miniapps are registered too — c1 must be present and the
|
||||
# dashboard scope must NOT restrict the chat host (superset check).
|
||||
assert {"c1"} <= chat_ids, "Nur host=dashboard wird begrenzt"
|
||||
Reference in New Issue
Block a user