feat: image upload, serving, and page management (Phase 3b Part B) #7

Merged
bjoern merged 4 commits from feat/image-upload-and-serving into main 2026-06-28 18:21:37 +02:00
Member

Phase 3b Part B: Image Upload, Serving, and Page Management

Adds image upload (multipart + ZIP), image/thumbnail serving, page management, and page reordering.

Architecture

Follows the established pattern: HTTP Endpoint → UseCase (orchestrator) → Service (BL) → EF Core

New services:

  • IImageService / ImageService — inspects images via IImageInspector, stores via IImageStorage, generates thumbnails via IThumbnailGenerator, manages ImageFile entities
  • IPageService / PageService — creates/list/reorder/delete Page entities, keeps Variant.PageCount in sync

New use cases (thin orchestrators):

  • UploadPagesUseCase — multipart upload → check variant exists → for each file: create image → create page → transaction-wrapped
  • UploadZipPagesUseCase — ZIP upload → extract → for each image: create image → create page → transaction-wrapped
  • GetImageUseCase — serve image file stream with media type
  • GetThumbnailUseCase — serve WebP thumbnail stream
  • ReorderPagesUseCase — check variant exists → reorder pages
  • DeletePageUseCase — delete page, decrement PageCount
  • ListPagesUseCase — list pages for variant

REST endpoints

Method Endpoint Description
POST /api/variants/{id}/pages Multipart image upload
POST /api/variants/{id}/pages/zip ZIP archive upload
GET /api/images/{id} Serve image file
GET /api/thumbnails/{id} Serve WebP thumbnail
GET /api/variants/{id}/pages List pages (ordered by SortOrder)
PUT /api/variants/{id}/pages/reorder Reorder pages
DELETE /api/pages/{id} Delete page

Infrastructure

  • ImageInfrastructureRegistration helper — shared DI registration for image ports, used by both Program.cs and tests
  • Upload use cases wrapped in BeginTransactionAsync/CommitAsync for atomicity
  • UploadImageFile(string FileName, Stream Content) decouples use cases from ASP.NET's IFormFile

Tests (28 new, 149 total)

Integration tests: multipart upload (creates pages + images), ZIP upload, get image (file stream), get thumbnail, list pages, reorder, delete, not-found cases, empty files

Unit tests with NSubstitute mocks: upload (variant not found, happy), ZIP upload (variant not found, happy), reorder (variant not found, happy), delete (happy, not found)

Test results

All 149 tests pass. 0 errors, 0 warnings.

## Phase 3b Part B: Image Upload, Serving, and Page Management Adds image upload (multipart + ZIP), image/thumbnail serving, page management, and page reordering. ### Architecture Follows the established pattern: `HTTP Endpoint → UseCase (orchestrator) → Service (BL) → EF Core` **New services:** - `IImageService` / `ImageService` — inspects images via `IImageInspector`, stores via `IImageStorage`, generates thumbnails via `IThumbnailGenerator`, manages `ImageFile` entities - `IPageService` / `PageService` — creates/list/reorder/delete `Page` entities, keeps `Variant.PageCount` in sync **New use cases (thin orchestrators):** - `UploadPagesUseCase` — multipart upload → check variant exists → for each file: create image → create page → transaction-wrapped - `UploadZipPagesUseCase` — ZIP upload → extract → for each image: create image → create page → transaction-wrapped - `GetImageUseCase` — serve image file stream with media type - `GetThumbnailUseCase` — serve WebP thumbnail stream - `ReorderPagesUseCase` — check variant exists → reorder pages - `DeletePageUseCase` — delete page, decrement PageCount - `ListPagesUseCase` — list pages for variant ### REST endpoints | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/api/variants/{id}/pages` | Multipart image upload | | POST | `/api/variants/{id}/pages/zip` | ZIP archive upload | | GET | `/api/images/{id}` | Serve image file | | GET | `/api/thumbnails/{id}` | Serve WebP thumbnail | | GET | `/api/variants/{id}/pages` | List pages (ordered by SortOrder) | | PUT | `/api/variants/{id}/pages/reorder` | Reorder pages | | DELETE | `/api/pages/{id}` | Delete page | ### Infrastructure - `ImageInfrastructureRegistration` helper — shared DI registration for image ports, used by both `Program.cs` and tests - Upload use cases wrapped in `BeginTransactionAsync`/`CommitAsync` for atomicity - `UploadImageFile(string FileName, Stream Content)` decouples use cases from ASP.NET's `IFormFile` ### Tests (28 new, 149 total) **Integration tests:** multipart upload (creates pages + images), ZIP upload, get image (file stream), get thumbnail, list pages, reorder, delete, not-found cases, empty files **Unit tests with NSubstitute mocks:** upload (variant not found, happy), ZIP upload (variant not found, happy), reorder (variant not found, happy), delete (happy, not found) ### Test results All 149 tests pass. 0 errors, 0 warnings.
feat: image upload, serving, and page management (Phase 3b Part B)
All checks were successful
CI / build (pull_request) Successful in 18s
CI / test (pull_request) Successful in 26s
efa2205222
New services:
- IImageService/ImageService: inspect, store, thumbnail, CRUD for ImageFile
- IPageService/PageService: create, list, reorder, delete, update chapter

New use cases (thin orchestrators):
- UploadPagesUseCase: multipart upload → inspect → store → thumbnail → pages
- UploadZipPagesUseCase: ZIP extract → inspect → store → thumbnail → pages
- GetImageUseCase: serve image file stream with media type
- GetThumbnailUseCase: serve WebP thumbnail stream
- ReorderPagesUseCase: reorder page sort order within variant
- DeletePageUseCase: delete page, decrement PageCount
- ListPagesUseCase: list pages for variant ordered by SortOrder

REST endpoints:
- POST /api/variants/{id}/pages (multipart upload)
- POST /api/variants/{id}/pages/zip (ZIP upload)
- GET /api/images/{id} (serve image)
- GET /api/thumbnails/{id} (serve thumbnail)
- PUT /api/variants/{id}/pages/reorder
- DELETE /api/pages/{id}
- GET /api/variants/{id}/pages

Infrastructure:
- ImageInfrastructureRegistration helper for shared DI registration
- Upload use cases wrapped in transactions for atomicity

Tests (28 new, 149 total):
- Integration: multipart upload, ZIP upload, get image/thumbnail, list pages,
  reorder, delete, not-found cases
- Unit with mocks: upload (variant not found, happy), ZIP (variant not found,
  happy), reorder (variant not found, happy), delete (happy, not found)

All 149 tests pass, 0 errors.

Summary

Summary
Generated on: 06/28/2026 - 16:09:02
Coverage date: 06/28/2026 - 16:08:54 - 06/28/2026 - 16:08:59
Parser: MultiReport (4x Cobertura)
Assemblies: 4
Classes: 192
Files: 82
Line coverage: 82.5% (3679 of 4459)
Covered lines: 3679
Uncovered lines: 780
Coverable lines: 4459
Total lines: 7519
Branch coverage: 44.4% (291 of 654)
Covered branches: 291
Total branches: 654
Method coverage: Feature is only available for sponsors

Coverage

