From 9b6ec3cb79f74b6deb8194b1b6993b60355acf15 Mon Sep 17 00:00:00 2001
From: supersonicwisd1 <supersonicwisd1>
Date: Mon, 9 Dec 2024 15:16:56 +0100
Subject: [PATCH] feat: media handling (image, video, and audio)

Signed-off-by: supersonicwisd1 <supersonicwisd1>
---
 README.md => README-old.md                    |   0
 pyproject.toml                                |  36 +-
 requirements.txt                              |   9 +
 setup.py                                      |  41 ++-
 src/pyfed/content/media.py                    | 211 +++++++++--
 src/pyfed/content/video.py                    | 139 +++++++
 src/pyfed/protocols/__init__.py               |  27 --
 src/pyfed/storage/backend.py                  | 177 +++++++++
 src/pyfed/storage/factory.py                  |  42 +++
 src/pyfed/storage/s3.py                       | 162 ++++++++
 tests/conftest.py                             | 105 +++---
 tests/pytest.ini                              |   8 +-
 tests/unit_tests/models/test_activities.py    | 265 ++++++++------
 .../serializers/test_serialization.py         | 345 +++++++++---------
 14 files changed, 1134 insertions(+), 433 deletions(-)
 rename README.md => README-old.md (100%)
 create mode 100644 src/pyfed/content/video.py
 delete mode 100644 src/pyfed/protocols/__init__.py
 create mode 100644 src/pyfed/storage/backend.py
 create mode 100644 src/pyfed/storage/factory.py
 create mode 100644 src/pyfed/storage/s3.py

diff --git a/README.md b/README-old.md
similarity index 100%
rename from README.md
rename to README-old.md
diff --git a/pyproject.toml b/pyproject.toml
index dbc1678..73e67d4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,34 +1,22 @@
 [build-system]
-requires = ["setuptools>=45", "wheel"]
+requires = ["setuptools>=42", "wheel"]
 build-backend = "setuptools.build_meta"
 
 [project]
 name = "pyfed"
 version = "0.1.0"
-description = "ActivityPub Federation Library"
+description = "A comprehensive Python library for ActivityPub federation"
 requires-python = ">=3.8"
-dependencies = [
-    "aiohttp>=3.8.0",
-    "pydantic>=2.0.0",
-    "sqlalchemy>=2.0.0",
-    "cryptography>=3.4.0",
-    "pytest>=7.0.0",
-    "pytest-asyncio>=0.18.0",
-    "aiosqlite>=0.17.0",
-]
+dynamic = ["readme", "dependencies"]
+
+[tool.setuptools.dynamic]
+dependencies = {file = ["requirements.txt"]}
+readme = {file = ["README.md"]}
 
 [tool.pytest.ini_options]
-asyncio_mode = "auto"
+minversion = "6.0"
+addopts = "-ra -q --import-mode=importlib"
 testpaths = ["tests"]
-python_files = ["test_*.py"]
-addopts = "-ra -q"
-markers = [
-    "asyncio: mark test as async",
-]
-
-[project.optional-dependencies]
-test = [
-    "pytest>=7.0.0",
-    "pytest-asyncio>=0.18.0",
-    "pytest-cov>=3.0.0",
-]
\ No newline at end of file
+pythonpath = ["src"]
+asyncio_mode = "auto"
+filterwarnings = ["ignore::DeprecationWarning"]
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 0c06fd5..e926c32 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -29,6 +29,7 @@ iniconfig==2.0.0
 Markdown==3.7
 motor==3.6.0
 multidict==6.1.0
+mutagen==1.47.0
 packaging==24.1
 pluggy==1.5.0
 prometheus_client==0.21.0
@@ -36,6 +37,7 @@ propcache==0.2.0
 pycparser==2.22
 pydantic==2.9.2
 pydantic_core==2.23.4
+pydub==0.25.1
 PyJWT==2.9.0
 pymongo==4.9.2
 pytest==8.3.3
@@ -52,3 +54,10 @@ starlette==0.41.2
 typing_extensions==4.12.2
 uvicorn==0.32.1
 yarl==1.15.2
+aioboto3==12.3.0
+blurhash==1.1.4
+python-magic==0.4.27
+Pillow==10.2.0
+audioread==3.0.1
+moviepy>=1.0.3
+ffmpeg-python>=0.2.0
diff --git a/setup.py b/setup.py
index 331dde7..50164fc 100644
--- a/setup.py
+++ b/setup.py
@@ -3,6 +3,12 @@ from setuptools import setup, find_packages
 setup(
     name="pyfed",
     version="0.1.0",
+    description="A comprehensive Python library for ActivityPub federation",
+    long_description=open("README.md").read(),
+    long_description_content_type="text/markdown",
+    author="PyFed Team",
+    author_email="team@pyfed.org",
+    url="https://github.com/pyfed/pyfed",
     package_dir={"": "src"},
     packages=find_packages(where="src"),
     python_requires=">=3.8",
@@ -16,6 +22,14 @@ setup(
         "redis>=4.0.0",
         "beautifulsoup4>=4.9.3",
         "markdown>=3.3.4",
+        "Pillow>=10.2.0",
+        "python-magic>=0.4.27",
+        "mutagen>=1.47.0",
+        "pydub>=0.25.1",
+        "numpy>=1.24.0",
+        "blurhash>=1.1.4",
+        "aioboto3>=12.3.0",
+        "aiofiles>=24.1.0",
     ],
     extras_require={
         "fastapi": ["fastapi>=0.68.0", "uvicorn>=0.15.0"],
@@ -25,6 +39,31 @@ setup(
             "pytest>=6.2.5",
             "pytest-asyncio>=0.15.1",
             "pytest-cov>=2.12.1",
+            "black>=22.3.0",
+            "isort>=5.10.1",
+            "flake8>=4.0.1",
+            "mypy>=0.950",
         ],
     },
-) 
\ No newline at end of file
+    classifiers=[
+        "Development Status :: 4 - Beta",
+        "Intended Audience :: Developers",
+        "License :: OSI Approved :: MIT License",
+        "Operating System :: OS Independent",
+        "Programming Language :: Python :: 3",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
+        "Programming Language :: Python :: 3.12",
+        "Topic :: Internet :: WWW/HTTP",
+        "Topic :: Software Development :: Libraries :: Python Modules",
+        "Framework :: AsyncIO",
+    ],
+    keywords="activitypub federation fediverse social-network",
+    project_urls={
+        "Documentation": "https://pyfed.readthedocs.io/",
+        "Source": "https://github.com/pyfed/pyfed",
+        "Tracker": "https://github.com/pyfed/pyfed/issues",
+    },
+)
\ No newline at end of file
diff --git a/src/pyfed/content/media.py b/src/pyfed/content/media.py
index 78e8a27..cd87a2e 100644
--- a/src/pyfed/content/media.py
+++ b/src/pyfed/content/media.py
@@ -2,7 +2,7 @@
 Media attachment handling implementation.
 """
 
-from typing import Dict, Any, Optional, List
+from typing import Dict, Any, Optional, List, Union, BinaryIO
 import aiohttp
 import mimetypes
 import hashlib
@@ -10,9 +10,18 @@ from pathlib import Path
 import magic
 from PIL import Image
 import asyncio
+import blurhash
+import io
+import mutagen
+from mutagen.easyid3 import EasyID3
+from pydub import AudioSegment
+import numpy as np
+import base64
+from .video import VideoProcessor
 
 from ..utils.exceptions import MediaError
 from ..utils.logging import get_logger
+from ..storage.backend import StorageBackend, LocalStorageBackend
 
 logger = get_logger(__name__)
 
@@ -20,10 +29,10 @@ class MediaHandler:
     """Handle media attachments."""
 
     def __init__(self,
-                 upload_path: str = "uploads",
-                 max_size: int = 10_000_000,  # 10MB
+                 storage_backend: Optional[StorageBackend] = None,
+                 max_size: int = 50 * 1024 * 1024,  # 50MB default
                  allowed_types: Optional[List[str]] = None):
-        self.upload_path = Path(upload_path)
+        self.storage = storage_backend or LocalStorageBackend()
         self.max_size = max_size
         self.allowed_types = allowed_types or [
             'image/jpeg',
@@ -31,9 +40,12 @@ class MediaHandler:
             'image/gif',
             'video/mp4',
             'audio/mpeg',
-            'audio/ogg'
+            'audio/mp3',
+            'audio/ogg',
+            'audio/wav',
+            'audio/flac',
+            'audio/aac'
         ]
-        self.upload_path.mkdir(parents=True, exist_ok=True)
 
     async def process_attachment(self,
                                url: str,
@@ -65,25 +77,30 @@ class MediaHandler:
             ext = mimetypes.guess_extension(mime_type) or ''
             filename = f"{file_hash}{ext}"
             
-            # Save file
-            file_path = self.upload_path / filename
-            with open(file_path, 'wb') as f:
-                f.write(content)
+            # Save file using storage backend
+            file_url = await self.storage.save(filename, content)
             
-            # Generate thumbnails for images
-            thumbnails = {}
+            # Process media based on type
+            media_info = {}
             if mime_type.startswith('image/'):
-                thumbnails = await self._generate_thumbnails(file_path)
+                image = Image.open(io.BytesIO(content))
+                media_info = {
+                    'width': image.width,
+                    'height': image.height,
+                    'blurhash': await self._generate_blurhash(image),
+                    'thumbnails': await self._generate_thumbnails(image, filename)
+                }
+            elif mime_type.startswith('audio/'):
+                media_info = await self._process_audio(content, filename)
+            elif mime_type.startswith('video/'):
+                media_info = await self._process_video(content, mime_type)
             
             return {
                 "type": "Document",
                 "mediaType": mime_type,
-                "url": f"/media/{filename}",
+                "url": file_url,
                 "name": description or filename,
-                "blurhash": await self._generate_blurhash(file_path) if mime_type.startswith('image/') else None,
-                "width": await self._get_image_width(file_path) if mime_type.startswith('image/') else None,
-                "height": await self._get_image_height(file_path) if mime_type.startswith('image/') else None,
-                "thumbnails": thumbnails
+                **media_info
             }
             
         except Exception as e:
@@ -101,25 +118,30 @@ class MediaHandler:
         except Exception as e:
             raise MediaError(f"Failed to download media: {e}")
 
-    async def _generate_thumbnails(self, file_path: Path) -> Dict[str, Dict[str, Any]]:
+    async def _generate_thumbnails(self, image: Image.Image, original_filename: str) -> Dict[str, Dict[str, Any]]:
         """Generate image thumbnails."""
         thumbnails = {}
         sizes = [(320, 320), (640, 640)]
         
         try:
-            image = Image.open(file_path)
             for width, height in sizes:
                 thumb = image.copy()
                 thumb.thumbnail((width, height))
                 
-                thumb_hash = hashlib.sha256(str(file_path).encode()).hexdigest()
+                # Convert to bytes
+                thumb_bytes = io.BytesIO()
+                thumb.save(thumb_bytes, format='JPEG', quality=85)
+                thumb_bytes.seek(0)
+                
+                # Generate thumbnail filename
+                thumb_hash = hashlib.sha256(f"{original_filename}_{width}x{height}".encode()).hexdigest()
                 thumb_filename = f"thumb_{width}x{height}_{thumb_hash}.jpg"
-                thumb_path = self.upload_path / thumb_filename
                 
-                thumb.save(thumb_path, "JPEG", quality=85)
+                # Save thumbnail using storage backend
+                thumb_url = await self.storage.save(thumb_filename, thumb_bytes.getvalue())
                 
                 thumbnails[f"{width}x{height}"] = {
-                    "url": f"/media/{thumb_filename}",
+                    "url": thumb_url,
                     "width": thumb.width,
                     "height": thumb.height
                 }
@@ -130,26 +152,137 @@ class MediaHandler:
             logger.error(f"Failed to generate thumbnails: {e}")
             return {}
 
-    async def _generate_blurhash(self, file_path: Path) -> Optional[str]:
+    async def _generate_blurhash(self, image: Image.Image) -> str:
         """Generate blurhash for image."""
         try:
-            # Implementation for blurhash generation
-            return None
-        except Exception:
+            # Convert image to RGB if necessary
+            if image.mode != 'RGB':
+                image = image.convert('RGB')
+            
+            # Calculate blurhash
+            return blurhash.encode(
+                image,
+                x_components=4,
+                y_components=3
+            )
+        except Exception as e:
+            logger.error(f"Failed to generate blurhash: {e}")
             return None
 
-    async def _get_image_width(self, file_path: Path) -> Optional[int]:
-        """Get image width."""
+    async def _process_audio(self, content: bytes, filename: str) -> Dict[str, Any]:
+        """Process audio file and extract metadata."""
+        try:
+            # Save temporary file for audio processing
+            temp_file = io.BytesIO(content)
+            temp_file.name = filename  # Required for mutagen
+            
+            # Extract metadata
+            audio = mutagen.File(temp_file)
+            if audio is None:
+                return {}
+            
+            # Get basic metadata
+            metadata = {
+                'duration': int(audio.info.length),
+                'bitrate': int(audio.info.bitrate),
+                'channels': getattr(audio.info, 'channels', None),
+                'sample_rate': getattr(audio.info, 'sample_rate', None)
+            }
+            
+            # Try to get ID3 tags if available
+            try:
+                if isinstance(audio, EasyID3) or hasattr(audio, 'tags'):
+                    tags = audio.tags or {}
+                    metadata.update({
+                        'title': str(tags.get('title', [''])[0]),
+                        'artist': str(tags.get('artist', [''])[0]),
+                        'album': str(tags.get('album', [''])[0]),
+                        'genre': str(tags.get('genre', [''])[0])
+                    })
+            except Exception as e:
+                logger.warning(f"Failed to extract audio tags: {e}")
+            
+            # Generate waveform
+            try:
+                waveform_data = await self._generate_waveform(content)
+                metadata['waveform'] = waveform_data
+            except Exception as e:
+                logger.warning(f"Failed to generate waveform: {e}")
+            
+            # Generate audio preview
+            try:
+                preview_url = await self._generate_audio_preview(content, filename)
+                metadata['preview_url'] = preview_url
+            except Exception as e:
+                logger.warning(f"Failed to generate audio preview: {e}")
+            
+            return metadata
+            
+        except Exception as e:
+            logger.error(f"Failed to process audio: {e}")
+            return {}
+
+    async def _generate_waveform(self, content: bytes, num_points: int = 100) -> str:
+        """Generate waveform data for audio visualization."""
         try:
-            with Image.open(file_path) as img:
-                return img.width
-        except Exception:
+            # Load audio using pydub
+            audio = AudioSegment.from_file(io.BytesIO(content))
+            
+            # Convert to mono and get raw data
+            samples = np.array(audio.get_array_of_samples())
+            
+            # Resample to desired number of points
+            samples = np.array_split(samples, num_points)
+            waveform = [float(abs(chunk).mean()) for chunk in samples]
+            
+            # Normalize to 0-1 range
+            max_val = max(waveform)
+            if max_val > 0:
+                waveform = [val/max_val for val in waveform]
+            
+            # Convert to base64 for efficient transfer
+            waveform_bytes = np.array(waveform, dtype=np.float32).tobytes()
+            return base64.b64encode(waveform_bytes).decode('utf-8')
+            
+        except Exception as e:
+            logger.error(f"Failed to generate waveform: {e}")
             return None
 
-    async def _get_image_height(self, file_path: Path) -> Optional[int]:
-        """Get image height."""
+    async def _generate_audio_preview(self, content: bytes, filename: str, duration: int = 30) -> Optional[str]:
+        """Generate a short preview of the audio file."""
         try:
-            with Image.open(file_path) as img:
-                return img.height
-        except Exception:
-            return None 
\ No newline at end of file
+            # Load audio
+            audio = AudioSegment.from_file(io.BytesIO(content))
+            
+            # Take first 30 seconds
+            preview_duration = min(duration * 1000, len(audio))
+            preview = audio[:preview_duration]
+            
+            # Convert to MP3 format
+            preview_bytes = io.BytesIO()
+            preview.export(preview_bytes, format='mp3', bitrate='128k')
+            preview_bytes.seek(0)
+            
+            # Generate preview filename
+            preview_hash = hashlib.sha256(f"preview_{filename}".encode()).hexdigest()
+            preview_filename = f"preview_{preview_hash}.mp3"
+            
+            # Save preview using storage backend
+            preview_url = await self.storage.save(preview_filename, preview_bytes.getvalue())
+            
+            return preview_url
+            
+        except Exception as e:
+            logger.error(f"Failed to generate audio preview: {e}")
+            return None
+
+    async def _process_video(self,
+                           file_data: Union[bytes, BinaryIO, Path],
+                           mime_type: str) -> Dict[str, Any]:
+        """Process video file and extract metadata."""
+        is_valid, detected_mime = await VideoProcessor.validate_video(file_data)
+        if not is_valid:
+            raise MediaError(f"Invalid or unsupported video format: {detected_mime}")
+            
+        metadata = await VideoProcessor.process_video(file_data, mime_type)
+        return metadata
\ No newline at end of file
diff --git a/src/pyfed/content/video.py b/src/pyfed/content/video.py
new file mode 100644
index 0000000..75c0272
--- /dev/null
+++ b/src/pyfed/content/video.py
@@ -0,0 +1,139 @@
+"""
+Video processing module for PyFed.
+"""
+
+from typing import Dict, Any, Optional, Tuple, BinaryIO
+import asyncio
+from pathlib import Path
+import tempfile
+import os
+import ffmpeg
+from moviepy.editor import VideoFileClip
+import io
+from PIL import Image
+import magic
+
+from ..utils.exceptions import MediaError
+from ..utils.logging import get_logger
+
+logger = get_logger(__name__)
+
+class VideoProcessor:
+    """Handle video processing operations."""
+    
+    SUPPORTED_FORMATS = {
+        'video/mp4': '.mp4',
+        'video/webm': '.webm',
+        'video/ogg': '.ogv',
+        'video/quicktime': '.mov',
+    }
+    
+    MAX_THUMBNAIL_SIZE = (1280, 720)  # 720p
+    THUMBNAIL_QUALITY = 85
+    
+    @classmethod
+    async def process_video(cls,
+                          video_data: Union[bytes, BinaryIO, Path],
+                          mime_type: Optional[str] = None) -> Dict[str, Any]:
+        """Process video file and extract metadata.
+        
+        Args:
+            video_data: Raw video data or file-like object
+            mime_type: Optional MIME type of the video
+            
+        Returns:
+            Dict containing video metadata and preview
+        """
+        with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as temp_file:
+            if isinstance(video_data, bytes):
+                temp_file.write(video_data)
+            elif isinstance(video_data, Path):
+                temp_file.write(video_data.read_bytes())
+            else:
+                temp_file.write(video_data.read())
+            temp_path = temp_file.name
+            
+        try:
+            # Get video information using ffmpeg
+            probe = await asyncio.to_thread(
+                ffmpeg.probe,
+                temp_path
+            )
+            
+            video_info = next(s for s in probe['streams'] if s['codec_type'] == 'video')
+            
+            # Generate thumbnail
+            thumbnail = await cls.generate_thumbnail(temp_path)
+            
+            metadata = {
+                'duration': float(probe['format'].get('duration', 0)),
+                'width': int(video_info.get('width', 0)),
+                'height': int(video_info.get('height', 0)),
+                'codec': video_info.get('codec_name', ''),
+                'bitrate': int(probe['format'].get('bit_rate', 0)),
+                'thumbnail': thumbnail,
+                'size': os.path.getsize(temp_path)
+            }
+            
+            return metadata
+            
+        except Exception as e:
+            raise MediaError(f"Failed to process video: {str(e)}") from e
+            
+        finally:
+            os.unlink(temp_path)
+    
+    @classmethod
+    async def generate_thumbnail(cls, video_path: str, time_offset: float = 1.0) -> bytes:
+        """Generate a thumbnail from the video.
+        
+        Args:
+            video_path: Path to video file
+            time_offset: Time offset in seconds for thumbnail extraction
+            
+        Returns:
+            bytes: Thumbnail image data in JPEG format
+        """
+        try:
+            # Extract frame using ffmpeg
+            out, _ = (
+                ffmpeg
+                .input(video_path, ss=time_offset)
+                .filter('scale', cls.MAX_THUMBNAIL_SIZE[0], cls.MAX_THUMBNAIL_SIZE[1])
+                .output('pipe:', format='image2', vframes=1)
+                .run(capture_stdout=True)
+            )
+            
+            # Convert to PIL Image and optimize
+            image = Image.open(io.BytesIO(out))
+            output = io.BytesIO()
+            image.save(output,
+                      format='JPEG',
+                      quality=cls.THUMBNAIL_QUALITY,
+                      optimize=True)
+            
+            return output.getvalue()
+            
+        except Exception as e:
+            raise MediaError(f"Failed to generate thumbnail: {str(e)}") from e
+    
+    @classmethod
+    async def validate_video(cls, file_data: Union[bytes, BinaryIO, Path]) -> Tuple[bool, str]:
+        """Validate video file format and content.
+        
+        Args:
+            file_data: Video file data
+            
+        Returns:
+            Tuple[bool, str]: (is_valid, mime_type)
+        """
+        if isinstance(file_data, (bytes, BinaryIO)):
+            mime = magic.from_buffer(
+                file_data if isinstance(file_data, bytes) else file_data.read(2048),
+                mime=True
+            )
+        else:
+            mime = magic.from_file(str(file_data), mime=True)
+            
+        is_valid = mime in cls.SUPPORTED_FORMATS
+        return is_valid, mime
diff --git a/src/pyfed/protocols/__init__.py b/src/pyfed/protocols/__init__.py
deleted file mode 100644
index b0d1be4..0000000
--- a/src/pyfed/protocols/__init__.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""
-Protocol implementations for PyFed.
-"""
-from .base import (
-    BaseProtocol, BaseProtocolConfig, ProtocolVersion,
-    ProtocolError, ValidationError, SecurityError, TimeoutError
-)
-from .c2s import C2SConfig, ClientToServerProtocol
-from .s2s import ServerToServerProtocol
-
-__all__ = [
-    # Base
-    'BaseProtocol',
-    'BaseProtocolConfig',
-    'ProtocolVersion',
-    'ProtocolError',
-    'ValidationError',
-    'SecurityError',
-    'TimeoutError',
-    
-    # Client-to-Server
-    'C2SConfig',
-    'ClientToServerProtocol',
-    
-    # Server-to-Server
-    'ServerToServerProtocol',
-]
diff --git a/src/pyfed/storage/backend.py b/src/pyfed/storage/backend.py
new file mode 100644
index 0000000..036b080
--- /dev/null
+++ b/src/pyfed/storage/backend.py
@@ -0,0 +1,177 @@
+from abc import ABC, abstractmethod
+from typing import Protocol, Optional, Union, BinaryIO, Dict, Any
+from pathlib import Path
+import asyncio
+from datetime import datetime
+import mimetypes
+import os
+
+class StorageBackend(Protocol):
+    """Protocol defining the unified storage interface for both data and media."""
+    
+    @abstractmethod
+    async def initialize(self, config: Dict[str, Any]) -> None:
+        """Initialize the storage backend with configuration."""
+        pass
+    
+    @abstractmethod
+    async def store_file(self, 
+                        file_data: Union[bytes, BinaryIO, Path], 
+                        path: str,
+                        mime_type: Optional[str] = None,
+                        metadata: Optional[Dict[str, Any]] = None) -> str:
+        """Store a file in the storage backend.
+        
+        Args:
+            file_data: The file data to store
+            path: The path/key where the file should be stored
+            mime_type: Optional MIME type of the file
+            metadata: Optional metadata to store with the file
+            
+        Returns:
+            str: The URI/path where the file was stored
+        """
+        pass
+    
+    @abstractmethod
+    async def retrieve_file(self, path: str) -> bytes:
+        """Retrieve a file from storage.
+        
+        Args:
+            path: Path/key of the file to retrieve
+            
+        Returns:
+            bytes: The file contents
+        """
+        pass
+    
+    @abstractmethod
+    async def delete_file(self, path: str) -> None:
+        """Delete a file from storage.
+        
+        Args:
+            path: Path/key of the file to delete
+        """
+        pass
+    
+    @abstractmethod
+    async def file_exists(self, path: str) -> bool:
+        """Check if a file exists in storage.
+        
+        Args:
+            path: Path/key to check
+            
+        Returns:
+            bool: True if file exists, False otherwise
+        """
+        pass
+    
+    @abstractmethod
+    async def get_file_metadata(self, path: str) -> Dict[str, Any]:
+        """Get metadata for a stored file.
+        
+        Args:
+            path: Path/key of the file
+            
+        Returns:
+            Dict[str, Any]: File metadata including size, creation time, etc.
+        """
+        pass
+
+class LocalStorageBackend:
+    """Local filesystem implementation of StorageBackend."""
+    
+    def __init__(self):
+        self.base_path: Optional[Path] = None
+        
+    async def initialize(self, config: Dict[str, Any]) -> None:
+        """Initialize local storage with base path."""
+        self.base_path = Path(config['base_path']).resolve()
+        self.base_path.mkdir(parents=True, exist_ok=True)
+        
+    async def store_file(self, 
+                        file_data: Union[bytes, BinaryIO, Path],
+                        path: str,
+                        mime_type: Optional[str] = None,
+                        metadata: Optional[Dict[str, Any]] = None) -> str:
+        """Store a file in the local filesystem."""
+        if self.base_path is None:
+            raise RuntimeError("Storage backend not initialized")
+            
+        full_path = self.base_path / path
+        full_path.parent.mkdir(parents=True, exist_ok=True)
+        
+        if isinstance(file_data, bytes):
+            full_path.write_bytes(file_data)
+        elif isinstance(file_data, Path):
+            if file_data.is_file():
+                full_path.write_bytes(file_data.read_bytes())
+        else:  # BinaryIO
+            with full_path.open('wb') as f:
+                f.write(file_data.read())
+                
+        if metadata:
+            meta_path = full_path.with_suffix(full_path.suffix + '.meta')
+            meta_path.write_text(str(metadata))
+            
+        return str(full_path.relative_to(self.base_path))
+    
+    async def retrieve_file(self, path: str) -> bytes:
+        """Retrieve a file from the local filesystem."""
+        if self.base_path is None:
+            raise RuntimeError("Storage backend not initialized")
+            
+        full_path = self.base_path / path
+        if not full_path.exists():
+            raise FileNotFoundError(f"File not found: {path}")
+            
+        return full_path.read_bytes()
+    
+    async def delete_file(self, path: str) -> None:
+        """Delete a file from the local filesystem."""
+        if self.base_path is None:
+            raise RuntimeError("Storage backend not initialized")
+            
+        full_path = self.base_path / path
+        if full_path.exists():
+            full_path.unlink()
+            
+        # Also delete metadata file if it exists
+        meta_path = full_path.with_suffix(full_path.suffix + '.meta')
+        if meta_path.exists():
+            meta_path.unlink()
+            
+    async def file_exists(self, path: str) -> bool:
+        """Check if a file exists in the local filesystem."""
+        if self.base_path is None:
+            raise RuntimeError("Storage backend not initialized")
+            
+        return (self.base_path / path).exists()
+    
+    async def get_file_metadata(self, path: str) -> Dict[str, Any]:
+        """Get metadata for a stored file."""
+        if self.base_path is None:
+            raise RuntimeError("Storage backend not initialized")
+            
+        full_path = self.base_path / path
+        if not full_path.exists():
+            raise FileNotFoundError(f"File not found: {path}")
+            
+        stat = full_path.stat()
+        metadata = {
+            'size': stat.st_size,
+            'created': datetime.fromtimestamp(stat.st_ctime),
+            'modified': datetime.fromtimestamp(stat.st_mtime),
+            'mime_type': mimetypes.guess_type(str(full_path))[0]
+        }
+        
+        # Check for additional metadata file
+        meta_path = full_path.with_suffix(full_path.suffix + '.meta')
+        if meta_path.exists():
+            try:
+                additional_meta = eval(meta_path.read_text())  # Simple evaluation for demo
+                metadata.update(additional_meta)
+            except Exception:
+                pass
+                
+        return metadata
diff --git a/src/pyfed/storage/factory.py b/src/pyfed/storage/factory.py
new file mode 100644
index 0000000..4251ae8
--- /dev/null
+++ b/src/pyfed/storage/factory.py
@@ -0,0 +1,42 @@
+from typing import Dict, Any, Type
+from .backend import StorageBackend, LocalStorageBackend
+from .s3 import S3StorageBackend
+
+class StorageFactory:
+    """Factory for creating storage backend instances."""
+    
+    _backends = {
+        'local': LocalStorageBackend,
+        's3': S3StorageBackend
+    }
+    
+    @classmethod
+    def register_backend(cls, name: str, backend_class: Type[StorageBackend]) -> None:
+        """Register a new storage backend type.
+        
+        Args:
+            name: Name to register the backend under
+            backend_class: The backend class to register
+        """
+        cls._backends[name] = backend_class
+        
+    @classmethod
+    async def create_backend(cls, backend_type: str, config: Dict[str, Any]) -> StorageBackend:
+        """Create and initialize a storage backend instance.
+        
+        Args:
+            backend_type: Type of backend to create ('local' or 's3')
+            config: Configuration dictionary for the backend
+            
+        Returns:
+            StorageBackend: Initialized storage backend instance
+            
+        Raises:
+            ValueError: If backend_type is not recognized
+        """
+        if backend_type not in cls._backends:
+            raise ValueError(f"Unknown storage backend type: {backend_type}")
+            
+        backend = cls._backends[backend_type]()
+        await backend.initialize(config)
+        return backend
diff --git a/src/pyfed/storage/s3.py b/src/pyfed/storage/s3.py
new file mode 100644
index 0000000..ca7880d
--- /dev/null
+++ b/src/pyfed/storage/s3.py
@@ -0,0 +1,162 @@
+from typing import Union, BinaryIO, Dict, Any, Optional
+from pathlib import Path
+import aioboto3
+from datetime import datetime
+import io
+import mimetypes
+import json
+
+from .backend import StorageBackend
+
+class S3StorageBackend:
+    """S3-compatible storage backend implementation."""
+    
+    def __init__(self):
+        self.session = None
+        self.bucket = None
+        self.prefix = None
+        self.client = None
+        
+    async def initialize(self, config: Dict[str, Any]) -> None:
+        """Initialize S3 storage with credentials and configuration."""
+        self.session = aioboto3.Session(
+            aws_access_key_id=config.get('aws_access_key_id'),
+            aws_secret_access_key=config.get('aws_secret_access_key')
+        )
+        self.bucket = config['bucket']
+        self.prefix = config.get('prefix', '')
+        self.endpoint_url = config.get('endpoint_url')  # For compatibility with other S3-compatible services
+        
+    async def _get_client(self):
+        """Get or create S3 client."""
+        if self.client is None:
+            self.client = self.session.client('s3', endpoint_url=self.endpoint_url)
+        return self.client
+        
+    def _get_full_path(self, path: str) -> str:
+        """Get full S3 path including prefix."""
+        return f"{self.prefix.rstrip('/')}/{path.lstrip('/')}" if self.prefix else path
+        
+    async def store_file(self,
+                        file_data: Union[bytes, BinaryIO, Path],
+                        path: str,
+                        mime_type: Optional[str] = None,
+                        metadata: Optional[Dict[str, Any]] = None) -> str:
+        """Store a file in S3 storage."""
+        if self.session is None:
+            raise RuntimeError("Storage backend not initialized")
+            
+        full_path = self._get_full_path(path)
+        
+        # Prepare the file data
+        if isinstance(file_data, bytes):
+            data = io.BytesIO(file_data)
+        elif isinstance(file_data, Path):
+            data = io.BytesIO(file_data.read_bytes())
+        else:
+            data = file_data
+            
+        # Prepare upload parameters
+        upload_kwargs = {
+            'Bucket': self.bucket,
+            'Key': full_path,
+            'Body': data
+        }
+        
+        if mime_type:
+            upload_kwargs['ContentType'] = mime_type
+            
+        if metadata:
+            # S3 metadata must be strings
+            upload_kwargs['Metadata'] = {
+                k: str(v) for k, v in metadata.items()
+            }
+            
+        async with await self._get_client() as client:
+            await client.upload_fileobj(**upload_kwargs)
+            
+        return full_path
+        
+    async def retrieve_file(self, path: str) -> bytes:
+        """Retrieve a file from S3 storage."""
+        if self.session is None:
+            raise RuntimeError("Storage backend not initialized")
+            
+        full_path = self._get_full_path(path)
+        data = io.BytesIO()
+        
+        async with await self._get_client() as client:
+            try:
+                await client.download_fileobj(
+                    Bucket=self.bucket,
+                    Key=full_path,
+                    Fileobj=data
+                )
+            except Exception as e:
+                raise FileNotFoundError(f"File not found: {path}") from e
+                
+        return data.getvalue()
+        
+    async def delete_file(self, path: str) -> None:
+        """Delete a file from S3 storage."""
+        if self.session is None:
+            raise RuntimeError("Storage backend not initialized")
+            
+        full_path = self._get_full_path(path)
+        
+        async with await self._get_client() as client:
+            try:
+                await client.delete_object(
+                    Bucket=self.bucket,
+                    Key=full_path
+                )
+            except Exception as e:
+                # Ignore if file doesn't exist
+                pass
+                
+    async def file_exists(self, path: str) -> bool:
+        """Check if a file exists in S3 storage."""
+        if self.session is None:
+            raise RuntimeError("Storage backend not initialized")
+            
+        full_path = self._get_full_path(path)
+        
+        async with await self._get_client() as client:
+            try:
+                await client.head_object(
+                    Bucket=self.bucket,
+                    Key=full_path
+                )
+                return True
+            except:
+                return False
+                
+    async def get_file_metadata(self, path: str) -> Dict[str, Any]:
+        """Get metadata for a stored file."""
+        if self.session is None:
+            raise RuntimeError("Storage backend not initialized")
+            
+        full_path = self._get_full_path(path)
+        
+        async with await self._get_client() as client:
+            try:
+                response = await client.head_object(
+                    Bucket=self.bucket,
+                    Key=full_path
+                )
+                
+                metadata = {
+                    'size': response['ContentLength'],
+                    'created': response.get('LastModified'),
+                    'mime_type': response.get('ContentType'),
+                    'etag': response.get('ETag'),
+                }
+                
+                # Include custom metadata if present
+                if 'Metadata' in response:
+                    metadata.update(response['Metadata'])
+                    
+                return metadata
+                
+            except Exception as e:
+                raise FileNotFoundError(f"File not found: {path}") from e
diff --git a/tests/conftest.py b/tests/conftest.py
index 544021b..5729d04 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,75 +1,64 @@
 """
-Global test configuration and fixtures.
+Common test fixtures for PyFed unit tests.
 """
