T04: DMS plugin backend — folders + files + preview + OnlyOffice + shares + search + bulk — 106 tests, 97.90% coverage

This commit is contained in:
leocrm-bot
2026-06-29 20:48:58 +02:00
parent a2452cc04b
commit fdb41dade1
14 changed files with 3764 additions and 66 deletions
+67 -1
View File
@@ -7,6 +7,8 @@ Auth helpers talk to the HTTP API (integration tests).
from __future__ import annotations
import asyncio
import os
import shutil
from collections.abc import AsyncGenerator
from typing import Any
@@ -24,6 +26,7 @@ from sqlalchemy.ext.asyncio import (
from app.core.auth import hash_password
from app.core.db import Base, close_engine, reset_engine_for_testing
from app.core.service_container import get_container # noqa: F401
from app.main import create_app
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
from app.models.company import Company
@@ -33,9 +36,15 @@ from app.models.role import Role
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401
from app.plugins.builtins.dms import DmsPlugin # noqa: F401
from app.plugins.builtins.dms.models import File as DmsFile # noqa: F401
from app.plugins.builtins.dms.models import Folder # noqa: F401
from app.plugins.builtins.entity_links.models import EntityLink # noqa: F401
from app.plugins.builtins.permissions import PermissionsPlugin # noqa: F401
from app.plugins.builtins.permissions.models import Permission, ShareLink # noqa: F401
from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401
from app.plugins.registry import reset_registry_for_testing # noqa: F401
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
# Import plugin models so Base.metadata.create_all includes their tables
@@ -94,7 +103,7 @@ def clean_tables(db_setup):
# TRUNCATE all tables with CASCADE — fast and reliable isolation
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;"
"TRUNCATE TABLE files, folders, 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()
@@ -283,3 +292,60 @@ async def get_auth_client(
"""Return a client that's logged in."""
await login_client(client, email, password)
return client
DMS_TEST_STORAGE = "/tmp/dms_test"
@pytest_asyncio.fixture
async def dms_app(engine: AsyncEngine, redis_client):
"""FastAPI app with DMS + Permissions plugins registered."""
os.environ["DMS_STORAGE_BASE"] = DMS_TEST_STORAGE
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())
registry.register_plugin(DmsPlugin())
reset_plugin_service_for_testing(registry)
yield app
await close_engine()
# Cleanup test storage
if os.path.exists(DMS_TEST_STORAGE):
shutil.rmtree(DMS_TEST_STORAGE, ignore_errors=True)
@pytest_asyncio.fixture
async def dms_client(dms_app) -> AsyncClient:
transport = ASGITransport(app=dms_app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
async def authed_client(
dms_client: AsyncClient, db_session: AsyncSession
) -> tuple[AsyncClient, dict]:
"""Authenticated admin client with seeded data and both plugins activated."""
seed = await seed_tenant_and_users(db_session)
await login_client(dms_client, "admin@tenanta.com")
# Install + activate permissions plugin first (DMS depends on it)
resp = await dms_client.post("/api/v1/plugins/permissions/install", headers=ORIGIN_HEADER)
assert resp.status_code == 200, f"Permissions install failed: {resp.text}"
resp = await dms_client.post("/api/v1/plugins/permissions/activate", headers=ORIGIN_HEADER)
assert resp.status_code == 200, f"Permissions activate failed: {resp.text}"
# Install + activate DMS plugin
resp = await dms_client.post("/api/v1/plugins/dms/install", headers=ORIGIN_HEADER)
assert resp.status_code == 200, f"DMS install failed: {resp.text}"
resp = await dms_client.post("/api/v1/plugins/dms/activate", headers=ORIGIN_HEADER)
assert resp.status_code == 200, f"DMS activate failed: {resp.text}"
return dms_client, seed
+740
View File
@@ -0,0 +1,740 @@
"""Tests for DMS plugin — folders, files, preview, OnlyOffice, sharing, search, bulk.
Tests all 19 acceptance criteria including AC14/AC15 which verify the existing
permissions plugin public share endpoints.
"""
from __future__ import annotations
import os
import pytest
from tests.conftest import ORIGIN_HEADER, login_client
PDF_CONTENT = b"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/MediaBox[0 0 612 792]>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000058 00000 n\n0000000115 00000 n\ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF"
# ─── AC1: List folders (tree) ───
@pytest.mark.asyncio
async def test_ac1_list_folders_empty(authed_client):
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (empty)."""
client, _ = authed_client
resp = await client.get("/api/v1/dms/folders", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_ac1_list_folders_tree(authed_client):
"""AC1: GET /api/v1/dms/folders → 200 + folder tree with nested children."""
client, _ = authed_client
# Create root folder
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "Root"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
root_id = resp.json()["id"]
# Create child folder
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "Child", "parent_id": root_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
# Get tree
resp = await client.get("/api/v1/dms/folders", headers=ORIGIN_HEADER)
assert resp.status_code == 200
tree = resp.json()
assert len(tree) == 1
assert tree[0]["name"] == "Root"
assert len(tree[0]["children"]) == 1
assert tree[0]["children"][0]["name"] == "Child"
# ─── AC2: Create folder ───
@pytest.mark.asyncio
async def test_ac2_create_folder(authed_client):
"""AC2: POST /api/v1/dms/folders → 201, folder created with path."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "Documents"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Documents"
assert data["path"] == "Documents"
assert data["parent_id"] is None
assert "id" in data
@pytest.mark.asyncio
async def test_ac2_create_folder_with_parent(authed_client):
"""AC2: POST /api/v1/dms/folders → 201, nested folder with full path."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "Parent"},
headers=ORIGIN_HEADER,
)
parent_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "Sub", "parent_id": parent_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Sub"
assert data["path"] == "Parent/Sub"
assert data["parent_id"] == parent_id
# ─── AC3: Update folder (rename/move) ───
@pytest.mark.asyncio
async def test_ac3_rename_folder(authed_client):
"""AC3: PATCH /api/v1/dms/folders/{id} → 200, renamed."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "OldName"},
headers=ORIGIN_HEADER,
)
folder_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/dms/folders/{folder_id}",
json={"name": "NewName"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["name"] == "NewName"
assert resp.json()["path"] == "NewName"
@pytest.mark.asyncio
async def test_ac3_move_folder(authed_client):
"""AC3: PATCH /api/v1/dms/folders/{id} → 200, moved."""
client, _ = authed_client
# Create two root folders
resp = await client.post("/api/v1/dms/folders", json={"name": "A"}, headers=ORIGIN_HEADER)
a_id = resp.json()["id"]
resp = await client.post("/api/v1/dms/folders", json={"name": "B"}, headers=ORIGIN_HEADER)
b_id = resp.json()["id"]
# Move B into A
resp = await client.patch(
f"/api/v1/dms/folders/{b_id}",
json={"parent_id": a_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["parent_id"] == a_id
assert resp.json()["path"] == "A/B"
# ─── AC4: Delete folder (soft-delete, cascade) ───
@pytest.mark.asyncio
async def test_ac4_delete_folder_cascade(authed_client):
"""AC4: DELETE /api/v1/dms/folders/{id} → 204, soft-delete with cascade."""
client, _ = authed_client
# Create parent with child and file in child
resp = await client.post("/api/v1/dms/folders", json={"name": "Parent"}, headers=ORIGIN_HEADER)
parent_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "Child", "parent_id": parent_id},
headers=ORIGIN_HEADER,
)
child_id = resp.json()["id"]
# Upload a file into child folder
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("test.pdf", PDF_CONTENT, "application/pdf")},
data={"folder_id": child_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
file_id = resp.json()["id"]
# Delete parent folder
resp = await client.delete(f"/api/v1/dms/folders/{parent_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
# Verify parent folder is gone from tree
resp = await client.get("/api/v1/dms/folders", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
# Verify file is soft-deleted (GET should 404)
resp = await client.get(f"/api/v1/dms/files/{file_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
# ─── AC5: Upload file (multipart) ───
@pytest.mark.asyncio
async def test_ac5_upload_file(authed_client):
"""AC5: POST /api/v1/dms/files/upload → 201, file stored + metadata."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("doc.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "doc.pdf"
assert data["mime_type"] == "application/pdf"
assert data["size_bytes"] == len(PDF_CONTENT)
assert data["folder_id"] is None
assert "id" in data
assert "storage_path" in data
# Verify file exists on disk
assert os.path.exists(data["storage_path"])
@pytest.mark.asyncio
async def test_ac5_upload_file_to_folder(authed_client):
"""AC5: POST /api/v1/dms/files/upload → 201, file stored in folder."""
client, _ = authed_client
# Create folder
resp = await client.post("/api/v1/dms/folders", json={"name": "Docs"}, headers=ORIGIN_HEADER)
folder_id = resp.json()["id"]
# Upload file to folder
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("doc.pdf", PDF_CONTENT, "application/pdf")},
data={"folder_id": folder_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
assert resp.json()["folder_id"] == folder_id
# ─── AC6: Get file metadata ───
@pytest.mark.asyncio
async def test_ac6_get_file_metadata(authed_client):
"""AC6: GET /api/v1/dms/files/{id} → 200 + file metadata."""
client, _ = authed_client
# Upload file first
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("report.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
# Get metadata
resp = await client.get(f"/api/v1/dms/files/{file_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == file_id
assert data["name"] == "report.pdf"
assert data["mime_type"] == "application/pdf"
assert data["size_bytes"] == len(PDF_CONTENT)
# ─── AC7: Rename/move file ───
@pytest.mark.asyncio
async def test_ac7_rename_file(authed_client):
"""AC7: PATCH /api/v1/dms/files/{id} → 200, renamed."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("old.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/dms/files/{file_id}",
json={"name": "new.pdf"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["name"] == "new.pdf"
@pytest.mark.asyncio
async def test_ac7_move_file(authed_client):
"""AC7: PATCH /api/v1/dms/files/{id} → 200, moved to folder."""
client, _ = authed_client
# Upload file at root
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("file.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
# Create folder
resp = await client.post("/api/v1/dms/folders", json={"name": "Archive"}, headers=ORIGIN_HEADER)
folder_id = resp.json()["id"]
# Move file
resp = await client.patch(
f"/api/v1/dms/files/{file_id}",
json={"folder_id": folder_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["folder_id"] == folder_id
# ─── AC8: Delete file (soft-delete) ───
@pytest.mark.asyncio
async def test_ac8_delete_file(authed_client):
"""AC8: DELETE /api/v1/dms/files/{id} → 204, soft-delete."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("delete.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.delete(f"/api/v1/dms/files/{file_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
# Verify file is gone from list
resp = await client.get(f"/api/v1/dms/files/{file_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
# ─── AC9: Restore file ───
@pytest.mark.asyncio
async def test_ac9_restore_file(authed_client):
"""AC9: POST /api/v1/dms/files/{id}/restore → 200, restored from trash."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("restore.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
# Delete file
resp = await client.delete(f"/api/v1/dms/files/{file_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
# Restore file
resp = await client.post(f"/api/v1/dms/files/{file_id}/restore", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == file_id
assert data["deleted_at"] is None
# Verify file is accessible again
resp = await client.get(f"/api/v1/dms/files/{file_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 200
# ─── AC10: PDF preview ───
@pytest.mark.asyncio
async def test_ac10_pdf_preview(authed_client):
"""AC10: GET /api/v1/dms/files/{id}/preview → 200 + PDF stream."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("preview.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.get(f"/api/v1/dms/files/{file_id}/preview", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/pdf"
assert resp.content == PDF_CONTENT
@pytest.mark.asyncio
async def test_ac10_non_pdf_preview_400(authed_client):
"""AC10: GET /api/v1/dms/files/{id}/preview → 400 for non-PDF files."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("doc.txt", b"hello world", "text/plain")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.get(f"/api/v1/dms/files/{file_id}/preview", headers=ORIGIN_HEADER)
assert resp.status_code == 400
# ─── AC11: OnlyOffice edit session ───
@pytest.mark.asyncio
async def test_ac11_edit_session_docx(authed_client):
"""AC11: POST /api/v1/dms/files/{id}/edit-session → 200 + OnlyOffice config."""
client, _ = authed_client
docx_content = b"PK\x03\x04" + b"\x00" * 100 # minimal zip-like header
resp = await client.post(
"/api/v1/dms/files/upload",
files={
"file": (
"report.docx",
docx_content,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.post(f"/api/v1/dms/files/{file_id}/edit-session", headers=ORIGIN_HEADER)
assert resp.status_code == 200
config = resp.json()
assert "document" in config
assert "editorConfig" in config
assert config["document"]["fileType"] == "docx"
assert config["document"]["title"] == "report.docx"
assert "key" in config["document"]
assert "url" in config["document"]
assert config["editorConfig"]["mode"] == "edit"
assert "callbackUrl" in config["editorConfig"]
assert "user" in config["editorConfig"]
assert "id" in config["editorConfig"]["user"]
assert "name" in config["editorConfig"]["user"]
@pytest.mark.asyncio
async def test_ac11_edit_session_non_office_400(authed_client):
"""AC11: POST /api/v1/dms/files/{id}/edit-session → 400 for non-Office files."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("image.png", b"\x89PNG", "image/png")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.post(f"/api/v1/dms/files/{file_id}/edit-session", headers=ORIGIN_HEADER)
assert resp.status_code == 400
# ─── AC12: Internal share ───
@pytest.mark.asyncio
async def test_ac12_internal_share(authed_client):
"""AC12: POST /api/v1/dms/files/{id}/share → 200, internal share created."""
client, seed = authed_client
# Upload file
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("shared.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
# Share with viewer_a
viewer_id = str(seed["viewer_a"].id)
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"user_ids": [viewer_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["file_id"] == file_id
assert data["count"] == 1
assert data["shared_with"][0]["user_id"] == viewer_id
assert data["shared_with"][0]["access_level"] == "read"
# ─── AC13: Remove share ───
@pytest.mark.asyncio
async def test_ac13_remove_share(authed_client):
"""AC13: DELETE /api/v1/dms/files/{id}/share → 204, share removed."""
client, seed = authed_client
# Upload file
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("unshare.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
# Share with viewer_a
viewer_id = str(seed["viewer_a"].id)
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"user_ids": [viewer_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
# Remove share
resp = await client.request(
"DELETE",
f"/api/v1/dms/files/{file_id}/share",
json={"user_id": viewer_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 204
# ─── AC14: Public share access (no auth) — via T11 permissions plugin ───
@pytest.mark.asyncio
async def test_ac14_public_share_access(authed_client):
"""AC14: GET /api/public/share/{token} → 200 (no auth, public access)."""
client, _ = authed_client
# Upload file
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("public.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
# Create share link (no password)
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share-link",
json={},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
token = resp.json()["token"]
# Access publicly without auth
resp = await client.get(f"/api/public/share/{token}")
assert resp.status_code == 200
data = resp.json()
assert data["file_id"] == file_id
# ─── AC15: Public share with password → 401 without password ───
@pytest.mark.asyncio
async def test_ac15_public_share_password_required(authed_client):
"""AC15: GET /api/public/share/{token} with password → 401 without password."""
client, _ = authed_client
# Upload file
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("protected.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
# Create share link WITH password
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share-link",
json={"password": "Secret123"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
token = resp.json()["token"]
# Access without password → 401
resp = await client.get(f"/api/public/share/{token}")
assert resp.status_code == 401
assert resp.json()["detail"]["code"] == "password_required"
# Access WITH password via POST → 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
# ─── AC16: Search files ───
@pytest.mark.asyncio
async def test_ac16_search_files(authed_client):
"""AC16: GET /api/v1/dms/search?q=text → 200 + matching files (ILIKE)."""
client, _ = authed_client
# Upload multiple files
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("invoice_2024.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("report_2024.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("photo.png", b"\x89PNG", "image/png")},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
# Search for "2024"
resp = await client.get("/api/v1/dms/search?q=2024", headers=ORIGIN_HEADER)
assert resp.status_code == 200
results = resp.json()
assert len(results) == 2
names = {r["name"] for r in results}
assert "invoice_2024.pdf" in names
assert "report_2024.pdf" in names
@pytest.mark.asyncio
async def test_ac16_search_case_insensitive(authed_client):
"""AC16: GET /api/v1/dms/search?q=INVOICE → 200, case-insensitive match."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("Invoice.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
resp = await client.get("/api/v1/dms/search?q=invoice", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert len(resp.json()) == 1
assert resp.json()[0]["name"] == "Invoice.pdf"
# ─── AC17: Shared-with-me ───
@pytest.mark.asyncio
async def test_ac17_shared_with_me(authed_client, dms_client, db_session):
"""AC17: GET /api/v1/dms/shared-with-me → 200 + shared files list."""
client, seed = authed_client
# Upload file as admin
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("shared_doc.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
# Share with viewer_a
viewer_id = str(seed["viewer_a"].id)
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"user_ids": [viewer_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
# Login as viewer_a and check shared-with-me
await login_client(dms_client, "viewer@tenanta.com")
resp = await dms_client.get("/api/v1/dms/shared-with-me", headers=ORIGIN_HEADER)
assert resp.status_code == 200
shared = resp.json()
assert len(shared) == 1
assert shared[0]["id"] == file_id
assert shared[0]["name"] == "shared_doc.pdf"
assert shared[0]["access_level"] == "read"
# ─── AC18: Bulk move ───
@pytest.mark.asyncio
async def test_ac18_bulk_move(authed_client):
"""AC18: POST /api/v1/dms/files/bulk-move → 200, files moved."""
client, _ = authed_client
# Upload 3 files at root
file_ids = []
for i in range(3):
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": (f"file{i}.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_ids.append(resp.json()["id"])
# Create target folder
resp = await client.post(
"/api/v1/dms/folders", json={"name": "BulkTarget"}, headers=ORIGIN_HEADER
)
folder_id = resp.json()["id"]
# Bulk move
resp = await client.post(
"/api/v1/dms/files/bulk-move",
json={"file_ids": file_ids, "target_folder_id": folder_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["moved"] == 3
assert data["target_folder_id"] == folder_id
# Verify files are in folder
for fid in file_ids:
resp = await client.get(f"/api/v1/dms/files/{fid}", headers=ORIGIN_HEADER)
assert resp.json()["folder_id"] == folder_id
# ─── AC19: Bulk delete ───
@pytest.mark.asyncio
async def test_ac19_bulk_delete(authed_client):
"""AC19: POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted."""
client, _ = authed_client
# Upload 3 files
file_ids = []
for i in range(3):
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": (f"del{i}.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_ids.append(resp.json()["id"])
# Bulk delete
resp = await client.post(
"/api/v1/dms/files/bulk-delete",
json={"file_ids": file_ids},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["deleted"] == 3
# Verify files are soft-deleted
for fid in file_ids:
resp = await client.get(f"/api/v1/dms/files/{fid}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
+832
View File
@@ -0,0 +1,832 @@
"""DMS coverage tests — exercise uncovered paths to reach ≥80% coverage.
Targets missing lines in routes.py: tree building, path construction,
deep cascade delete, file too large, preview missing on disk,
multi-user/group sharing, share removal, search edge cases,
shared-with-me with data, bulk mixed IDs, tenant isolation.
"""
from __future__ import annotations
import os
import uuid
from unittest.mock import patch
import pytest
from tests.conftest import ORIGIN_HEADER, login_client
PDF_CONTENT = b"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n%%EOF"
# ─── Folder coverage ───
class TestFolderCoverage:
"""Coverage tests for folder endpoints — tree, path, cascade, isolation."""
@pytest.mark.asyncio
async def test_list_folders_deeply_nested_tree(self, authed_client):
"""GET /folders → deeply nested tree with correct paths."""
client, _ = authed_client
resp = await client.post("/api/v1/dms/folders", json={"name": "A"}, headers=ORIGIN_HEADER)
a_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "B", "parent_id": a_id},
headers=ORIGIN_HEADER,
)
b_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "C", "parent_id": b_id},
headers=ORIGIN_HEADER,
)
resp = await client.get("/api/v1/dms/folders", headers=ORIGIN_HEADER)
assert resp.status_code == 200
tree = resp.json()
assert len(tree) == 1
assert tree[0]["name"] == "A"
assert tree[0]["path"] == "A"
assert len(tree[0]["children"]) == 1
child = tree[0]["children"][0]
assert child["name"] == "B"
assert child["path"] == "A/B"
assert len(child["children"]) == 1
assert child["children"][0]["name"] == "C"
assert child["children"][0]["path"] == "A/B/C"
@pytest.mark.asyncio
async def test_list_folders_invalid_parent_id(self, authed_client):
"""GET /folders?parent_id=invalid → 400."""
client, _ = authed_client
resp = await client.get("/api/v1/dms/folders?parent_id=not-a-uuid", headers=ORIGIN_HEADER)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_create_folder_deep_nested_path(self, authed_client):
"""POST /folders → 201 with 3-level nested path."""
client, _ = authed_client
resp = await client.post("/api/v1/dms/folders", json={"name": "L1"}, headers=ORIGIN_HEADER)
l1_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "L2", "parent_id": l1_id},
headers=ORIGIN_HEADER,
)
l2_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "L3", "parent_id": l2_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
assert resp.json()["path"] == "L1/L2/L3"
@pytest.mark.asyncio
async def test_update_folder_rename_and_move(self, authed_client):
"""PATCH /folders/{id} → 200, rename + move in one request."""
client, _ = authed_client
resp = await client.post("/api/v1/dms/folders", json={"name": "A"}, headers=ORIGIN_HEADER)
a_id = resp.json()["id"]
resp = await client.post("/api/v1/dms/folders", json={"name": "B"}, headers=ORIGIN_HEADER)
b_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "C", "parent_id": b_id},
headers=ORIGIN_HEADER,
)
c_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/dms/folders/{c_id}",
json={"name": "D", "parent_id": a_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["name"] == "D"
assert resp.json()["parent_id"] == a_id
assert resp.json()["path"] == "A/D"
@pytest.mark.asyncio
async def test_update_folder_move_to_root(self, authed_client):
"""PATCH /folders/{id} → 200, move to root via parent_id=null."""
client, _ = authed_client
resp = await client.post("/api/v1/dms/folders", json={"name": "P"}, headers=ORIGIN_HEADER)
p_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "C", "parent_id": p_id},
headers=ORIGIN_HEADER,
)
c_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/dms/folders/{c_id}",
json={"parent_id": None},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["parent_id"] is None
assert resp.json()["path"] == "C"
@pytest.mark.asyncio
async def test_update_folder_no_changes(self, authed_client):
"""PATCH /folders/{id} → 200 with empty body (no changes)."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/folders", json={"name": "NoChange"}, headers=ORIGIN_HEADER
)
folder_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/dms/folders/{folder_id}",
json={},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["name"] == "NoChange"
@pytest.mark.asyncio
async def test_update_folder_invalid_id(self, authed_client):
"""PATCH /folders/{id} → 400 invalid id."""
client, _ = authed_client
resp = await client.patch(
"/api/v1/dms/folders/not-a-uuid",
json={"name": "New"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_delete_folder_deep_cascade(self, authed_client):
"""DELETE /folders/{id} → 204, 3-level cascade with files in each."""
client, _ = authed_client
resp = await client.post("/api/v1/dms/folders", json={"name": "A"}, headers=ORIGIN_HEADER)
a_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "B", "parent_id": a_id},
headers=ORIGIN_HEADER,
)
b_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "C", "parent_id": b_id},
headers=ORIGIN_HEADER,
)
c_id = resp.json()["id"]
file_ids = []
for folder_id in [a_id, b_id, c_id]:
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": (f"f_{folder_id[:4]}.pdf", PDF_CONTENT, "application/pdf")},
data={"folder_id": folder_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
file_ids.append(resp.json()["id"])
resp = await client.delete(f"/api/v1/dms/folders/{a_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
resp = await client.get("/api/v1/dms/folders", headers=ORIGIN_HEADER)
assert resp.json() == []
for fid in file_ids:
resp = await client.get(f"/api/v1/dms/files/{fid}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_folder_tenant_isolation(self, authed_client, dms_client, db_session):
"""Folders visible only within their tenant."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/folders", json={"name": "TenantA"}, headers=ORIGIN_HEADER
)
folder_id = resp.json()["id"]
await login_client(dms_client, "admin@tenantb.com")
resp = await dms_client.get("/api/v1/dms/folders", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
resp = await dms_client.get(
f"/api/v1/dms/folders?parent_id={folder_id}", headers=ORIGIN_HEADER
)
assert resp.status_code == 404
# ─── File coverage ───
class TestFileCoverage:
"""Coverage tests for file endpoints — upload edge cases, invalid IDs, preview."""
@pytest.mark.asyncio
async def test_upload_file_too_large(self, authed_client):
"""POST /files/upload → 413 file too large."""
client, _ = authed_client
with patch("app.plugins.builtins.dms.routes.MAX_FILE_SIZE", 10):
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("large.bin", b"\x00" * 100, "application/octet-stream")},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 413
assert resp.json()["detail"]["code"] == "file_too_large"
@pytest.mark.asyncio
async def test_upload_empty_file(self, authed_client):
"""POST /files/upload → 201 for empty file."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("empty.txt", b"", "text/plain")},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
assert resp.json()["size_bytes"] == 0
@pytest.mark.asyncio
async def test_upload_file_invalid_folder_id(self, authed_client):
"""POST /files/upload → 400 invalid folder_id."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("test.pdf", PDF_CONTENT, "application/pdf")},
data={"folder_id": "not-a-uuid"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_delete_file_invalid_id(self, authed_client):
"""DELETE /files/{id} → 400 invalid id."""
client, _ = authed_client
resp = await client.delete("/api/v1/dms/files/bad-id", headers=ORIGIN_HEADER)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_restore_file_invalid_id(self, authed_client):
"""POST /files/{id}/restore → 400 invalid id."""
client, _ = authed_client
resp = await client.post("/api/v1/dms/files/bad-id/restore", headers=ORIGIN_HEADER)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_update_file_rename_and_move(self, authed_client):
"""PATCH /files/{id} → 200, rename + move in one request."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("original.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/folders", json={"name": "Target"}, headers=ORIGIN_HEADER
)
folder_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/dms/files/{file_id}",
json={"name": "renamed.pdf", "folder_id": folder_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["name"] == "renamed.pdf"
assert resp.json()["folder_id"] == folder_id
@pytest.mark.asyncio
async def test_update_file_move_to_root(self, authed_client):
"""PATCH /files/{id} → 200, move file to root via folder_id=null."""
client, _ = authed_client
resp = await client.post("/api/v1/dms/folders", json={"name": "F"}, headers=ORIGIN_HEADER)
folder_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("test.pdf", PDF_CONTENT, "application/pdf")},
data={"folder_id": folder_id},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/dms/files/{file_id}",
json={"folder_id": None},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["folder_id"] is None
@pytest.mark.asyncio
async def test_update_file_no_changes(self, authed_client):
"""PATCH /files/{id} → 200 with empty body (no changes)."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("test.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/dms/files/{file_id}",
json={},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["name"] == "test.pdf"
@pytest.mark.asyncio
async def test_update_file_invalid_id(self, authed_client):
"""PATCH /files/{id} → 400 invalid id."""
client, _ = authed_client
resp = await client.patch(
"/api/v1/dms/files/bad-id",
json={"name": "new.pdf"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_preview_file_invalid_id(self, authed_client):
"""GET /files/{id}/preview → 400 invalid id."""
client, _ = authed_client
resp = await client.get("/api/v1/dms/files/bad-id/preview", headers=ORIGIN_HEADER)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_preview_file_missing_on_disk(self, authed_client):
"""GET /files/{id}/preview → 404 when file missing on disk."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("ondisk.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
storage_path = resp.json()["storage_path"]
if os.path.exists(storage_path):
os.remove(storage_path)
resp = await client.get(f"/api/v1/dms/files/{file_id}/preview", headers=ORIGIN_HEADER)
assert resp.status_code == 404
assert resp.json()["detail"]["code"] == "file_missing"
@pytest.mark.asyncio
async def test_edit_session_invalid_id(self, authed_client):
"""POST /files/{id}/edit-session → 400 invalid id."""
client, _ = authed_client
resp = await client.post("/api/v1/dms/files/bad-id/edit-session", headers=ORIGIN_HEADER)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_file_tenant_isolation(self, authed_client, dms_client, db_session):
"""Files visible only within their tenant."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("tenant_a.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
await login_client(dms_client, "admin@tenantb.com")
resp = await dms_client.get(f"/api/v1/dms/files/{file_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
# ─── Share coverage ───
class TestShareCoverage:
"""Coverage tests for share endpoints — multi-user, groups, removal."""
@pytest.mark.asyncio
async def test_share_with_multiple_users(self, authed_client):
"""POST /files/{id}/share → 200 with multiple users."""
client, seed = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("multi.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
viewer_id = str(seed["viewer_a"].id)
editor_id = str(seed["editor_a"].id)
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"user_ids": [viewer_id, editor_id], "access_level": "write"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["count"] == 2
@pytest.mark.asyncio
async def test_share_with_users_and_groups(self, authed_client):
"""POST /files/{id}/share → 200 with both users and groups."""
client, seed = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("both.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
viewer_id = str(seed["viewer_a"].id)
group_id = str(uuid.uuid4())
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"user_ids": [viewer_id], "group_ids": [group_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["count"] == 2
@pytest.mark.asyncio
async def test_share_with_invalid_group_id(self, authed_client):
"""POST /files/{id}/share → 400 invalid group_id."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("badgroup.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"group_ids": ["not-a-uuid"], "access_level": "read"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_share_duplicate_group_ignored(self, authed_client):
"""POST /files/{id}/share → 200, duplicate group share ignored."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("dupgroup.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
group_id = str(uuid.uuid4())
await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"group_ids": [group_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"group_ids": [group_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["count"] == 0
@pytest.mark.asyncio
async def test_remove_share_user(self, authed_client):
"""DELETE /files/{id}/share → 204, user share removed."""
client, seed = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("unshare_user.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
viewer_id = str(seed["viewer_a"].id)
await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"user_ids": [viewer_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
resp = await client.request(
"DELETE",
f"/api/v1/dms/files/{file_id}/share",
json={"user_id": viewer_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 204
@pytest.mark.asyncio
async def test_remove_share_no_match(self, authed_client):
"""DELETE /files/{id}/share → 204, no matching share (idempotent)."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("nomatch.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.request(
"DELETE",
f"/api/v1/dms/files/{file_id}/share",
json={"user_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 204
@pytest.mark.asyncio
async def test_remove_share_invalid_group_id(self, authed_client):
"""DELETE /files/{id}/share → 400 invalid group_id."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("badgid.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.request(
"DELETE",
f"/api/v1/dms/files/{file_id}/share",
json={"group_id": "not-a-uuid"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_remove_share_both_user_and_group(self, authed_client):
"""DELETE /files/{id}/share → 204, removes both user and group shares."""
client, seed = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("bothremoval.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
viewer_id = str(seed["viewer_a"].id)
group_id = str(uuid.uuid4())
await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"user_ids": [viewer_id], "group_ids": [group_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
resp = await client.request(
"DELETE",
f"/api/v1/dms/files/{file_id}/share",
json={"user_id": viewer_id, "group_id": group_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 204
# ─── Search coverage ───
class TestSearchCoverage:
"""Coverage tests for search — special chars, partial match, tenant isolation."""
@pytest.mark.asyncio
async def test_search_special_characters(self, authed_client):
"""GET /search?q=50% → 200, handles special characters in filename."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("test_50%.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
resp = await client.get("/api/v1/dms/search?q=50%", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert len(resp.json()) == 1
assert resp.json()[0]["name"] == "test_50%.pdf"
@pytest.mark.asyncio
async def test_search_partial_match(self, authed_client):
"""GET /search → partial name match."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("monthly_report.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
resp = await client.get("/api/v1/dms/search?q=monthly", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert len(resp.json()) == 1
@pytest.mark.asyncio
async def test_search_tenant_isolation(self, authed_client, dms_client, db_session):
"""GET /search → only returns files from current tenant."""
client, _ = authed_client
await client.post(
"/api/v1/dms/files/upload",
files={"file": ("tenant_a_file.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
await login_client(dms_client, "admin@tenantb.com")
resp = await dms_client.get("/api/v1/dms/search?q=tenant_a", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
# ─── Shared-with-me coverage ───
class TestSharedWithMeCoverage:
"""Coverage tests for shared-with-me — multiple files, deleted exclusion."""
@pytest.mark.asyncio
async def test_shared_with_me_multiple_files(self, authed_client, dms_client, db_session):
"""GET /shared-with-me → 200 with multiple shared files."""
client, seed = authed_client
viewer_id = str(seed["viewer_a"].id)
file_ids = []
for i in range(3):
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": (f"shared_{i}.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
fid = resp.json()["id"]
file_ids.append(fid)
await client.post(
f"/api/v1/dms/files/{fid}/share",
json={"user_ids": [viewer_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
await login_client(dms_client, "viewer@tenanta.com")
resp = await dms_client.get("/api/v1/dms/shared-with-me", headers=ORIGIN_HEADER)
assert resp.status_code == 200
shared = resp.json()
assert len(shared) == 3
returned_ids = {s["id"] for s in shared}
assert returned_ids == set(file_ids)
for s in shared:
assert s["access_level"] == "read"
@pytest.mark.asyncio
async def test_shared_with_me_excludes_deleted(self, authed_client, dms_client, db_session):
"""GET /shared-with-me → excludes soft-deleted files."""
client, seed = authed_client
viewer_id = str(seed["viewer_a"].id)
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("del_share.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"user_ids": [viewer_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
await client.delete(f"/api/v1/dms/files/{file_id}", headers=ORIGIN_HEADER)
await login_client(dms_client, "viewer@tenanta.com")
resp = await dms_client.get("/api/v1/dms/shared-with-me", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_shared_with_me_write_access(self, authed_client, dms_client, db_session):
"""GET /shared-with-me → shows write access_level correctly."""
client, seed = authed_client
viewer_id = str(seed["viewer_a"].id)
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("write_share.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"user_ids": [viewer_id], "access_level": "write"},
headers=ORIGIN_HEADER,
)
await login_client(dms_client, "viewer@tenanta.com")
resp = await dms_client.get("/api/v1/dms/shared-with-me", headers=ORIGIN_HEADER)
assert resp.status_code == 200
shared = resp.json()
assert len(shared) == 1
assert shared[0]["access_level"] == "write"
# ─── Bulk operations coverage ───
class TestBulkCoverage:
"""Coverage tests for bulk operations — mixed IDs, tenant isolation."""
@pytest.mark.asyncio
async def test_bulk_move_mixed_valid_invalid(self, authed_client):
"""POST /files/bulk-move → 200 with mixed valid/invalid file IDs."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/folders", json={"name": "Target"}, headers=ORIGIN_HEADER
)
folder_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("valid.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
valid_file_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/files/bulk-move",
json={"file_ids": [valid_file_id, str(uuid.uuid4())], "target_folder_id": folder_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["moved"] == 1
@pytest.mark.asyncio
async def test_bulk_delete_mixed_valid_invalid(self, authed_client):
"""POST /files/bulk-delete → 200 with mixed valid/invalid file IDs."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("valid.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
valid_file_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/files/bulk-delete",
json={"file_ids": [valid_file_id, str(uuid.uuid4())]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["deleted"] == 1
@pytest.mark.asyncio
async def test_bulk_move_invalid_target_id(self, authed_client):
"""POST /files/bulk-move → 400 invalid target_folder_id."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("f.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/files/bulk-move",
json={"file_ids": [file_id], "target_folder_id": "not-a-uuid"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_bulk_delete_tenant_isolation(self, authed_client, dms_client, db_session):
"""POST /files/bulk-delete → 0 deleted for cross-tenant file IDs."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("tenant_a.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
tenant_a_file_id = resp.json()["id"]
await login_client(dms_client, "admin@tenantb.com")
resp = await dms_client.post(
"/api/v1/dms/files/bulk-delete",
json={"file_ids": [tenant_a_file_id]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["deleted"] == 0
@pytest.mark.asyncio
async def test_bulk_move_tenant_isolation(self, authed_client, dms_client, db_session):
"""POST /files/bulk-move → 0 moved for cross-tenant file IDs."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("cross_tenant.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
tenant_a_file_id = resp.json()["id"]
await login_client(dms_client, "admin@tenantb.com")
resp = await dms_client.post(
"/api/v1/dms/files/bulk-move",
json={"file_ids": [tenant_a_file_id]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["moved"] == 0
+564
View File
@@ -0,0 +1,564 @@
"""Additional DMS error-path tests to boost coverage above 80%."""
from __future__ import annotations
import uuid
import pytest
from tests.conftest import ORIGIN_HEADER
PDF_CONTENT = b"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n%%EOF"
class TestFolderErrorPaths:
"""Error path tests for folder endpoints."""
@pytest.mark.asyncio
async def test_create_folder_duplicate_name(self, authed_client):
"""POST /folders → 409 duplicate name."""
client, _ = authed_client
await client.post("/api/v1/dms/folders", json={"name": "Dup"}, headers=ORIGIN_HEADER)
resp = await client.post("/api/v1/dms/folders", json={"name": "Dup"}, headers=ORIGIN_HEADER)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_create_folder_parent_not_found(self, authed_client):
"""POST /folders → 404 parent not found."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "Child", "parent_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_create_folder_invalid_parent_id(self, authed_client):
"""POST /folders → 400 invalid parent_id."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "Child", "parent_id": "not-a-uuid"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_list_folders_with_parent_id(self, authed_client):
"""GET /folders?parent_id → children of specified parent."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/folders", json={"name": "Root"}, headers=ORIGIN_HEADER
)
root_id = resp.json()["id"]
await client.post(
"/api/v1/dms/folders",
json={"name": "Child1", "parent_id": root_id},
headers=ORIGIN_HEADER,
)
await client.post(
"/api/v1/dms/folders",
json={"name": "Child2", "parent_id": root_id},
headers=ORIGIN_HEADER,
)
resp = await client.get(f"/api/v1/dms/folders?parent_id={root_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 200
children = resp.json()
assert len(children) == 2
@pytest.mark.asyncio
async def test_list_folders_parent_not_found(self, authed_client):
"""GET /folders?parent_id=nonexistent → 404."""
client, _ = authed_client
resp = await client.get(
f"/api/v1/dms/folders?parent_id={uuid.uuid4()}", headers=ORIGIN_HEADER
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_update_folder_not_found(self, authed_client):
"""PATCH /folders/{id} → 404 not found."""
client, _ = authed_client
resp = await client.patch(
f"/api/v1/dms/folders/{uuid.uuid4()}",
json={"name": "New"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_update_folder_duplicate_name(self, authed_client):
"""PATCH /folders/{id} → 409 duplicate name."""
client, _ = authed_client
await client.post("/api/v1/dms/folders", json={"name": "A"}, headers=ORIGIN_HEADER)
resp = await client.post("/api/v1/dms/folders", json={"name": "B"}, headers=ORIGIN_HEADER)
b_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/dms/folders/{b_id}",
json={"name": "A"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_update_folder_move_into_self(self, authed_client):
"""PATCH /folders/{id} → 400 cannot move into itself."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/folders", json={"name": "Self"}, headers=ORIGIN_HEADER
)
folder_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/dms/folders/{folder_id}",
json={"parent_id": folder_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_update_folder_move_parent_not_found(self, authed_client):
"""PATCH /folders/{id} → 404 parent not found."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/folders", json={"name": "Move"}, headers=ORIGIN_HEADER
)
folder_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/dms/folders/{folder_id}",
json={"parent_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_update_folder_cycle_detection(self, authed_client):
"""PATCH /folders/{id} → 400 cannot move into descendant."""
client, _ = authed_client
resp = await client.post("/api/v1/dms/folders", json={"name": "P"}, headers=ORIGIN_HEADER)
p_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/folders",
json={"name": "C", "parent_id": p_id},
headers=ORIGIN_HEADER,
)
c_id = resp.json()["id"]
# Try to move P into C (its own child)
resp = await client.patch(
f"/api/v1/dms/folders/{p_id}",
json={"parent_id": c_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_delete_folder_not_found(self, authed_client):
"""DELETE /folders/{id} → 404 not found."""
client, _ = authed_client
resp = await client.delete(f"/api/v1/dms/folders/{uuid.uuid4()}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_delete_folder_invalid_id(self, authed_client):
"""DELETE /folders/{id} → 400 invalid id."""
client, _ = authed_client
resp = await client.delete("/api/v1/dms/folders/not-a-uuid", headers=ORIGIN_HEADER)
assert resp.status_code == 400
class TestFileErrorPaths:
"""Error path tests for file endpoints."""
@pytest.mark.asyncio
async def test_upload_file_folder_not_found(self, authed_client):
"""POST /files/upload → 404 folder not found."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("test.pdf", PDF_CONTENT, "application/pdf")},
data={"folder_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_get_file_not_found(self, authed_client):
"""GET /files/{id} → 404 not found."""
client, _ = authed_client
resp = await client.get(f"/api/v1/dms/files/{uuid.uuid4()}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_get_file_invalid_id(self, authed_client):
"""GET /files/{id} → 400 invalid id."""
client, _ = authed_client
resp = await client.get("/api/v1/dms/files/bad-id", headers=ORIGIN_HEADER)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_update_file_not_found(self, authed_client):
"""PATCH /files/{id} → 404 not found."""
client, _ = authed_client
resp = await client.patch(
f"/api/v1/dms/files/{uuid.uuid4()}",
json={"name": "new.pdf"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_update_file_move_folder_not_found(self, authed_client):
"""PATCH /files/{id} → 404 folder not found."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("test.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/dms/files/{file_id}",
json={"folder_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_delete_file_not_found(self, authed_client):
"""DELETE /files/{id} → 404 not found."""
client, _ = authed_client
resp = await client.delete(f"/api/v1/dms/files/{uuid.uuid4()}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_restore_file_not_found(self, authed_client):
"""POST /files/{id}/restore → 404 not found."""
client, _ = authed_client
resp = await client.post(f"/api/v1/dms/files/{uuid.uuid4()}/restore", headers=ORIGIN_HEADER)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_restore_file_not_deleted(self, authed_client):
"""POST /files/{id}/restore → 404 file not in trash."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("active.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.post(f"/api/v1/dms/files/{file_id}/restore", headers=ORIGIN_HEADER)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_preview_file_not_found(self, authed_client):
"""GET /files/{id}/preview → 404 not found."""
client, _ = authed_client
resp = await client.get(f"/api/v1/dms/files/{uuid.uuid4()}/preview", headers=ORIGIN_HEADER)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_edit_session_not_found(self, authed_client):
"""POST /files/{id}/edit-session → 404 not found."""
client, _ = authed_client
resp = await client.post(
f"/api/v1/dms/files/{uuid.uuid4()}/edit-session", headers=ORIGIN_HEADER
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_edit_session_xlsx(self, authed_client):
"""POST /files/{id}/edit-session → 200 for xlsx file."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={
"file": (
"sheet.xlsx",
b"PK\x03\x04",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.post(f"/api/v1/dms/files/{file_id}/edit-session", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json()["document"]["fileType"] == "xlsx"
@pytest.mark.asyncio
async def test_edit_session_pptx(self, authed_client):
"""POST /files/{id}/edit-session → 200 for pptx file."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={
"file": (
"slides.pptx",
b"PK\x03\x04",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
)
},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.post(f"/api/v1/dms/files/{file_id}/edit-session", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json()["document"]["fileType"] == "pptx"
class TestShareErrorPaths:
"""Error path tests for sharing endpoints."""
@pytest.mark.asyncio
async def test_share_file_not_found(self, authed_client):
"""POST /files/{id}/share → 404 file not found."""
client, seed = authed_client
viewer_id = str(seed["viewer_a"].id)
resp = await client.post(
f"/api/v1/dms/files/{uuid.uuid4()}/share",
json={"user_ids": [viewer_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_share_with_invalid_user_id(self, authed_client):
"""POST /files/{id}/share → 400 invalid user_id."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("share.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"user_ids": ["not-a-uuid"], "access_level": "read"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_share_with_group(self, authed_client):
"""POST /files/{id}/share → 200 with group_ids."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("group.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
group_id = str(uuid.uuid4())
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"group_ids": [group_id], "access_level": "write"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["count"] == 1
assert resp.json()["shared_with"][0]["group_id"] == group_id
assert resp.json()["shared_with"][0]["access_level"] == "write"
@pytest.mark.asyncio
async def test_share_duplicate_ignored(self, authed_client):
"""POST /files/{id}/share → 200, duplicate shares ignored."""
client, seed = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("dup.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
viewer_id = str(seed["viewer_a"].id)
# Share once
await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"user_ids": [viewer_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
# Share again — should be ignored
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"user_ids": [viewer_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["count"] == 0
@pytest.mark.asyncio
async def test_remove_share_group(self, authed_client):
"""DELETE /files/{id}/share → 204, group share removed."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("grp.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
group_id = str(uuid.uuid4())
await client.post(
f"/api/v1/dms/files/{file_id}/share",
json={"group_ids": [group_id], "access_level": "read"},
headers=ORIGIN_HEADER,
)
resp = await client.request(
"DELETE",
f"/api/v1/dms/files/{file_id}/share",
json={"group_id": group_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 204
@pytest.mark.asyncio
async def test_remove_share_invalid_id(self, authed_client):
"""DELETE /files/{id}/share → 400 invalid user_id."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("bad.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.request(
"DELETE",
f"/api/v1/dms/files/{file_id}/share",
json={"user_id": "not-a-uuid"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
class TestBulkErrorPaths:
"""Error path tests for bulk operations."""
@pytest.mark.asyncio
async def test_bulk_move_target_not_found(self, authed_client):
"""POST /files/bulk-move → 404 target folder not found."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("f.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/files/bulk-move",
json={"file_ids": [file_id], "target_folder_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_bulk_move_to_root(self, authed_client):
"""POST /files/bulk-move → 200, move to root (null target)."""
client, _ = authed_client
# Create folder and upload file to it
resp = await client.post("/api/v1/dms/folders", json={"name": "Src"}, headers=ORIGIN_HEADER)
folder_id = resp.json()["id"]
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("f.pdf", PDF_CONTENT, "application/pdf")},
data={"folder_id": folder_id},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
# Move to root (no target_folder_id)
resp = await client.post(
"/api/v1/dms/files/bulk-move",
json={"file_ids": [file_id]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["target_folder_id"] is None
@pytest.mark.asyncio
async def test_bulk_move_invalid_file_id(self, authed_client):
"""POST /files/bulk-move → 400 invalid file_id."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/bulk-move",
json={"file_ids": ["not-a-uuid"]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_bulk_delete_invalid_file_id(self, authed_client):
"""POST /files/bulk-delete → 400 invalid file_id."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/bulk-delete",
json={"file_ids": ["not-a-uuid"]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_bulk_delete_nonexistent_files(self, authed_client):
"""POST /files/bulk-delete → 200, 0 deleted for nonexistent files."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/bulk-delete",
json={"file_ids": [str(uuid.uuid4())]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["deleted"] == 0
class TestSearchEdgeCases:
"""Edge case tests for search endpoint."""
@pytest.mark.asyncio
async def test_search_no_results(self, authed_client):
"""GET /search?q=nonexistent → 200 + empty list."""
client, _ = authed_client
resp = await client.get("/api/v1/dms/search?q=nonexistent", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_search_excludes_deleted(self, authed_client):
"""GET /search → excludes soft-deleted files."""
client, _ = authed_client
resp = await client.post(
"/api/v1/dms/files/upload",
files={"file": ("deleteme.pdf", PDF_CONTENT, "application/pdf")},
headers=ORIGIN_HEADER,
)
file_id = resp.json()["id"]
await client.delete(f"/api/v1/dms/files/{file_id}", headers=ORIGIN_HEADER)
resp = await client.get("/api/v1/dms/search?q=deleteme", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
class TestSharedWithMeEdgeCases:
"""Edge case tests for shared-with-me endpoint."""
@pytest.mark.asyncio
async def test_shared_with_me_empty(self, authed_client):
"""GET /shared-with-me → 200 + empty list for admin (no shares)."""
client, _ = authed_client
resp = await client.get("/api/v1/dms/shared-with-me", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []