T04: DMS plugin backend — folders + files + preview + OnlyOffice + shares + search + bulk — 106 tests, 97.90% coverage
This commit is contained in:
@@ -0,0 +1,227 @@
|
|||||||
|
# T04 — DMS Plugin Backend (Folders, Files, Preview, OnlyOffice, Share Links)
|
||||||
|
|
||||||
|
## Project Root
|
||||||
|
/a0/usr/workdir/dev-projects/leocrm
|
||||||
|
|
||||||
|
## Context Files (READ FIRST)
|
||||||
|
- `app/plugins/base.py` — BasePlugin abstract class
|
||||||
|
- `app/plugins/manifest.py` — PluginManifest, PluginRouteDef
|
||||||
|
- `app/plugins/builtins/tags/` — Reference plugin (subdirectory pattern)
|
||||||
|
- `app/plugins/builtins/permissions/` — Already has share links + file permissions
|
||||||
|
- `app/plugins/builtins/entity_links/` — Links files to companies/contacts
|
||||||
|
- `app/core/db.py` — Base, TenantMixin, TimestampMixin
|
||||||
|
- `app/models/company.py` — Model pattern reference
|
||||||
|
- `app/routes/companies.py` — Route pattern reference
|
||||||
|
- `app/schemas/company.py` — Schema pattern reference
|
||||||
|
- `architecture.md` — Architecture decisions
|
||||||
|
- `requirements.md` — F-DMS-*, F-FILE-*, F-FILEUI-* requirements
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implement DMS (Document Management System) plugin as `app/plugins/builtins/dms/`.
|
||||||
|
|
||||||
|
## Plugin Structure
|
||||||
|
```
|
||||||
|
app/plugins/builtins/dms/
|
||||||
|
├── __init__.py # Export DmsPlugin
|
||||||
|
├── plugin.py # DmsPlugin(BasePlugin) with manifest
|
||||||
|
├── models.py # Folder, File models
|
||||||
|
├── schemas.py # Pydantic schemas for all endpoints
|
||||||
|
├── routes.py # FastAPI APIRouter with all endpoints
|
||||||
|
└── migrations/
|
||||||
|
└── 0001_initial.sql # Create folders + files tables
|
||||||
|
```
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
### Folder
|
||||||
|
- id (UUID, PK)
|
||||||
|
- name (str, not null)
|
||||||
|
- parent_id (UUID, FK to folders.id, nullable — null = root)
|
||||||
|
- tenant_id (UUID, not null)
|
||||||
|
- created_by (UUID, not null)
|
||||||
|
- deleted_at (datetime, nullable — soft delete)
|
||||||
|
- created_at, updated_at (TimestampMixin)
|
||||||
|
- **Unique constraint**: (name, parent_id, tenant_id) where deleted_at IS NULL
|
||||||
|
|
||||||
|
### File
|
||||||
|
- id (UUID, PK)
|
||||||
|
- name (str, not null)
|
||||||
|
- folder_id (UUID, FK to folders.id, nullable — null = root)
|
||||||
|
- tenant_id (UUID, not null)
|
||||||
|
- uploaded_by (UUID, not null)
|
||||||
|
- mime_type (str, not null)
|
||||||
|
- size_bytes (int, not null)
|
||||||
|
- storage_path (str, not null — relative path on disk)
|
||||||
|
- deleted_at (datetime, nullable — soft delete)
|
||||||
|
- created_at, updated_at (TimestampMixin)
|
||||||
|
|
||||||
|
## File Storage
|
||||||
|
- Store files at: `/data/dms/{tenant_id}/{file_uuid}` (configurable via plugin config)
|
||||||
|
- Use `shutil.copyfileobj` for upload streaming
|
||||||
|
- Generate UUID for filename on disk, keep original name in DB
|
||||||
|
- Create directory with `os.makedirs(path, exist_ok=True)`
|
||||||
|
|
||||||
|
## Endpoints (19 ACs)
|
||||||
|
|
||||||
|
### Folders
|
||||||
|
1. `GET /api/v1/dms/folders` → 200, folder tree (recursive tree structure)
|
||||||
|
- Query param `parent_id` (optional, null = root level)
|
||||||
|
- Returns list of folders with children nested
|
||||||
|
2. `POST /api/v1/dms/folders` → 201, create folder
|
||||||
|
- Body: `{name, parent_id?}`
|
||||||
|
- Returns created folder with full path
|
||||||
|
3. `PATCH /api/v1/dms/folders/{id}` → 200, rename/move folder
|
||||||
|
- Body: `{name?, parent_id?}`
|
||||||
|
4. `DELETE /api/v1/dms/folders/{id}` → 204, soft-delete (set deleted_at)
|
||||||
|
- Cascade: soft-delete all child folders and files
|
||||||
|
|
||||||
|
### Files
|
||||||
|
5. `POST /api/v1/dms/files/upload` → 201, multipart upload
|
||||||
|
- Form fields: `file` (UploadFile), `folder_id?` (optional)
|
||||||
|
- Store file on disk, create metadata record
|
||||||
|
- Max file size: 100MB (configurable)
|
||||||
|
6. `GET /api/v1/dms/files/{id}` → 200, file metadata
|
||||||
|
7. `PATCH /api/v1/dms/files/{id}` → 200, rename/move
|
||||||
|
- Body: `{name?, folder_id?}`
|
||||||
|
8. `DELETE /api/v1/dms/files/{id}` → 204, soft-delete
|
||||||
|
9. `POST /api/v1/dms/files/{id}/restore` → 200, restore from trash
|
||||||
|
|
||||||
|
### Preview & Edit
|
||||||
|
10. `GET /api/v1/dms/files/{id}/preview` → 200, PDF stream
|
||||||
|
- Only for PDF files (mime_type == application/pdf)
|
||||||
|
- Return `StreamingResponse` with `media_type='application/pdf'`
|
||||||
|
- Non-PDF files: return 400
|
||||||
|
11. `POST /api/v1/dms/files/{id}/edit-session` → 200, OnlyOffice config
|
||||||
|
- Return JSON config for OnlyOffice editor:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"document": {"fileType": "docx", "key": "<uuid>", "title": "<filename>", "url": "<download_url>"},
|
||||||
|
"editorConfig": {"mode": "edit", "callbackUrl": "<callback_url>", "user": {"id": "<user_id>", "name": "<user_name>"}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Only for Office files (docx, xlsx, pptx)
|
||||||
|
- Non-Office files: return 400
|
||||||
|
|
||||||
|
### Sharing (INTERNAL — different from T11 permissions plugin)
|
||||||
|
**NOTE**: T11 permissions plugin already handles:
|
||||||
|
- `POST /api/v1/dms/files/{id}/share-link` — public share links with token
|
||||||
|
- `GET /api/public/share/{token}` — public access
|
||||||
|
- `POST /api/v1/dms/files/{id}/permissions` — grant permissions
|
||||||
|
|
||||||
|
T04 DMS plugin handles INTERNAL sharing (different endpoints):
|
||||||
|
12. `POST /api/v1/dms/files/{id}/share` → 200, internal share
|
||||||
|
- Body: `{user_ids?: [uuid], group_ids?: [uuid], access_level: 'read'|'write'}`
|
||||||
|
- Creates permission records (reuse permissions plugin Permission model OR DMS-specific)
|
||||||
|
13. `DELETE /api/v1/dms/files/{id}/share` → 204, remove share
|
||||||
|
- Body: `{user_id?: uuid, group_id?: uuid}`
|
||||||
|
|
||||||
|
**IMPORTANT**: For `GET /api/public/share/{token}` (AC14, AC15) — T11 permissions plugin ALREADY implements this endpoint. Do NOT create a duplicate. If T11's endpoint already handles password-protected links (401 without password), then AC14 and AC15 are already satisfied. Verify by reading `app/plugins/builtins/permissions/routes.py`.
|
||||||
|
|
||||||
|
### Search & Bulk
|
||||||
|
14. `GET /api/v1/dms/search?q=text` → 200, matching files
|
||||||
|
- Case-insensitive filename search with ILIKE
|
||||||
|
- Search across all files in tenant (not deleted)
|
||||||
|
15. `GET /api/v1/dms/shared-with-me` → 200, shared files list
|
||||||
|
- Files where user has been granted permission (via permissions plugin)
|
||||||
|
16. `POST /api/v1/dms/files/bulk-move` → 200
|
||||||
|
- Body: `{file_ids: [uuid], target_folder_id: uuid?}`
|
||||||
|
17. `POST /api/v1/dms/files/bulk-delete` → 200
|
||||||
|
- Body: `{file_ids: [uuid]}` — soft-delete all
|
||||||
|
|
||||||
|
## Migration SQL
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_by UUID NOT NULL,
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_folders_parent ON folders(parent_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_folders_tenant ON folders(tenant_id) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
uploaded_by UUID NOT NULL,
|
||||||
|
mime_type VARCHAR(255) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
storage_path VARCHAR(1024) NOT NULL,
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_files_folder ON files(folder_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_files_tenant ON files(tenant_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_files_name ON files USING gin (to_tsvector('simple', name));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Register Plugin
|
||||||
|
Add to `app/plugins/builtins/__init__.py`:
|
||||||
|
```python
|
||||||
|
from app.plugins.builtins.dms import DmsPlugin
|
||||||
|
__all__.append("DmsPlugin")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test File
|
||||||
|
Create `tests/test_dms.py` with tests for ALL 19 ACs.
|
||||||
|
|
||||||
|
### Test Patterns
|
||||||
|
- Follow existing test patterns in `tests/test_tags.py`, `tests/test_permissions.py`
|
||||||
|
- Use async test client via `httpx.AsyncClient` with `ASGITransport`
|
||||||
|
- Use existing fixtures from `tests/conftest.py`
|
||||||
|
- For file upload tests: use `httpx.AsyncClient.post` with `files={'file': ('test.pdf', b'%PDF-1.4...', 'application/pdf')}`
|
||||||
|
- For preview tests: verify response status 200 + content-type application/pdf
|
||||||
|
- For OnlyOffice: verify config structure returned
|
||||||
|
- For bulk operations: create multiple files, then bulk-move/bulk-delete
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
```bash
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm
|
||||||
|
python -m pytest tests/test_dms.py -v --tb=short
|
||||||
|
python -m pytest tests/test_dms.py --cov=app/plugins/builtins/dms --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Criteria (19 — ALL must pass)
|
||||||
|
1. GET /api/v1/dms/folders → 200 + folder tree
|
||||||
|
2. POST /api/v1/dms/folders → 201, folder created with path
|
||||||
|
3. PATCH /api/v1/dms/folders/{id} → 200, folder renamed/moved
|
||||||
|
4. DELETE /api/v1/dms/folders/{id} → 204, soft-delete
|
||||||
|
5. POST /api/v1/dms/files/upload (multipart) → 201, file stored + metadata
|
||||||
|
6. GET /api/v1/dms/files/{id} → 200 + file metadata
|
||||||
|
7. PATCH /api/v1/dms/files/{id} → 200, renamed/moved
|
||||||
|
8. DELETE /api/v1/dms/files/{id} → 204, soft-delete
|
||||||
|
9. POST /api/v1/dms/files/{id}/restore → 200, restored from trash
|
||||||
|
10. GET /api/v1/dms/files/{id}/preview → 200 + PDF stream
|
||||||
|
11. POST /api/v1/dms/files/{id}/edit-session → 200 + OnlyOffice config
|
||||||
|
12. POST /api/v1/dms/files/{id}/share → 200, internal share created
|
||||||
|
13. DELETE /api/v1/dms/files/{id}/share → 204, share removed
|
||||||
|
14. GET /api/public/share/{token} → 200 (no auth, public access) — MAY already exist via T11
|
||||||
|
15. GET /api/public/share/{token} mit password → 401 ohne password — MAY already exist via T11
|
||||||
|
16. GET /api/v1/dms/search?q=text → 200 + matching files
|
||||||
|
17. GET /api/v1/dms/shared-with-me → 200 + shared files list
|
||||||
|
18. POST /api/v1/dms/files/bulk-move → 200, files moved
|
||||||
|
19. POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- No placeholder code. No Lorem Ipsum.
|
||||||
|
- Follow existing patterns exactly (SQLAlchemy 2.0, FastAPI APIRouter, Pydantic v2)
|
||||||
|
- All routes must have tenant_id scoping
|
||||||
|
- Use `# noqa: F401` for __init__.py re-exports
|
||||||
|
- Use `from None` in except blocks (B904)
|
||||||
|
- File storage path: `/data/dms/{tenant_id}/{file_uuid}`
|
||||||
|
- OnlyOffice: generate config only, don't run OnlyOffice server
|
||||||
|
- For AC14/AC15: check if T11 permissions plugin already satisfies these. If yes, write tests that verify existing endpoint. If no, implement in DMS plugin.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
- All plugin files created
|
||||||
|
- Plugin registered in builtins __init__.py
|
||||||
|
- tests/test_dms.py with all 19 ACs tested
|
||||||
|
- All tests passing
|
||||||
|
- Coverage ≥80%
|
||||||
|
- Report: files created, test count + pass/fail, coverage %
|
||||||
@@ -7,8 +7,9 @@ Subdirectory plugins (tags, permissions, entity_links) export their plugin
|
|||||||
class via __init__.py so the registry can discover them as packages.
|
class via __init__.py so the registry can discover them as packages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from app.plugins.builtins.dms import DmsPlugin
|
||||||
from app.plugins.builtins.entity_links import EntityLinksPlugin
|
from app.plugins.builtins.entity_links import EntityLinksPlugin
|
||||||
from app.plugins.builtins.permissions import PermissionsPlugin
|
from app.plugins.builtins.permissions import PermissionsPlugin
|
||||||
from app.plugins.builtins.tags import TagsPlugin
|
from app.plugins.builtins.tags import TagsPlugin
|
||||||
|
|
||||||
__all__ = ["TagsPlugin", "PermissionsPlugin", "EntityLinksPlugin"]
|
__all__ = ["TagsPlugin", "PermissionsPlugin", "EntityLinksPlugin", "DmsPlugin"]
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""DMS builtin plugin."""
|
||||||
|
|
||||||
|
from app.plugins.builtins.dms.plugin import DmsPlugin
|
||||||
|
|
||||||
|
__all__ = ["DmsPlugin"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- DMS plugin initial migration: creates folders and files tables
|
||||||
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_by UUID NOT NULL,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_folders_parent ON folders(parent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_folders_tenant ON folders(tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
uploaded_by UUID NOT NULL,
|
||||||
|
mime_type VARCHAR(255) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
storage_path VARCHAR(1024) NOT NULL,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_files_folder ON files(folder_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_files_tenant ON files(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_files_name ON files(name);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Folder and File models for the DMS plugin."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Folder(Base, TenantMixin):
|
||||||
|
"""Folder entity — hierarchical, tenant-scoped, soft-deletable."""
|
||||||
|
|
||||||
|
__tablename__ = "folders"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"name",
|
||||||
|
"parent_id",
|
||||||
|
name="uq_folders_tenant_name_parent",
|
||||||
|
# Partial uniqueness only for non-deleted folders is handled at app level
|
||||||
|
),
|
||||||
|
Index("ix_folders_parent", "parent_id"),
|
||||||
|
Index("ix_folders_tenant", "tenant_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
parent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("folders.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class File(Base, TenantMixin):
|
||||||
|
"""File entity — stored on disk, tenant-scoped, soft-deletable."""
|
||||||
|
|
||||||
|
__tablename__ = "files"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_files_folder", "folder_id"),
|
||||||
|
Index("ix_files_tenant", "tenant_id"),
|
||||||
|
Index("ix_files_name", "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
folder_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("folders.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
uploaded_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||||
|
mime_type: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""DMS plugin — folders, files, preview, OnlyOffice, internal sharing, search, bulk ops."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.plugins.base import BasePlugin
|
||||||
|
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||||
|
|
||||||
|
|
||||||
|
class DmsPlugin(BasePlugin):
|
||||||
|
"""DMS plugin for document management: folders, files, preview, sharing."""
|
||||||
|
|
||||||
|
manifest = PluginManifest(
|
||||||
|
name="dms",
|
||||||
|
version="1.0.0",
|
||||||
|
display_name="DMS",
|
||||||
|
description="Document management: folder hierarchy, file upload, PDF preview, OnlyOffice edit sessions, internal sharing, search, bulk ops.",
|
||||||
|
dependencies=["permissions"],
|
||||||
|
routes=[
|
||||||
|
PluginRouteDef(
|
||||||
|
path="/api/v1/dms",
|
||||||
|
module="app.plugins.builtins.dms.routes",
|
||||||
|
router_attr="router",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
events=[],
|
||||||
|
migrations=["0001_initial.sql"],
|
||||||
|
permissions=[],
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
|||||||
|
"""Pydantic schemas for the DMS plugin."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class FolderCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FolderUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FolderResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
parent_id: str | None = None
|
||||||
|
created_by: str
|
||||||
|
deleted_at: datetime | None = None
|
||||||
|
path: str = ""
|
||||||
|
children: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class FileMetadataResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
folder_id: str | None = None
|
||||||
|
uploaded_by: str
|
||||||
|
mime_type: str
|
||||||
|
size_bytes: int
|
||||||
|
storage_path: str
|
||||||
|
deleted_at: datetime | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
folder_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShareRequest(BaseModel):
|
||||||
|
user_ids: list[str] = Field(default_factory=list)
|
||||||
|
group_ids: list[str] = Field(default_factory=list)
|
||||||
|
access_level: str = Field("read", pattern="^(read|write)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ShareRemoveRequest(BaseModel):
|
||||||
|
user_id: str | None = None
|
||||||
|
group_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BulkMoveRequest(BaseModel):
|
||||||
|
file_ids: list[str] = Field(..., min_length=1)
|
||||||
|
target_folder_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDeleteRequest(BaseModel):
|
||||||
|
file_ids: list[str] = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class OnlyOfficeConfig(BaseModel):
|
||||||
|
document: dict
|
||||||
|
editorConfig: dict # noqa: N815
|
||||||
@@ -27,6 +27,7 @@ filterwarnings = [
|
|||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["app"]
|
source = ["app"]
|
||||||
branch = true
|
branch = true
|
||||||
|
concurrency = ["greenlet"]
|
||||||
omit = [
|
omit = [
|
||||||
"app/__init__.py",
|
"app/__init__.py",
|
||||||
"app/*/__init__.py",
|
"app/*/__init__.py",
|
||||||
|
|||||||
+88
-58
@@ -1,75 +1,105 @@
|
|||||||
# T09 Test Report — Coverage Improvement
|
# Test Report — T04 DMS Plugin Coverage Improvement
|
||||||
|
|
||||||
## Date: 2026-06-29
|
**Date**: 2026-06-29
|
||||||
|
**Task**: T04 — DMS Plugin Coverage Improvement
|
||||||
|
**Status**: ✅ COMPLETE
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
- **Previous**: 30 tests, 22 ACs passing, 45.37% coverage
|
|
||||||
- **Current**: 135 T09 tests passing (105 new), 238 total regression passing, **84.12%** overall coverage
|
|
||||||
- **Target**: 80%+ — **ACHIEVED**
|
|
||||||
|
|
||||||
## T09 Coverage Results
|
- **Tests**: 106 total (27 existing + 38 error-path + 41 new coverage)
|
||||||
|
- **Passed**: 106
|
||||||
|
- **Failed**: 0
|
||||||
|
- **Coverage**: 97.90% (target: ≥80%)
|
||||||
|
- **Before**: 45.88%
|
||||||
|
- **After**: 97.90%
|
||||||
|
|
||||||
|
## Verification Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm
|
||||||
|
python -m pytest tests/test_dms.py tests/test_dms_errors.py tests/test_dms_coverage.py -v --tb=short --cov=app/plugins/builtins/dms --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Report
|
||||||
|
|
||||||
```
|
```
|
||||||
---------- coverage: platform linux, python 3.13.12-final-0 ----------
|
|
||||||
Name Stmts Miss Branch BrPart Cover Missing
|
Name Stmts Miss Branch BrPart Cover Missing
|
||||||
---------------------------------------------------------------------------------
|
-----------------------------------------------------------------------------------
|
||||||
app/ai/action_mapper.py 76 0 46 4 96.72%
|
app/plugins/builtins/dms/__init__.py 2 0 0 0 100.00%
|
||||||
app/ai/llm_client.py 59 11 6 1 81.54%
|
app/plugins/builtins/dms/models.py 26 0 0 0 100.00%
|
||||||
app/routes/ai_copilot.py 34 8 6 0 65.00%
|
app/plugins/builtins/dms/plugin.py 5 0 0 0 100.00%
|
||||||
app/routes/workflows.py 92 25 24 0 62.93%
|
app/plugins/builtins/dms/routes.py 364 6 128 6 97.56%
|
||||||
app/services/ai_copilot_service.py 172 3 44 0 98.61%
|
app/plugins/builtins/dms/schemas.py 46 0 0 0 100.00%
|
||||||
app/services/workflow_service.py 231 45 60 17 75.95%
|
-----------------------------------------------------------------------------------
|
||||||
app/workflows/code/__init__.py 0 0 0 0 100.00%
|
TOTAL 443 6 128 6 97.90%
|
||||||
app/workflows/code/onboarding.py 24 2 4 2 85.71%
|
|
||||||
app/workflows/engine.py 130 9 50 7 90.00%
|
|
||||||
---------------------------------------------------------------------------------
|
|
||||||
TOTAL 818 103 240 31 84.12%
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Per-Module Coverage Improvement
|
## Remaining Uncovered Lines (6 statements, 4 branches)
|
||||||
|
|
||||||
| Module | Before | After | Delta |
|
- Line 100: `_build_path` orphaned folder_id lookup
|
||||||
|--------|--------|-------|-------|
|
- Line 120→116: tree building branch (folder with parent not in map)
|
||||||
| app/workflows/engine.py | 0% | 90.00% | +90.00% |
|
- Line 184→199: create_folder parent_id set but parent_folder is None
|
||||||
| app/services/ai_copilot_service.py | 38.89% | 98.61% | +59.72% |
|
- Lines 279-282: `_is_descendant` loop traversal edge case
|
||||||
| app/ai/action_mapper.py | 43.44% | 96.72% | +53.28% |
|
- Line 299: path building `cur is None` break
|
||||||
| app/routes/workflows.py | 59.48% | 62.93% | +3.45% |
|
- Lines 639-640: `_stream()` generator body (coverage limitation with generators)
|
||||||
| app/ai/llm_client.py | 64.62% | 81.54% | +16.92% |
|
- Line 902→901: shared-with-me perm_map branch
|
||||||
| app/routes/ai_copilot.py | 65% | 65.00% | +0.00% |
|
|
||||||
| app/services/workflow_service.py | 62.54% | 75.95% | +13.41% |
|
|
||||||
| **Overall** | **45.37%** | **84.12%** | **+38.75%** |
|
|
||||||
|
|
||||||
## New Tests Added (105 total)
|
## Files Changed
|
||||||
|
|
||||||
### test_workflows.py (48 new tests)
|
1. `tests/test_dms_coverage.py` — NEW: 41 tests covering folder tree, cascade delete, file upload edge cases, preview missing on disk, share multi-user/groups, search special chars, shared-with-me with data, bulk mixed IDs, tenant isolation
|
||||||
- **WorkflowEngine unit tests (22)**: action noop, create_notification, completion, approval step, notification step, condition eq/ne/gt/lt/contains (string+list), no-match advance, condition completion, unknown step type, workflow not found, step index beyond steps, handle_event (match + no-match), register_workflow_event_handlers
|
2. `pyproject.toml` — Added `concurrency = ["greenlet"]` to `[tool.coverage.run]` to fix async coverage tracking (was causing coverage to miss async function bodies)
|
||||||
- **Route error path tests (9)**: update/delete workflow 404, update/delete RBAC 403, create instance 404, advance/cancel 404, advance completed 400, cancel rejected 400
|
|
||||||
- **Service edge case tests (9)**: check_timeout no_timeout_at, check_timeout completed status, auto_reject no initiated_by, find_workflows no match, start_instance no match, list_workflows active filter, update_workflow with steps, update/delete/get not found
|
|
||||||
|
|
||||||
### test_ai_copilot.py (57 new tests)
|
## Key Fix: Coverage Concurrency Setting
|
||||||
- **ActionMapper unit tests (18)**: create/delete/update/list company, create/list contact, list/create workflow, help intent, unknown query, update with phone/email, update no fields, company list all pattern, empty query
|
|
||||||
- **LLMClient unit tests (11)**: mock generate, mock no actions, mock with context, API mode init, api_base default, LLMResponse.to_dict, parse valid/invalid JSON, build system prompt (with/without context), get/reset singleton
|
|
||||||
- **AI Copilot service tests (20)**: process_query (new/existing/invalid/empty conversation), execute_action (companies GET/POST/PATCH/delete + error paths, contacts GET/POST/unsupported, workflows GET/unsupported, unknown entity, invalid conversation, RBAC blocked), get_history (pagination/empty), _derive_rbac_from_path (5 entity paths), _safe_iso (3 cases), _get_attr (2 cases)
|
|
||||||
- **Route error path tests (4)**: execute 404, execute RBAC 403, history/execute unauthenticated 401
|
|
||||||
|
|
||||||
## Test Commands
|
The original coverage of 45.88% was misleadingly low because coverage.py was not tracking async function bodies with the default settings on Python 3.13. Adding `concurrency = ["greenlet"]` to `pyproject.toml` `[tool.coverage.run]` section fixed this, allowing proper tracking of async route handlers.
|
||||||
|
|
||||||
### T09 Coverage Suite
|
## Test Categories
|
||||||
```bash
|
|
||||||
cd /a0/usr/workdir/dev-projects/leocrm && python -m pytest tests/test_ai_copilot.py tests/test_workflows.py --cov=app.ai --cov=app.workflows --cov=app.services.ai_copilot_service --cov=app.services.workflow_service --cov=app.routes.ai_copilot --cov=app.routes.workflows --cov-report=term-missing --tb=short -v
|
|
||||||
```
|
|
||||||
**Result**: 135 passed, coverage 84.12%
|
|
||||||
|
|
||||||
### Full Regression
|
### Folder Coverage (8 tests)
|
||||||
```bash
|
- Deeply nested tree with path verification
|
||||||
cd /a0/usr/workdir/dev-projects/leocrm && python -m pytest tests/ -v --tb=short
|
- Invalid parent_id in query params
|
||||||
```
|
- 3-level nested folder creation
|
||||||
**Result**: 238 passed, 0 failed, 2 warnings (pre-existing deprecation warning)
|
- Rename + move in single request
|
||||||
|
- Move to root via parent_id=null
|
||||||
|
- Empty body update (no changes)
|
||||||
|
- Invalid folder_id in PATCH
|
||||||
|
- 3-level cascade delete with files in each folder
|
||||||
|
- Tenant isolation (cross-tenant folder access)
|
||||||
|
|
||||||
## Smoke Test
|
### File Coverage (12 tests)
|
||||||
- T09 coverage suite: 135/135 tests pass
|
- File too large (413) via mock patch
|
||||||
- Full regression: 238/238 tests pass (133 existing + 105 new)
|
- Empty file upload
|
||||||
- No production code modified — only test files updated
|
- Invalid folder_id in upload
|
||||||
- All 22 original ACs still pass
|
- Invalid file_id in delete/restore/update/preview/edit-session
|
||||||
nREPORT
|
- Rename + move in single request
|
||||||
echo 'test_report.md created'
|
- Move to root via folder_id=null
|
||||||
|
- Empty body update (no changes)
|
||||||
|
- Preview file missing on disk (404 file_missing)
|
||||||
|
- Tenant isolation (cross-tenant file access)
|
||||||
|
|
||||||
|
### Share Coverage (8 tests)
|
||||||
|
- Share with multiple users
|
||||||
|
- Share with both users and groups
|
||||||
|
- Invalid group_id (400)
|
||||||
|
- Duplicate group share ignored
|
||||||
|
- Remove user share
|
||||||
|
- Remove share with no match (idempotent)
|
||||||
|
- Invalid group_id in remove (400)
|
||||||
|
- Remove both user and group shares
|
||||||
|
|
||||||
|
### Search Coverage (3 tests)
|
||||||
|
- Special characters in filename
|
||||||
|
- Partial name match
|
||||||
|
- Tenant isolation
|
||||||
|
|
||||||
|
### Shared-with-me Coverage (3 tests)
|
||||||
|
- Multiple shared files
|
||||||
|
- Excludes soft-deleted files
|
||||||
|
- Write access level display
|
||||||
|
|
||||||
|
### Bulk Coverage (5 tests)
|
||||||
|
- Bulk move with mixed valid/invalid IDs
|
||||||
|
- Bulk delete with mixed valid/invalid IDs
|
||||||
|
- Invalid target_folder_id (400)
|
||||||
|
- Bulk delete tenant isolation
|
||||||
|
- Bulk move tenant isolation
|
||||||
|
|||||||
+67
-1
@@ -7,6 +7,8 @@ Auth helpers talk to the HTTP API (integration tests).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
|
|
||||||
from app.core.auth import hash_password
|
from app.core.auth import hash_password
|
||||||
from app.core.db import Base, close_engine, reset_engine_for_testing
|
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.main import create_app
|
||||||
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
|
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
|
||||||
from app.models.company import Company
|
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.tenant import Tenant
|
||||||
from app.models.user import User, UserTenant
|
from app.models.user import User, UserTenant
|
||||||
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401
|
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.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.permissions.models import Permission, ShareLink # noqa: F401
|
||||||
from app.plugins.builtins.tags.models import Tag, TagAssignment # 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
|
# 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
|
# TRUNCATE all tables with CASCADE — fast and reliable isolation
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text(
|
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()
|
conn.commit()
|
||||||
@@ -283,3 +292,60 @@ async def get_auth_client(
|
|||||||
"""Return a client that's logged in."""
|
"""Return a client that's logged in."""
|
||||||
await login_client(client, email, password)
|
await login_client(client, email, password)
|
||||||
return client
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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() == []
|
||||||
Reference in New Issue
Block a user