-
 import pytest
-from aiohttp import web
-import asyncio
-from typing import AsyncGenerator, Callable
-from unittest.mock import AsyncMock, Mock
+from datetime import datetime, timezone
+from pathlib import Path
+import sys
 
-pytest_plugins = ['pytest_aiohttp']
+# Add src directory to Python path
+src_path = Path(__file__).parent.parent / "src"
+sys.path.insert(0, str(src_path))
 
-@pytest.fixture
-def app() -> web.Application:
-    """Create base application for testing."""
-    return web.Application()
+from pyfed.models import APObject, APPerson, APNote
 
 @pytest.fixture
-def auth_middleware() -> Callable:
-    """Create auth middleware for testing."""
-    async def middleware(app: web.Application, handler: Callable):
-        async def auth_handler(request: web.Request):
-            request['user'] = {"sub": "test_user"}
-            return await handler(request)
-        return auth_handler
-    return middleware
+def valid_actor():
+    """Return a valid actor URL."""
+    return "https://example.com/user/1"
 
 @pytest.fixture
-def rate_limit_middleware() -> Callable:
-    """Create rate limit middleware for testing."""
-    async def middleware(app: web.Application, handler: Callable):
-        async def rate_limit_handler(request: web.Request):
-            # Simple in-memory rate limiting for tests
-            key = f"rate_limit:{request.remote}"
-            count = app.get(key, 0) + 1
-            app[key] = count
-            
-            if count > 5:  # Rate limit after 5 requests
-                return web.Response(status=429)
-            
-            return await handler(request)
-        return rate_limit_handler
-    return middleware
+def valid_object():
+    """Return a valid object dictionary."""
+    return {
+        "id": "https://example.com/note/123",
+        "type": "Note",
+        "content": "Hello, World!"
+    }
 
 @pytest.fixture
