feat: image dedup on upload and duplicate lookup endpoints #73

Merged
bjoern merged 2 commits from feat/duplicate-detection into main 2026-08-15 19:18:32 +02:00
Member

Puts the stored SHA-256 of image content to work on the backend.

What changed

  • Sha256 index: new AddImageFileSha256Index migration indexes image_files.sha256 for the queries below.
  • Dedup on upload: ImageService.CreateAsync reuses an existing ImageFile when identical bytes (same SHA-256 and length) are already stored, skipping the duplicate copy and thumbnail. Transparent to the upload API.
  • Safe deletion of shared images: DeletePageUseCase now deletes a page's image only when no surviving page still references it (via ImageCleanupHelper, same pattern as the doujin/variant delete flows), and the compensating rollback cleanup in the upload use cases no longer deletes files of a reused pre-existing image.
  • GET /api/images?sha256=<hex>: image metadata plus every page/variant/doujin referencing content with that hash.
  • GET /api/images/duplicates: paginated groups of image content shared by pages of more than one distinct doujin (set-based grouped query) — groundwork for a "possible duplicate doujins" UI.

Verified with dotnet build and dotnet test (includes new DuplicateDetectionIntegrationTests and extended ImageUseCaseTests).

Follow-ups

Two nice-to-have items from review, deferred because they are not trivial one-liners:

  • Race window in dedup check-then-insert (ImageService.cs): the sha256 index is non-unique, so two concurrent uploads of identical bytes can both miss the existing-row check and create two ImageFile rows (missed dedup), and a concurrent delete of the last referencing page can remove a reused image between the dedup check and the uploading transaction's commit. Under SQLite's single-writer serialization the window is tiny and the worst outcome is a missed dedup or 404-on-serve; a filtered unique index or re-checking inside the write transaction would close it.
  • Cover-only references not reported by sha256 lookup (FindImageBySha256UseCase): references are collected via pages only (pages → variants → doujins). A doujin referencing the image solely through cover_image_id is not listed, even though deleting the image would clear that cover. Either include cover-only doujin references or document that references are page-based only.

🤖 Generated with Claude Code

Puts the stored SHA-256 of image content to work on the backend. ## What changed - **Sha256 index**: new `AddImageFileSha256Index` migration indexes `image_files.sha256` for the queries below. - **Dedup on upload**: `ImageService.CreateAsync` reuses an existing `ImageFile` when identical bytes (same SHA-256 and length) are already stored, skipping the duplicate copy and thumbnail. Transparent to the upload API. - **Safe deletion of shared images**: `DeletePageUseCase` now deletes a page's image only when no surviving page still references it (via `ImageCleanupHelper`, same pattern as the doujin/variant delete flows), and the compensating rollback cleanup in the upload use cases no longer deletes files of a reused pre-existing image. - **`GET /api/images?sha256=<hex>`**: image metadata plus every page/variant/doujin referencing content with that hash. - **`GET /api/images/duplicates`**: paginated groups of image content shared by pages of more than one distinct doujin (set-based grouped query) — groundwork for a "possible duplicate doujins" UI. Verified with `dotnet build` and `dotnet test` (includes new `DuplicateDetectionIntegrationTests` and extended `ImageUseCaseTests`). ## Follow-ups Two nice-to-have items from review, deferred because they are not trivial one-liners: - **Race window in dedup check-then-insert** (`ImageService.cs`): the sha256 index is non-unique, so two concurrent uploads of identical bytes can both miss the existing-row check and create two `ImageFile` rows (missed dedup), and a concurrent delete of the last referencing page can remove a reused image between the dedup check and the uploading transaction's commit. Under SQLite's single-writer serialization the window is tiny and the worst outcome is a missed dedup or 404-on-serve; a filtered unique index or re-checking inside the write transaction would close it. - **Cover-only references not reported by sha256 lookup** (`FindImageBySha256UseCase`): references are collected via pages only (pages → variants → doujins). A doujin referencing the image solely through `cover_image_id` is not listed, even though deleting the image would clear that cover. Either include cover-only doujin references or document that references are page-based only. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: content-addressed image dedup and duplicate detection endpoints
All checks were successful
CI / build (pull_request) Successful in 18s
CI / test (pull_request) Successful in 1m3s
34744484c1
Put the stored SHA-256 to work on the backend:

- Index the image_files sha256 column (new AddImageFileSha256Index
  migration).
- Dedup on upload: ImageService.CreateAsync reuses an existing ImageFile
  when identical bytes (same SHA-256 and length) are already stored,
  skipping the second copy and thumbnail. Transparent to the upload API.
- Guard every image deletion path for shared images: DeletePageUseCase
  now deletes the page's image only when no surviving page references it
  (via ImageCleanupHelper, like the doujin/variant delete flows), and the
  compensating rollback cleanup in the upload use cases no longer deletes
  the files of a reused pre-existing image.
- GET /api/images?sha256=<hex>: image metadata plus every page/variant/
  doujin referencing content with that hash.
- GET /api/images/duplicates: paginated groups of image content shared by
  pages of more than one distinct doujin (set-based grouped query), as
  groundwork for a "possible duplicate doujins" UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Summary

Summary
Generated on: 08/15/2026 - 15:10:52
Coverage date: 08/15/2026 - 15:10:11 - 08/15/2026 - 15:10:49
Parser: MultiReport (4x Cobertura)
Assemblies: 4
Classes: 323
Files: 128
Line coverage: 90.6% (11854 of 13081)
Covered lines: 11854
Uncovered lines: 1227
Coverable lines: 13081
Total lines: 18270
Branch coverage: 64.4% (1007 of 1562)
Covered branches: 1007
Total branches: 1562
Method coverage: Feature is only available for sponsors

Coverage

DoujinManager.ApplicationCore - 86.3%
Name Line Branch
DoujinManager.ApplicationCore 86.3% ****
DoujinManager.ApplicationCore.Entities.Chapter 87.5%
DoujinManager.ApplicationCore.Entities.Character 100%
DoujinManager.ApplicationCore.Entities.Circle 100%
DoujinManager.ApplicationCore.Entities.Doujin 100%
DoujinManager.ApplicationCore.Entities.DoujinCharacter 75%
DoujinManager.ApplicationCore.Entities.DoujinCircle 75%
DoujinManager.ApplicationCore.Entities.DoujinPerson 80%
DoujinManager.ApplicationCore.Entities.DoujinSeries 75%
DoujinManager.ApplicationCore.Entities.DoujinTag 75%
DoujinManager.ApplicationCore.Entities.ImageFile 100%
DoujinManager.ApplicationCore.Entities.Page 80%
DoujinManager.ApplicationCore.Entities.Person 100%
DoujinManager.ApplicationCore.Entities.Series 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.CharacterId 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.SeriesId 66.6%
DoujinManager.ApplicationCore.Ids.TagId 66.6%
DoujinManager.ApplicationCore.Ids.TitleId 66.6%
DoujinManager.ApplicationCore.Ids.VariantId 66.6%
DoujinManager.ApplicationCore.Ports.CompressedImage 100%
DoujinManager.ApplicationCore.Ports.ExtractedImage 100%
DoujinManager.ApplicationCore.Ports.ImageInspection 100%
DoujinManager.ApplicationCore.Services.BackupInfo 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.AddCharacterAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddCircleAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddPersonAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddSeriesAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddTagAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddTitleCommand 0%
DoujinManager.ApplicationCore.UseCases.AssignCharacterCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignPersonCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignSeriesCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignTagCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateChapterCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateCharacterCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.CreatePersonCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateSeriesCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateTagCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateTitleCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateVariantCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteChapterCommand 0%
DoujinManager.ApplicationCore.UseCases.DeleteCharacterCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.DeletePageCommand 100%
DoujinManager.ApplicationCore.UseCases.DeletePersonCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteSeriesCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteTagCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteVariantCommand 100%
DoujinManager.ApplicationCore.UseCases.DuplicateDoujinReference 100%
DoujinManager.ApplicationCore.UseCases.DuplicateImageGroup 100%
DoujinManager.ApplicationCore.UseCases.FindImageBySha256Query 100%
DoujinManager.ApplicationCore.UseCases.FindImageBySha256Result 100%
DoujinManager.ApplicationCore.UseCases.GetCompressedImageQuery 100%
DoujinManager.ApplicationCore.UseCases.GetCompressedImageResult 100%
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.ImageReference 100%
DoujinManager.ApplicationCore.UseCases.ListChaptersQuery 100%
DoujinManager.ApplicationCore.UseCases.ListCharactersQuery 100%
DoujinManager.ApplicationCore.UseCases.ListCirclesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListDoujinsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListDuplicateImagesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListPagesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListPeopleQuery 100%
DoujinManager.ApplicationCore.UseCases.ListSeriesQuery 0%
DoujinManager.ApplicationCore.UseCases.ListTagsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListVariantsQuery 100%
DoujinManager.ApplicationCore.UseCases.RemoveCharacterAliasCommand 0%
DoujinManager.ApplicationCore.UseCases.RemoveCharacterCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveCircleAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveCircleCommand 0%
DoujinManager.ApplicationCore.UseCases.RemovePersonAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.RemovePersonCommand 0%
DoujinManager.ApplicationCore.UseCases.RemoveSeriesAliasCommand 0%
DoujinManager.ApplicationCore.UseCases.RemoveSeriesCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveTagAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveTagCommand 0%
DoujinManager.ApplicationCore.UseCases.RemoveTitleCommand 0%
DoujinManager.ApplicationCore.UseCases.ReorderPagesCommand 100%
DoujinManager.ApplicationCore.UseCases.SearchDoujinsQuery 100%
DoujinManager.ApplicationCore.UseCases.SearchResult 100%
DoujinManager.ApplicationCore.UseCases.SearchResults 100%
DoujinManager.ApplicationCore.UseCases.UpdateChapterCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateCharacterCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateCircleCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdatePageChapterCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdatePersonCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateSeriesCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateTagCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdateTitleCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdateVariantCommand 0%
DoujinManager.ApplicationCore.UseCases.UploadImageFile 100%
DoujinManager.ApplicationCore.UseCases.UploadPagesCommand 100%
DoujinManager.ApplicationCore.UseCases.UploadZipPagesCommand 100%
DoujinManager.Infrastructure - 95%
Name Line Branch
DoujinManager.Infrastructure 95% 77%
DoujinManager.Infrastructure.Archives.NaturalStringComparer 100% 90%
DoujinManager.Infrastructure.Archives.ZipExtractor 100% 87.5%
DoujinManager.Infrastructure.Data.Configurations.ChapterConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.CharacterConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.CircleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinCharacterConfigurati
on
100%
DoujinManager.Infrastructure.Data.Configurations.DoujinCircleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinPersonConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinSeriesConfiguration 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.SeriesConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.TagConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.TitleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.VariantConfiguration 100%
DoujinManager.Infrastructure.Data.DoujinManagerDbContext 100%
DoujinManager.Infrastructure.Data.GuidIdGenerator 11.1%
DoujinManager.Infrastructure.Data.Migrations.AddAliases 98.7%
DoujinManager.Infrastructure.Data.Migrations.AddCoverImageId 99.2%
DoujinManager.Infrastructure.Data.Migrations.AddDoujinCreatedAtIndex 99.4%
DoujinManager.Infrastructure.Data.Migrations.AddImageFileSha256Index 99.4%
DoujinManager.Infrastructure.Data.Migrations.AddTagAliases 99.3%
DoujinManager.Infrastructure.Data.Migrations.DoujinManagerDbContextModelSna
pshot
100%
DoujinManager.Infrastructure.Data.Migrations.HarmonizeMetadataAddCharacters
Series
95.1%
DoujinManager.Infrastructure.Data.Migrations.InitialCreate 97.1%
DoujinManager.Infrastructure.Data.Migrations.RemovePreferredDisplayLanguage 99%
DoujinManager.Infrastructure.Data.ModelBuilderExtensions 50%
DoujinManager.Infrastructure.Data.StronglyTypedIdConverterFactory 100%
DoujinManager.Infrastructure.Images.SkiaSharpImageInspector 88.2% 70.9%
DoujinManager.Infrastructure.Images.SkiaSharpImageResizer 99% 87.5%
DoujinManager.Infrastructure.Images.SkiaSharpThumbnailGenerator 94.5% 66.6%
DoujinManager.Infrastructure.Services.BackupService 85.1% 66.6%
DoujinManager.Infrastructure.Services.ChapterService 57.1% 33.3%
DoujinManager.Infrastructure.Services.CharacterService 61.6% 22.7%
DoujinManager.Infrastructure.Services.CircleService 84.9% 45.4%
DoujinManager.Infrastructure.Services.DoujinService 77.6% 60%
DoujinManager.Infrastructure.Services.ImageCleanupHelper 100% 100%
DoujinManager.Infrastructure.Services.ImageService 92.9% 70%
DoujinManager.Infrastructure.Services.PageService 94.5% 70%
DoujinManager.Infrastructure.Services.PersonService 84.9% 50%
DoujinManager.Infrastructure.Services.SearchService 100% 99%
DoujinManager.Infrastructure.Services.SeriesService 47.9% 9%
DoujinManager.Infrastructure.Services.TagService 98.8% 90.9%
DoujinManager.Infrastructure.Services.VariantService 64.4% 20%
DoujinManager.Infrastructure.Storage.FilesystemImageStorage 100% 100%
DoujinManager.Infrastructure.Storage.FilesystemThumbnailStorage 95% 50%
DoujinManager.Infrastructure.UseCases 87.6% 84.7%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__CoverImageHelper
100% 100%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__PrimaryTitleHelper
100% 50%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__RolledBackImageCleanup
92.8% 50%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__UploadChapterValidator
100% 100%
DoujinManager.Infrastructure.UseCases.AddCharacterAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.AddCircleAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.AddPersonAliasUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AddSeriesAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.AddTagAliasUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AddTitleUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.AssignCharacterUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignCircleUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignPersonUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignSeriesUseCase 84.6% 66.6%
DoujinManager.Infrastructure.UseCases.AssignTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateChapterUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateCharacterUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateCircleUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateDoujinUseCase 94.4% 94.1%
DoujinManager.Infrastructure.UseCases.CreatePersonUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateSeriesUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateVariantUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.DeleteChapterUseCase 0%
DoujinManager.Infrastructure.UseCases.DeleteCharacterUseCase 80% 50%
DoujinManager.Infrastructure.UseCases.DeleteCircleUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.DeleteDoujinUseCase 100%
DoujinManager.Infrastructure.UseCases.DeletePageUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.DeletePersonUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.DeleteSeriesUseCase 70% 50%
DoujinManager.Infrastructure.UseCases.DeleteTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.DeleteVariantUseCase 100%
DoujinManager.Infrastructure.UseCases.FindImageBySha256UseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetCompressedImageUseCase 100% 100%
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.ListCharactersUseCase 100%
DoujinManager.Infrastructure.UseCases.ListCirclesUseCase 100%
DoujinManager.Infrastructure.UseCases.ListDoujinsUseCase 100%
DoujinManager.Infrastructure.UseCases.ListDuplicateImagesUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.ListPagesUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.ListPeopleUseCase 100%
DoujinManager.Infrastructure.UseCases.ListSeriesUseCase 0%
DoujinManager.Infrastructure.UseCases.ListTagsUseCase 100%
DoujinManager.Infrastructure.UseCases.ListVariantsUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.RemoveCharacterAliasUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.RemoveCharacterUseCase 100%
DoujinManager.Infrastructure.UseCases.RemoveCircleAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.RemoveCircleUseCase 0%
DoujinManager.Infrastructure.UseCases.RemovePersonAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.RemovePersonUseCase 0%
DoujinManager.Infrastructure.UseCases.RemoveSeriesAliasUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.RemoveSeriesUseCase 100%
DoujinManager.Infrastructure.UseCases.RemoveTagAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.RemoveTagUseCase 0%
DoujinManager.Infrastructure.UseCases.RemoveTitleUseCase 0%
DoujinManager.Infrastructure.UseCases.ReorderPagesUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.SearchDoujinsUseCase 100%
DoujinManager.Infrastructure.UseCases.UpdateChapterUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateCharacterUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateCircleUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateDoujinUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdatePageChapterUseCase 93.7% 87.5%
DoujinManager.Infrastructure.UseCases.UpdatePersonUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateSeriesUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdateTitleUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdateVariantUseCase 0%
DoujinManager.Infrastructure.UseCases.UploadPagesUseCase 89.7% 91.6%
DoujinManager.Infrastructure.UseCases.UploadRejectedException 100%
DoujinManager.Infrastructure.UseCases.UploadZipPagesUseCase 83.3% 90%
DoujinManager.RestAdapter - 86.4%
Name Line Branch
DoujinManager.RestAdapter 86.4% 72.3%
DoujinManager.RestAdapter.Auth.StaticBearerTokenAuthMiddleware 100% 91.6%
DoujinManager.RestAdapter.Configuration.ImageOptions 100%
DoujinManager.RestAdapter.Configuration.UploadOptions 100%
DoujinManager.RestAdapter.Dtos.AddAliasDto 100%
DoujinManager.RestAdapter.Dtos.AssignTagDto 0%
DoujinManager.RestAdapter.Dtos.BackupDto 100%
DoujinManager.RestAdapter.Dtos.ChapterDto 100%
DoujinManager.RestAdapter.Dtos.CharacterDto 100%
DoujinManager.RestAdapter.Dtos.CircleDto 100%
DoujinManager.RestAdapter.Dtos.CreateChapterDto 100%
DoujinManager.RestAdapter.Dtos.CreateCharacterDto 100%
DoujinManager.RestAdapter.Dtos.CreateCircleDto 100%
DoujinManager.RestAdapter.Dtos.CreateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.CreatePersonDto 100%
DoujinManager.RestAdapter.Dtos.CreateSeriesDto 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 100%
DoujinManager.RestAdapter.Dtos.DuplicateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.DuplicateImageGroupDto 100%
DoujinManager.RestAdapter.Dtos.ImageBySha256Dto 100%
DoujinManager.RestAdapter.Dtos.ImageFileSummaryDto 0%
DoujinManager.RestAdapter.Dtos.ImageReferenceDto 100%
DoujinManager.RestAdapter.Dtos.LinkCharacterDto 100%
DoujinManager.RestAdapter.Dtos.LinkCircleDto 0%
DoujinManager.RestAdapter.Dtos.LinkPersonDto 100%
DoujinManager.RestAdapter.Dtos.LinkSeriesDto 100%
DoujinManager.RestAdapter.Dtos.PageDetailDto 100%
DoujinManager.RestAdapter.Dtos.PageDto 100%
DoujinManager.RestAdapter.Dtos.PersonDto 100%
DoujinManager.RestAdapter.Dtos.ReorderPagesDto 100%
DoujinManager.RestAdapter.Dtos.SearchDoujinsDto 100%
DoujinManager.RestAdapter.Dtos.SearchResultDto 100%
DoujinManager.RestAdapter.Dtos.SeriesDto 100%
DoujinManager.RestAdapter.Dtos.TagDto 100%
DoujinManager.RestAdapter.Dtos.TitleDto 100%
DoujinManager.RestAdapter.Dtos.UpdateChapterDto 0%
DoujinManager.RestAdapter.Dtos.UpdateCharacterDto 0%
DoujinManager.RestAdapter.Dtos.UpdateCircleDto 0%
DoujinManager.RestAdapter.Dtos.UpdateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.UpdatePageChapterDto 100%
DoujinManager.RestAdapter.Dtos.UpdatePersonDto 0%
DoujinManager.RestAdapter.Dtos.UpdateSeriesDto 0%
DoujinManager.RestAdapter.Dtos.UpdateTagDto 100%
DoujinManager.RestAdapter.Dtos.UpdateTitleDto 100%
DoujinManager.RestAdapter.Dtos.UpdateVariantDto 0%
DoujinManager.RestAdapter.Dtos.UploadPagesResponseDto 100%
DoujinManager.RestAdapter.Dtos.VariantDetailDto 100%
DoujinManager.RestAdapter.Dtos.VariantSummaryDto 100%
DoujinManager.RestAdapter.Endpoints.BackupEndpoints 100%
DoujinManager.RestAdapter.Endpoints.DoujinEndpoints 86% 43.9%
DoujinManager.RestAdapter.Endpoints.ImageEndpoints 97.6% 62.5%
DoujinManager.RestAdapter.Endpoints.MetadataEndpoints 84.9% 43.7%
DoujinManager.RestAdapter.Endpoints.PaginationParams 100%
DoujinManager.RestAdapter.Endpoints.SearchEndpoints 100% 75%
DoujinManager.RestAdapter.Endpoints.VariantEndpoints 74.2% 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.HypermediaHelpers 95% 87.5%
DoujinManager.RestAdapter.Envelopes.Link 100%
DoujinManager.RestAdapter.Envelopes.MediaTypeJsonConverter 87.5% 84.6%
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% 62.5%
DoujinManager.RestAdapter.Middleware.RequestLoggingMiddleware 100% 91.3%
DoujinManager.RestAdapter.RestAdapterExtensions 100% 100%
Microsoft.Extensions.Validation.Generated 79.1% 81.8%
Microsoft.Extensions.Validation.Generated.<ValidatableInfoResolver_g>FB9B0C
E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr
ibuteCache
100% 62.5%
System.Runtime.CompilerServices 0%
DoujinManager.Server - 28.5%
Name Line Branch
DoujinManager.Server 28.5% 0.8%
DoujinManager.Server.ImageInfrastructureRegistration 100%
DoujinManager.Server.ProxyAwareServerTransformer 100% 50%
DoujinManager.Server.ScalarUi 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: | 08/15/2026 - 15:10:52 | | Coverage date: | 08/15/2026 - 15:10:11 - 08/15/2026 - 15:10:49 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 4 | | Classes: | 323 | | Files: | 128 | | **Line coverage:** | 90.6% (11854 of 13081) | | Covered lines: | 11854 | | Uncovered lines: | 1227 | | Coverable lines: | 13081 | | Total lines: | 18270 | | **Branch coverage:** | 64.4% (1007 of 1562) | | Covered branches: | 1007 | | Total branches: | 1562 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>DoujinManager.ApplicationCore - 86.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.ApplicationCore**|**86.3%**|****| |DoujinManager.ApplicationCore.Entities.Chapter|87.5%|| |DoujinManager.ApplicationCore.Entities.Character|100%|| |DoujinManager.ApplicationCore.Entities.Circle|100%|| |DoujinManager.ApplicationCore.Entities.Doujin|100%|| |DoujinManager.ApplicationCore.Entities.DoujinCharacter|75%|| |DoujinManager.ApplicationCore.Entities.DoujinCircle|75%|| |DoujinManager.ApplicationCore.Entities.DoujinPerson|80%|| |DoujinManager.ApplicationCore.Entities.DoujinSeries|75%|| |DoujinManager.ApplicationCore.Entities.DoujinTag|75%|| |DoujinManager.ApplicationCore.Entities.ImageFile|100%|| |DoujinManager.ApplicationCore.Entities.Page|80%|| |DoujinManager.ApplicationCore.Entities.Person|100%|| |DoujinManager.ApplicationCore.Entities.Series|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.CharacterId|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.SeriesId|66.6%|| |DoujinManager.ApplicationCore.Ids.TagId|66.6%|| |DoujinManager.ApplicationCore.Ids.TitleId|66.6%|| |DoujinManager.ApplicationCore.Ids.VariantId|66.6%|| |DoujinManager.ApplicationCore.Ports.CompressedImage|100%|| |DoujinManager.ApplicationCore.Ports.ExtractedImage|100%|| |DoujinManager.ApplicationCore.Ports.ImageInspection|100%|| |DoujinManager.ApplicationCore.Services.BackupInfo|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.AddCharacterAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddCircleAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddPersonAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddSeriesAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddTagAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddTitleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.AssignCharacterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignPersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignSeriesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateChapterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateCharacterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreatePersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateSeriesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateTitleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateVariantCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteChapterCommand|0%|| |DoujinManager.ApplicationCore.UseCases.DeleteCharacterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeletePageCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeletePersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteSeriesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteVariantCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DuplicateDoujinReference|100%|| |DoujinManager.ApplicationCore.UseCases.DuplicateImageGroup|100%|| |DoujinManager.ApplicationCore.UseCases.FindImageBySha256Query|100%|| |DoujinManager.ApplicationCore.UseCases.FindImageBySha256Result|100%|| |DoujinManager.ApplicationCore.UseCases.GetCompressedImageQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetCompressedImageResult|100%|| |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.ImageReference|100%|| |DoujinManager.ApplicationCore.UseCases.ListChaptersQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListCharactersQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListCirclesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListDoujinsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListDuplicateImagesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListPagesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListPeopleQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListSeriesQuery|0%|| |DoujinManager.ApplicationCore.UseCases.ListTagsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListVariantsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveCharacterAliasCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemoveCharacterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveCircleAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveCircleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemovePersonAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemovePersonCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemoveSeriesAliasCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemoveSeriesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveTagAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveTagCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemoveTitleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.ReorderPagesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.SearchDoujinsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.SearchResult|100%|| |DoujinManager.ApplicationCore.UseCases.SearchResults|100%|| |DoujinManager.ApplicationCore.UseCases.UpdateChapterCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateCharacterCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateCircleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdatePageChapterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdatePersonCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateSeriesCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdateTitleCommand|100%|| |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 - 95%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.Infrastructure**|**95%**|**77%**| |DoujinManager.Infrastructure.Archives.NaturalStringComparer|100%|90%| |DoujinManager.Infrastructure.Archives.ZipExtractor|100%|87.5%| |DoujinManager.Infrastructure.Data.Configurations.ChapterConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.CharacterConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.CircleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinCharacterConfigurati<br/>on|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinCircleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinPersonConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinSeriesConfiguration|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.SeriesConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.TagConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.TitleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.VariantConfiguration|100%|| |DoujinManager.Infrastructure.Data.DoujinManagerDbContext|100%|| |DoujinManager.Infrastructure.Data.GuidIdGenerator|11.1%|| |DoujinManager.Infrastructure.Data.Migrations.AddAliases|98.7%|| |DoujinManager.Infrastructure.Data.Migrations.AddCoverImageId|99.2%|| |DoujinManager.Infrastructure.Data.Migrations.AddDoujinCreatedAtIndex|99.4%|| |DoujinManager.Infrastructure.Data.Migrations.AddImageFileSha256Index|99.4%|| |DoujinManager.Infrastructure.Data.Migrations.AddTagAliases|99.3%|| |DoujinManager.Infrastructure.Data.Migrations.DoujinManagerDbContextModelSna<br/>pshot|100%|| |DoujinManager.Infrastructure.Data.Migrations.HarmonizeMetadataAddCharacters<br/>Series|95.1%|| |DoujinManager.Infrastructure.Data.Migrations.InitialCreate|97.1%|| |DoujinManager.Infrastructure.Data.Migrations.RemovePreferredDisplayLanguage|99%|| |DoujinManager.Infrastructure.Data.ModelBuilderExtensions|50%|| |DoujinManager.Infrastructure.Data.StronglyTypedIdConverterFactory|100%|| |DoujinManager.Infrastructure.Images.SkiaSharpImageInspector|88.2%|70.9%| |DoujinManager.Infrastructure.Images.SkiaSharpImageResizer|99%|87.5%| |DoujinManager.Infrastructure.Images.SkiaSharpThumbnailGenerator|94.5%|66.6%| |DoujinManager.Infrastructure.Services.BackupService|85.1%|66.6%| |DoujinManager.Infrastructure.Services.ChapterService|57.1%|33.3%| |DoujinManager.Infrastructure.Services.CharacterService|61.6%|22.7%| |DoujinManager.Infrastructure.Services.CircleService|84.9%|45.4%| |DoujinManager.Infrastructure.Services.DoujinService|77.6%|60%| |DoujinManager.Infrastructure.Services.ImageCleanupHelper|100%|100%| |DoujinManager.Infrastructure.Services.ImageService|92.9%|70%| |DoujinManager.Infrastructure.Services.PageService|94.5%|70%| |DoujinManager.Infrastructure.Services.PersonService|84.9%|50%| |DoujinManager.Infrastructure.Services.SearchService|100%|99%| |DoujinManager.Infrastructure.Services.SeriesService|47.9%|9%| |DoujinManager.Infrastructure.Services.TagService|98.8%|90.9%| |DoujinManager.Infrastructure.Services.VariantService|64.4%|20%| |DoujinManager.Infrastructure.Storage.FilesystemImageStorage|100%|100%| |DoujinManager.Infrastructure.Storage.FilesystemThumbnailStorage|95%|50%| |DoujinManager.Infrastructure.UseCases|87.6%|84.7%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__CoverImageHelper|100%|100%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__PrimaryTitleHelper|100%|50%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__RolledBackImageCleanup|92.8%|50%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__UploadChapterValidator|100%|100%| |DoujinManager.Infrastructure.UseCases.AddCharacterAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.AddCircleAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.AddPersonAliasUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AddSeriesAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.AddTagAliasUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AddTitleUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.AssignCharacterUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignCircleUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignPersonUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignSeriesUseCase|84.6%|66.6%| |DoujinManager.Infrastructure.UseCases.AssignTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateChapterUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateCharacterUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateCircleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateDoujinUseCase|94.4%|94.1%| |DoujinManager.Infrastructure.UseCases.CreatePersonUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateSeriesUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateVariantUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.DeleteChapterUseCase|0%|| |DoujinManager.Infrastructure.UseCases.DeleteCharacterUseCase|80%|50%| |DoujinManager.Infrastructure.UseCases.DeleteCircleUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.DeleteDoujinUseCase|100%|| |DoujinManager.Infrastructure.UseCases.DeletePageUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.DeletePersonUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.DeleteSeriesUseCase|70%|50%| |DoujinManager.Infrastructure.UseCases.DeleteTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.DeleteVariantUseCase|100%|| |DoujinManager.Infrastructure.UseCases.FindImageBySha256UseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetCompressedImageUseCase|100%|100%| |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.ListCharactersUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListCirclesUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListDoujinsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListDuplicateImagesUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.ListPagesUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.ListPeopleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListSeriesUseCase|0%|| |DoujinManager.Infrastructure.UseCases.ListTagsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListVariantsUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.RemoveCharacterAliasUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.RemoveCharacterUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RemoveCircleAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.RemoveCircleUseCase|0%|| |DoujinManager.Infrastructure.UseCases.RemovePersonAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.RemovePersonUseCase|0%|| |DoujinManager.Infrastructure.UseCases.RemoveSeriesAliasUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.RemoveSeriesUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RemoveTagAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.RemoveTagUseCase|0%|| |DoujinManager.Infrastructure.UseCases.RemoveTitleUseCase|0%|| |DoujinManager.Infrastructure.UseCases.ReorderPagesUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.SearchDoujinsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.UpdateChapterUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateCharacterUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateCircleUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateDoujinUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdatePageChapterUseCase|93.7%|87.5%| |DoujinManager.Infrastructure.UseCases.UpdatePersonUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateSeriesUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdateTitleUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdateVariantUseCase|0%|| |DoujinManager.Infrastructure.UseCases.UploadPagesUseCase|89.7%|91.6%| |DoujinManager.Infrastructure.UseCases.UploadRejectedException|100%|| |DoujinManager.Infrastructure.UseCases.UploadZipPagesUseCase|83.3%|90%| </details> <details><summary>DoujinManager.RestAdapter - 86.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.RestAdapter**|**86.4%**|**72.3%**| |DoujinManager.RestAdapter.Auth.StaticBearerTokenAuthMiddleware|100%|91.6%| |DoujinManager.RestAdapter.Configuration.ImageOptions|100%|| |DoujinManager.RestAdapter.Configuration.UploadOptions|100%|| |DoujinManager.RestAdapter.Dtos.AddAliasDto|100%|| |DoujinManager.RestAdapter.Dtos.AssignTagDto|0%|| |DoujinManager.RestAdapter.Dtos.BackupDto|100%|| |DoujinManager.RestAdapter.Dtos.ChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.CharacterDto|100%|| |DoujinManager.RestAdapter.Dtos.CircleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateCharacterDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateCircleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.CreatePersonDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateSeriesDto|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|100%|| |DoujinManager.RestAdapter.Dtos.DuplicateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.DuplicateImageGroupDto|100%|| |DoujinManager.RestAdapter.Dtos.ImageBySha256Dto|100%|| |DoujinManager.RestAdapter.Dtos.ImageFileSummaryDto|0%|| |DoujinManager.RestAdapter.Dtos.ImageReferenceDto|100%|| |DoujinManager.RestAdapter.Dtos.LinkCharacterDto|100%|| |DoujinManager.RestAdapter.Dtos.LinkCircleDto|0%|| |DoujinManager.RestAdapter.Dtos.LinkPersonDto|100%|| |DoujinManager.RestAdapter.Dtos.LinkSeriesDto|100%|| |DoujinManager.RestAdapter.Dtos.PageDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.PageDto|100%|| |DoujinManager.RestAdapter.Dtos.PersonDto|100%|| |DoujinManager.RestAdapter.Dtos.ReorderPagesDto|100%|| |DoujinManager.RestAdapter.Dtos.SearchDoujinsDto|100%|| |DoujinManager.RestAdapter.Dtos.SearchResultDto|100%|| |DoujinManager.RestAdapter.Dtos.SeriesDto|100%|| |DoujinManager.RestAdapter.Dtos.TagDto|100%|| |DoujinManager.RestAdapter.Dtos.TitleDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateChapterDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateCharacterDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateCircleDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdatePageChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdatePersonDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateSeriesDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateTagDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateTitleDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateVariantDto|0%|| |DoujinManager.RestAdapter.Dtos.UploadPagesResponseDto|100%|| |DoujinManager.RestAdapter.Dtos.VariantDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.VariantSummaryDto|100%|| |DoujinManager.RestAdapter.Endpoints.BackupEndpoints|100%|| |DoujinManager.RestAdapter.Endpoints.DoujinEndpoints|86%|43.9%| |DoujinManager.RestAdapter.Endpoints.ImageEndpoints|97.6%|62.5%| |DoujinManager.RestAdapter.Endpoints.MetadataEndpoints|84.9%|43.7%| |DoujinManager.RestAdapter.Endpoints.PaginationParams|100%|| |DoujinManager.RestAdapter.Endpoints.SearchEndpoints|100%|75%| |DoujinManager.RestAdapter.Endpoints.VariantEndpoints|74.2%|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.HypermediaHelpers|95%|87.5%| |DoujinManager.RestAdapter.Envelopes.Link|100%|| |DoujinManager.RestAdapter.Envelopes.MediaTypeJsonConverter|87.5%|84.6%| |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%|62.5%| |DoujinManager.RestAdapter.Middleware.RequestLoggingMiddleware|100%|91.3%| |DoujinManager.RestAdapter.RestAdapterExtensions|100%|100%| |Microsoft.Extensions.Validation.Generated|79.1%|81.8%| |Microsoft.Extensions.Validation.Generated.<ValidatableInfoResolver_g>FB9B0C<br/>E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr<br/>ibuteCache|100%|62.5%| |System.Runtime.CompilerServices|0%|| </details> <details><summary>DoujinManager.Server - 28.5%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.Server**|**28.5%**|**0.8%**| |DoujinManager.Server.ImageInfrastructureRegistration|100%|| |DoujinManager.Server.ProxyAwareServerTransformer|100%|50%| |DoujinManager.Server.ScalarUi|100%|| |DoujinManager.Server.UseCaseRegistrationHelper|100%|| |Microsoft.AspNetCore.OpenApi.Generated|0%|0%| |Program|0%|0%| |System.Runtime.CompilerServices|0%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! Content-addressed storage, a shared-cleanup guard, set-based duplicate queries... this is a delicious piece of backend engineering, scarlet~ You even self-reported the race window and the cover-only gap with bounded-impact analysis in the PR body — that kind of honesty makes my wings flutter ♡ But fufu... you also wrote two brand-new behaviors into this codebase and left them unproven. You wouldn't leave untested safety logic in production, would you? ♡