DoujinManager.ApplicationCore - 81.9%
Name Line Branch
DoujinManager.ApplicationCore 81.9% ****
DoujinManager.ApplicationCore.Entities.Chapter 87.5%
DoujinManager.ApplicationCore.Entities.Circle 100%
DoujinManager.ApplicationCore.Entities.Doujin 100%
DoujinManager.ApplicationCore.Entities.DoujinCircle 0%
DoujinManager.ApplicationCore.Entities.DoujinPerson 80%
DoujinManager.ApplicationCore.Entities.DoujinTag 75%
DoujinManager.ApplicationCore.Entities.ImageFile 100%
DoujinManager.ApplicationCore.Entities.Page 80%
DoujinManager.ApplicationCore.Entities.Person 100%
DoujinManager.ApplicationCore.Entities.Tag 100%
DoujinManager.ApplicationCore.Entities.Title 83.3%
DoujinManager.ApplicationCore.Entities.Variant 91.6%
DoujinManager.ApplicationCore.Ids.ChapterId 66.6%
DoujinManager.ApplicationCore.Ids.CircleId 66.6%
DoujinManager.ApplicationCore.Ids.DoujinId 100%
DoujinManager.ApplicationCore.Ids.ImageFileId 66.6%
DoujinManager.ApplicationCore.Ids.PageId 66.6%
DoujinManager.ApplicationCore.Ids.PersonId 66.6%
DoujinManager.ApplicationCore.Ids.TagId 66.6%
DoujinManager.ApplicationCore.Ids.TitleId 66.6%
DoujinManager.ApplicationCore.Ids.VariantId 66.6%
DoujinManager.ApplicationCore.Ports.ExtractedImage 100%
DoujinManager.ApplicationCore.Ports.ImageInspection 100%
DoujinManager.ApplicationCore.Services.ITagService 100%
DoujinManager.ApplicationCore.Services.ServiceResult 100%
DoujinManager.ApplicationCore.Services.ServiceResult`1 33.3%
DoujinManager.ApplicationCore.Services.VoidResult 88.8%
DoujinManager.ApplicationCore.UseCases.AddTitleCommand 0%
DoujinManager.ApplicationCore.UseCases.AssignCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignPersonCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignTagCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateChapterCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.CreatePersonCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateTagCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateTitleCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateVariantCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteChapterCommand 0%
DoujinManager.ApplicationCore.UseCases.DeleteDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.DeletePageCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteVariantCommand 0%
DoujinManager.ApplicationCore.UseCases.GetDoujinQuery 100%
DoujinManager.ApplicationCore.UseCases.GetImageQuery 100%
DoujinManager.ApplicationCore.UseCases.GetImageResult 100%
DoujinManager.ApplicationCore.UseCases.GetThumbnailQuery 100%
DoujinManager.ApplicationCore.UseCases.GetThumbnailResult 100%
DoujinManager.ApplicationCore.UseCases.GetVariantQuery 100%
DoujinManager.ApplicationCore.UseCases.ListChaptersQuery 100%
DoujinManager.ApplicationCore.UseCases.ListCirclesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListDoujinsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListPagesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListPeopleQuery 100%
DoujinManager.ApplicationCore.UseCases.ListTagsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListVariantsQuery 100%
DoujinManager.ApplicationCore.UseCases.RemoveCircleCommand 0%
DoujinManager.ApplicationCore.UseCases.RemovePersonCommand 0%
DoujinManager.ApplicationCore.UseCases.RemoveTagCommand 0%
DoujinManager.ApplicationCore.UseCases.RemoveTitleCommand 0%
DoujinManager.ApplicationCore.UseCases.ReorderPagesCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdateChapterCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateCircleCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdatePersonCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateVariantCommand 0%
DoujinManager.ApplicationCore.UseCases.UploadImageFile 100%
DoujinManager.ApplicationCore.UseCases.UploadPagesCommand 100%
DoujinManager.ApplicationCore.UseCases.UploadZipPagesCommand 100%
DoujinManager.Infrastructure - 91.3%
Name Line Branch
DoujinManager.Infrastructure 91.3% 66%
DoujinManager.Infrastructure.Archives.ZipExtractor 100% 87.5%
DoujinManager.Infrastructure.Data.Configurations.ChapterConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.CircleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinCircleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinPersonConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinTagConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.ImageFileConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.PageConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.PersonConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.TagConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.TitleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.VariantConfiguration 100%
DoujinManager.Infrastructure.Data.DoujinManagerDbContext 93.7%
DoujinManager.Infrastructure.Data.GuidIdGenerator 11.1%
DoujinManager.Infrastructure.Data.Migrations.DoujinManagerDbContextModelSna
pshot
100%
DoujinManager.Infrastructure.Data.Migrations.InitialCreate 97.1%
DoujinManager.Infrastructure.Data.ModelBuilderExtensions 50%
DoujinManager.Infrastructure.Data.StronglyTypedIdConverterFactory 69.2%
DoujinManager.Infrastructure.Images.SkiaSharpImageInspector 88.2% 70.9%
DoujinManager.Infrastructure.Images.SkiaSharpThumbnailGenerator 94.5% 66.6%
DoujinManager.Infrastructure.Services.ChapterService 54.2% 25%
DoujinManager.Infrastructure.Services.CircleService 66.6% 0%
DoujinManager.Infrastructure.Services.DoujinService 66.6% 45.8%
DoujinManager.Infrastructure.Services.ImageService 91% 50%
DoujinManager.Infrastructure.Services.PageService 84.9% 65%
DoujinManager.Infrastructure.Services.PersonService 69.6% 0%
DoujinManager.Infrastructure.Services.TagService 94.7% 50%
DoujinManager.Infrastructure.Services.VariantService 50.7% 16.6%
DoujinManager.Infrastructure.Storage.FilesystemImageStorage 100% 100%
DoujinManager.Infrastructure.Storage.FilesystemThumbnailStorage 95% 75%
DoujinManager.Infrastructure.UseCases.AddTitleUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.AssignCircleUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignPersonUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateChapterUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateCircleUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateDoujinUseCase 100% 98%
DoujinManager.Infrastructure.UseCases.CreatePersonUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateVariantUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.DeleteChapterUseCase 0%
DoujinManager.Infrastructure.UseCases.DeleteDoujinUseCase 100%
DoujinManager.Infrastructure.UseCases.DeletePageUseCase 90.9% 75%
DoujinManager.Infrastructure.UseCases.DeleteVariantUseCase 0%
DoujinManager.Infrastructure.UseCases.GetDoujinUseCase 100%
DoujinManager.Infrastructure.UseCases.GetImageUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetThumbnailUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetVariantUseCase 100%
DoujinManager.Infrastructure.UseCases.ListChaptersUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.ListCirclesUseCase 100%
DoujinManager.Infrastructure.UseCases.ListDoujinsUseCase 100%
DoujinManager.Infrastructure.UseCases.ListPagesUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.ListPeopleUseCase 100%
DoujinManager.Infrastructure.UseCases.ListTagsUseCase 100%
DoujinManager.Infrastructure.UseCases.ListVariantsUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.RemoveCircleUseCase 0%
DoujinManager.Infrastructure.UseCases.RemovePersonUseCase 0%
DoujinManager.Infrastructure.UseCases.RemoveTagUseCase 0%
DoujinManager.Infrastructure.UseCases.RemoveTitleUseCase 0%
DoujinManager.Infrastructure.UseCases.ReorderPagesUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdateChapterUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateCircleUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateDoujinUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdatePersonUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateVariantUseCase 0%
DoujinManager.Infrastructure.UseCases.UploadPagesUseCase 92.8% 92.8%
DoujinManager.Infrastructure.UseCases.UploadZipPagesUseCase 74.1% 62.5%
DoujinManager.RestAdapter - 80.2%
Name Line Branch
DoujinManager.RestAdapter 80.2% 71.8%
DoujinManager.RestAdapter.Auth.StaticBearerTokenAuthMiddleware 100% 91.6%
DoujinManager.RestAdapter.Dtos.AssignTagDto 0%
DoujinManager.RestAdapter.Dtos.ChapterDto 100%
DoujinManager.RestAdapter.Dtos.CircleDto 100%
DoujinManager.RestAdapter.Dtos.CreateChapterDto 100%
DoujinManager.RestAdapter.Dtos.CreateCircleDto 100%
DoujinManager.RestAdapter.Dtos.CreateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.CreatePersonDto 100%
DoujinManager.RestAdapter.Dtos.CreateTagDto 100%
DoujinManager.RestAdapter.Dtos.CreateTitleDto 100%
DoujinManager.RestAdapter.Dtos.CreateVariantDto 100%
DoujinManager.RestAdapter.Dtos.DoujinDetailDto 100%
DoujinManager.RestAdapter.Dtos.DoujinPersonDto 100%
DoujinManager.RestAdapter.Dtos.DoujinSummaryDto 0%
DoujinManager.RestAdapter.Dtos.ImageFileSummaryDto 0%
DoujinManager.RestAdapter.Dtos.LinkCircleDto 0%
DoujinManager.RestAdapter.Dtos.LinkPersonDto 100%
DoujinManager.RestAdapter.Dtos.PageDetailDto 100%
DoujinManager.RestAdapter.Dtos.PageDto 100%
DoujinManager.RestAdapter.Dtos.PersonDto 100%
DoujinManager.RestAdapter.Dtos.ReorderPagesDto 100%
DoujinManager.RestAdapter.Dtos.TagDto 100%
DoujinManager.RestAdapter.Dtos.TitleDto 100%
DoujinManager.RestAdapter.Dtos.UpdateChapterDto 0%
DoujinManager.RestAdapter.Dtos.UpdateCircleDto 0%
DoujinManager.RestAdapter.Dtos.UpdateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.UpdatePersonDto 0%
DoujinManager.RestAdapter.Dtos.UpdateVariantDto 0%
DoujinManager.RestAdapter.Dtos.UploadPagesResponseDto 100%
DoujinManager.RestAdapter.Dtos.VariantDetailDto 100%
DoujinManager.RestAdapter.Dtos.VariantSummaryDto 100%
DoujinManager.RestAdapter.Endpoints.DoujinEndpoints 78.5% 37.5%
DoujinManager.RestAdapter.Endpoints.ImageEndpoints 96.6% 50%
DoujinManager.RestAdapter.Endpoints.MetadataEndpoints 85.7%
DoujinManager.RestAdapter.Endpoints.PaginationParams 100%
DoujinManager.RestAdapter.Endpoints.VariantEndpoints 71.5% 37.5%
DoujinManager.RestAdapter.Envelopes.CollectionResponse`1 83.3%
DoujinManager.RestAdapter.Envelopes.EnvelopeDefaults 100%
DoujinManager.RestAdapter.Envelopes.EnvelopeJsonOptions 100%
DoujinManager.RestAdapter.Envelopes.ErrorResponse 100%
DoujinManager.RestAdapter.Envelopes.HypermediaAction 100%
DoujinManager.RestAdapter.Envelopes.Link 100%
DoujinManager.RestAdapter.Envelopes.PageInfo 100%
DoujinManager.RestAdapter.Envelopes.ResourceResponse`1 80%
DoujinManager.RestAdapter.Envelopes.ValidationError 100%
DoujinManager.RestAdapter.Envelopes.ValidationErrorResponse 100%
DoujinManager.RestAdapter.Middleware.GlobalExceptionMiddleware 100% 50%
DoujinManager.RestAdapter.RestAdapterExtensions 100% 100%
Microsoft.Extensions.Validation.Generated 74.8% 81.8%
Microsoft.Extensions.Validation.Generated.<ValidatableInfoResolver_g>FB9B0C
E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr
ibuteCache
100% 87.5%
System.Runtime.CompilerServices 0%
DoujinManager.Server - 18%
Name Line Branch
DoujinManager.Server 18% 0%
DoujinManager.Server.ImageInfrastructureRegistration 100%
DoujinManager.Server.UseCaseRegistrationHelper 100%
Microsoft.AspNetCore.OpenApi.Generated 0% 0%
Program 0% 0%
System.Runtime.CompilerServices 0%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 06/28/2026 - 16:09:02 | | Coverage date: | 06/28/2026 - 16:08:54 - 06/28/2026 - 16:08:59 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 4 | | Classes: | 192 | | Files: | 82 | | **Line coverage:** | 82.5% (3679 of 4459) | | Covered lines: | 3679 | | Uncovered lines: | 780 | | Coverable lines: | 4459 | | Total lines: | 7519 | | **Branch coverage:** | 44.4% (291 of 654) | | Covered branches: | 291 | | Total branches: | 654 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>DoujinManager.ApplicationCore - 81.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.ApplicationCore**|**81.9%**|****| |DoujinManager.ApplicationCore.Entities.Chapter|87.5%|| |DoujinManager.ApplicationCore.Entities.Circle|100%|| |DoujinManager.ApplicationCore.Entities.Doujin|100%|| |DoujinManager.ApplicationCore.Entities.DoujinCircle|0%|| |DoujinManager.ApplicationCore.Entities.DoujinPerson|80%|| |DoujinManager.ApplicationCore.Entities.DoujinTag|75%|| |DoujinManager.ApplicationCore.Entities.ImageFile|100%|| |DoujinManager.ApplicationCore.Entities.Page|80%|| |DoujinManager.ApplicationCore.Entities.Person|100%|| |DoujinManager.ApplicationCore.Entities.Tag|100%|| |DoujinManager.ApplicationCore.Entities.Title|83.3%|| |DoujinManager.ApplicationCore.Entities.Variant|91.6%|| |DoujinManager.ApplicationCore.Ids.ChapterId|66.6%|| |DoujinManager.ApplicationCore.Ids.CircleId|66.6%|| |DoujinManager.ApplicationCore.Ids.DoujinId|100%|| |DoujinManager.ApplicationCore.Ids.ImageFileId|66.6%|| |DoujinManager.ApplicationCore.Ids.PageId|66.6%|| |DoujinManager.ApplicationCore.Ids.PersonId|66.6%|| |DoujinManager.ApplicationCore.Ids.TagId|66.6%|| |DoujinManager.ApplicationCore.Ids.TitleId|66.6%|| |DoujinManager.ApplicationCore.Ids.VariantId|66.6%|| |DoujinManager.ApplicationCore.Ports.ExtractedImage|100%|| |DoujinManager.ApplicationCore.Ports.ImageInspection|100%|| |DoujinManager.ApplicationCore.Services.ITagService|100%|| |DoujinManager.ApplicationCore.Services.ServiceResult|100%|| |DoujinManager.ApplicationCore.Services.ServiceResult`1|33.3%|| |DoujinManager.ApplicationCore.Services.VoidResult|88.8%|| |DoujinManager.ApplicationCore.UseCases.AddTitleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.AssignCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignPersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateChapterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreatePersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateTitleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateVariantCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteChapterCommand|0%|| |DoujinManager.ApplicationCore.UseCases.DeleteDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeletePageCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteVariantCommand|0%|| |DoujinManager.ApplicationCore.UseCases.GetDoujinQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetImageQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetImageResult|100%|| |DoujinManager.ApplicationCore.UseCases.GetThumbnailQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetThumbnailResult|100%|| |DoujinManager.ApplicationCore.UseCases.GetVariantQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListChaptersQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListCirclesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListDoujinsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListPagesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListPeopleQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListTagsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListVariantsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveCircleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemovePersonCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemoveTagCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemoveTitleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.ReorderPagesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdateChapterCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateCircleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdatePersonCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateVariantCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UploadImageFile|100%|| |DoujinManager.ApplicationCore.UseCases.UploadPagesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UploadZipPagesCommand|100%|| </details> <details><summary>DoujinManager.Infrastructure - 91.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.Infrastructure**|**91.3%**|**66%**| |DoujinManager.Infrastructure.Archives.ZipExtractor|100%|87.5%| |DoujinManager.Infrastructure.Data.Configurations.ChapterConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.CircleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinCircleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinPersonConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinTagConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.ImageFileConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.PageConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.PersonConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.TagConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.TitleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.VariantConfiguration|100%|| |DoujinManager.Infrastructure.Data.DoujinManagerDbContext|93.7%|| |DoujinManager.Infrastructure.Data.GuidIdGenerator|11.1%|| |DoujinManager.Infrastructure.Data.Migrations.DoujinManagerDbContextModelSna<br/>pshot|100%|| |DoujinManager.Infrastructure.Data.Migrations.InitialCreate|97.1%|| |DoujinManager.Infrastructure.Data.ModelBuilderExtensions|50%|| |DoujinManager.Infrastructure.Data.StronglyTypedIdConverterFactory|69.2%|| |DoujinManager.Infrastructure.Images.SkiaSharpImageInspector|88.2%|70.9%| |DoujinManager.Infrastructure.Images.SkiaSharpThumbnailGenerator|94.5%|66.6%| |DoujinManager.Infrastructure.Services.ChapterService|54.2%|25%| |DoujinManager.Infrastructure.Services.CircleService|66.6%|0%| |DoujinManager.Infrastructure.Services.DoujinService|66.6%|45.8%| |DoujinManager.Infrastructure.Services.ImageService|91%|50%| |DoujinManager.Infrastructure.Services.PageService|84.9%|65%| |DoujinManager.Infrastructure.Services.PersonService|69.6%|0%| |DoujinManager.Infrastructure.Services.TagService|94.7%|50%| |DoujinManager.Infrastructure.Services.VariantService|50.7%|16.6%| |DoujinManager.Infrastructure.Storage.FilesystemImageStorage|100%|100%| |DoujinManager.Infrastructure.Storage.FilesystemThumbnailStorage|95%|75%| |DoujinManager.Infrastructure.UseCases.AddTitleUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.AssignCircleUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignPersonUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateChapterUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateCircleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateDoujinUseCase|100%|98%| |DoujinManager.Infrastructure.UseCases.CreatePersonUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateVariantUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.DeleteChapterUseCase|0%|| |DoujinManager.Infrastructure.UseCases.DeleteDoujinUseCase|100%|| |DoujinManager.Infrastructure.UseCases.DeletePageUseCase|90.9%|75%| |DoujinManager.Infrastructure.UseCases.DeleteVariantUseCase|0%|| |DoujinManager.Infrastructure.UseCases.GetDoujinUseCase|100%|| |DoujinManager.Infrastructure.UseCases.GetImageUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetThumbnailUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetVariantUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListChaptersUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.ListCirclesUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListDoujinsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListPagesUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.ListPeopleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListTagsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListVariantsUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.RemoveCircleUseCase|0%|| |DoujinManager.Infrastructure.UseCases.RemovePersonUseCase|0%|| |DoujinManager.Infrastructure.UseCases.RemoveTagUseCase|0%|| |DoujinManager.Infrastructure.UseCases.RemoveTitleUseCase|0%|| |DoujinManager.Infrastructure.UseCases.ReorderPagesUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdateChapterUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateCircleUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateDoujinUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdatePersonUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateVariantUseCase|0%|| |DoujinManager.Infrastructure.UseCases.UploadPagesUseCase|92.8%|92.8%| |DoujinManager.Infrastructure.UseCases.UploadZipPagesUseCase|74.1%|62.5%| </details> <details><summary>DoujinManager.RestAdapter - 80.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.RestAdapter**|**80.2%**|**71.8%**| |DoujinManager.RestAdapter.Auth.StaticBearerTokenAuthMiddleware|100%|91.6%| |DoujinManager.RestAdapter.Dtos.AssignTagDto|0%|| |DoujinManager.RestAdapter.Dtos.ChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.CircleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateCircleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.CreatePersonDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateTagDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateTitleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateVariantDto|100%|| |DoujinManager.RestAdapter.Dtos.DoujinDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.DoujinPersonDto|100%|| |DoujinManager.RestAdapter.Dtos.DoujinSummaryDto|0%|| |DoujinManager.RestAdapter.Dtos.ImageFileSummaryDto|0%|| |DoujinManager.RestAdapter.Dtos.LinkCircleDto|0%|| |DoujinManager.RestAdapter.Dtos.LinkPersonDto|100%|| |DoujinManager.RestAdapter.Dtos.PageDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.PageDto|100%|| |DoujinManager.RestAdapter.Dtos.PersonDto|100%|| |DoujinManager.RestAdapter.Dtos.ReorderPagesDto|100%|| |DoujinManager.RestAdapter.Dtos.TagDto|100%|| |DoujinManager.RestAdapter.Dtos.TitleDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateChapterDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateCircleDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdatePersonDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateVariantDto|0%|| |DoujinManager.RestAdapter.Dtos.UploadPagesResponseDto|100%|| |DoujinManager.RestAdapter.Dtos.VariantDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.VariantSummaryDto|100%|| |DoujinManager.RestAdapter.Endpoints.DoujinEndpoints|78.5%|37.5%| |DoujinManager.RestAdapter.Endpoints.ImageEndpoints|96.6%|50%| |DoujinManager.RestAdapter.Endpoints.MetadataEndpoints|85.7%|| |DoujinManager.RestAdapter.Endpoints.PaginationParams|100%|| |DoujinManager.RestAdapter.Endpoints.VariantEndpoints|71.5%|37.5%| |DoujinManager.RestAdapter.Envelopes.CollectionResponse`1|83.3%|| |DoujinManager.RestAdapter.Envelopes.EnvelopeDefaults|100%|| |DoujinManager.RestAdapter.Envelopes.EnvelopeJsonOptions|100%|| |DoujinManager.RestAdapter.Envelopes.ErrorResponse|100%|| |DoujinManager.RestAdapter.Envelopes.HypermediaAction|100%|| |DoujinManager.RestAdapter.Envelopes.Link|100%|| |DoujinManager.RestAdapter.Envelopes.PageInfo|100%|| |DoujinManager.RestAdapter.Envelopes.ResourceResponse`1|80%|| |DoujinManager.RestAdapter.Envelopes.ValidationError|100%|| |DoujinManager.RestAdapter.Envelopes.ValidationErrorResponse|100%|| |DoujinManager.RestAdapter.Middleware.GlobalExceptionMiddleware|100%|50%| |DoujinManager.RestAdapter.RestAdapterExtensions|100%|100%| |Microsoft.Extensions.Validation.Generated|74.8%|81.8%| |Microsoft.Extensions.Validation.Generated.<ValidatableInfoResolver_g>FB9B0C<br/>E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr<br/>ibuteCache|100%|87.5%| |System.Runtime.CompilerServices|0%|| </details> <details><summary>DoujinManager.Server - 18%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.Server**|**18%**|**0%**| |DoujinManager.Server.ImageInfrastructureRegistration|100%|| |DoujinManager.Server.UseCaseRegistrationHelper|100%|| |Microsoft.AspNetCore.OpenApi.Generated|0%|0%| |Program|0%|0%| |System.Runtime.CompilerServices|0%|| </details>
Author
Member

