Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.0.2] - 2026-04-02

### Added

- SQLite-backed integration tests for `PostgresImageRepository`, covering all
repository methods with real SQL queries instead of mocked calls (28 tests).
- Unit tests for `ListImagesUseCase` — pagination, status filtering, and
response mapping (5 tests).
- Direct in-process tests for `_generate_thumbnail_sync` and
`_extract_metadata_sync` worker functions, plus `shutdown_executor` lifecycle
tests (12 tests).
- Lifespan integration tests verifying startup table creation, shutdown cleanup
(`engine.dispose`, `shutdown_executor`), and log output (3 tests).

### Fixed

- Replaced deprecated `HTTP_422_UNPROCESSABLE_ENTITY` with
`HTTP_422_UNPROCESSABLE_CONTENT` in the upload validation error response.

## [2.0.1] - 2026-04-01

### Added
Expand Down Expand Up @@ -230,7 +249,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix `type: ignore` comment on `rowcount` to use correct mypy error code `attr-defined`.
- Add proper type annotation for `settings` parameter in retention sweep endpoint.

[unreleased]: https://github.com/vlantonov/ImageProcessingServiceDemo/compare/v1.2.3...HEAD
[unreleased]: https://github.com/vlantonov/ImageProcessingServiceDemo/compare/v2.0.2...HEAD
[2.0.2]: https://github.com/vlantonov/ImageProcessingServiceDemo/compare/v2.0.1...v2.0.2
[2.0.1]: https://github.com/vlantonov/ImageProcessingServiceDemo/compare/v2.0.0...v2.0.1
[2.0.0]: https://github.com/vlantonov/ImageProcessingServiceDemo/compare/v1.3.0...v2.0.0
[1.3.0]: https://github.com/vlantonov/ImageProcessingServiceDemo/compare/v1.2.4...v1.3.0
[1.2.4]: https://github.com/vlantonov/ImageProcessingServiceDemo/compare/v1.2.3...v1.2.4
[1.2.3]: https://github.com/vlantonov/ImageProcessingServiceDemo/compare/v1.2.2...v1.2.3
[1.2.2]: https://github.com/vlantonov/ImageProcessingServiceDemo/compare/v1.2.1...v1.2.2
[1.2.1]: https://github.com/vlantonov/ImageProcessingServiceDemo/compare/v1.2.0...v1.2.1
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "image-processing-service"
version = "2.0.1"
version = "2.0.2"
description = "High-performance image processing microservice with Clean Architecture"
requires-python = ">=3.11"
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion src/presentation/api/routes/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ async def upload_image(
except InvalidImageError as exc:
logger.warning("Upload rejected: image validation failed: %s", exc)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Image validation failed: {exc}",
) from exc
safe_filename = sanitize_filename(file.filename or "unnamed")
Expand Down
107 changes: 107 additions & 0 deletions tests/application/test_list_images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Tests for ListImagesUseCase — pagination, status filtering, and response mapping."""

from __future__ import annotations

import uuid
from datetime import UTC, datetime
from unittest.mock import AsyncMock

from src.application.dto.image_dto import ImageListResponse
from src.application.use_cases.list_images import ListImagesUseCase
from src.domain.entities.image import Image, ImageMetadata, ProcessingStatus


def _make_image(
*,
status: ProcessingStatus = ProcessingStatus.PENDING,
metadata: ImageMetadata | None = None,
thumbnail_path: str | None = None,
) -> Image:
return Image(
id=uuid.uuid4(),
filename="img.png",
original_path="/data/img.png",
thumbnail_path=thumbnail_path,
metadata=metadata,
status=status,
tags=["tag"],
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)


class TestListImagesUseCase:
async def test_empty_list(self, mock_repository: AsyncMock):
use_case = ListImagesUseCase(mock_repository)

result = await use_case.execute()

assert isinstance(result, ImageListResponse)
assert result.images == []
assert result.total == 0
assert result.offset == 0
assert result.limit == 50
mock_repository.list_images.assert_awaited_once_with(offset=0, limit=50, status=None)
mock_repository.count.assert_awaited_once_with(status=None)

async def test_returns_images_with_correct_mapping(self, mock_repository: AsyncMock):
images = [
_make_image(
status=ProcessingStatus.COMPLETED,
metadata=ImageMetadata(
width=800, height=600, format="PNG", size_bytes=1024, channels=3
),
thumbnail_path="/data/thumb.png",
),
_make_image(status=ProcessingStatus.PENDING),
]
mock_repository.list_images.return_value = images
mock_repository.count.return_value = 2
use_case = ListImagesUseCase(mock_repository)

result = await use_case.execute()

assert len(result.images) == 2
assert result.total == 2

completed = result.images[0]
assert completed.status == "completed"
assert completed.width == 800
assert completed.height == 600
assert completed.format == "PNG"
assert completed.size_bytes == 1024
assert completed.thumbnail_available is True

pending = result.images[1]
assert pending.status == "pending"
assert pending.width is None
assert pending.thumbnail_available is False

async def test_pagination_params_forwarded(self, mock_repository: AsyncMock):
mock_repository.count.return_value = 100
use_case = ListImagesUseCase(mock_repository)

result = await use_case.execute(offset=10, limit=25)

assert result.offset == 10
assert result.limit == 25
mock_repository.list_images.assert_awaited_once_with(offset=10, limit=25, status=None)

async def test_status_filter_forwarded(self, mock_repository: AsyncMock):
mock_repository.count.return_value = 5
use_case = ListImagesUseCase(mock_repository)

await use_case.execute(status="completed")

mock_repository.list_images.assert_awaited_once_with(offset=0, limit=50, status="completed")
mock_repository.count.assert_awaited_once_with(status="completed")

async def test_total_reflects_count_not_page_size(self, mock_repository: AsyncMock):
mock_repository.list_images.return_value = [_make_image() for _ in range(10)]
mock_repository.count.return_value = 42
use_case = ListImagesUseCase(mock_repository)

result = await use_case.execute(offset=0, limit=10)

assert len(result.images) == 10
assert result.total == 42
142 changes: 141 additions & 1 deletion tests/infrastructure/test_pillow_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
import pytest
from PIL import Image as PILImage

from src.infrastructure.processing.pillow_processor import PillowImageProcessor
from src.infrastructure.processing.pillow_processor import (
PillowImageProcessor,
_extract_metadata_sync,
_generate_thumbnail_sync,
get_executor,
shutdown_executor,
)


@pytest.fixture
Expand Down Expand Up @@ -48,3 +54,137 @@ async def test_extract_metadata(processor, png_bytes):
assert meta["format"] == "PNG"
assert meta["channels"] == 3
assert meta["size_bytes"] == len(png_bytes)


# ── Direct tests for sync worker functions ───────────────────────────────────
# These run in-process so coverage can track them (ProcessPoolExecutor runs
# in child processes invisible to the coverage collector).


class TestGenerateThumbnailSync:
def test_basic_thumbnail(self, png_bytes):
result = _generate_thumbnail_sync(png_bytes, (128, 128))

assert result["width"] <= 128
assert result["height"] <= 128
assert result["format"] == "PNG"
assert result["channels"] == 3
assert result["size_bytes"] == len(png_bytes)
assert len(result["thumbnail_data"]) > 0

# Verify output is a valid image
thumb = PILImage.open(io.BytesIO(result["thumbnail_data"]))
assert thumb.width == result["width"]
assert thumb.height == result["height"]

def test_preserves_aspect_ratio(self):
img = PILImage.new("RGB", (800, 400), color=(255, 0, 0))
buf = io.BytesIO()
img.save(buf, format="PNG")
data = buf.getvalue()

result = _generate_thumbnail_sync(data, (200, 200))

assert result["width"] == 200
assert result["height"] == 100

def test_rgba_image(self):
img = PILImage.new("RGBA", (300, 200), color=(0, 255, 0, 128))
buf = io.BytesIO()
img.save(buf, format="PNG")
data = buf.getvalue()

result = _generate_thumbnail_sync(data, (100, 100))

assert result["channels"] == 4
assert result["width"] <= 100
assert result["height"] <= 100

def test_already_small_image(self):
img = PILImage.new("RGB", (50, 30))
buf = io.BytesIO()
img.save(buf, format="PNG")
data = buf.getvalue()

result = _generate_thumbnail_sync(data, (256, 256))

assert result["width"] == 50
assert result["height"] == 30

def test_jpeg_format_preserved(self):
img = PILImage.new("RGB", (400, 300))
buf = io.BytesIO()
img.save(buf, format="JPEG")
data = buf.getvalue()

result = _generate_thumbnail_sync(data, (100, 100))

assert result["format"] == "JPEG"

def test_corrupt_data_raises(self):
with pytest.raises((OSError, ValueError)):
_generate_thumbnail_sync(b"not-an-image", (128, 128))


class TestExtractMetadataSync:
def test_basic_metadata(self, png_bytes):
meta = _extract_metadata_sync(png_bytes)

assert meta["width"] == 640
assert meta["height"] == 480
assert meta["format"] == "PNG"
assert meta["channels"] == 3
assert meta["size_bytes"] == len(png_bytes)
assert meta["mode"] == "RGB"

def test_rgba_metadata(self):
img = PILImage.new("RGBA", (100, 50))
buf = io.BytesIO()
img.save(buf, format="PNG")
data = buf.getvalue()

meta = _extract_metadata_sync(data)

assert meta["width"] == 100
assert meta["height"] == 50
assert meta["channels"] == 4
assert meta["mode"] == "RGBA"

def test_grayscale_metadata(self):
img = PILImage.new("L", (200, 150))
buf = io.BytesIO()
img.save(buf, format="PNG")
data = buf.getvalue()

meta = _extract_metadata_sync(data)

assert meta["channels"] == 1
assert meta["mode"] == "L"

def test_corrupt_data_raises(self):
with pytest.raises((OSError, ValueError)):
_extract_metadata_sync(b"bad-data")


# ── Executor lifecycle tests ─────────────────────────────────────────────────


class TestExecutorLifecycle:
def test_shutdown_and_recreate(self):
# Ensure an executor exists
executor = get_executor(max_workers=1)
assert executor is not None

# Shutdown should succeed
shutdown_executor()

# A new call should create a fresh executor
new_executor = get_executor(max_workers=1)
assert new_executor is not None

# Cleanup
shutdown_executor()

def test_shutdown_when_no_executor(self):
shutdown_executor() # ensure clean state
shutdown_executor() # should be a no-op, not raise
Loading
Loading