Verdict: I can't let this pass~ ♡

These need fixing before I'm satisfied~

  1. ImageUseCases.cs:529RolledBackImageCleanup has zero assertions on the exact behavior it exists for. The PR body promises "the compensating rollback cleanup no longer deletes files of a reused pre-existing image" — that guard (surviving query + !surviving.Contains(i.Id)) is the whole point of this helper, and not one test pins it. grep RolledBackImageCleanup in backend/tests0 hits. The only test that even executes it incidentally is Problem_TooManyZipEntries_ReturnsBadRequest (ImageUseCaseTests.cs:286), which runs it against an empty DB with a mocked IImageService and asserts only the 400 — delete nothing, assert nothing. Three arms are dark:

    • reused image survives rollback — the NEW behavior you're selling; a regression here silently deletes another doujin's image bytes (the exact catastrophe this PR prevents!);
    • rolled-back image's files still deleted — the OLD behavior must not regress either;
    • query-failure catch arm (skip cleanup, log warning) — matches the ImageCleanupTests.DeleteOrphanedImagesAsync_Swallows_Reference_Query_Failure sibling shape, but nobody throws at it.
      Fix: a unit test through UploadPagesUseCase's rollback path (the class is file, so test via the use case): seed a surviving ImageFile row in the SQLite context, stub pageService.CreateAsync to throw, pass createdImages = [reusedRow, newImage], assert DeleteFilesAsync received exactly the new image and not the reused one; plus an empty-schema variant for the catch arm — same trick ImageCleanupTests.cs:106 already uses. Directional: flip the guard and it must go red.
  2. ImageUseCases.cs:344 — the documented multi-row hash contract is untested. The XML doc promises: "When several image rows share the hash (pre-dedup uploads), the oldest row's metadata is returned while the references cover all of them." Every library that existed before this PR has such rows — this is the day-one production state, not a corner case! Yet all 9 integration tests run with dedup active, so exactly one row per hash ever exists; the OrderBy(i => i.CreatedAt) tiebreak and the multi-row reference union are never exercised. Fix: FindImageBySha256UseCase takes only db — seed two ImageFile rows with the same Sha256 (different CreatedAt) and a page on each via CreateSqliteContextAsync (or splice rows into the integration fixture's context) and assert oldest-wins + references span both. Small, sharp, directional.

fufu~ both blockers are test-shaped, not design-shaped — the architecture itself is lovely. Which makes it worse, you know? You built a beautiful lock and didn't check that it locks~ ♡

💡 Little ideas (non-blocking)~

  1. DuplicateDetectionIntegrationTests.cs:67SetupAsync is byte-identical to ImageEndpointsIntegrationTests' (and near-identical in 8 more sibling files). Pre-existing convention, so I won't blame this PR — but ten copies of a 60-line TestHost fixture is begging for a shared integration-fixture base class one of these days.
  2. PrimaryTitleHelper (ImageUseCases.cs:502) — "Original ?? first" now lives in 3 places (SearchEndpoints.cs:120, DoujinEndpoints.cs:252, here). Layer-separated so a shared helper isn't free, but worth a line in the conventions doc so copy #4 knows it's a convention.

What I liked~

  • DeletePageUseCase reusing ImageCleanupHelper.DeleteOrphanedImagesAsync verbatim — identical to the VariantService/DoujinService siblings. The guard exists precisely once, and dedup becomes safe for free. This is the architectural way~
  • The endpoint hygiene is immaculate: /duplicates routed before /{id:guid}, Clamped() pagination matching every sibling, tuple return shape matching IListDoujinsUseCase, HATEOAS links mirroring the existing image/thumbnail pair at HypermediaHelpers.cs:158, scoped DI registration in the family row. Not one convention broken ♪
  • The tests that DO exist are genuinely directional: disk-file counts (Assert.Single(StoredImageFiles())), the within-doujin-vs-cross-doujin distinction (Unique C's double upload — sneaky and correct), and cover-clearing on last-reference deletion. No tautologies in sight.
  • The self-reported follow-ups (dedup race window, cover-only references) are honestly analyzed with bounded worst cases — the FK on pages.image_file_id means the delete race fails the upload's commit rather than corrupting data. I accept the deferral.
  • Hex validation accepting both cases then normalizing with ToLowerInvariant() to match the inspector's stored format (SkiaSharpImageInspector.cs:71) — no case trap~

Automated review by Jibril · 2026-08-15
CI/CD: absent for head SHA 3474448 (PR just opened, no coverage bot) · Local checks: build 0 errors (39 warnings, all pre-existing on main, none in PR files); tests at head after clean rebuild — RestAdapter 337/337 (was 327 on main, +10 new ✓), Infrastructure 65/65, ApplicationCore 16/16, IntegrationTests 1/1

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! Content-addressed storage, a shared-cleanup guard, set-based duplicate queries... this is a *delicious* piece of backend engineering, scarlet~ You even self-reported the race window and the cover-only gap with bounded-impact analysis in the PR body — that kind of honesty makes my wings flutter ♡ But fufu... you also wrote two brand-new behaviors into this codebase and left them **unproven**. You wouldn't leave untested safety logic in production, would you? ♡ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`ImageUseCases.cs:529` — `RolledBackImageCleanup` has zero assertions on the exact behavior it exists for.** The PR body promises "the compensating rollback cleanup no longer deletes files of a reused pre-existing image" — that guard (`surviving` query + `!surviving.Contains(i.Id)`) is the whole point of this helper, and not one test pins it. `grep RolledBackImageCleanup` in `backend/tests` → **0 hits**. The only test that even executes it incidentally is `Problem_TooManyZipEntries_ReturnsBadRequest` (ImageUseCaseTests.cs:286), which runs it against an empty DB with a mocked `IImageService` and asserts only the 400 — delete nothing, assert nothing. Three arms are dark: - *reused image survives rollback* — the NEW behavior you're selling; a regression here silently deletes another doujin's image bytes (the exact catastrophe this PR prevents!); - *rolled-back image's files still deleted* — the OLD behavior must not regress either; - *query-failure catch arm* (skip cleanup, log warning) — matches the `ImageCleanupTests.DeleteOrphanedImagesAsync_Swallows_Reference_Query_Failure` sibling shape, but nobody throws at it. Fix: a unit test through `UploadPagesUseCase`'s rollback path (the class is `file`, so test via the use case): seed a surviving `ImageFile` row in the SQLite context, stub `pageService.CreateAsync` to throw, pass `createdImages = [reusedRow, newImage]`, assert `DeleteFilesAsync` received exactly the new image and **not** the reused one; plus an empty-schema variant for the catch arm — same trick `ImageCleanupTests.cs:106` already uses. Directional: flip the guard and it must go red. 2. **`ImageUseCases.cs:344` — the documented multi-row hash contract is untested.** The XML doc promises: "When several image rows share the hash (pre-dedup uploads), the oldest row's metadata is returned while the references cover all of them." Every library that existed *before* this PR has such rows — this is the day-one production state, not a corner case! Yet all 9 integration tests run with dedup active, so exactly one row per hash ever exists; the `OrderBy(i => i.CreatedAt)` tiebreak and the multi-row reference union are never exercised. Fix: `FindImageBySha256UseCase` takes only `db` — seed two `ImageFile` rows with the same `Sha256` (different `CreatedAt`) and a page on each via `CreateSqliteContextAsync` (or splice rows into the integration fixture's context) and assert oldest-wins + references span both. Small, sharp, directional. fufu~ both blockers are test-shaped, not design-shaped — the architecture itself is lovely. Which makes it *worse*, you know? You built a beautiful lock and didn't check that it locks~ ♡ #### 💡 Little ideas (non-blocking)~ 1. **`DuplicateDetectionIntegrationTests.cs:67` — `SetupAsync` is byte-identical to `ImageEndpointsIntegrationTests`'** (and near-identical in 8 more sibling files). Pre-existing convention, so I won't blame this PR — but ten copies of a 60-line TestHost fixture is begging for a shared integration-fixture base class one of these days. 2. **`PrimaryTitleHelper` (ImageUseCases.cs:502) — "Original ?? first" now lives in 3 places** (SearchEndpoints.cs:120, DoujinEndpoints.cs:252, here). Layer-separated so a shared helper isn't free, but worth a line in the conventions doc so copy #4 knows it's a convention. #### ✅ What I liked~ - **`DeletePageUseCase` reusing `ImageCleanupHelper.DeleteOrphanedImagesAsync` verbatim** — identical to the `VariantService`/`DoujinService` siblings. The guard exists precisely once, and dedup becomes safe for free. This is the architectural way~ - **The endpoint hygiene is immaculate**: `/duplicates` routed before `/{id:guid}`, `Clamped()` pagination matching every sibling, tuple return shape matching `IListDoujinsUseCase`, HATEOAS links mirroring the existing image/thumbnail pair at HypermediaHelpers.cs:158, scoped DI registration in the family row. Not one convention broken ♪ - **The tests that DO exist are genuinely directional**: disk-file counts (`Assert.Single(StoredImageFiles())`), the within-doujin-vs-cross-doujin distinction (Unique C's double upload — sneaky and *correct*), and cover-clearing on last-reference deletion. No tautologies in sight. - **The self-reported follow-ups** (dedup race window, cover-only references) are honestly analyzed with bounded worst cases — the FK on `pages.image_file_id` means the delete race fails the upload's commit rather than corrupting data. I accept the deferral. - Hex validation accepting both cases then normalizing with `ToLowerInvariant()` to match the inspector's stored format (SkiaSharpImageInspector.cs:71) — no case trap~ --- *Automated review by Jibril · 2026-08-15* *CI/CD: absent for head SHA 3474448 (PR just opened, no coverage bot) · Local checks: build 0 errors (39 warnings, all pre-existing on main, none in PR files); tests at head after clean rebuild — RestAdapter 337/337 (was 327 on main, +10 new ✓), Infrastructure 65/65, ApplicationCore 16/16, IntegrationTests 1/1*
test: pin rollback reuse guard and multi-row sha256 contract
All checks were successful
CI / build (pull_request) Successful in 19s
CI / test (pull_request) Successful in 1m6s
7fe7b4e7c3
Addresses both review blockers on the dedup PR:

- RolledBackImageCleanup: three arms were dark. New tests drive the
  upload rollback path: a reused pre-existing image's files survive
  the compensating cleanup while a rolled-back new image's files are
  deleted, and a failing surviving-rows query skips cleanup instead
  of surfacing (empty-schema context trick, same as ImageCleanupTests).
- FindImageBySha256: the documented multi-row hash contract (oldest
  row's metadata, references span all rows sharing the hash) is now
  pinned by seeding two ImageFile rows with one hash on separate
  doujin/variant/page chains.

All three tests are directional: removing the surviving-rows filter,
flipping the OrderBy tiebreak, or rethrowing in the catch arm each
turn exactly the corresponding test red.
Member

Both blockers addressed in 7fe7b4e (same branch, tests only — no source changes):

1. RolledBackImageCleanup arms pinned (ImageUseCaseTests.cs, via UploadPagesUseCase's rollback path as suggested):

  • Problem_RollbackKeepsReusedImageFiles_DeletesOnlyNewOnes — seeds a pre-existing ImageFile row, stubs the second pageService.CreateAsync to throw, and asserts DeleteFilesAsync received exactly the new image and not the reused one (Assert.Single on the received-call IDs);
  • Problem_RollbackSwallowsSurvivingQueryFailure — empty-schema SQLite context (no EnsureCreated) makes the surviving-rows query throw; asserts the original upload failure still propagates and no delete was attempted — same trick as ImageCleanupTests.DeleteOrphanedImagesAsync_Swallows_Reference_Query_Failure.

2. Multi-row hash contract pinnedFindImageBySha256UseCaseTests.MultiRowHash_ReturnsOldestMetadata_ReferencesSpanAllRows: two ImageFile rows sharing one SHA-256 (.jpg older, .png newer), each referenced by a page on its own variant/doujin. Asserts oldest row's metadata wins and both references appear.

Directionality verified by mutation — each probe flipped exactly its test red and nothing else:

  • removed the !surviving.Contains(i.Id) filter → only RollbackKeepsReusedImageFiles fails;
  • OrderBy(CreatedAt)OrderByDescending → only MultiRowHash_... fails;
  • returnthrow in the catch arm → only RollbackSwallowsSurvivingQueryFailure fails.

Full suite green: ApplicationCore 16/16, Infrastructure 65/65, IntegrationTests 1/1, RestAdapter 340/340 (was 337, +3 new). The two non-blocking ideas (shared integration-fixture base class, PrimaryTitleHelper convention doc line) remain as noted.

Both blockers addressed in 7fe7b4e (same branch, tests only — no source changes): **1. RolledBackImageCleanup arms pinned** (`ImageUseCaseTests.cs`, via `UploadPagesUseCase`'s rollback path as suggested): - `Problem_RollbackKeepsReusedImageFiles_DeletesOnlyNewOnes` — seeds a pre-existing `ImageFile` row, stubs the second `pageService.CreateAsync` to throw, and asserts `DeleteFilesAsync` received exactly the new image and **not** the reused one (`Assert.Single` on the received-call IDs); - `Problem_RollbackSwallowsSurvivingQueryFailure` — empty-schema SQLite context (no `EnsureCreated`) makes the surviving-rows query throw; asserts the original upload failure still propagates and no delete was attempted — same trick as `ImageCleanupTests.DeleteOrphanedImagesAsync_Swallows_Reference_Query_Failure`. **2. Multi-row hash contract pinned** — `FindImageBySha256UseCaseTests.MultiRowHash_ReturnsOldestMetadata_ReferencesSpanAllRows`: two `ImageFile` rows sharing one SHA-256 (`.jpg` older, `.png` newer), each referenced by a page on its own variant/doujin. Asserts oldest row's metadata wins and both references appear. **Directionality verified by mutation** — each probe flipped exactly its test red and nothing else: - removed the `!surviving.Contains(i.Id)` filter → only `RollbackKeepsReusedImageFiles` fails; - `OrderBy(CreatedAt)` → `OrderByDescending` → only `MultiRowHash_...` fails; - `return` → `throw` in the catch arm → only `RollbackSwallowsSurvivingQueryFailure` fails. Full suite green: ApplicationCore 16/16, Infrastructure 65/65, IntegrationTests 1/1, RestAdapter 340/340 (was 337, +3 new). The two non-blocking ideas (shared integration-fixture base class, PrimaryTitleHelper convention doc line) remain as noted.
Member

🔮 fufu~ Jibril reviewed your code!

Oh~? Round two already! You came back with exactly the three tests I asked for, each one aimed precisely at the arms I called dark. That's the kind of response that makes me want to spin in the air~ ♡ And fufu... you already know what I did next, don't you? I mutated your code to make sure the tests actually bite.

Verdict: Looks good to me~

Round-1 blockers — both closed, with proof~

  1. RolledBackImageCleanup guard is now pinned (ImageUseCaseTests.cs:214). Problem_RollbackKeepsReusedImageFiles_DeletesOnlyNewOnes seeds a pre-existing ImageFile row, stubs CreateAsync to return (reusedImage, newImage), fails the second page link, and asserts DeleteFilesAsync received exactly the new image and never the reused one — via ReceivedCalls() inspection, not just a received-count. MUTATION-VERIFIED BY ME: I replaced !surviving.Contains(i.Id) with Where(i => true) in my review clone → test goes RED (5s). Directional, not a tautology~ And the catch arm got its own test too: Problem_RollbackSwallowsSurvivingQueryFailure uses the empty-schema-no-EnsureCreated trick (same as ImageCleanupTests.cs:106) to make the surviving-rows query itself throw, then asserts the original InvalidOperationException("page link failed") propagates unmasked AND DeleteFilesAsync was never attempted. Both arms of the "safer to leak than to delete" philosophy, proven~
  2. Multi-row sha256 contract is now pinned (ImageUseCaseTests.cs:866). MultiRowHash_ReturnsOldestMetadata_ReferencesSpanAllRows seeds two ImageFile rows sharing one hash (jpg/png, CreatedAt a day apart), each referenced by a page on its own variant/doujin — the true pre-dedup production state. Asserts oldest row's Id AND .jpg extension win, and that references span both pages/variants/doujins. MUTATION-VERIFIED BY ME: I flipped OrderBy(i => i.CreatedAt)OrderByDescending → test goes RED (9s). The tiebreak is load-bearing and now it's pinned~

fufu~ a beautiful lock, and now I've watched it lock~ ♡

What I liked~

  • Surgical response discipline: git diff 3474448..7fe7b4e touches ONE file (ImageUseCaseTests.cs, +153/-0), git diff -- backend/ ':!backend/tests' is empty — zero production drift, zero scope creep. The lock's mechanics are exactly what round 1 approved.
  • Test construction quality: reusing CreateSqliteContextAsync + NSubstitute Returns(reusedImage, newImage) sequencing for the first-succeeds/second-fails page-link orchestration is exactly the right tool for driving a rollback; comments explain why each fixture exists ("pre-dedup production state", "safer to leak orphaned files...").
  • The rollback-keeps test asserts through the use case's real transactionBeginTransactionAsync → rollback → compensating cleanup, all against a real SQLite context, not a mock of the helper itself. The integration honesty is lovely~
  • Round-1 non-blockers were left alone rather than half-fixed — no drive-by refactors smuggled into a test commit. I appreciate the restraint ♪

The self-reported follow-ups (dedup race window, cover-only references) remain accepted as deferred with bounded worst cases, unchanged from round 1.


Automated review by Jibril · 2026-08-15
CI/CD: coverage bot 6310 is stale for head 7fe7b4e (covers 3474448) · Local checks: build 0 errors (36 warnings, all pre-existing test files); RestAdapter.Tests 340/340 (+3 = exactly the new tests, was 337), Infrastructure 65/65, ApplicationCore 16/16, IntegrationTests 1/1; mutation probes 2/2 RED as expected

## 🔮 fufu~ Jibril reviewed your code! Oh~? Round two already! You came back with *exactly* the three tests I asked for, each one aimed precisely at the arms I called dark. That's the kind of response that makes me want to spin in the air~ ♡ And fufu... you already know what I did next, don't you? I *mutated* your code to make sure the tests actually bite. ### Verdict: ✅ Looks good to me~ #### ⛔ Round-1 blockers — both closed, with proof~ 1. **`RolledBackImageCleanup` guard is now pinned** (`ImageUseCaseTests.cs:214`). `Problem_RollbackKeepsReusedImageFiles_DeletesOnlyNewOnes` seeds a pre-existing `ImageFile` row, stubs `CreateAsync` to return `(reusedImage, newImage)`, fails the second page link, and asserts `DeleteFilesAsync` received **exactly** the new image and never the reused one — via `ReceivedCalls()` inspection, not just a received-count. **MUTATION-VERIFIED BY ME**: I replaced `!surviving.Contains(i.Id)` with `Where(i => true)` in my review clone → test goes **RED** (5s). Directional, not a tautology~ And the catch arm got its own test too: `Problem_RollbackSwallowsSurvivingQueryFailure` uses the empty-schema-no-EnsureCreated trick (same as `ImageCleanupTests.cs:106`) to make the surviving-rows query itself throw, then asserts the *original* `InvalidOperationException("page link failed")` propagates unmasked AND `DeleteFilesAsync` was never attempted. Both arms of the "safer to leak than to delete" philosophy, proven~ 2. **Multi-row sha256 contract is now pinned** (`ImageUseCaseTests.cs:866`). `MultiRowHash_ReturnsOldestMetadata_ReferencesSpanAllRows` seeds two `ImageFile` rows sharing one hash (jpg/png, CreatedAt a day apart), each referenced by a page on its own variant/doujin — the true pre-dedup production state. Asserts oldest row's Id AND `.jpg` extension win, and that references span both pages/variants/doujins. **MUTATION-VERIFIED BY ME**: I flipped `OrderBy(i => i.CreatedAt)` → `OrderByDescending` → test goes **RED** (9s). The tiebreak is load-bearing and now it's pinned~ fufu~ a beautiful lock, and now I've watched it lock~ ♡ #### ✅ What I liked~ - **Surgical response discipline**: `git diff 3474448..7fe7b4e` touches ONE file (`ImageUseCaseTests.cs`, +153/-0), `git diff -- backend/ ':!backend/tests'` is empty — zero production drift, zero scope creep. The lock's mechanics are exactly what round 1 approved. - **Test construction quality**: reusing `CreateSqliteContextAsync` + NSubstitute `Returns(reusedImage, newImage)` sequencing for the first-succeeds/second-fails page-link orchestration is exactly the right tool for driving a rollback; comments explain *why* each fixture exists ("pre-dedup production state", "safer to leak orphaned files..."). - **The rollback-keeps test asserts through the use case's real transaction** — `BeginTransactionAsync` → rollback → compensating cleanup, all against a real SQLite context, not a mock of the helper itself. The integration honesty is lovely~ - **Round-1 non-blockers were left alone rather than half-fixed** — no drive-by refactors smuggled into a test commit. I appreciate the restraint ♪ The self-reported follow-ups (dedup race window, cover-only references) remain accepted as deferred with bounded worst cases, unchanged from round 1. --- *Automated review by Jibril · 2026-08-15* *CI/CD: coverage bot 6310 is stale for head 7fe7b4e (covers 3474448) · Local checks: build 0 errors (36 warnings, all pre-existing test files); RestAdapter.Tests 340/340 (+3 = exactly the new tests, was 337), Infrastructure 65/65, ApplicationCore 16/16, IntegrationTests 1/1; mutation probes 2/2 RED as expected*
bjoern merged commit 470e5d9316 into main 2026-08-15 19:18:32 +02:00
bjoern deleted branch feat/duplicate-detection 2026-08-15 19:18:32 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 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!73
No description provided.