🤖 Hermes automated review: changes requested

Reviewed base fe993d4 → head efa2205 (15 files, +1610/−7). Ran the test suites locally: RestAdapter.Tests 92/92 passed, Infrastructure.Tests 43/43 passed. Architecture is clean (endpoint → use case → service → EF Core), and the security-sensitive pieces are handled well — FilesystemImageStorage validates extensions with a regex and derives paths solely from server-generated UUIDs (no path traversal), ZipExtractor has decompression-bomb protection and never writes extracted entries to disk by name (content goes to MemoryStream, so zip-slip is impossible), and upload endpoints correctly dispose streams.

One finding is worth addressing before merge, plus a few minor notes.

Major

  • Orphaned image/thumbnail files on transaction rollbackbackend/src/DoujinManager.Infrastructure/UseCases/ImageUseCases.cs (UploadPagesUseCase.ExecuteAsync ~line 30, UploadZipPagesUseCase.ExecuteAsync ~line 62)
    Both upload use cases wrap the per-file loop in db.Database.BeginTransactionAsync(), but ImageService.CreateAsync (ImageService.cs ~line 19) writes the image and thumbnail to disk via imageStorage.SaveAsync / thumbnailStorage.SaveAsync and then calls SaveChangesAsync(ct) inside the loop. If a later file in the loop fails (e.g. a corrupt image throws in inspector.InspectAsync, or a DB constraint fires), the DB transaction rolls back the rows for earlier files — but their physical files on disk remain, now with no referencing DB row. This leaks storage on every partial-failure upload and leaves unreferenced files that no code path cleans up.
    Suggested fix: wrap the loop in try { ... } catch { /* delete already-created images */ ; await tx.RollbackAsync(ct); throw; }. On failure, iterate the pages/created images so far and call imageService.DeleteAsync(image.Id, ct) (which removes both files and the DB row), then rethrow. Alternatively, defer filesystem writes until after the transaction commits — but the compensating-delete approach is the smaller change.