-async def client(aiohttp_client: Callable, app: web.Application) -> AsyncGenerator:
-    """Create test client with configured application."""
-    return await aiohttp_client(app)
-
-@pytest.fixture
-def mock_storage() -> AsyncMock:
-    """Create mock storage."""
-    storage = AsyncMock()
-    storage.create_activity = AsyncMock(return_value="test_activity_id")
-    storage.create_object = AsyncMock(return_value="test_object_id")
-    storage.is_following = AsyncMock(return_value=False)
-    return storage
+def valid_person():
+    """Return a valid APPerson object."""
+    return APPerson(
+        id="https://example.com/users/alice",
+        type="Person",
+        name="Alice",
+        preferredUsername="alice",
+        inbox="https://example.com/users/alice/inbox",
+        outbox="https://example.com/users/alice/outbox"
+    )
 
 @pytest.fixture
-def mock_delivery() -> AsyncMock:
-    """Create mock delivery service."""
-    delivery = AsyncMock()
-    delivery.deliver_activity = AsyncMock(
-        return_value={"success": ["inbox1"], "failed": []}
+def valid_note():
+    """Return a valid APNote object."""
+    return APNote(
+        id="https://example.com/notes/123",
+        type="Note",
+        content="This is a test note",
+        published=datetime.now(timezone.utc)
     )
-    return delivery
 
 @pytest.fixture
-def mock_resolver() -> AsyncMock:
-    """Create mock resolver."""
-    resolver = AsyncMock()
-    resolver.resolve_actor = AsyncMock(
-        return_value={"id": "https://example.com/users/test"}
+def complex_object(valid_person):
+    """Return a complex APObject with nested objects."""
+    return APObject(
+        id="https://example.com/object/123",
+        type="Object",
+        name="Test Object",
+        content="This is a test object.",
+        attachment=[
+            APObject(id="https://example.com/attachment/1", type="Image"),
+            APObject(id="https://example.com/attachment/2", type="Video")
+        ],
+        attributedTo=valid_person.id
     )
-    return resolver
\ No newline at end of file
diff --git a/tests/pytest.ini b/tests/pytest.ini
index f9025ee..669471c 100644
--- a/tests/pytest.ini
+++ b/tests/pytest.ini
@@ -1,5 +1,7 @@
-[pytest]  
+[pytest]
 asyncio_mode = auto
-filterwarnings =  
+asyncio_default_fixture_loop_scope = function
+filterwarnings =
     ignore::DeprecationWarning
-pythonpath = ../src
\ No newline at end of file
+pythonpath = ../src
+addopts = --import-mode=importlib
\ No newline at end of file
diff --git a/tests/unit_tests/models/test_activities.py b/tests/unit_tests/models/test_activities.py
index 5f51632..b870eea 100644
--- a/tests/unit_tests/models/test_activities.py
+++ b/tests/unit_tests/models/test_activities.py
@@ -1,135 +1,166 @@
 """
-test_activities.py
-This module contains tests for the Activity types in ActivityPub.
+Tests for ActivityPub activity types.
 """
 import pytest
-from datetime import datetime
+from datetime import datetime, timezone
 from pydantic import ValidationError
+
 from pyfed.models import (
     APCreate, APUpdate, APDelete, APFollow,
-    APUndo, APLike, APAnnounce
+    APUndo, APLike, APAnnounce, APActivity
 )
 
-def test_create_activity():
-    """Test creating a Create activity."""
-    activity = APCreate(
-        id="https://example.com/activity/123",
-        actor="https://example.com/user/1",
-        object={
-            "id": "https://example.com/note/123",
-            "type": "Note",
-            "content": "Hello, World!"
-        }
-    )
-    assert activity.type == "Create"
-    assert str(activity.actor) == "https://example.com/user/1"
-    assert activity.object["type"] == "Note"
-
-def test_update_activity():
-    """Test creating an Update activity."""
-    activity = APUpdate(
-        id="https://example.com/activity/123",
-        actor="https://example.com/user/1",
-        object={
+@pytest.fixture
+def valid_actor():
+    return "https://example.com/user/1"
+
+@pytest.fixture
+def valid_object():
+    return {"id": "https://example.com/note/123", "type": "Note", "content": "Hello, World!"}
+
+@pytest.fixture
+def valid_person():
+    return {"id": "https://example.com/person/1", "type": "Person"}
+
+@pytest.fixture
+def valid_note():
+    return {"id": "https://example.com/note/123", "type": "Note"}
+
+class TestActivityCreation:
+    @pytest.mark.parametrize("activity_class,activity_type", [
+        (APCreate, "Create"),
+        (APUpdate, "Update"),
+        (APDelete, "Delete"),
+        (APFollow, "Follow"),
+        (APUndo, "Undo"),
+        (APLike, "Like"),
+        (APAnnounce, "Announce")
+    ])
+    def test_activity_type(self, activity_class, activity_type, valid_actor, valid_object):
+        """Test that each activity has the correct type."""
+        activity = activity_class(
+            id="https://example.com/activity/123",
+            actor=valid_actor,
+            object=valid_object
+        )
+        assert activity.type == activity_type
+        assert str(activity.actor) == valid_actor
+
+    @pytest.mark.parametrize("invalid_actor", [
+        None,
+        "",
+        "not-a-url",
+        "http:/invalid.com"
+    ])
+    def test_invalid_actor(self, invalid_actor, valid_object):
+        """Test activity creation with invalid actors."""
+        with pytest.raises(ValidationError):
+            APCreate(
+                id="https://example.com/activity/123",
+                actor=invalid_actor,
+                object=valid_object
+            )
+
+    @pytest.mark.parametrize("activity_class", [
+        APCreate, APUpdate, APDelete, APLike, APAnnounce
+    ])
+    def test_missing_object(self, activity_class, valid_actor):
+        """Test that non-intransitive activities require an object."""
+        with pytest.raises(ValidationError):
+            activity_class(
+                id="https://example.com/activity/123",
+                actor=valid_actor
+            )
+
+class TestActivityMetadata:
+    def test_activity_with_published(self, valid_actor, valid_object):
+        """Test activity with published timestamp."""
+        now = datetime.now(timezone.utc)
+        activity = APCreate(
+            id="https://example.com/activity/123",
+            actor=valid_actor,
+            object=valid_object,
+            published=now
+        )
+        assert activity.published == now
+
+    def test_activity_with_target(self, valid_actor, valid_object):
+        """Test activity with a target."""
+        target = {"id": "https://example.com/target/123", "type": "Collection"}
+        activity = APCreate(
+            id="https://example.com/activity/123",
+            actor=valid_actor,
+            object=valid_object,
+            target=target
+        )
+        assert activity.target["id"] == target["id"]
+        assert activity.target["type"] == target["type"]
+
+    def test_activity_with_result(self, valid_actor, valid_object):
+        """Test activity with a result."""
+        result = {"id": "https://example.com/result/123", "type": "Note"}
+        activity = APCreate(
+            id="https://example.com/activity/123",
+            actor=valid_actor,
+            object=valid_object,
+            result=result
+        )
+        assert activity.result["id"] == result["id"]
+        assert activity.result["type"] == result["type"]
+
+class TestSpecificActivities:
+    def test_create_activity(self, valid_actor, valid_object):
+        """Test specific behavior of Create activity."""
+        activity = APCreate(
+            id="https://example.com/activity/123",
+            actor=valid_actor,
+            object=valid_object
+        )
+        assert activity.type == "Create"
+        assert activity.object["content"] == valid_object["content"]
+
+    def test_update_activity(self, valid_actor):
+        """Test specific behavior of Update activity."""
+        updated_object = {
             "id": "https://example.com/note/123",
             "type": "Note",
             "content": "Updated content"
         }
-    )
-    assert activity.type == "Update"
-    assert activity.object["content"] == "Updated content"
-
-def test_delete_activity():
-    """Test creating a Delete activity."""
-    activity = APDelete(
-        id="https://example.com/activity/123",
-        actor="https://example.com/user/1",
-        object="https://example.com/note/123"
-    )
-    assert activity.type == "Delete"
-    assert str(activity.object) == "https://example.com/note/123"
-
-def test_follow_activity():
-    """Test creating a Follow activity."""
-    activity = APFollow(
-        id="https://example.com/activity/123",
-        actor="https://example.com/user/1",
-        object="https://example.com/user/2"
-    )
-    assert activity.type == "Follow"
-    assert str(activity.object) == "https://example.com/user/2"
-
-def test_undo_activity():
-    """Test creating an Undo activity."""
-    activity = APUndo(
-        id="https://example.com/activity/123",
-        actor="https://example.com/user/1",
-        object={
-            "id": "https://example.com/activity/456",
-            "type": "Follow",
-            "actor": "https://example.com/user/1",
-            "object": "https://example.com/user/2"
-        }
-    )
-    assert activity.type == "Undo"
-    assert activity.object["type"] == "Follow"
-
-def test_like_activity():
-    """Test creating a Like activity."""
-    activity = APLike(
-        id="https://example.com/activity/123",
-        actor="https://example.com/user/1",
-        object="https://example.com/note/123"
-    )
-    assert activity.type == "Like"
-    assert str(activity.object) == "https://example.com/note/123"
-
-def test_announce_activity():
-    """Test creating an Announce activity."""
-    activity = APAnnounce(
-        id="https://example.com/activity/123",
-        actor="https://example.com/user/1",
-        object="https://example.com/note/123"
-    )
-    assert activity.type == "Announce"
-    assert str(activity.object) == "https://example.com/note/123"
-
-def test_activity_with_target():
-    """Test creating an activity with a target."""
-    activity = APCreate(
-        id="https://example.com/activity/123",
-        actor="https://example.com/user/1",
-        object="https://example.com/note/123",
-        target="https://example.com/collection/1"
-    )
-    assert str(activity.target) == "https://example.com/collection/1"
-
-def test_activity_with_result():
-    """Test creating an activity with a result."""
-    activity = APCreate(
-        id="https://example.com/activity/123",
-        actor="https://example.com/user/1",
-        object="https://example.com/note/123",
-        result={
-            "id": "https://example.com/result/1",
-            "type": "Note",
-            "content": "Result content"
-        }
-    )
-    assert activity.result["type"] == "Note"
+        activity = APUpdate(
+            id="https://example.com/activity/123",
+            actor=valid_actor,
+            object=updated_object
+        )
+        assert activity.type == "Update"
+        assert activity.object["content"] == "Updated content"
+
+    def test_delete_activity(self, valid_actor):
+        """Test specific behavior of Delete activity."""
+        object_id = "https://example.com/note/123"
+        activity = APDelete(
+            id="https://example.com/activity/123",
+            actor=valid_actor,
+            object=object_id
+        )
+        assert activity.type == "Delete"
+        assert str(activity.object) == object_id
 
-def test_invalid_activity_missing_actor():
-    """Test that activity creation fails when actor is missing."""
-    with pytest.raises(ValidationError):
-        APCreate(
+    def test_follow_activity(self, valid_actor, valid_person):
+        """Test specific behavior of Follow activity."""
+        activity = APFollow(
             id="https://example.com/activity/123",
-            object="https://example.com/note/123"
+            actor=valid_actor,
+            object=valid_person.id
         )
+        assert activity.type == "Follow"
+        assert str(activity.object) == valid_person.id
 
-def test_invalid_activity_missing_object():
-    """Test that activity creation fails when object is missing for non-intransitive activities."""
-    with pytest.raises(ValidationError):
-        APCreate(
+    def test_like_activity(self, valid_actor, valid_note):
+        """Test specific behavior of Like activity."""
+        activity = APLike(
             id="https://example.com/activity/123",
-            actor="https://example.com/user/1"
+            actor=valid_actor,
+            object=valid_note.id
         )
+        assert activity.type == "Like"
+        assert str(activity.object) == valid_note.id
diff --git a/tests/unit_tests/serializers/test_serialization.py b/tests/unit_tests/serializers/test_serialization.py
index 9d30b32..594d884 100644
--- a/tests/unit_tests/serializers/test_serialization.py
+++ b/tests/unit_tests/serializers/test_serialization.py
@@ -1,7 +1,6 @@
 """
 Tests for ActivityPub serialization.
 """
-
 import pytest
 from datetime import datetime, timezone
 import json
@@ -12,176 +11,194 @@ from pyfed.models import (
 )
 from pyfed.serializers.json_serializer import ActivityPubSerializer
 
-def test_serialize_ap_object():
-    """Test basic object serialization."""
-    obj = APObject(
-        id="https://example.com/object/123",
-        type="Object",
-        name="Test Object",
-        content="This is a test object."
-    )
-    serialized = obj.serialize()
-    
-    # Verify serialization
-    assert serialized["@context"] == "https://www.w3.org/ns/activitystreams"
-    assert serialized["id"] == "https://example.com/object/123"
-    assert serialized["type"] == "Object"
-    assert serialized["name"] == "Test Object"
-    assert serialized["content"] == "This is a test object."
+class TestBasicSerialization:
+    @pytest.mark.parametrize("field,value", [
+        ("name", "Test Object"),
+        ("content", "This is a test object."),
+        ("summary", "A summary"),
+        ("url", "https://example.com/object"),
+    ])
+    def test_simple_field_serialization(self, field, value):
+        """Test serialization of simple field types."""
+        obj = APObject(
+            id="https://example.com/object/123",
+            type="Object",
+            **{field: value}
+        )
+        serialized = obj.serialize()
+        assert serialized[field] == value
 
-def test_serialize_with_datetime():
-    """Test serialization of objects with datetime fields."""
-    now = datetime.now(timezone.utc)
-    obj = APObject(
-        id="https://example.com/object/123",
-        type="Object",
-        published=now,
-        updated=now
-    )
-    serialized = obj.serialize()
-    
-    # Verify datetime serialization
-    assert serialized["published"] == now.isoformat()
-    assert serialized["updated"] == now.isoformat()
+    def test_datetime_serialization(self):
+        """Test serialization of datetime fields."""
+        now = datetime.now(timezone.utc)
+        obj = APObject(
+            id="https://example.com/object/123",
+            type="Object",
+            published=now,
+            updated=now
+        )
+        serialized = obj.serialize()
+        assert serialized["published"] == now.isoformat()
+        assert serialized["updated"] == now.isoformat()
 
-def test_serialize_nested_objects():
-    """Test serialization of objects with nested objects."""
-    author = APPerson(
-        id="https://example.com/users/alice",
-        type="Person",
-        name="Alice",
-        inbox="https://example.com/users/alice/inbox",
-        outbox="https://example.com/users/alice/outbox"
-    )
-    note = APNote(
-        id="https://example.com/notes/123",
-        type="Note",
-        content="Hello, World!",
-        attributed_to=author
-    )
-    serialized = note.serialize()
-    
-    # Verify nested object serialization
-    assert serialized["attributedTo"]["id"] == "https://example.com/users/alice"
-    assert serialized["attributedTo"]["type"] == "Person"
-    assert serialized["attributedTo"]["name"] == "Alice"
+    def test_context_handling(self):
+        """Test that @context is properly handled."""
+        obj = APObject(
+            id="https://example.com/object/123",
+            type="Object"
+        )
+        serialized = obj.serialize()
+        assert "@context" in serialized
+        assert serialized["@context"] == "https://www.w3.org/ns/activitystreams"
 
-def test_serialize_collection():
-    """Test serialization of collections."""
-    items = [
-        APNote(
-            id=f"https://example.com/notes/{i}",
-            type="Note",
-            content=f"Note {i}"
-        ) for i in range(3)
-    ]
-    collection = APCollection(
-        id="https://example.com/collection/1",
-        type="Collection",
-        total_items=len(items),
-        items=items
-    )
-    serialized = collection.serialize()
-    
-    # Verify collection serialization
-    assert serialized["type"] == "Collection"
-    assert serialized["totalItems"] == 3
-    assert len(serialized["items"]) == 3
-    assert all(item["type"] == "Note" for item in serialized["items"])
+class TestComplexSerialization:
+    def test_nested_object_serialization(self, valid_person, valid_note):
+        """Test serialization of objects with nested objects."""
+        note = valid_note
+        note.attributedTo = valid_person
+        serialized = note.serialize()
+        
+        assert serialized["attributedTo"]["id"] == valid_person.id
+        assert serialized["attributedTo"]["type"] == "Person"
+        assert serialized["attributedTo"]["name"] == valid_person.name
+
+    def test_collection_serialization(self):
+        """Test serialization of collections."""
+        items = [
+            APObject(id=f"https://example.com/item/{i}", type="Object")
+            for i in range(3)
+        ]
+        collection = APCollection(
+            id="https://example.com/collection/1",
+            type="Collection",
+            totalItems=len(items),
+            items=items
+        )
+        serialized = collection.serialize()
+        
+        assert serialized["type"] == "Collection"
+        assert serialized["totalItems"] == 3
+        assert len(serialized["items"]) == 3
+        assert all(item["type"] == "Object" for item in serialized["items"])
 
-def test_serialize_activity():
-    """Test serialization of activities."""
-    note = APNote(
-        id="https://example.com/notes/123",
-        type="Note",
-        content="Hello, World!"
-    )
-    create = APCreate(
-        id="https://example.com/activities/1",
-        type="Create",
-        actor="https://example.com/users/alice",
-        object=note
-    )
-    serialized = create.serialize()
-    
-    # Verify activity serialization
-    assert serialized["type"] == "Create"
-    assert serialized["actor"] == "https://example.com/users/alice"
-    assert serialized["object"]["type"] == "Note"
-    assert serialized["object"]["content"] == "Hello, World!"
+    def test_activity_serialization(self, valid_person, valid_note):
+        """Test serialization of activities."""
+        activity = APCreate(
+            id="https://example.com/activity/1",
+            type="Create",
+            actor=valid_person,
+            object=valid_note
+        )
+        serialized = activity.serialize()
+        
+        assert serialized["type"] == "Create"
+        assert serialized["actor"]["id"] == valid_person.id
+        assert serialized["object"]["id"] == valid_note.id
 
-def test_deserialize_ap_object():
-    """Test basic object deserialization."""
-    data = {
-        "@context": "https://www.w3.org/ns/activitystreams",
-        "type": "Object",
-        "id": "https://example.com/object/123",
-        "name": "Test Object",
-        "content": "This is a test object."
-    }
-    obj = ActivityPubSerializer.deserialize(data, APObject)
-    
-    # Verify deserialization
-    assert str(obj.id) == "https://example.com/object/123"
-    assert obj.type == "Object"
-    assert obj.name == "Test Object"
-    assert obj.content == "This is a test object."
+class TestDeserialization:
+    def test_basic_deserialization(self):
+        """Test basic object deserialization."""
+        json_data = {
+            "@context": "https://www.w3.org/ns/activitystreams",
+            "id": "https://example.com/object/123",
+            "type": "Object",
+            "name": "Test Object"
+        }
+        obj = APObject.deserialize(json.dumps(json_data))
+        assert obj.id == json_data["id"]
+        assert obj.type == json_data["type"]
+        assert obj.name == json_data["name"]
 
-def test_deserialize_from_json_string():
-    """Test deserialization from JSON string."""
-    json_str = json.dumps({
-        "type": "Object",
-        "id": "https://example.com/object/123",
-        "name": "Test Object"
-    })
-    obj = ActivityPubSerializer.deserialize(json_str, APObject)
-    
-    # Verify deserialization from string
-    assert str(obj.id) == "https://example.com/object/123"
-    assert obj.type == "Object"
-    assert obj.name == "Test Object"
+    @pytest.mark.parametrize("invalid_json", [
+        "",
+        "not json",
+        "{invalid json}",
+        "[]",
+        "null"
+    ])
+    def test_invalid_json_deserialization(self, invalid_json):
+        """Test deserialization with invalid JSON."""
+        with pytest.raises((json.JSONDecodeError, ValidationError)):
+            APObject.deserialize(invalid_json)
 
-def test_deserialize_invalid_json():
-    """Test deserialization of invalid JSON."""
-    with pytest.raises(ValueError):
-        ActivityPubSerializer.deserialize("invalid json", APObject)
+    def test_missing_required_fields(self):
+        """Test deserialization with missing required fields."""
+        json_data = {
+            "@context": "https://www.w3.org/ns/activitystreams",
+            "name": "Test Object"
+        }
+        with pytest.raises(ValidationError):
+            APObject.deserialize(json.dumps(json_data))
 
-def test_deserialize_missing_required_fields():
-    """Test deserialization with missing required fields."""
-    data = {"type": "Object", "name": "Test"}  # Missing required 'id'
-    with pytest.raises(Exception):  # Pydantic will raise validation error
-        ActivityPubSerializer.deserialize(data, APObject)
+    def test_extra_fields_handling(self):
+        """Test deserialization with extra unknown fields."""
+        json_data = {
+            "@context": "https://www.w3.org/ns/activitystreams",
+            "id": "https://example.com/object/123",
+            "type": "Object",
+            "name": "Test Object",
+            "unknownField": "value",
+            "anotherUnknown": 123
+        }
+        obj = APObject.deserialize(json.dumps(json_data))
+        assert obj.id == json_data["id"]
+        assert obj.type == json_data["type"]
+        assert obj.name == json_data["name"]
 
-def test_serialize_deserialize_complex_object():
-    """Test round-trip serialization and deserialization."""
-    original = APNote(
-        id="https://example.com/notes/123",
-        type="Note",
-        content="Test content",
-        to=["https://example.com/users/bob"],
-        cc=["https://www.w3.org/ns/activitystreams#Public"]
-    )
-    serialized = original.serialize()
-    deserialized = ActivityPubSerializer.deserialize(serialized, APNote)
-    
-    # Verify round-trip
-    assert str(deserialized.id) == str(original.id)
-    assert deserialized.type == original.type
-    assert deserialized.content == original.content
-    assert deserialized.to == original.to
-    assert deserialized.cc == original.cc
+class TestEdgeCases:
+    def test_empty_collection_serialization(self):
+        """Test serialization of empty collections."""
+        collection = APCollection(
+            id="https://example.com/collection/1",
+            type="Collection",
+            totalItems=0,
+            items=[]
+        )
+        serialized = collection.serialize()
+        assert serialized["totalItems"] == 0
+        assert serialized["items"] == []
 
-def test_deserialize_with_extra_fields():
-    """Test deserialization with extra fields in JSON."""
-    data = {
-        "type": "Object",
-        "id": "https://example.com/object/123",
-        "name": "Test Object",
-        "extra_field": "Should be ignored"
-    }
-    obj = ActivityPubSerializer.deserialize(data, APObject)
-    
-    # Verify extra fields are handled
-    assert str(obj.id) == "https://example.com/object/123"
-    assert obj.type == "Object"
-    assert obj.name == "Test Object"
+    def test_large_collection_serialization(self):
+        """Test serialization of large collections."""
+        items = [
+            APObject(id=f"https://example.com/item/{i}", type="Object")
+            for i in range(1000)
+        ]
+        collection = APCollection(
+            id="https://example.com/collection/1",
+            type="Collection",
+            totalItems=len(items),
+            items=items
+        )
+        serialized = collection.serialize()
+        assert len(serialized["items"]) == 1000
+
+    @pytest.mark.parametrize("long_string", [
+        "a" * 1000,
+        "b" * 10000,
+        "c" * 100000
+    ])
+    def test_long_string_serialization(self, long_string):
+        """Test serialization of objects with very long string fields."""
+        obj = APObject(
+            id="https://example.com/object/123",
+            type="Object",
+            content=long_string
+        )
+        serialized = obj.serialize()
+        assert serialized["content"] == long_string
+
+    def test_circular_reference_handling(self, valid_person):
+        """Test handling of circular references in serialization."""
+        person = valid_person
+        note = APNote(
+            id="https://example.com/note/123",
+            type="Note",
+            content="Test note",
+            attributedTo=person
+        )
+        person.liked = [note]  # Create circular reference
+        
+        # Should not cause infinite recursion
+        serialized_person = person.serialize()
+        assert serialized_person["liked"][0]["id"] == note.id
-- 
GitLab