Files
ai-contract-platform/backend/tests/test_files_api.py
T

502 lines
18 KiB
Python

import io
import tempfile
import unittest
from pathlib import Path
from datetime import datetime, timezone
from fastapi import HTTPException, status
from fastapi.testclient import TestClient
from minio.error import S3Error
from starlette.datastructures import UploadFile
from app.api.files import get_audit_log_service, get_file_service, get_tenant_service, require_current_user
from app.main import app
class StubAuditLogService:
def __init__(self):
self.events = []
def record_event(self, **kwargs):
self.events.append(kwargs)
return kwargs
class StubFileService:
def list_files(self, current_user, page=1, page_size=20, file_type=None):
items = [
{
"id": "file-123",
"tenant_id": current_user["tenant_id"],
"uploaded_by": current_user["id"],
"bucket": "default",
"object_key": "tenant-demo-001/document/abc/demo.txt",
"file_name": "demo.txt",
"mime_type": "text/plain",
"file_size": 11,
"file_hash": "hash-demo",
"file_type": "document",
"status": "active",
"created_at": "2026-04-17T00:00:00+00:00",
}
]
if file_type:
items = [item for item in items if item["file_type"] == file_type]
return {
"items": items,
"page": page,
"page_size": page_size,
"total": len(items),
}
def upload_file(self, current_user, file, file_type, bucket="default"):
return {
"id": "file-123",
"tenant_id": current_user["tenant_id"],
"uploaded_by": current_user["id"],
"bucket": bucket,
"object_key": f"{current_user['tenant_id']}/{file_type}/abc/{file.filename}",
"file_name": file.filename,
"mime_type": file.content_type or "application/octet-stream",
"file_size": 11,
"file_hash": "hash-demo",
"file_type": file_type,
"status": "active",
"created_at": "2026-04-17T00:00:00+00:00",
}
def get_file(self, current_user, file_id):
return self.list_files(current_user)["items"][0]
def delete_file(self, current_user, file_id):
return True
def get_download_url(self, current_user, file_id):
return {
"id": file_id,
"file_name": "demo.txt",
"download_url": f"/api/v1/files/{file_id}/download",
"expires_in": 3600,
}
def get_download_payload(self, current_user, file_id, allow_owner_access=False):
return {
"id": file_id,
"file_name": "demo.txt",
"mime_type": "text/plain",
"content": b"hello world",
}
class StubTenantService:
def __init__(self, blocked=False):
self.blocked = blocked
self.calls = []
def assert_billing_allows(self, current_user, action, requested_file_size_bytes=0):
self.calls.append((action, requested_file_size_bytes))
if self.blocked:
raise HTTPException(status_code=402, detail="存储容量不足,请升级套餐或购买存储扩容包。")
class FilesApiTestCase(unittest.TestCase):
def setUp(self):
self.audit_log_service = StubAuditLogService()
self.tenant_service = StubTenantService()
app.dependency_overrides[get_file_service] = lambda: StubFileService()
app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service
app.dependency_overrides[get_tenant_service] = lambda: self.tenant_service
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
self.client = TestClient(app)
def tearDown(self):
app.dependency_overrides.clear()
def test_list_files_returns_filtered_total(self):
response = self.client.get("/api/v1/files", params={"file_type": "document"})
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["code"], 0)
self.assertEqual(body["data"]["total"], 1)
self.assertEqual(body["data"]["items"][0]["file_type"], "document")
def test_upload_file_records_audit_event(self):
response = self.client.post(
"/api/v1/files",
data={"file_type": "document", "bucket": "default"},
files={"file": ("demo.txt", b"hello world", "text/plain")},
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["data"]["file_name"], "demo.txt")
self.assertEqual(self.tenant_service.calls[0][0], "storage")
self.assertEqual(self.audit_log_service.events[0]["action"], "upload_file")
self.assertEqual(
self.audit_log_service.events[0]["detail_json"],
{
"file_name": "demo.txt",
"file_type": "document",
"file_size": 11,
"mime_type": "text/plain",
"file_hash": "hash-demo",
"bucket": "default",
},
)
def test_get_file_records_audit_event(self):
response = self.client.get("/api/v1/files/file-123")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["data"]["id"], "file-123")
self.assertEqual(self.audit_log_service.events[0]["action"], "get_file")
self.assertEqual(
self.audit_log_service.events[0]["detail_json"],
{
"file_name": "demo.txt",
"file_type": "document",
"mime_type": "text/plain",
"file_size": 11,
"file_hash": "hash-demo",
},
)
def test_delete_file_records_audit_event(self):
response = self.client.delete("/api/v1/files/file-123")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertTrue(body["data"]["success"])
self.assertEqual(self.audit_log_service.events[0]["action"], "delete_file")
self.assertEqual(
self.audit_log_service.events[0]["detail_json"],
{
"file_name": "demo.txt",
"file_hash": "hash-demo",
"mime_type": "text/plain",
"file_size": 11,
"file_type": "document",
},
)
def test_get_download_url_records_audit_event(self):
response = self.client.get("/api/v1/files/file-123/download-url")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["data"]["id"], "file-123")
self.assertEqual(self.audit_log_service.events[0]["action"], "get_download_url")
self.assertEqual(
self.audit_log_service.events[0]["detail_json"],
{
"file_name": "demo.txt",
"download_url": "/api/v1/files/file-123/download",
"expires_in": 3600,
},
)
def test_download_file_returns_attachment_response(self):
response = self.client.get("/api/v1/files/file-123/download")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"hello world")
self.assertEqual(response.headers["content-type"], "text/plain; charset=utf-8")
self.assertEqual(response.headers["content-disposition"], "attachment; filename*=UTF-8''demo.txt")
self.assertEqual(self.audit_log_service.events[0]["action"], "download_file")
class FileServiceStorageTestCase(unittest.TestCase):
def test_upload_download_delete_manage_minio_storage(self):
from app.services.file_service import FileService
class FakeObjectResponse:
def __init__(self, content: bytes):
self._content = content
def read(self):
return self._content
def close(self):
return None
def release_conn(self):
return None
class FakeMinioClient:
def __init__(self):
self.buckets = set()
self.objects = {}
def bucket_exists(self, bucket):
return bucket in self.buckets
def make_bucket(self, bucket):
self.buckets.add(bucket)
def put_object(self, bucket_name, object_name, data, length, content_type):
if bucket_name not in self.buckets:
raise RuntimeError("bucket missing")
self.objects[(bucket_name, object_name)] = data.read(length)
def get_object(self, bucket_name, object_name):
key = (bucket_name, object_name)
if key not in self.objects:
raise S3Error(
code="NoSuchKey",
message="Not Found",
resource=object_name,
request_id="req",
host_id="host",
response=None,
bucket_name=bucket_name,
object_name=object_name,
)
return FakeObjectResponse(self.objects[key])
def remove_object(self, bucket_name, object_name):
key = (bucket_name, object_name)
if key not in self.objects:
raise S3Error(
code="NoSuchKey",
message="Not Found",
resource=object_name,
request_id="req",
host_id="host",
response=None,
bucket_name=bucket_name,
object_name=object_name,
)
del self.objects[key]
class InMemoryRepository:
def __init__(self):
self.created = None
self.deleted = None
def create(self, file_record):
if file_record.created_at is None:
file_record.created_at = datetime.now(timezone.utc)
self.created = file_record
return file_record
def get_by_id(self, file_id, tenant_id):
if self.created and self.created.id == file_id and self.created.tenant_id == tenant_id and self.created.status == "active":
return self.created
return None
def soft_delete(self, file_record):
file_record.status = "deleted"
self.deleted = file_record
return file_record
def list_by_type(self, tenant_id, file_type, page, page_size):
return []
def list_paginated(self, tenant_id, page, page_size):
return []
def count(self, tenant_id, file_type=None):
return 0
service = FileService(db=None)
repository = InMemoryRepository()
fake_minio = FakeMinioClient()
service.repository = repository
service.minio_client = fake_minio
current_user = {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
upload = UploadFile(filename="nested/contract.txt", file=io.BytesIO(b"payload"), headers={"content-type": "text/plain"})
created = service.upload_file(current_user, upload, "document", "contracts")
object_key = repository.created.object_key
self.assertEqual(created["file_name"], "contract.txt")
self.assertIn("contracts", fake_minio.buckets)
self.assertEqual(fake_minio.objects[("contracts", object_key)], b"payload")
download = service.get_download_payload(current_user, repository.created.id)
self.assertEqual(download["content"], b"payload")
service.delete_file(current_user, repository.created.id)
self.assertNotIn(("contracts", object_key), fake_minio.objects)
self.assertEqual(repository.deleted.status, "deleted")
def test_download_missing_object_returns_404(self):
from app.services.file_service import FileService
class FakeMinioClient:
def get_object(self, bucket_name, object_name):
raise S3Error(
code="NoSuchKey",
message="Not Found",
resource=object_name,
request_id="req",
host_id="host",
response=None,
bucket_name=bucket_name,
object_name=object_name,
)
class InMemoryRepository:
def __init__(self):
self.created = type(
"FileRecord",
(),
{
"id": "file-123",
"tenant_id": "tenant-demo-001",
"status": "active",
"bucket": "contracts",
"object_key": "tenant-demo-001/document/object.txt",
"file_name": "object.txt",
"mime_type": "text/plain",
},
)()
def get_by_id(self, file_id, tenant_id):
if self.created.id == file_id and self.created.tenant_id == tenant_id and self.created.status == "active":
return self.created
return None
def list_by_type(self, tenant_id, file_type, page, page_size):
return []
def list_paginated(self, tenant_id, page, page_size):
return []
def count(self, tenant_id, file_type=None):
return 0
service = FileService(db=None)
service.repository = InMemoryRepository()
service.minio_client = FakeMinioClient()
with self.assertRaises(HTTPException) as ctx:
service.get_download_payload({"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"}, "file-123")
self.assertEqual(ctx.exception.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(ctx.exception.detail, "file content not found")
def test_owner_can_download_without_admin_role_when_enabled(self):
from app.services.file_service import FileService
class FakeMinioClient:
def get_object(self, bucket_name, object_name):
class FakeObjectResponse:
def read(self_inner):
return b"owner-payload"
def close(self_inner):
return None
def release_conn(self_inner):
return None
return FakeObjectResponse()
class InMemoryRepository:
def __init__(self):
self.created = type(
"FileRecord",
(),
{
"id": "file-123",
"tenant_id": "tenant-demo-001",
"uploaded_by": "user-owner",
"status": "active",
"bucket": "contracts",
"object_key": "tenant-demo-001/document/object.txt",
"file_name": "object.txt",
"mime_type": "text/plain",
},
)()
def get_by_id(self, file_id, tenant_id):
if self.created.id == file_id and self.created.tenant_id == tenant_id and self.created.status == "active":
return self.created
return None
service = FileService(db=None)
service.repository = InMemoryRepository()
service.minio_client = FakeMinioClient()
payload = service.get_download_payload(
{"id": "user-owner", "tenant_id": "tenant-demo-001", "role": "member"},
"file-123",
allow_owner_access=True,
)
self.assertEqual(payload["content"], b"owner-payload")
def test_local_storage_backend_persists_downloads_and_deletes_files(self):
from app.services.file_service import FileService
class InMemoryRepository:
def __init__(self):
self.created = None
self.deleted = None
def create(self, file_record):
if file_record.created_at is None:
file_record.created_at = datetime.now(timezone.utc)
self.created = file_record
return file_record
def get_by_id(self, file_id, tenant_id):
if self.created and self.created.id == file_id and self.created.tenant_id == tenant_id and self.created.status == "active":
return self.created
return None
def soft_delete(self, file_record):
file_record.status = "deleted"
self.deleted = file_record
return file_record
def list_by_type(self, tenant_id, file_type, page, page_size):
return []
def list_paginated(self, tenant_id, page, page_size):
return []
def count(self, tenant_id, file_type=None):
return 0
with tempfile.TemporaryDirectory() as temp_dir:
service = FileService(db=None)
service.repository = InMemoryRepository()
service.storage_backend = "local"
service.local_storage_dir = Path(temp_dir)
current_user = {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
upload = UploadFile(filename="nested/contract.txt", file=io.BytesIO(b"local-payload"), headers={"content-type": "text/plain"})
created = service.upload_file(current_user, upload, "document", "contracts")
object_path = service._resolve_local_object_path("contracts", service.repository.created.object_key)
self.assertEqual(created["file_name"], "contract.txt")
self.assertTrue(object_path.exists())
self.assertEqual(object_path.read_bytes(), b"local-payload")
download = service.get_download_payload(current_user, service.repository.created.id)
self.assertEqual(download["content"], b"local-payload")
service.delete_file(current_user, service.repository.created.id)
self.assertFalse(object_path.exists())
self.assertEqual(service.repository.deleted.status, "deleted")
if __name__ == "__main__":
unittest.main()