Minor

  • No upper bound on multipart upload count/sizeImageEndpoints.cs POST /api/variants/{variantId}/pages (~line 44) iterates form.Files without a cap on file count or total bytes. ASP.NET's default RequestFormLimits apply, but an explicit cap (e.g. reject > N files or > M bytes total) would make the limit intentional and protect the thumbnail generator (Skia) from doing unbounded work. The ZIP path is protected by maxEntrySizeBytes per entry but has no cap on entry count.
  • SaveChangesAsync per image inside the transactionImageService.CreateAsync does one DB round-trip per file. For large uploads this is N round-trips. Not a correctness issue; consider batching if upload sizes grow.
  • DeletePageUseCase does not delete the associated ImageFilePageService.DeleteAsync (PageService.cs ~line 110) removes the page row and decrements PageCount but leaves the ImageFile entity and its filesystem files behind. This is safe if images may be shared across pages, but if the page→image relation is 1:1 (as the upload path implies), it leaks files over time. Worth a confirm-and-document or a cascade delete.
  • ReorderAsync silently ignores unknown/duplicate page IDsPageService.cs ~line 78: a pageId in the request that doesn't belong to the variant is dropped without error (the orderMap lookup simply never matches), and duplicate IDs in the input collapse via dict overwrite. If strict validation is desired, reject unknown IDs; otherwise document the lenient behavior.
  • ImageEndpoints.cs:44 multipart OpenReadStream() — streams are correctly disposed after the use case returns, but on the early-return 400 path (form.Files.Count == 0) no streams are opened, so that's fine. Just noting the disposal pattern is correct.

