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
+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