"""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<>endobj\n2 0 obj<>endobj\n3 0 obj<>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000058 00000 n\n0000000115 00000 n\ntrailer<>\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