The pre-existing CS8602/CS8604 nullable warnings in DoujinEndpoints.cs and DoujinCrudIntegrationTests.cs are not introduced by this PR.

Automated daily review. I never merge PRs. Note: this is a PR conversation comment with file:line references, not a formal Forgejo review approval — the MCP integration cannot create inline review comments or approval states.

## 🤖 Hermes automated review: changes requested Reviewed base `fe993d4` → head `efa2205` (15 files, +1610/−7). Ran the test suites locally: **RestAdapter.Tests 92/92 passed**, **Infrastructure.Tests 43/43 passed**. Architecture is clean (endpoint → use case → service → EF Core), and the security-sensitive pieces are handled well — `FilesystemImageStorage` validates extensions with a regex and derives paths solely from server-generated UUIDs (no path traversal), `ZipExtractor` has decompression-bomb protection and never writes extracted entries to disk by name (content goes to `MemoryStream`, so zip-slip is impossible), and upload endpoints correctly dispose streams. One finding is worth addressing before merge, plus a few minor notes. ### Major - **Orphaned image/thumbnail files on transaction rollback** — `backend/src/DoujinManager.Infrastructure/UseCases/ImageUseCases.cs` (`UploadPagesUseCase.ExecuteAsync` ~line 30, `UploadZipPagesUseCase.ExecuteAsync` ~line 62) Both upload use cases wrap the per-file loop in `db.Database.BeginTransactionAsync()`, but `ImageService.CreateAsync` (`ImageService.cs` ~line 19) writes the image **and** thumbnail to disk via `imageStorage.SaveAsync` / `thumbnailStorage.SaveAsync` and then calls `SaveChangesAsync(ct)` *inside* the loop. If a later file in the loop fails (e.g. a corrupt image throws in `inspector.InspectAsync`, or a DB constraint fires), the DB transaction rolls back the rows for earlier files — but their physical files on disk remain, now with no referencing DB row. This leaks storage on every partial-failure upload and leaves unreferenced files that no code path cleans up. **Suggested fix:** wrap the loop in `try { ... } catch { /* delete already-created images */ ; await tx.RollbackAsync(ct); throw; }`. On failure, iterate the `pages`/created images so far and call `imageService.DeleteAsync(image.Id, ct)` (which removes both files and the DB row), then rethrow. Alternatively, defer filesystem writes until after the transaction commits — but the compensating-delete approach is the smaller change. ### Minor - **No upper bound on multipart upload count/size** — `ImageEndpoints.cs` POST `/api/variants/{variantId}/pages` (~line 44) iterates `form.Files` without a cap on file count or total bytes. ASP.NET's default `RequestFormLimits` apply, but an explicit cap (e.g. reject > N files or > M bytes total) would make the limit intentional and protect the thumbnail generator (Skia) from doing unbounded work. The ZIP path is protected by `maxEntrySizeBytes` per entry but has no cap on entry *count*. - **`SaveChangesAsync` per image inside the transaction** — `ImageService.CreateAsync` does one DB round-trip per file. For large uploads this is N round-trips. Not a correctness issue; consider batching if upload sizes grow. - **`DeletePageUseCase` does not delete the associated `ImageFile`** — `PageService.DeleteAsync` (`PageService.cs` ~line 110) removes the page row and decrements `PageCount` but leaves the `ImageFile` entity and its filesystem files behind. This is safe if images may be shared across pages, but if the page→image relation is 1:1 (as the upload path implies), it leaks files over time. Worth a confirm-and-document or a cascade delete. - **`ReorderAsync` silently ignores unknown/duplicate page IDs** — `PageService.cs` ~line 78: a `pageId` in the request that doesn't belong to the variant is dropped without error (the `orderMap` lookup simply never matches), and duplicate IDs in the input collapse via dict overwrite. If strict validation is desired, reject unknown IDs; otherwise document the lenient behavior. - **`ImageEndpoints.cs:44` multipart `OpenReadStream()`** — streams are correctly disposed after the use case returns, but on the early-return `400` path (`form.Files.Count == 0`) no streams are opened, so that's fine. Just noting the disposal pattern is correct. The pre-existing `CS8602`/`CS8604` nullable warnings in `DoujinEndpoints.cs` and `DoujinCrudIntegrationTests.cs` are not introduced by this PR. *Automated daily review. I never merge PRs. Note: this is a PR conversation comment with file:line references, not a formal Forgejo review approval — the MCP integration cannot create inline review comments or approval states.*
fix: orphaned files on rollback, upload caps, image cleanup, reorder validation
All checks were successful
CI / build (pull_request) Successful in 19s
CI / test (pull_request) Successful in 26s
e72b654894
Address automated review feedback on PR #7:

