T11: tags plugin + permissions plugin + entity links backend — 68 tests, 66.61% coverage
This commit is contained in:
+5
-1
@@ -35,6 +35,10 @@ from app.models.contact import Contact, CompanyContact
|
||||
from app.models.plugin import Plugin, PluginMigration
|
||||
from app.models.ai_conversation import AIConversation, AIMessage
|
||||
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||
# Import plugin models so Base.metadata.create_all includes their tables
|
||||
from app.plugins.builtins.tags.models import Tag, TagAssignment
|
||||
from app.plugins.builtins.permissions.models import Permission, ShareLink
|
||||
from app.plugins.builtins.entity_links.models import EntityLink
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@@ -90,7 +94,7 @@ def clean_tables(db_setup):
|
||||
sync_eng = _get_sync_engine()
|
||||
with sync_eng.connect() as conn:
|
||||
# TRUNCATE all tables with CASCADE — fast and reliable isolation
|
||||
conn.execute(text("TRUNCATE TABLE workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
|
||||
conn.execute(text("TRUNCATE TABLE entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
|
||||
conn.commit()
|
||||
sync_eng.dispose()
|
||||
yield
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
"""Tests for Entity Links plugin — link, unlink, reverse links, multi-links, event cleanup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||
|
||||
from app.core.db import reset_engine_for_testing, close_engine
|
||||
from app.core.event_bus import get_event_bus
|
||||
from app.core.service_container import get_container
|
||||
from app.main import create_app
|
||||
from app.plugins.registry import reset_registry_for_testing
|
||||
from app.plugins.builtins.entity_links import EntityLinksPlugin
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||
from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def plugin_app(engine: AsyncEngine, redis_client):
|
||||
"""FastAPI app with entity_links plugin registered."""
|
||||
reset_engine_for_testing(engine)
|
||||
app = create_app()
|
||||
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, app)
|
||||
|
||||
container = get_container()
|
||||
await container.initialize()
|
||||
|
||||
registry.register_plugin(EntityLinksPlugin())
|
||||
reset_plugin_service_for_testing(registry)
|
||||
|
||||
yield app
|
||||
await close_engine()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def plugin_client(plugin_app) -> AsyncClient:
|
||||
transport = ASGITransport(app=plugin_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def authed_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient:
|
||||
"""Authenticated admin client with seeded data."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
await login_client(plugin_client, "admin@tenanta.com")
|
||||
resp = await plugin_client.post("/api/v1/plugins/entity_links/install", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
resp = await plugin_client.post("/api/v1/plugins/entity_links/activate", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
return plugin_client, seed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_file_to_company(authed_client: AsyncClient):
|
||||
"""AC2: POST /api/v1/dms/files/{id}/link → 200, file linked to entity."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
company_id = str(seed["company_a"].id)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "company", "entity_id": company_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["file_id"] == file_id
|
||||
assert data["entity_type"] == "company"
|
||||
assert data["entity_id"] == company_id
|
||||
assert data["already_linked"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_file_to_contact(authed_client: AsyncClient):
|
||||
"""POST /api/v1/dms/files/{id}/link → 200, file linked to contact."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
# Use a random UUID for contact (no contact seeded, but link is N:M metadata)
|
||||
contact_id = str(uuid.uuid4())
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "contact", "entity_id": contact_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["entity_type"] == "contact"
|
||||
assert data["entity_id"] == contact_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlink_file_from_entity(authed_client: AsyncClient):
|
||||
"""AC3: DELETE /api/v1/dms/files/{id}/link → 204, link removed."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
company_id = str(seed["company_a"].id)
|
||||
|
||||
# Link first
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "company", "entity_id": company_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Unlink
|
||||
resp = await client.request(
|
||||
"DELETE",
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "company", "entity_id": company_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Verify links list is empty
|
||||
resp = await client.get(f"/api/v1/dms/files/{file_id}/links", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_file_links(authed_client: AsyncClient):
|
||||
"""GET /api/v1/dms/files/{id}/links → 200, list all linked entities for file."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
company_id = str(seed["company_a"].id)
|
||||
contact_id = str(uuid.uuid4())
|
||||
|
||||
# Link to company
|
||||
await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "company", "entity_id": company_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
# Link to contact
|
||||
await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "contact", "entity_id": contact_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
|
||||
resp = await client.get(f"/api/v1/dms/files/{file_id}/links", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_links_one_file_many_entities(authed_client: AsyncClient):
|
||||
"""Multi-links: one file → many entities."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
|
||||
# Link to 3 different companies
|
||||
for _ in range(3):
|
||||
entity_id = str(uuid.uuid4())
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "company", "entity_id": entity_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = await client.get(f"/api/v1/dms/files/{file_id}/links", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reverse_link_company_files(authed_client: AsyncClient):
|
||||
"""GET /api/v1/companies/{id}/files → 200, list linked files for company."""
|
||||
client, seed = authed_client
|
||||
company_id = str(seed["company_a"].id)
|
||||
|
||||
# Link 2 files to the company
|
||||
for _ in range(2):
|
||||
file_id = str(uuid.uuid4())
|
||||
await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "company", "entity_id": company_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
|
||||
resp = await client.get(f"/api/v1/companies/{company_id}/files", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
assert all(link["entity_type"] == "company" for link in data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reverse_link_contact_files(authed_client: AsyncClient):
|
||||
"""GET /api/v1/contacts/{id}/files → 200, list linked files for contact."""
|
||||
client, seed = authed_client
|
||||
contact_id = str(uuid.uuid4())
|
||||
|
||||
# Link 1 file to the contact
|
||||
file_id = str(uuid.uuid4())
|
||||
await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "contact", "entity_id": contact_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
|
||||
resp = await client.get(f"/api/v1/contacts/{contact_id}/files", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["entity_type"] == "contact"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_cleanup_on_company_deleted(authed_client: AsyncClient):
|
||||
"""AC13: DMS plugin listens to company.deleted event → linked files cleanup."""
|
||||
client, seed = authed_client
|
||||
company_id = seed["company_a"].id
|
||||
tenant_id = seed["tenant_a"].id
|
||||
|
||||
# Link a file to the company
|
||||
file_id = str(uuid.uuid4())
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "company", "entity_id": str(company_id)},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify link exists
|
||||
resp = await client.get(f"/api/v1/companies/{company_id}/files", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
# Publish company.deleted event
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish("company.deleted", {
|
||||
"entity_id": str(company_id),
|
||||
"company_id": str(company_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
})
|
||||
|
||||
# Allow async event handler to complete
|
||||
import asyncio
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Verify link is cleaned up
|
||||
resp = await client.get(f"/api/v1/companies/{company_id}/files", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_cleanup_on_contact_deleted(authed_client: AsyncClient):
|
||||
"""Event cleanup on contact.deleted → linked files removed."""
|
||||
client, seed = authed_client
|
||||
contact_id = uuid.uuid4()
|
||||
tenant_id = seed["tenant_a"].id
|
||||
|
||||
# Link a file to the contact
|
||||
file_id = str(uuid.uuid4())
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "contact", "entity_id": str(contact_id)},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify link exists
|
||||
resp = await client.get(f"/api/v1/contacts/{contact_id}/files", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
# Publish contact.deleted event
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish("contact.deleted", {
|
||||
"entity_id": str(contact_id),
|
||||
"contact_id": str(contact_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
})
|
||||
|
||||
# Allow async event handler to complete
|
||||
import asyncio
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Verify link is cleaned up
|
||||
resp = await client.get(f"/api/v1/contacts/{contact_id}/files", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_invalid_file_id(authed_client: AsyncClient):
|
||||
"""POST /api/v1/dms/files/{invalid}/link → 400."""
|
||||
client, seed = authed_client
|
||||
resp = await client.post(
|
||||
"/api/v1/dms/files/bad-uuid/link",
|
||||
json={"entity_type": "company", "entity_id": str(uuid.uuid4())},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_invalid_entity_id(authed_client: AsyncClient):
|
||||
"""POST /api/v1/dms/files/{id}/link with invalid entity_id → 400."""
|
||||
client, seed = authed_client
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{uuid.uuid4()}/link",
|
||||
json={"entity_type": "company", "entity_id": "bad-uuid"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_invalid_entity_type(authed_client: AsyncClient):
|
||||
"""POST /api/v1/dms/files/{id}/link with invalid entity_type → 400."""
|
||||
client, seed = authed_client
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{uuid.uuid4()}/link",
|
||||
json={"entity_type": "invalid", "entity_id": str(uuid.uuid4())},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_already_linked(authed_client: AsyncClient):
|
||||
"""POST /api/v1/dms/files/{id}/link twice → already_linked=True."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
company_id = str(seed["company_a"].id)
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "company", "entity_id": company_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["already_linked"] is False
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "company", "entity_id": company_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["already_linked"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlink_not_found(authed_client: AsyncClient):
|
||||
"""DELETE /api/v1/dms/files/{id}/link with nonexistent link → 404."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
company_id = str(seed["company_a"].id)
|
||||
resp = await client.request(
|
||||
"DELETE",
|
||||
f"/api/v1/dms/files/{file_id}/link",
|
||||
json={"entity_type": "company", "entity_id": company_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlink_invalid_file_id(authed_client: AsyncClient):
|
||||
"""DELETE /api/v1/dms/files/{invalid}/link → 400."""
|
||||
client, seed = authed_client
|
||||
resp = await client.request(
|
||||
"DELETE",
|
||||
"/api/v1/dms/files/bad-uuid/link",
|
||||
json={"entity_type": "company", "entity_id": str(uuid.uuid4())},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_file_links_invalid_id(authed_client: AsyncClient):
|
||||
"""GET /api/v1/dms/files/{invalid}/links → 400."""
|
||||
client, seed = authed_client
|
||||
resp = await client.get("/api/v1/dms/files/bad-uuid/links", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_company_files_invalid_id(authed_client: AsyncClient):
|
||||
"""GET /api/v1/companies/{invalid}/files → 400."""
|
||||
client, seed = authed_client
|
||||
resp = await client.get("/api/v1/companies/bad-uuid/files", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contact_files_invalid_id(authed_client: AsyncClient):
|
||||
"""GET /api/v1/contacts/{invalid}/files → 400."""
|
||||
client, seed = authed_client
|
||||
resp = await client.get("/api/v1/contacts/bad-uuid/files", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_company_files_empty(authed_client: AsyncClient):
|
||||
"""GET /api/v1/companies/{id}/files with no links → 200 + empty list."""
|
||||
client, seed = authed_client
|
||||
company_id = str(seed["company_a"].id)
|
||||
resp = await client.get(f"/api/v1/companies/{company_id}/files", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contact_files_empty(authed_client: AsyncClient):
|
||||
"""GET /api/v1/contacts/{id}/files with no links → 200 + empty list."""
|
||||
client, seed = authed_client
|
||||
contact_id = str(uuid.uuid4())
|
||||
resp = await client.get(f"/api/v1/contacts/{contact_id}/files", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
@@ -0,0 +1,457 @@
|
||||
"""Tests for Permissions plugin — permissions, share links, public access, 403 enforcement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||
|
||||
from app.core.db import reset_engine_for_testing, close_engine
|
||||
from app.core.service_container import get_container
|
||||
from app.main import create_app
|
||||
from app.plugins.registry import reset_registry_for_testing
|
||||
from app.plugins.builtins.permissions import PermissionsPlugin
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||
from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def plugin_app(engine: AsyncEngine, redis_client):
|
||||
"""FastAPI app with permissions plugin registered."""
|
||||
reset_engine_for_testing(engine)
|
||||
app = create_app()
|
||||
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, app)
|
||||
|
||||
container = get_container()
|
||||
await container.initialize()
|
||||
|
||||
registry.register_plugin(PermissionsPlugin())
|
||||
reset_plugin_service_for_testing(registry)
|
||||
|
||||
yield app
|
||||
await close_engine()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def plugin_client(plugin_app) -> AsyncClient:
|
||||
transport = ASGITransport(app=plugin_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def authed_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient:
|
||||
"""Authenticated admin client with seeded data."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
await login_client(plugin_client, "admin@tenanta.com")
|
||||
resp = await plugin_client.post("/api/v1/plugins/permissions/install", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
resp = await plugin_client.post("/api/v1/plugins/permissions/activate", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
return plugin_client, seed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_permissions_empty(authed_client: AsyncClient):
|
||||
"""AC1: GET /api/v1/dms/files/{id}/permissions → 200 + permission list."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
resp = await client.get(f"/api/v1/dms/files/{file_id}/permissions", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grant_permission(authed_client: AsyncClient):
|
||||
"""POST /api/v1/dms/files/{id}/permissions → 201, permission granted."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
user_id = str(seed["admin_a"].id)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/permissions",
|
||||
json={"user_id": user_id, "access_level": "read"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["user_id"] == user_id
|
||||
assert data["access_level"] == "read"
|
||||
|
||||
# List permissions should show it
|
||||
resp = await client.get(f"/api/v1/dms/files/{file_id}/permissions", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_permission(authed_client: AsyncClient):
|
||||
"""DELETE /api/v1/dms/files/{id}/permissions/{user_id} → 204."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
user_id = str(seed["admin_a"].id)
|
||||
|
||||
# Grant first
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/permissions",
|
||||
json={"user_id": user_id, "access_level": "write"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
# Revoke
|
||||
resp = await client.delete(
|
||||
f"/api/v1/dms/files/{file_id}/permissions/{user_id}",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# List should be empty
|
||||
resp = await client.get(f"/api/v1/dms/files/{file_id}/permissions", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_share_link(authed_client: AsyncClient):
|
||||
"""AC4: POST /api/v1/dms/files/{id}/share-link → 200 + public token URL."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/share-link",
|
||||
json={"access_level": "download"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "token" in data
|
||||
assert "public_url" in data
|
||||
assert data["has_password"] is False
|
||||
assert data["file_id"] == file_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_share_link_with_password(authed_client: AsyncClient):
|
||||
"""Share link with password — POST verify with correct password succeeds."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/share-link",
|
||||
json={"password": "Secret123", "access_level": "download"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["has_password"] is True
|
||||
token = data["token"]
|
||||
|
||||
# GET without password → 401
|
||||
resp = await client.get(f"/api/public/share/{token}")
|
||||
assert resp.status_code == 401
|
||||
|
||||
# POST with wrong password → 403
|
||||
resp = await client.post(
|
||||
f"/api/public/share/{token}",
|
||||
json={"password": "WrongPass"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
# POST with correct password → 200
|
||||
resp = await client.post(
|
||||
f"/api/public/share/{token}",
|
||||
json={"password": "Secret123"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["file_id"] == file_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_share_link(authed_client: AsyncClient):
|
||||
"""AC5: GET /api/public/share/{token} with expired link → 410."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
|
||||
# Create link with expiry in the past
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/share-link",
|
||||
json={"expires_at": past.isoformat(), "access_level": "download"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
token = resp.json()["token"]
|
||||
|
||||
# GET → 410 Gone
|
||||
resp = await client.get(f"/api/public/share/{token}")
|
||||
assert resp.status_code == 410
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_share_no_password(authed_client: AsyncClient):
|
||||
"""Public share link without password — GET returns file info."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/share-link",
|
||||
json={"access_level": "preview"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
token = resp.json()["token"]
|
||||
|
||||
# Public GET — no auth, no Origin header needed
|
||||
resp = await client.get(f"/api/public/share/{token}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["file_id"] == file_id
|
||||
assert data["access_level"] == "preview"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_share_link(authed_client: AsyncClient):
|
||||
"""DELETE /api/v1/dms/share-links/{id} → 204, link revoked."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/share-link",
|
||||
json={"access_level": "download"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
link_id = resp.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/api/v1/dms/share-links/{link_id}", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_403_for_unauthorized_user(plugin_client: AsyncClient, db_session: AsyncSession):
|
||||
"""AC14: Folder permissions enforced: user without read → 403.
|
||||
|
||||
The permissions plugin does not intercept file access (no DMS file system yet).
|
||||
We test the permission check helper directly: a user with no permission record
|
||||
gets denied (False), which translates to 403 at the route layer.
|
||||
"""
|
||||
from app.plugins.builtins.permissions.routes import check_user_file_permission
|
||||
from app.core.db import get_session_factory
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
await login_client(plugin_client, "admin@tenanta.com")
|
||||
|
||||
# Install + activate permissions plugin
|
||||
registry = reset_registry_for_testing()
|
||||
# We need to set up the registry properly
|
||||
# Since plugin_client fixture wasn't used, manually install
|
||||
resp = await plugin_client.post("/api/v1/plugins/permissions/install", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
resp = await plugin_client.post("/api/v1/plugins/permissions/activate", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
|
||||
file_id = uuid.uuid4()
|
||||
tenant_id = seed["tenant_a"].id
|
||||
viewer_id = seed["viewer_a"].id
|
||||
admin_id = seed["admin_a"].id
|
||||
|
||||
# No permission record for viewer → check returns False
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
has_perm = await check_user_file_permission(session, tenant_id, file_id, viewer_id, "read")
|
||||
assert has_perm is False
|
||||
|
||||
# Grant read to admin
|
||||
from app.plugins.builtins.permissions.models import Permission
|
||||
perm = Permission(
|
||||
tenant_id=tenant_id,
|
||||
file_id=file_id,
|
||||
user_id=admin_id,
|
||||
access_level="read",
|
||||
)
|
||||
session.add(perm)
|
||||
await session.flush()
|
||||
|
||||
has_perm = await check_user_file_permission(session, tenant_id, file_id, admin_id, "read")
|
||||
assert has_perm is True
|
||||
|
||||
# Viewer still has no permission
|
||||
has_perm = await check_user_file_permission(session, tenant_id, file_id, viewer_id, "read")
|
||||
assert has_perm is False
|
||||
await session.rollback()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_permissions_invalid_file_id(authed_client: AsyncClient):
|
||||
"""GET /api/v1/dms/files/{invalid}/permissions → 400."""
|
||||
client, seed = authed_client
|
||||
resp = await client.get("/api/v1/dms/files/bad-uuid/permissions", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grant_permission_duplicate(authed_client: AsyncClient):
|
||||
"""POST /api/v1/dms/files/{id}/permissions twice → 409."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
user_id = str(seed["admin_a"].id)
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/permissions",
|
||||
json={"user_id": user_id, "access_level": "read"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/permissions",
|
||||
json={"user_id": user_id, "access_level": "read"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grant_permission_invalid_ids(authed_client: AsyncClient):
|
||||
"""POST /api/v1/dms/files/{invalid}/permissions → 400."""
|
||||
client, seed = authed_client
|
||||
resp = await client.post(
|
||||
"/api/v1/dms/files/bad-uuid/permissions",
|
||||
json={"user_id": str(uuid.uuid4()), "access_level": "read"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grant_permission_with_group(authed_client: AsyncClient):
|
||||
"""POST /api/v1/dms/files/{id}/permissions with group_id → 201."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
user_id = str(seed["admin_a"].id)
|
||||
group_id = str(uuid.uuid4())
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/permissions",
|
||||
json={"user_id": user_id, "group_id": group_id, "access_level": "read"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["group_id"] == group_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_permission_not_found(authed_client: AsyncClient):
|
||||
"""DELETE /api/v1/dms/files/{id}/permissions/{user_id} with no perms → 404."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
user_id = str(seed["admin_a"].id)
|
||||
resp = await client.delete(
|
||||
f"/api/v1/dms/files/{file_id}/permissions/{user_id}",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_permission_invalid_ids(authed_client: AsyncClient):
|
||||
"""DELETE /api/v1/dms/files/{invalid}/permissions/{user_id} → 400."""
|
||||
client, seed = authed_client
|
||||
user_id = str(seed["admin_a"].id)
|
||||
resp = await client.delete(
|
||||
f"/api/v1/dms/files/bad-uuid/permissions/{user_id}",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_share_link_invalid_file_id(authed_client: AsyncClient):
|
||||
"""POST /api/v1/dms/files/{invalid}/share-link → 400."""
|
||||
client, seed = authed_client
|
||||
resp = await client.post(
|
||||
"/api/v1/dms/files/bad-uuid/share-link",
|
||||
json={"access_level": "download"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_share_link_not_found(authed_client: AsyncClient):
|
||||
"""DELETE /api/v1/dms/share-links/{nonexistent} → 404."""
|
||||
client, seed = authed_client
|
||||
resp = await client.delete(f"/api/v1/dms/share-links/{uuid.uuid4()}", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_share_link_invalid_id(authed_client: AsyncClient):
|
||||
"""DELETE /api/v1/dms/share-links/{invalid} → 400."""
|
||||
client, seed = authed_client
|
||||
resp = await client.delete("/api/v1/dms/share-links/bad-uuid", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_access_not_found(authed_client: AsyncClient):
|
||||
"""GET /api/public/share/{nonexistent} → 404."""
|
||||
client, seed = authed_client
|
||||
resp = await client.get("/api/public/share/nonexistent-token-xyz")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_access_post_not_found(authed_client: AsyncClient):
|
||||
"""POST /api/public/share/{nonexistent} → 404."""
|
||||
client, seed = authed_client
|
||||
resp = await client.post(
|
||||
"/api/public/share/nonexistent-token-xyz",
|
||||
json={"password": "test"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_access_post_expired(authed_client: AsyncClient):
|
||||
"""POST /api/public/share/{token} with expired link → 410."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/share-link",
|
||||
json={"expires_at": past.isoformat(), "access_level": "download"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
token = resp.json()["token"]
|
||||
resp = await client.post(
|
||||
f"/api/public/share/{token}",
|
||||
json={"password": "test"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 410
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_access_post_no_password_required(authed_client: AsyncClient):
|
||||
"""POST /api/public/share/{token} for link without password → 200."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/share-link",
|
||||
json={"access_level": "preview"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
token = resp.json()["token"]
|
||||
resp = await client.post(
|
||||
f"/api/public/share/{token}",
|
||||
json={},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_password"] is False
|
||||
@@ -0,0 +1,490 @@
|
||||
"""Tests for Tags plugin — CRUD, assignment, bulk assign, cascade delete, counts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||
|
||||
from app.core.db import Base, reset_engine_for_testing, close_engine
|
||||
from app.core.service_container import get_container
|
||||
from app.main import create_app
|
||||
from app.plugins.registry import get_registry, reset_registry_for_testing
|
||||
from app.plugins.builtins.tags import TagsPlugin
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||
from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def plugin_app(engine: AsyncEngine, redis_client):
|
||||
"""FastAPI app with tags plugin registered."""
|
||||
reset_engine_for_testing(engine)
|
||||
app = create_app()
|
||||
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, app)
|
||||
|
||||
container = get_container()
|
||||
await container.initialize()
|
||||
|
||||
registry.register_plugin(TagsPlugin())
|
||||
reset_plugin_service_for_testing(registry)
|
||||
|
||||
yield app
|
||||
await close_engine()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def plugin_client(plugin_app) -> AsyncClient:
|
||||
transport = ASGITransport(app=plugin_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def authed_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient:
|
||||
"""Authenticated admin client with seeded data."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
await login_client(plugin_client, "admin@tenanta.com")
|
||||
# Install + activate the tags plugin
|
||||
resp = await plugin_client.post("/api/v1/plugins/tags/install", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
resp = await plugin_client.post("/api/v1/plugins/tags/activate", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
return plugin_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_empty(authed_client: AsyncClient):
|
||||
"""AC6: GET /api/v1/tags → 200 + tags with counts (empty)."""
|
||||
resp = await authed_client.get("/api/v1/tags", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tag(authed_client: AsyncClient):
|
||||
"""AC7: POST /api/v1/tags → 201, tag created."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags",
|
||||
json={"name": "Important", "color": "#FF0000"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "Important"
|
||||
assert data["color"] == "#FF0000"
|
||||
assert data["entity_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_with_counts(authed_client: AsyncClient):
|
||||
"""AC6: GET /api/v1/tags → 200 + tags with counts."""
|
||||
# Create a tag
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags",
|
||||
json={"name": "VIP", "color": "#00FF00"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
tag_id = resp.json()["id"]
|
||||
|
||||
# List tags — should show entity_count=0
|
||||
resp = await authed_client.get("/api/v1/tags", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "VIP"
|
||||
assert data[0]["entity_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tag(authed_client: AsyncClient):
|
||||
"""AC8: PATCH /api/v1/tags/{id} → 200."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags",
|
||||
json={"name": "Old Name", "color": "#000000"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
|
||||
resp = await authed_client.patch(
|
||||
f"/api/v1/tags/{tag_id}",
|
||||
json={"name": "New Name", "color": "#FFFFFF"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "New Name"
|
||||
assert data["color"] == "#FFFFFF"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_tag_cascade(authed_client: AsyncClient):
|
||||
"""AC9: DELETE /api/v1/tags/{id} → 204, cascade delete assignments."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags",
|
||||
json={"name": "ToDelete", "color": "#123456"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
|
||||
# Assign tag to an entity
|
||||
entity_id = str(uuid.uuid4())
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/assign",
|
||||
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Delete tag
|
||||
resp = await authed_client.delete(f"/api/v1/tags/{tag_id}", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Verify tag is gone
|
||||
resp = await authed_client.get("/api/v1/tags", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_tag(authed_client: AsyncClient):
|
||||
"""AC10: POST /api/v1/tags/assign → 200, tag assigned to entity."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags",
|
||||
json={"name": "AssignMe", "color": "#FF00FF"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
entity_id = str(uuid.uuid4())
|
||||
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/assign",
|
||||
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["tag_id"] == tag_id
|
||||
assert data["entity_type"] == "company"
|
||||
assert data["entity_id"] == entity_id
|
||||
assert data["already_assigned"] is False
|
||||
|
||||
# Verify count increased
|
||||
resp = await authed_client.get("/api/v1/tags", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()[0]["entity_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unassign_tag(authed_client: AsyncClient):
|
||||
"""AC11: DELETE /api/v1/tags/assign → 204, tag removed."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags",
|
||||
json={"name": "UnassignMe", "color": "#AAAAAA"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
entity_id = str(uuid.uuid4())
|
||||
|
||||
# Assign first
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/assign",
|
||||
json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Unassign
|
||||
resp = await authed_client.request(
|
||||
"DELETE",
|
||||
"/api/v1/tags/assign",
|
||||
json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_assign_tags(authed_client: AsyncClient):
|
||||
"""AC12: POST /api/v1/tags/bulk-assign → 200, multiple tags assigned."""
|
||||
tag_ids = []
|
||||
for i in range(3):
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags",
|
||||
json={"name": f"BulkTag{i}", "color": "#0000FF"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_ids.append(resp.json()["id"])
|
||||
|
||||
entity_id = str(uuid.uuid4())
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/bulk-assign",
|
||||
json={"tag_ids": tag_ids, "entity_type": "file", "entity_id": entity_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 3
|
||||
assert len(data["assigned"]) == 3
|
||||
assert len(data["already_assigned"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tag_entities(authed_client: AsyncClient):
|
||||
"""GET /api/v1/tags/{id}/entities → 200, list entities with this tag."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags",
|
||||
json={"name": "EntitiesTag", "color": "#FF8800"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
|
||||
entity_ids = [str(uuid.uuid4()) for _ in range(2)]
|
||||
for eid in entity_ids:
|
||||
await authed_client.post(
|
||||
"/api/v1/tags/assign",
|
||||
json={"tag_id": tag_id, "entity_type": "company", "entity_id": eid},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
|
||||
resp = await authed_client.get(f"/api/v1/tags/{tag_id}/entities", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tag_duplicate(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags with existing name → 409."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags",
|
||||
json={"name": "DupeTag", "color": "#111111"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags",
|
||||
json={"name": "DupeTag", "color": "#222222"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tag_not_found(authed_client: AsyncClient):
|
||||
"""PATCH /api/v1/tags/{nonexistent} → 404."""
|
||||
resp = await authed_client.patch(
|
||||
f"/api/v1/tags/{uuid.uuid4()}",
|
||||
json={"name": "NewName"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tag_invalid_id(authed_client: AsyncClient):
|
||||
"""PATCH /api/v1/tags/{invalid_uuid} → 400."""
|
||||
resp = await authed_client.patch(
|
||||
"/api/v1/tags/not-a-uuid",
|
||||
json={"name": "NewName"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tag_duplicate_name(authed_client: AsyncClient):
|
||||
"""PATCH /api/v1/tags/{id} with existing name → 409."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "TagA", "color": "#AAAAAA"}, headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "TagB", "color": "#BBBBBB"}, headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
tag_b_id = resp.json()["id"]
|
||||
resp = await authed_client.patch(
|
||||
f"/api/v1/tags/{tag_b_id}",
|
||||
json={"name": "TagA"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tag_color_only(authed_client: AsyncClient):
|
||||
"""PATCH /api/v1/tags/{id} with only color → 200."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "ColorTag", "color": "#CCCCCC"}, headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
resp = await authed_client.patch(
|
||||
f"/api/v1/tags/{tag_id}",
|
||||
json={"color": "#DDDDDD"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["color"] == "#DDDDDD"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_tag_not_found(authed_client: AsyncClient):
|
||||
"""DELETE /api/v1/tags/{nonexistent} → 404."""
|
||||
resp = await authed_client.delete(f"/api/v1/tags/{uuid.uuid4()}", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_tag_invalid_id(authed_client: AsyncClient):
|
||||
"""DELETE /api/v1/tags/{invalid} → 400."""
|
||||
resp = await authed_client.delete("/api/v1/tags/bad-uuid", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_tag_invalid_entity_type(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/assign with invalid entity_type → 400."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "ETTag", "color": "#EEEEEE"}, headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/assign",
|
||||
json={"tag_id": tag_id, "entity_type": "invalid", "entity_id": str(uuid.uuid4())},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_tag_not_found(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/assign with nonexistent tag → 404."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/assign",
|
||||
json={"tag_id": str(uuid.uuid4()), "entity_type": "company", "entity_id": str(uuid.uuid4())},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_tag_already_assigned(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/assign twice → already_assigned=True."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "DupAssign", "color": "#FFFFFF"}, headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
entity_id = str(uuid.uuid4())
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/assign",
|
||||
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["already_assigned"] is False
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/assign",
|
||||
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["already_assigned"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_tag_invalid_ids(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/assign with invalid tag_id → 400."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/assign",
|
||||
json={"tag_id": "bad", "entity_type": "company", "entity_id": str(uuid.uuid4())},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unassign_tag_not_found(authed_client: AsyncClient):
|
||||
"""DELETE /api/v1/tags/assign with nonexistent assignment → 404."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "Unassign404", "color": "#ABABAB"}, headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
resp = await authed_client.request(
|
||||
"DELETE",
|
||||
"/api/v1/tags/assign",
|
||||
json={"tag_id": tag_id, "entity_type": "company", "entity_id": str(uuid.uuid4())},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_assign_invalid_entity_type(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/bulk-assign with invalid entity_type → 400."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "BulkBad", "color": "#000001"}, headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/bulk-assign",
|
||||
json={"tag_ids": [tag_id], "entity_type": "invalid", "entity_id": str(uuid.uuid4())},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_assign_tag_not_found(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/bulk-assign with nonexistent tag → 404."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/bulk-assign",
|
||||
json={"tag_ids": [str(uuid.uuid4())], "entity_type": "file", "entity_id": str(uuid.uuid4())},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_assign_already_assigned(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/bulk-assign twice → already_assigned populated."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "BulkDup1", "color": "#001100"}, headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
entity_id = str(uuid.uuid4())
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/bulk-assign",
|
||||
json={"tag_ids": [tag_id], "entity_type": "file", "entity_id": entity_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/bulk-assign",
|
||||
json={"tag_ids": [tag_id], "entity_type": "file", "entity_id": entity_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["already_assigned"]) == 1
|
||||
assert len(data["assigned"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tag_entities_not_found(authed_client: AsyncClient):
|
||||
"""GET /api/v1/tags/{nonexistent}/entities → 404."""
|
||||
resp = await authed_client.get(f"/api/v1/tags/{uuid.uuid4()}/entities", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tag_entities_invalid_id(authed_client: AsyncClient):
|
||||
"""GET /api/v1/tags/{invalid}/entities → 400."""
|
||||
resp = await authed_client.get("/api/v1/tags/bad-uuid/entities", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 400
|
||||
Reference in New Issue
Block a user