Complete implementation of the production line incident response system (生產線異常即時反應系統) including: Backend (FastAPI): - User authentication with AD integration and session management - Chat room management (create, list, update, members, roles) - Real-time messaging via WebSocket (typing indicators, reactions) - File storage with MinIO (upload, download, image preview) Frontend (React + Vite): - Authentication flow with token management - Room list with filtering, search, and pagination - Real-time chat interface with WebSocket - File upload with drag-and-drop and image preview - Member management and room settings - Breadcrumb navigation - 53 unit tests (Vitest) Specifications: - authentication: AD auth, sessions, JWT tokens - chat-room: rooms, members, templates - realtime-messaging: WebSocket, messages, reactions - file-storage: MinIO integration, file management - frontend-core: React SPA structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
571 lines
21 KiB
Python
571 lines
21 KiB
Python
"""Test suite for file storage module
|
|
|
|
Tests cover:
|
|
- Section 7.1: Unit tests for file validators
|
|
- Section 7.2: Integration tests for file upload flow
|
|
- Section 7.3: Integration tests for file download flow
|
|
- Section 7.4: Comprehensive test suite
|
|
"""
|
|
import pytest
|
|
import io
|
|
from unittest.mock import Mock, patch, MagicMock
|
|
from fastapi import UploadFile, HTTPException
|
|
from datetime import datetime
|
|
import uuid
|
|
|
|
from app.modules.file_storage.validators import (
|
|
detect_mime_type,
|
|
validate_file_type,
|
|
validate_file_size,
|
|
get_file_type_and_limits,
|
|
validate_upload_file,
|
|
IMAGE_TYPES,
|
|
DOCUMENT_TYPES,
|
|
LOG_TYPES,
|
|
IMAGE_MAX_SIZE,
|
|
DOCUMENT_MAX_SIZE,
|
|
LOG_MAX_SIZE
|
|
)
|
|
from app.modules.file_storage.models import RoomFile
|
|
from app.modules.file_storage.schemas import (
|
|
FileUploadResponse,
|
|
FileMetadata,
|
|
FileListResponse,
|
|
FileType
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Section 7.1: Unit Tests for File Validators
|
|
# ============================================================================
|
|
|
|
class TestMimeTypeDetection:
|
|
"""Tests for MIME type detection"""
|
|
|
|
def test_detect_jpeg_image(self):
|
|
"""Test detecting JPEG image from file header"""
|
|
# JPEG magic bytes
|
|
jpeg_header = b'\xff\xd8\xff\xe0\x00\x10JFIF'
|
|
with patch('magic.Magic') as MockMagic:
|
|
mock_magic = MockMagic.return_value
|
|
mock_magic.from_buffer.return_value = 'image/jpeg'
|
|
result = detect_mime_type(jpeg_header)
|
|
assert result == 'image/jpeg'
|
|
|
|
def test_detect_png_image(self):
|
|
"""Test detecting PNG image from file header"""
|
|
# PNG magic bytes
|
|
png_header = b'\x89PNG\r\n\x1a\n'
|
|
with patch('magic.Magic') as MockMagic:
|
|
mock_magic = MockMagic.return_value
|
|
mock_magic.from_buffer.return_value = 'image/png'
|
|
result = detect_mime_type(png_header)
|
|
assert result == 'image/png'
|
|
|
|
def test_detect_pdf_document(self):
|
|
"""Test detecting PDF document from file header"""
|
|
# PDF magic bytes
|
|
pdf_header = b'%PDF-1.4'
|
|
with patch('magic.Magic') as MockMagic:
|
|
mock_magic = MockMagic.return_value
|
|
mock_magic.from_buffer.return_value = 'application/pdf'
|
|
result = detect_mime_type(pdf_header)
|
|
assert result == 'application/pdf'
|
|
|
|
def test_detect_text_file(self):
|
|
"""Test detecting plain text file"""
|
|
text_content = b'Hello, this is a plain text file.'
|
|
with patch('magic.Magic') as MockMagic:
|
|
mock_magic = MockMagic.return_value
|
|
mock_magic.from_buffer.return_value = 'text/plain'
|
|
result = detect_mime_type(text_content)
|
|
assert result == 'text/plain'
|
|
|
|
def test_detect_unknown_fallback(self):
|
|
"""Test fallback to application/octet-stream on error"""
|
|
with patch('magic.Magic') as MockMagic:
|
|
MockMagic.side_effect = Exception("Magic library error")
|
|
result = detect_mime_type(b'some data')
|
|
assert result == 'application/octet-stream'
|
|
|
|
|
|
class TestFileTypeValidation:
|
|
"""Tests for file type validation"""
|
|
|
|
def create_mock_upload_file(self, content: bytes, filename: str = "test.txt"):
|
|
"""Helper to create a mock UploadFile"""
|
|
file = Mock(spec=UploadFile)
|
|
file.filename = filename
|
|
file.file = io.BytesIO(content)
|
|
return file
|
|
|
|
def test_validate_allowed_image_type(self):
|
|
"""Test validation passes for allowed image types"""
|
|
file = self.create_mock_upload_file(b'\xff\xd8\xff', "test.jpg")
|
|
|
|
with patch('app.modules.file_storage.validators.detect_mime_type') as mock_detect:
|
|
mock_detect.return_value = 'image/jpeg'
|
|
result = validate_file_type(file, IMAGE_TYPES)
|
|
assert result == 'image/jpeg'
|
|
|
|
def test_validate_disallowed_type_raises_exception(self):
|
|
"""Test validation fails for disallowed file types"""
|
|
file = self.create_mock_upload_file(b'MZ', "virus.exe")
|
|
|
|
with patch('app.modules.file_storage.validators.detect_mime_type') as mock_detect:
|
|
mock_detect.return_value = 'application/x-executable'
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
validate_file_type(file, IMAGE_TYPES)
|
|
assert exc_info.value.status_code == 400
|
|
assert "File type not allowed" in exc_info.value.detail
|
|
|
|
def test_validate_pdf_in_document_types(self):
|
|
"""Test validation passes for PDF in document types"""
|
|
file = self.create_mock_upload_file(b'%PDF-1.4', "document.pdf")
|
|
|
|
with patch('app.modules.file_storage.validators.detect_mime_type') as mock_detect:
|
|
mock_detect.return_value = 'application/pdf'
|
|
result = validate_file_type(file, DOCUMENT_TYPES)
|
|
assert result == 'application/pdf'
|
|
|
|
|
|
class TestFileSizeValidation:
|
|
"""Tests for file size validation"""
|
|
|
|
def create_mock_upload_file(self, size: int, filename: str = "test.txt"):
|
|
"""Helper to create a mock UploadFile with specific size"""
|
|
content = b'x' * size
|
|
file = Mock(spec=UploadFile)
|
|
file.filename = filename
|
|
file.file = io.BytesIO(content)
|
|
return file
|
|
|
|
def test_validate_file_within_limit(self):
|
|
"""Test validation passes for file within size limit"""
|
|
file = self.create_mock_upload_file(1024) # 1KB
|
|
result = validate_file_size(file, IMAGE_MAX_SIZE)
|
|
assert result == 1024
|
|
|
|
def test_validate_file_exceeds_limit_raises_exception(self):
|
|
"""Test validation fails for file exceeding size limit"""
|
|
file = self.create_mock_upload_file(IMAGE_MAX_SIZE + 1) # Just over limit
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
validate_file_size(file, IMAGE_MAX_SIZE)
|
|
assert exc_info.value.status_code == 413
|
|
assert "File size exceeds limit" in exc_info.value.detail
|
|
|
|
def test_validate_exact_limit_passes(self):
|
|
"""Test validation passes for file at exact size limit"""
|
|
file = self.create_mock_upload_file(IMAGE_MAX_SIZE)
|
|
result = validate_file_size(file, IMAGE_MAX_SIZE)
|
|
assert result == IMAGE_MAX_SIZE
|
|
|
|
|
|
class TestFileTypeCategorization:
|
|
"""Tests for file type categorization"""
|
|
|
|
def test_image_type_returns_image_category(self):
|
|
"""Test image MIME types return 'image' category"""
|
|
for mime_type in IMAGE_TYPES:
|
|
file_type, max_size = get_file_type_and_limits(mime_type)
|
|
assert file_type == "image"
|
|
assert max_size == IMAGE_MAX_SIZE
|
|
|
|
def test_document_type_returns_document_category(self):
|
|
"""Test document MIME types return 'document' category"""
|
|
for mime_type in DOCUMENT_TYPES:
|
|
file_type, max_size = get_file_type_and_limits(mime_type)
|
|
assert file_type == "document"
|
|
assert max_size == DOCUMENT_MAX_SIZE
|
|
|
|
def test_log_type_returns_log_category(self):
|
|
"""Test log MIME types return 'log' category"""
|
|
for mime_type in LOG_TYPES:
|
|
file_type, max_size = get_file_type_and_limits(mime_type)
|
|
assert file_type == "log"
|
|
assert max_size == LOG_MAX_SIZE
|
|
|
|
def test_unknown_type_raises_exception(self):
|
|
"""Test unknown MIME type raises exception"""
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
get_file_type_and_limits("application/x-executable")
|
|
assert exc_info.value.status_code == 400
|
|
assert "Unsupported file type" in exc_info.value.detail
|
|
|
|
|
|
class TestCompleteFileValidation:
|
|
"""Tests for complete file validation flow"""
|
|
|
|
def create_mock_upload_file(self, content: bytes, filename: str):
|
|
"""Helper to create a mock UploadFile"""
|
|
file = Mock(spec=UploadFile)
|
|
file.filename = filename
|
|
file.file = io.BytesIO(content)
|
|
return file
|
|
|
|
def test_validate_upload_image_file(self):
|
|
"""Test complete validation for image file"""
|
|
content = b'\xff\xd8\xff' + b'x' * 1000
|
|
file = self.create_mock_upload_file(content, "photo.jpg")
|
|
|
|
with patch('app.modules.file_storage.validators.detect_mime_type') as mock_detect:
|
|
mock_detect.return_value = 'image/jpeg'
|
|
file_type, mime_type, file_size = validate_upload_file(file)
|
|
|
|
assert file_type == "image"
|
|
assert mime_type == "image/jpeg"
|
|
assert file_size == len(content)
|
|
|
|
def test_validate_upload_pdf_file(self):
|
|
"""Test complete validation for PDF file"""
|
|
content = b'%PDF-1.4' + b'x' * 2000
|
|
file = self.create_mock_upload_file(content, "document.pdf")
|
|
|
|
with patch('app.modules.file_storage.validators.detect_mime_type') as mock_detect:
|
|
mock_detect.return_value = 'application/pdf'
|
|
file_type, mime_type, file_size = validate_upload_file(file)
|
|
|
|
assert file_type == "document"
|
|
assert mime_type == "application/pdf"
|
|
assert file_size == len(content)
|
|
|
|
def test_validate_upload_log_file(self):
|
|
"""Test complete validation for log file"""
|
|
content = b'2024-01-01 INFO: Log message\n' * 100
|
|
file = self.create_mock_upload_file(content, "app.log")
|
|
|
|
with patch('app.modules.file_storage.validators.detect_mime_type') as mock_detect:
|
|
mock_detect.return_value = 'text/plain'
|
|
file_type, mime_type, file_size = validate_upload_file(file)
|
|
|
|
assert file_type == "log"
|
|
assert mime_type == "text/plain"
|
|
assert file_size == len(content)
|
|
|
|
|
|
# ============================================================================
|
|
# Section 7.2: Integration Tests for File Upload Flow (Mocked MinIO)
|
|
# ============================================================================
|
|
|
|
class TestFileUploadFlow:
|
|
"""Integration tests for file upload flow"""
|
|
|
|
@pytest.fixture
|
|
def mock_minio_service(self):
|
|
"""Mock MinIO service"""
|
|
with patch('app.modules.file_storage.services.file_service.minio_service') as mock:
|
|
mock.upload_file.return_value = True
|
|
mock.generate_presigned_url.return_value = "https://minio.example.com/bucket/file.jpg?signed=123"
|
|
mock.delete_file.return_value = True
|
|
yield mock
|
|
|
|
def test_file_upload_response_schema(self):
|
|
"""Test FileUploadResponse schema validation"""
|
|
response = FileUploadResponse(
|
|
file_id=str(uuid.uuid4()),
|
|
filename="test.jpg",
|
|
file_type=FileType.IMAGE,
|
|
file_size=1024,
|
|
mime_type="image/jpeg",
|
|
download_url="https://example.com/file.jpg",
|
|
uploaded_at=datetime.utcnow(),
|
|
uploader_id="user@example.com"
|
|
)
|
|
assert response.file_id is not None
|
|
assert response.file_type == FileType.IMAGE
|
|
assert response.file_size == 1024
|
|
|
|
|
|
# ============================================================================
|
|
# Section 7.3: Integration Tests for File Download Flow
|
|
# ============================================================================
|
|
|
|
class TestFileDownloadFlow:
|
|
"""Integration tests for file download and listing"""
|
|
|
|
def test_file_metadata_schema(self):
|
|
"""Test FileMetadata schema validation"""
|
|
metadata = FileMetadata(
|
|
file_id=str(uuid.uuid4()),
|
|
room_id=str(uuid.uuid4()),
|
|
filename="document.pdf",
|
|
file_type=FileType.DOCUMENT,
|
|
mime_type="application/pdf",
|
|
file_size=2048,
|
|
minio_bucket="task-reporter-files",
|
|
minio_object_path="room-123/documents/abc.pdf",
|
|
uploaded_at=datetime.utcnow(),
|
|
uploader_id="user@example.com",
|
|
download_url="https://example.com/file.pdf"
|
|
)
|
|
assert metadata.file_type == FileType.DOCUMENT
|
|
assert metadata.download_url is not None
|
|
|
|
def test_file_list_response_schema(self):
|
|
"""Test FileListResponse schema validation"""
|
|
files = [
|
|
FileMetadata(
|
|
file_id=str(uuid.uuid4()),
|
|
room_id=str(uuid.uuid4()),
|
|
filename=f"file{i}.jpg",
|
|
file_type=FileType.IMAGE,
|
|
mime_type="image/jpeg",
|
|
file_size=1024 * (i + 1),
|
|
minio_bucket="task-reporter-files",
|
|
minio_object_path=f"room-123/images/file{i}.jpg",
|
|
uploaded_at=datetime.utcnow(),
|
|
uploader_id="user@example.com"
|
|
)
|
|
for i in range(3)
|
|
]
|
|
|
|
response = FileListResponse(
|
|
files=files,
|
|
total=10,
|
|
limit=3,
|
|
offset=0,
|
|
has_more=True
|
|
)
|
|
assert len(response.files) == 3
|
|
assert response.has_more is True
|
|
assert response.total == 10
|
|
|
|
|
|
# ============================================================================
|
|
# Section 7.4: Model Tests
|
|
# ============================================================================
|
|
|
|
class TestRoomFileModel:
|
|
"""Tests for RoomFile SQLAlchemy model"""
|
|
|
|
def test_room_file_creation(self, db_session):
|
|
"""Test creating a RoomFile record"""
|
|
from app.modules.chat_room.models import IncidentRoom, RoomMember, MemberRole
|
|
from app.modules.chat_room.schemas import IncidentType, SeverityLevel
|
|
|
|
# Create a room first (required for foreign key)
|
|
room = IncidentRoom(
|
|
room_id=str(uuid.uuid4()),
|
|
title="Test Room",
|
|
location="Line 1",
|
|
incident_type=IncidentType.EQUIPMENT_FAILURE,
|
|
severity=SeverityLevel.HIGH,
|
|
created_by="test@example.com"
|
|
)
|
|
db_session.add(room)
|
|
db_session.commit()
|
|
|
|
# Create member
|
|
member = RoomMember(
|
|
room_id=room.room_id,
|
|
user_id="test@example.com",
|
|
role=MemberRole.OWNER,
|
|
added_by="system"
|
|
)
|
|
db_session.add(member)
|
|
db_session.commit()
|
|
|
|
# Create file record
|
|
file = RoomFile(
|
|
file_id=str(uuid.uuid4()),
|
|
room_id=room.room_id,
|
|
uploader_id="test@example.com",
|
|
filename="test.jpg",
|
|
file_type="image",
|
|
mime_type="image/jpeg",
|
|
file_size=1024,
|
|
minio_bucket="task-reporter-files",
|
|
minio_object_path=f"room-{room.room_id}/images/test.jpg",
|
|
uploaded_at=datetime.utcnow()
|
|
)
|
|
db_session.add(file)
|
|
db_session.commit()
|
|
|
|
# Verify
|
|
retrieved = db_session.query(RoomFile).filter(RoomFile.file_id == file.file_id).first()
|
|
assert retrieved is not None
|
|
assert retrieved.filename == "test.jpg"
|
|
assert retrieved.file_type == "image"
|
|
assert retrieved.deleted_at is None
|
|
|
|
def test_room_file_soft_delete(self, db_session):
|
|
"""Test soft deleting a RoomFile record"""
|
|
from app.modules.chat_room.models import IncidentRoom, RoomMember, MemberRole
|
|
from app.modules.chat_room.schemas import IncidentType, SeverityLevel
|
|
|
|
# Create room and file
|
|
room = IncidentRoom(
|
|
room_id=str(uuid.uuid4()),
|
|
title="Test Room",
|
|
location="Line 1",
|
|
incident_type=IncidentType.EQUIPMENT_FAILURE,
|
|
severity=SeverityLevel.HIGH,
|
|
created_by="test@example.com"
|
|
)
|
|
db_session.add(room)
|
|
db_session.commit()
|
|
|
|
file = RoomFile(
|
|
file_id=str(uuid.uuid4()),
|
|
room_id=room.room_id,
|
|
uploader_id="test@example.com",
|
|
filename="test.pdf",
|
|
file_type="document",
|
|
mime_type="application/pdf",
|
|
file_size=2048,
|
|
minio_bucket="task-reporter-files",
|
|
minio_object_path=f"room-{room.room_id}/documents/test.pdf",
|
|
uploaded_at=datetime.utcnow()
|
|
)
|
|
db_session.add(file)
|
|
db_session.commit()
|
|
|
|
# Soft delete
|
|
file.deleted_at = datetime.utcnow()
|
|
db_session.commit()
|
|
|
|
# Verify soft delete
|
|
retrieved = db_session.query(RoomFile).filter(RoomFile.file_id == file.file_id).first()
|
|
assert retrieved.deleted_at is not None
|
|
|
|
|
|
# ============================================================================
|
|
# WebSocket Schemas Tests
|
|
# ============================================================================
|
|
|
|
class TestWebSocketSchemas:
|
|
"""Tests for WebSocket broadcast schemas"""
|
|
|
|
def test_file_uploaded_broadcast_schema(self):
|
|
"""Test FileUploadedBroadcast schema"""
|
|
from app.modules.realtime.schemas import FileUploadedBroadcast
|
|
|
|
broadcast = FileUploadedBroadcast(
|
|
file_id=str(uuid.uuid4()),
|
|
room_id=str(uuid.uuid4()),
|
|
uploader_id="user@example.com",
|
|
filename="photo.jpg",
|
|
file_type="image",
|
|
file_size=1024,
|
|
mime_type="image/jpeg",
|
|
download_url="https://example.com/file.jpg",
|
|
uploaded_at=datetime.utcnow()
|
|
)
|
|
|
|
data = broadcast.to_dict()
|
|
assert data["type"] == "file_uploaded"
|
|
assert data["filename"] == "photo.jpg"
|
|
assert "uploaded_at" in data
|
|
|
|
def test_file_deleted_broadcast_schema(self):
|
|
"""Test FileDeletedBroadcast schema"""
|
|
from app.modules.realtime.schemas import FileDeletedBroadcast
|
|
|
|
broadcast = FileDeletedBroadcast(
|
|
file_id=str(uuid.uuid4()),
|
|
room_id=str(uuid.uuid4()),
|
|
deleted_by="admin@example.com",
|
|
deleted_at=datetime.utcnow()
|
|
)
|
|
|
|
data = broadcast.to_dict()
|
|
assert data["type"] == "file_deleted"
|
|
assert data["deleted_by"] == "admin@example.com"
|
|
|
|
def test_file_upload_ack_schema(self):
|
|
"""Test FileUploadAck schema"""
|
|
from app.modules.realtime.schemas import FileUploadAck
|
|
|
|
ack = FileUploadAck(
|
|
file_id=str(uuid.uuid4()),
|
|
status="success",
|
|
download_url="https://example.com/file.jpg"
|
|
)
|
|
|
|
data = ack.to_dict()
|
|
assert data["type"] == "file_upload_ack"
|
|
assert data["status"] == "success"
|
|
assert data["download_url"] is not None
|
|
|
|
|
|
# ============================================================================
|
|
# File Reference Message Tests
|
|
# ============================================================================
|
|
|
|
class TestFileReferenceMessage:
|
|
"""Tests for file reference message helper"""
|
|
|
|
def test_create_image_reference_message(self, db_session):
|
|
"""Test creating an image reference message"""
|
|
from app.modules.chat_room.models import IncidentRoom, RoomMember, MemberRole
|
|
from app.modules.chat_room.schemas import IncidentType, SeverityLevel
|
|
from app.modules.file_storage.services.file_service import FileService
|
|
from app.modules.realtime.models import MessageType
|
|
|
|
# Create room
|
|
room = IncidentRoom(
|
|
room_id=str(uuid.uuid4()),
|
|
title="Test Room",
|
|
location="Line 1",
|
|
incident_type=IncidentType.EQUIPMENT_FAILURE,
|
|
severity=SeverityLevel.HIGH,
|
|
created_by="test@example.com"
|
|
)
|
|
db_session.add(room)
|
|
db_session.commit()
|
|
|
|
# Create file reference message
|
|
file_id = str(uuid.uuid4())
|
|
message = FileService.create_file_reference_message(
|
|
db=db_session,
|
|
room_id=room.room_id,
|
|
sender_id="test@example.com",
|
|
file_id=file_id,
|
|
filename="photo.jpg",
|
|
file_type="image",
|
|
file_url="https://example.com/photo.jpg",
|
|
description="Equipment damage photo"
|
|
)
|
|
|
|
assert message is not None
|
|
assert message.message_type == MessageType.IMAGE_REF
|
|
assert message.content == "Equipment damage photo"
|
|
assert message.message_metadata["file_id"] == file_id
|
|
|
|
def test_create_file_reference_message(self, db_session):
|
|
"""Test creating a document reference message"""
|
|
from app.modules.chat_room.models import IncidentRoom
|
|
from app.modules.chat_room.schemas import IncidentType, SeverityLevel
|
|
from app.modules.file_storage.services.file_service import FileService
|
|
from app.modules.realtime.models import MessageType
|
|
|
|
# Create room
|
|
room = IncidentRoom(
|
|
room_id=str(uuid.uuid4()),
|
|
title="Test Room",
|
|
location="Line 1",
|
|
incident_type=IncidentType.EQUIPMENT_FAILURE,
|
|
severity=SeverityLevel.HIGH,
|
|
created_by="test@example.com"
|
|
)
|
|
db_session.add(room)
|
|
db_session.commit()
|
|
|
|
# Create file reference message without description
|
|
file_id = str(uuid.uuid4())
|
|
message = FileService.create_file_reference_message(
|
|
db=db_session,
|
|
room_id=room.room_id,
|
|
sender_id="test@example.com",
|
|
file_id=file_id,
|
|
filename="report.pdf",
|
|
file_type="document",
|
|
file_url="https://example.com/report.pdf"
|
|
)
|
|
|
|
assert message is not None
|
|
assert message.message_type == MessageType.FILE_REF
|
|
assert message.content == "[File] report.pdf"
|
|
assert message.message_metadata["filename"] == "report.pdf"
|