Major:
- Orphaned image/thumbnail files on transaction rollback: both upload
  use cases now wrap the loop in try/catch. On failure, rolls back the
  DB transaction AND performs compensating deletes on already-created
  image files (best-effort) before rethrowing.

Minor:
- Upload count caps: multipart limited to 100 files, ZIP limited to 500
  entries. Rejects with BadRequest/InvalidOperationException if exceeded.
- DeletePageUseCase now deletes the associated ImageFile (files + DB row)
  after removing the page, preventing storage leaks over time.
- PageService.ReorderAsync validates page IDs: rejects unknown IDs and
  duplicate IDs with ArgumentException instead of silently ignoring.

Updated DeletePageUseCase unit tests to mock IImageService.

149 tests pass, 0 errors.
Author
Member

Addressed all review findings in commit e72b654. 149 tests pass.

Major — Orphaned files on transaction rollback

Both UploadPagesUseCase and UploadZipPagesUseCase now wrap the file loop in try/catch. On failure:

  1. DB transaction is rolled back
  2. Compensating deletes run on all already-created image files (best-effort, catches and ignores errors)
  3. Original exception is rethrown

This prevents orphaned image/thumbnail files on disk when a later file in the upload fails.

Minor fixes

Upload count caps — Multipart upload limited to 100 files per request. ZIP upload limited to 500 entries. Both reject with clear error messages.

DeletePageUseCase cleans up images — Now injects IImageService and deletes the associated ImageFile (both filesystem files and DB row) after removing the page. Prevents storage leaks over time.

ReorderAsync validates page IDs — Now rejects:

  • Page IDs that don't belong to the variant (ArgumentException with the unknown IDs listed)
  • Duplicate page IDs in the input (ArgumentException)

Previously these were silently ignored.

Not addressed (deferred)

  • SaveChangesAsync per image — N round-trips for large uploads. Not a correctness issue; batching is a future optimization.
  • Explicit RequestFormLimits — ASP.NET's defaults apply; an explicit cap would be a deployment-config concern.
Addressed all review findings in commit `e72b654`. 149 tests pass. ### Major — Orphaned files on transaction rollback ✅ Both `UploadPagesUseCase` and `UploadZipPagesUseCase` now wrap the file loop in `try/catch`. On failure: 1. DB transaction is rolled back 2. Compensating deletes run on all already-created image files (best-effort, catches and ignores errors) 3. Original exception is rethrown This prevents orphaned image/thumbnail files on disk when a later file in the upload fails. ### Minor fixes **Upload count caps** — Multipart upload limited to 100 files per request. ZIP upload limited to 500 entries. Both reject with clear error messages. **DeletePageUseCase cleans up images** — Now injects `IImageService` and deletes the associated `ImageFile` (both filesystem files and DB row) after removing the page. Prevents storage leaks over time. **ReorderAsync validates page IDs** — Now rejects: - Page IDs that don't belong to the variant (`ArgumentException` with the unknown IDs listed) - Duplicate page IDs in the input (`ArgumentException`) Previously these were silently ignored. ### Not addressed (deferred) - **SaveChangesAsync per image** — N round-trips for large uploads. Not a correctness issue; batching is a future optimization. - **Explicit RequestFormLimits** — ASP.NET's defaults apply; an explicit cap would be a deployment-config concern.
Author
Member

🤖 Hermes automated review: changes requested

Re-reviewed the fix commit e72b654 (base fe993d4, delta from previous head efa2205). CI/CD: CI / build (19s) and CI / test (26s) both passed for head e72b654 — local build/test skipped per CI policy.

The minor items from the previous review are all properly addressed:

  • Upload count caps (100 multipart / 500 ZIP entries)
  • DeletePageUseCase now cleans up the associated ImageFile
  • ReorderAsync now validates unknown/duplicate page IDs

However, the major finding — orphaned files on rollback — is not actually fixed by the compensating-delete approach. Here is why:

Major: Compensating delete is ineffective after rollback

backend/src/DoujinManager.Infrastructure/Services/ImageService.cs:79-82:

public async Task<bool> DeleteAsync(ImageFileId id, CancellationToken ct = default)
{
    var image = await db.ImageFiles.FirstOrDefaultAsync(i => i.Id == id, ct);
    if (image is null) return false;   // ← exits here after rollback
    // ... file deletion needs image.Extension ...

The compensating-delete path in ImageUseCases.cs calls imageService.DeleteAsync(imageId) after await tx.RollbackAsync(ct). The problem:

  1. During the loop, ImageService.CreateAsync wrote each ImageFile row via SaveChangesAsync inside the transaction.
  2. On failure, tx.RollbackAsync() removes those rows from the database.
  3. The compensating DeleteAsync then runs FirstOrDefaultAsync(i => i.Id == id) — this is a LINQ query that always hits the database (unlike Find(), which checks the change tracker first).
  4. The DB no longer has the row → query returns nullDeleteAsync returns falsethe physical image/thumbnail files on disk are never deleted.

The createdImages list stores only ImageFileId values — it does not retain the Extension needed to construct the storage path. So even if the delete method were restructured, the extension information is lost once the DB row is gone.

This means the original orphaned-files bug is still present. The physical files written by imageStorage.SaveAsync / thumbnailStorage.SaveAsync remain on disk after a partial-failure upload, with no referencing DB row.

Suggested fix (pick one):

  1. Track full entities — change createdImages from List<ImageFileId> to List<ImageFile>, and add an IImageService method like DeleteFilesAsync(ImageFile image, ct) that deletes physical files directly using image.Extension without querying the DB:

    // In IImageService / ImageService:
    public async Task DeleteFilesAsync(ImageFile image, CancellationToken ct)
    {
        try { await imageStorage.DeleteAsync(image.Id, image.Extension, ct); } catch (FileNotFoundException) { }
        try { await thumbnailStorage.DeleteAsync(image.Id, ct); } catch (FileNotFoundException) { }
    }
    

    Then in the catch block: foreach (var img in createdImages) { try { await imageService.DeleteFilesAsync(img, ct); } catch { } }

  2. Defer filesystem writes — buffer image bytes in memory, commit the DB transaction first, then write files to disk only after CommitAsync succeeds. This is cleaner but uses more memory for large uploads.

  3. Verify with a test — if you believe the change tracker returns the entity after rollback, add an integration test that forces a mid-loop failure (e.g., inject a corrupt image as the 2nd file in a 2-file upload) and assert the first file's image/thumbnail are physically deleted from disk. The current test suite uses NSubstitute mocks for IImageService, so the real DB-after-rollback behavior is untested.

The cleanest approach is option 1 — it's a small change and makes the compensation independent of DB state.


Automated daily review. I never merge PRs. Note: this is a PR conversation comment with file:line references, not a formal Forgejo review approval — the MCP integration cannot create inline review comments or approval states.

## 🤖 Hermes automated review: changes requested Re-reviewed the fix commit `e72b654` (base `fe993d4`, delta from previous head `efa2205`). CI/CD: **CI / build** ✅ (19s) and **CI / test** ✅ (26s) both passed for head `e72b654` — local build/test skipped per CI policy. The minor items from the previous review are all properly addressed: - ✅ Upload count caps (100 multipart / 500 ZIP entries) - ✅ `DeletePageUseCase` now cleans up the associated `ImageFile` - ✅ `ReorderAsync` now validates unknown/duplicate page IDs However, the **major finding — orphaned files on rollback — is not actually fixed** by the compensating-delete approach. Here is why: ### Major: Compensating delete is ineffective after rollback `backend/src/DoujinManager.Infrastructure/Services/ImageService.cs:79-82`: ```csharp public async Task<bool> DeleteAsync(ImageFileId id, CancellationToken ct = default) { var image = await db.ImageFiles.FirstOrDefaultAsync(i => i.Id == id, ct); if (image is null) return false; // ← exits here after rollback // ... file deletion needs image.Extension ... ``` The compensating-delete path in `ImageUseCases.cs` calls `imageService.DeleteAsync(imageId)` **after** `await tx.RollbackAsync(ct)`. The problem: 1. During the loop, `ImageService.CreateAsync` wrote each `ImageFile` row via `SaveChangesAsync` *inside* the transaction. 2. On failure, `tx.RollbackAsync()` removes those rows from the database. 3. The compensating `DeleteAsync` then runs `FirstOrDefaultAsync(i => i.Id == id)` — this is a LINQ query that **always hits the database** (unlike `Find()`, which checks the change tracker first). 4. The DB no longer has the row → query returns `null` → `DeleteAsync` returns `false` → **the physical image/thumbnail files on disk are never deleted**. The `createdImages` list stores only `ImageFileId` values — it does not retain the `Extension` needed to construct the storage path. So even if the delete method were restructured, the extension information is lost once the DB row is gone. **This means the original orphaned-files bug is still present.** The physical files written by `imageStorage.SaveAsync` / `thumbnailStorage.SaveAsync` remain on disk after a partial-failure upload, with no referencing DB row. **Suggested fix (pick one):** 1. **Track full entities** — change `createdImages` from `List<ImageFileId>` to `List<ImageFile>`, and add an `IImageService` method like `DeleteFilesAsync(ImageFile image, ct)` that deletes physical files directly using `image.Extension` without querying the DB: ```csharp // In IImageService / ImageService: public async Task DeleteFilesAsync(ImageFile image, CancellationToken ct) { try { await imageStorage.DeleteAsync(image.Id, image.Extension, ct); } catch (FileNotFoundException) { } try { await thumbnailStorage.DeleteAsync(image.Id, ct); } catch (FileNotFoundException) { } } ``` Then in the catch block: `foreach (var img in createdImages) { try { await imageService.DeleteFilesAsync(img, ct); } catch { } }` 2. **Defer filesystem writes** — buffer image bytes in memory, commit the DB transaction first, then write files to disk only after `CommitAsync` succeeds. This is cleaner but uses more memory for large uploads. 3. **Verify with a test** — if you believe the change tracker returns the entity after rollback, add an integration test that forces a mid-loop failure (e.g., inject a corrupt image as the 2nd file in a 2-file upload) and assert the first file's image/thumbnail are physically deleted from disk. The current test suite uses NSubstitute mocks for `IImageService`, so the real DB-after-rollback behavior is untested. The cleanest approach is option 1 — it's a small change and makes the compensation independent of DB state. --- *Automated daily review. I never merge PRs. Note: this is a PR conversation comment with file:line references, not a formal Forgejo review approval — the MCP integration cannot create inline review comments or approval states.*
fix: compensating delete now actually works after rollback
All checks were successful
CI / build (pull_request) Successful in 16s
CI / test (pull_request) Successful in 27s
cd85bdedfe
The previous compensating-delete approach called ImageService.DeleteAsync
which queries the DB for the ImageFile row — but after RollbackAsync,
those rows are gone, so DeleteAsync returned false and the physical files
were never cleaned up.

Fix:
- Track full ImageFile entities (not just IDs) in createdImages list
- Add IImageService.DeleteFilesAsync(ImageFile) that deletes physical
  files directly using image.Extension WITHOUT querying the DB
- Catch blocks now call DeleteFilesAsync instead of DeleteAsync

This makes the compensating delete independent of DB state, which is
exactly what's needed after a transaction rollback.
Author
Member

Fixed the compensating delete in commit cd85bde. The reviewer was exactly right — DeleteAsync queries the DB, which is useless after rollback.

What changed:

  1. IImageService.DeleteFilesAsync(ImageFile image, ct) — new method that deletes physical files (image + thumbnail) directly using image.Id and image.Extension, without any DB query. Independent of transaction state.

  2. createdImages changed from List<ImageFileId> to List<ImageFile> — retains the full entity including Extension, so the catch block has everything it needs to delete files.

  3. Catch blocks now call DeleteFilesAsync instead of DeleteAsync — works correctly after RollbackAsync because it never touches the DB.

The compensating delete is now truly independent of DB state. 149 tests pass.

Fixed the compensating delete in commit `cd85bde`. The reviewer was exactly right — `DeleteAsync` queries the DB, which is useless after rollback. **What changed:** 1. **`IImageService.DeleteFilesAsync(ImageFile image, ct)`** — new method that deletes physical files (image + thumbnail) directly using `image.Id` and `image.Extension`, **without any DB query**. Independent of transaction state. 2. **`createdImages` changed from `List<ImageFileId>` to `List<ImageFile>`** — retains the full entity including `Extension`, so the catch block has everything it needs to delete files. 3. **Catch blocks now call `DeleteFilesAsync` instead of `DeleteAsync`** — works correctly after `RollbackAsync` because it never touches the DB. The compensating delete is now truly independent of DB state. 149 tests pass.
test: integration test for compensating delete on rollback
All checks were successful
CI / build (pull_request) Successful in 21s
CI / test (pull_request) Successful in 27s
623bc41b45
Upload_WithCorruptSecondFile_RollsBackAndCleansUpFiles:
- Uploads 1 valid JPEG + 1 corrupt file (not an image)
- Asserts response is 500 (corrupt image throws in SkiaSharp)
- Asserts NO image files remain on disk (compensating delete worked)
- Asserts NO thumbnail files remain on disk
- Asserts no pages were created (DB transaction rolled back)
- Asserts variant PageCount is still 0

This proves the DeleteFilesAsync compensating delete actually works
after transaction rollback — files are physically removed from disk.
Author
Member

Added integration test in commit 623bc41. 150 tests pass.

Upload_WithCorruptSecondFile_RollsBackAndCleansUpFiles — proves the compensating delete actually works:

  1. Uploads 1 valid JPEG + 1 corrupt file (raw text bytes with image/jpeg content type)
  2. Asserts response is 500 (corrupt image throws in SkiaSharp during inspection)
  3. Asserts zero image files on diskDirectory.GetFiles(_imageDir, "*", SearchOption.AllDirectories) is empty
  4. Asserts zero thumbnail files on disk — same check on _thumbnailDir
  5. Asserts no pages created — lists pages for the variant, collection is empty (DB transaction rolled back)
  6. Asserts variant PageCount is 0 — confirms the page creation was never committed

This test would have failed with the previous DeleteAsync approach (files would remain on disk) and passes with the DeleteFilesAsync fix.

Added integration test in commit `623bc41`. 150 tests pass. **`Upload_WithCorruptSecondFile_RollsBackAndCleansUpFiles`** — proves the compensating delete actually works: 1. Uploads 1 valid JPEG + 1 corrupt file (raw text bytes with `image/jpeg` content type) 2. Asserts response is `500` (corrupt image throws in SkiaSharp during inspection) 3. Asserts **zero image files on disk** — `Directory.GetFiles(_imageDir, "*", SearchOption.AllDirectories)` is empty 4. Asserts **zero thumbnail files on disk** — same check on `_thumbnailDir` 5. Asserts **no pages created** — lists pages for the variant, collection is empty (DB transaction rolled back) 6. Asserts **variant PageCount is 0** — confirms the page creation was never committed This test would have failed with the previous `DeleteAsync` approach (files would remain on disk) and passes with the `DeleteFilesAsync` fix.
Author
Member

🤖 Hermes automated review: no blocking issues found

Re-reviewed the new commits since the last review (e72b654623bc41, delta +64/−10 across 4 files). CI/CD: CI / build (21s) and CI / test (27s) both passed for head 623bc41 — local build/test skipped per CI policy.

The major finding from the previous review — compensating delete was ineffective after rollback — is now correctly fixed:

Major: Compensating delete now works after rollback

Commit cd85bde implements exactly the approach suggested in the previous review:

  1. createdImages changed from List<ImageFileId>List<ImageFile> — retains the full entity including Extension, which is needed to construct the storage path. ImageUseCases.cs:39, 97
  2. New IImageService.DeleteFilesAsync(ImageFile image, ct) — deletes physical files (image + thumbnail) directly using image.Id and image.Extension, with no DB query. Confirmed independent of transaction state: FilesystemImageStorage.DeleteAsync(id, extension) and FilesystemThumbnailStorage.DeleteAsync(id) are pure-filesystem operations. ImageService.cs:95-101
  3. Both FileNotFoundException paths swallowed — makes the cleanup idempotent and safe even if a file was already gone. Correct.
  4. Applied consistently to both UploadPagesUseCase and UploadZipPagesUseCase — the catch blocks now call DeleteFilesAsync(image, ct) instead of the DB-dependent DeleteAsync(imageId, ct). ImageUseCases.cs:59-65, 123-129

This resolves the root cause: the previous DeleteAsync ran FirstOrDefaultAsync against a DB whose rows had just been rolled back, so it returned null and never deleted the physical files. DeleteFilesAsync bypasses the DB entirely.

Integration test proves the fix

Commit 623bc41 adds Upload_WithCorruptSecondFile_RollsBackAndCleansUpFiles:

  • Uploads 1 valid JPEG + 1 corrupt file (text bytes with image/jpeg content-type) → corrupt image throws in SkiaSharp during inspection → triggers the rollback path
  • Asserts zero files in both _imageDir and _thumbnailDir (uses SearchOption.AllDirectories)
  • Asserts no pages created and PageCount == 0 (confirms DB rollback)

The test fixture (ImageEndpointsIntegrationTests.cs:25-64) wires real temp directories into a WebApplicationFactory via ImageInfrastructureRegistration.Register, so this exercises the real storage layer — not a mock. This test would have failed against the previous DeleteAsync implementation (files would have remained on disk). Good regression coverage.

Minor / non-blocking

  • The catch { /* best-effort cleanup */ } around DeleteFilesAsync swallows all exceptions, not just file-related ones. If imageStorage or thumbnailStorage ever throws something unexpected (e.g. a permissions error worth surfacing), it will be silently dropped. Acceptable for a compensating path, but a debug-level log there would aid future diagnosis.
  • The orphaned-files concern is now well-handled for the upload path. The SaveChangesAsync-per-image observation from the first review remains a non-correctness performance note (deferred as intended).

Verdict: The fix is precise, correctly addresses the rollback-stale-files bug, and is backed by a real integration test. No blocking issues.


Automated daily review. I never merge PRs. Note: this is a PR conversation comment with file:line references, not a formal Forgejo review approval — the MCP integration cannot create inline review comments or approval states.

## 🤖 Hermes automated review: no blocking issues found Re-reviewed the new commits since the last review (`e72b654` → `623bc41`, delta +64/−10 across 4 files). CI/CD: **CI / build** ✅ (21s) and **CI / test** ✅ (27s) both passed for head `623bc41` — local build/test skipped per CI policy. The major finding from the previous review — **compensating delete was ineffective after rollback** — is now **correctly fixed**: ### ✅ Major: Compensating delete now works after rollback Commit `cd85bde` implements exactly the approach suggested in the previous review: 1. **`createdImages` changed from `List<ImageFileId>` → `List<ImageFile>`** — retains the full entity including `Extension`, which is needed to construct the storage path. `ImageUseCases.cs:39, 97` 2. **New `IImageService.DeleteFilesAsync(ImageFile image, ct)`** — deletes physical files (image + thumbnail) directly using `image.Id` and `image.Extension`, **with no DB query**. Confirmed independent of transaction state: `FilesystemImageStorage.DeleteAsync(id, extension)` and `FilesystemThumbnailStorage.DeleteAsync(id)` are pure-filesystem operations. `ImageService.cs:95-101` 3. **Both `FileNotFoundException` paths swallowed** — makes the cleanup idempotent and safe even if a file was already gone. Correct. 4. **Applied consistently to both `UploadPagesUseCase` and `UploadZipPagesUseCase`** — the catch blocks now call `DeleteFilesAsync(image, ct)` instead of the DB-dependent `DeleteAsync(imageId, ct)`. `ImageUseCases.cs:59-65, 123-129` This resolves the root cause: the previous `DeleteAsync` ran `FirstOrDefaultAsync` against a DB whose rows had just been rolled back, so it returned `null` and never deleted the physical files. `DeleteFilesAsync` bypasses the DB entirely. ### ✅ Integration test proves the fix Commit `623bc41` adds `Upload_WithCorruptSecondFile_RollsBackAndCleansUpFiles`: - Uploads 1 valid JPEG + 1 corrupt file (text bytes with `image/jpeg` content-type) → corrupt image throws in SkiaSharp during inspection → triggers the rollback path - Asserts **zero files** in both `_imageDir` and `_thumbnailDir` (uses `SearchOption.AllDirectories`) - Asserts **no pages created** and **`PageCount == 0`** (confirms DB rollback) The test fixture (`ImageEndpointsIntegrationTests.cs:25-64`) wires real temp directories into a `WebApplicationFactory` via `ImageInfrastructureRegistration.Register`, so this exercises the real storage layer — not a mock. This test would have failed against the previous `DeleteAsync` implementation (files would have remained on disk). Good regression coverage. ### Minor / non-blocking - The `catch { /* best-effort cleanup */ }` around `DeleteFilesAsync` swallows all exceptions, not just file-related ones. If `imageStorage` or `thumbnailStorage` ever throws something unexpected (e.g. a permissions error worth surfacing), it will be silently dropped. Acceptable for a compensating path, but a debug-level log there would aid future diagnosis. - The orphaned-files concern is now well-handled for the upload path. The `SaveChangesAsync`-per-image observation from the first review remains a non-correctness performance note (deferred as intended). **Verdict:** The fix is precise, correctly addresses the rollback-stale-files bug, and is backed by a real integration test. No blocking issues. --- *Automated daily review. I never merge PRs. Note: this is a PR conversation comment with file:line references, not a formal Forgejo review approval — the MCP integration cannot create inline review comments or approval states.*
bjoern merged commit 67e37fdaba into main 2026-06-28 18:21:37 +02:00
bjoern deleted branch feat/image-upload-and-serving 2026-06-28 18:21:37 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/doujin-manager!7
No description provided.