fix: a doujin that has variants always has exactly one default #111

Merged
bjoern merged 1 commit from fix/default-variant-invariant into main 2026-09-08 19:16:25 +02:00
Member

What's in

VariantService.UpdateAsync refuses to un-set the last default variant with a 409, but DeleteAsync removed the default and saved without checking — so DELETE /api/variants/{id} was a public back door into exactly the state that guard exists to prevent: a doujin with two or more variants and zero defaults. CreateAsync had the same hole from the other end: a doujin's first variant posted with isDefault: false — which is what the app's variant dialog sends unless the user ticks the box — left the doujin with a variant and no default.

The invariant now holds across the whole variant lifecycle:

  • Create: a doujin's first variant is the default whatever isDefault says. Later variants are untouched — a second variant that did not ask for the flag does not steal it.
  • Delete: deleting the default promotes the oldest surviving variant of the same doujin, ordered exactly as ListByDoujinAsync orders, so the new default is the one the client already sees at the top of the list. The promotion shares the single SaveChanges with the removal. Deleting a non-default variant never moves the flag; deleting the last variant promotes nobody and still answers 204.

Both live in VariantService, which is the only writer of IsDefault and the only caller of db.Variants.Remove in the backend — so the fix is Infrastructure-only and no endpoint, use case or import path can bypass it.

ADR 0004 is amended in the same commit. Its Rules list mentioned IsDefault nowhere, which is precisely why one mutation enforced the invariant and two did not. The rule is written down now, plus a Consequences bullet giving clients the guarantee that any non-empty variant list has exactly one isDefault: true.

Amending it surfaced an adjacent ADR 0021 gap: PUT /api/variants/{id} answers the 409 that is now ADR-mandated behaviour but declared only 200/400/404/401. It now declares .ProducesProblem(409), pinned by a named case in EndpointResponseTypeTests (the file's generic route-shape rules cannot infer a 409).

Verification

  • REST + persistence (VariantDefaultInvariantIntegrationTests, 8 cases on IntegrationTestBase — TestServer over a real migrated SQLite file): first-variant defaulting, later variants not promoted, create-clears-previous, un-set-last-default 409, delete-default-promotes-oldest, delete-non-default-keeps-flag, delete-only-variant, and cross-doujin scoping. Each asserts through the HTTP surface and re-reads the list, so the endpoint, use case, service and database rows are all exercised.
  • OpenAPI document (EndpointResponseTypeTests): route-table reflection over the real endpoint mapping.
  • 9 mutation probes, one per branch, each reddening a named test — promotion removed, promotion unconditional, newest-instead-of-oldest, promotion unscoped from the doujin, FirstAsync for FirstOrDefaultAsync, deleted row not excluded from candidates, first-variant rule removed, every variant forced default, 409 declaration dropped. The scoping probe passed on the first attempt: the fixture pointed at a neighbour whose oldest variant was already its default, so a cross-doujin promotion changed nothing observable. The neighbour now has a non-default oldest variant plus a pre-delete fixture guard, and the probe reddens.
  • 538 backend tests green across all four projects (ApplicationCore 16, Infrastructure 85, RestAdapter 436, IntegrationTests 1).
  • Not verified: no UI layer was exercised — the change is backend-only with no DTO or wire-model change, so nothing in the Flutter app was touched and no codegen ran. Concurrency is unverified: nothing serialises two simultaneous deletes.

Follow-ups

  • No database backstop for the invariant: is_default has no filtered unique index, so it is enforced entirely in service code. Two concurrent deletes of the same doujin's default could each promote a different survivor and leave two defaults. Wants a filtered unique index or an explicit transaction with a re-check.
  • The promotion is deliberately not tie-broken by id. A ThenBy(v => v.Id) was written and then removed: probing showed SQLite already returns these ties in id order, so no test could redden the tie-break — an unpinned branch rather than a guarantee.
  • Pre-existing rows are not migrated: a doujin already left with no default by the old delete path stays that way until its variants are next mutated. Worth checking the live database.

Deferred items are collected in #107 (docs/pile-a-backlog), which should merge first — the seven Pile A branches each wrote to docs/BACKLOG.md and would otherwise all conflict on it. This PR, #113 and #114 all amend ADR 0004; merge #114 last of the seven, since it also rewrites the VariantService.cs this PR changes.

Verified independently on this branch with the full scripts/verify.sh (backend build and tests, app codegen, analyze and tests): PASS.

🤖 Generated with Claude Code

## What's in `VariantService.UpdateAsync` refuses to un-set the last default variant with a 409, but `DeleteAsync` removed the default and saved without checking — so `DELETE /api/variants/{id}` was a public back door into exactly the state that guard exists to prevent: a doujin with two or more variants and zero defaults. `CreateAsync` had the same hole from the other end: a doujin's first variant posted with `isDefault: false` — which is what the app's variant dialog sends unless the user ticks the box — left the doujin with a variant and no default. The invariant now holds across the whole variant lifecycle: - **Create**: a doujin's first variant is the default whatever `isDefault` says. Later variants are untouched — a second variant that did not ask for the flag does not steal it. - **Delete**: deleting the default promotes the oldest surviving variant of the same doujin, ordered exactly as `ListByDoujinAsync` orders, so the new default is the one the client already sees at the top of the list. The promotion shares the single `SaveChanges` with the removal. Deleting a non-default variant never moves the flag; deleting the last variant promotes nobody and still answers 204. Both live in `VariantService`, which is the only writer of `IsDefault` and the only caller of `db.Variants.Remove` in the backend — so the fix is Infrastructure-only and no endpoint, use case or import path can bypass it. **ADR 0004 is amended in the same commit.** Its Rules list mentioned `IsDefault` nowhere, which is precisely why one mutation enforced the invariant and two did not. The rule is written down now, plus a Consequences bullet giving clients the guarantee that any non-empty variant list has exactly one `isDefault: true`. Amending it surfaced an adjacent **ADR 0021** gap: `PUT /api/variants/{id}` answers the 409 that is now ADR-mandated behaviour but declared only 200/400/404/401. It now declares `.ProducesProblem(409)`, pinned by a named case in `EndpointResponseTypeTests` (the file's generic route-shape rules cannot infer a 409). ## Verification - **REST + persistence** (`VariantDefaultInvariantIntegrationTests`, 8 cases on `IntegrationTestBase` — TestServer over a real migrated SQLite file): first-variant defaulting, later variants not promoted, create-clears-previous, un-set-last-default 409, delete-default-promotes-oldest, delete-non-default-keeps-flag, delete-only-variant, and cross-doujin scoping. Each asserts through the HTTP surface and re-reads the list, so the endpoint, use case, service and database rows are all exercised. - **OpenAPI document** (`EndpointResponseTypeTests`): route-table reflection over the real endpoint mapping. - **9 mutation probes, one per branch**, each reddening a named test — promotion removed, promotion unconditional, newest-instead-of-oldest, promotion unscoped from the doujin, `FirstAsync` for `FirstOrDefaultAsync`, deleted row not excluded from candidates, first-variant rule removed, every variant forced default, 409 declaration dropped. The scoping probe passed on the first attempt: the fixture pointed at a neighbour whose oldest variant was already its default, so a cross-doujin promotion changed nothing observable. The neighbour now has a non-default oldest variant plus a pre-delete fixture guard, and the probe reddens. - **538 backend tests green** across all four projects (ApplicationCore 16, Infrastructure 85, RestAdapter 436, IntegrationTests 1). - **Not verified**: no UI layer was exercised — the change is backend-only with no DTO or wire-model change, so nothing in the Flutter app was touched and no codegen ran. Concurrency is unverified: nothing serialises two simultaneous deletes. ## Follow-ups - No database backstop for the invariant: `is_default` has no filtered unique index, so it is enforced entirely in service code. Two concurrent deletes of the same doujin's default could each promote a different survivor and leave two defaults. Wants a filtered unique index or an explicit transaction with a re-check. - The promotion is deliberately **not** tie-broken by id. A `ThenBy(v => v.Id)` was written and then removed: probing showed SQLite already returns these ties in id order, so no test could redden the tie-break — an unpinned branch rather than a guarantee. - **Pre-existing rows are not migrated**: a doujin already left with no default by the old delete path stays that way until its variants are next mutated. Worth checking the live database. --- Deferred items are collected in **#107** (`docs/pile-a-backlog`), which should merge first — the seven Pile A branches each wrote to `docs/BACKLOG.md` and would otherwise all conflict on it. This PR, **#113** and **#114** all amend **ADR 0004**; merge **#114** last of the seven, since it also rewrites the `VariantService.cs` this PR changes. Verified independently on this branch with the full `scripts/verify.sh` (backend build and tests, app codegen, analyze and tests): **PASS**. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: a doujin that has variants always has exactly one default
Some checks failed
CI / build (pull_request) Successful in 22s
Deploy Smoke Test / smoke (pull_request) Failing after 2m45s
CI / test (pull_request) Successful in 1m18s
abbde9dc0e
`VariantService.UpdateAsync` refuses to un-set the last default variant with
a 409, but `DeleteAsync` removed the default and saved without a second
thought — so `DELETE /api/variants/{id}` was a back door into precisely the
state that guard exists to prevent: a doujin with variants and no default.
`CreateAsync` had the same hole from the other end: a doujin's first variant
posted with `isDefault: false` (what the app's dialog sends unless the user
ticks the box) left the doujin with a variant and no default.

The invariant now holds across the whole lifecycle:

- Creating a doujin's first variant makes it the default, whatever the client
  asked for. Later variants are untouched — a second variant that did not ask
  for the flag does not steal it.
- Deleting the default promotes the oldest surviving variant of the same
  doujin, ordered exactly as `ListByDoujinAsync` orders, so the new default is
  the one the client already sees at the top of the list. The promotion and
  the removal share one `SaveChanges`. Deleting a non-default variant never
  moves the flag, and deleting the last variant promotes nobody (a doujin with
  no variants has no default), so that delete still answers 204.

ADR 0004's Rules said nothing about `IsDefault` at all, which is why one
mutation enforced the invariant and two did not — it is written down now,
with a Consequences note that clients may read the default straight off any
non-empty variant list. Amending it also surfaced an ADR 0021 gap next door:
PUT /api/variants/{id} answers 409 but declared only 200/400/404/401, so the
declaration and a route-table test for it are here too.

Scope notes: the promotion is deliberately not tie-broken by id. SQLite
already returns these ties in id order, so a `ThenBy(v => v.Id)` was code no
test could redden — an unpinned branch rather than a guarantee. The invariant
also still has no database backstop (no filtered unique index on
`is_default`), so concurrent deletes could each promote a different survivor;
that is in docs/BACKLOG.md, not fixed here.

Verification: 9 mutation probes, one per branch — promotion removed,
promotion unconditional, newest-instead-of-oldest, promotion unscoped from
the doujin, `FirstAsync` instead of `FirstOrDefaultAsync`, the deleted row
not excluded from candidates, the first-variant rule removed, every variant
forced default, and the 409 declaration dropped — each reddens a named test.
The scoping probe passed at first: its fixture pointed at a neighbour whose
oldest variant was already the default, so a cross-doujin promotion changed
nothing observable; the neighbour now has a non-default oldest variant and
the probe reddens. 538 backend tests green across all four test projects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 09/08/2026 - 16:39:15
Coverage date: 09/08/2026 - 16:38:21 - 09/08/2026 - 16:39:11
Parser: MultiReport (4x Cobertura)
Assemblies: 4
Classes: 340
Files: 135
Line coverage: 91.8% (12460 of 13559)
Covered lines: 12460
Uncovered lines: 1099
Coverable lines: 13559
Total lines: 19065
Branch coverage: 67.4% (1092 of 1618)
Covered branches: 1092
Total branches: 1618
Method coverage: Feature is only available for sponsors

Coverage

DoujinManager.ApplicationCore - 89.2%
Name Line Branch
DoujinManager.ApplicationCore 89.2% ****
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 100%
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.DuplicateDoujinGroup 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.ImageCoverReference 100%
DoujinManager.ApplicationCore.UseCases.ImageReference 100%
DoujinManager.ApplicationCore.UseCases.LibraryCounts 100%
DoujinManager.ApplicationCore.UseCases.LibraryStats 100%
DoujinManager.ApplicationCore.UseCases.ListChaptersQuery 100%
DoujinManager.ApplicationCore.UseCases.ListCharactersQuery 100%
DoujinManager.ApplicationCore.UseCases.ListCirclesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListDoujinsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListDuplicateDoujinsQuery 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 100%
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.StorageUsage 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 100%
DoujinManager.ApplicationCore.UseCases.UploadImageFile 100%
DoujinManager.ApplicationCore.UseCases.UploadPagesCommand 100%
DoujinManager.ApplicationCore.UseCases.UploadZipPagesCommand 100%
DoujinManager.Infrastructure - 95.6%
Name Line Branch
DoujinManager.Infrastructure 95.6% 80.4%
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 90.1% 83.8%
DoujinManager.Infrastructure.Images.SkiaSharpImageResizer 99% 87.5%
DoujinManager.Infrastructure.Images.SkiaSharpThumbnailGenerator 94.5% 66.6%
DoujinManager.Infrastructure.Services.BackupService 87.2% 83.3%
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.DoujinOrdering 100% 100%
DoujinManager.Infrastructure.Services.DoujinService 83.6% 65%
DoujinManager.Infrastructure.Services.ImageCleanupHelper 100% 100%
DoujinManager.Infrastructure.Services.ImageService 93.5% 75%
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 90.8% 75%
DoujinManager.Infrastructure.Storage.DirectorySize 100% 100%
DoujinManager.Infrastructure.Storage.FilesystemImageStorage 100% 100%
DoujinManager.Infrastructure.Storage.FilesystemThumbnailStorage 95.2% 50%
DoujinManager.Infrastructure.UseCases 90.5% 85.3%
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 83.3% 50%
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 97.1% 100%
DoujinManager.Infrastructure.UseCases.GetCompressedImageUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetDoujinUseCase 100%
DoujinManager.Infrastructure.UseCases.GetImageUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetStatsUseCase 100% 75%
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.ListDuplicateDoujinsUseCase 100% 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 100%
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 100%
DoujinManager.Infrastructure.UseCases.UploadPagesUseCase 89.7% 91.6%
DoujinManager.Infrastructure.UseCases.UploadRejectedException 100%
DoujinManager.Infrastructure.UseCases.UploadZipPagesUseCase 83.3% 90%
DoujinManager.RestAdapter - 89.6%
Name Line Branch
DoujinManager.RestAdapter 89.6% 73.8%
DoujinManager.RestAdapter.Auth.StaticBearerTokenAuthMiddleware 100% 91.6%
DoujinManager.RestAdapter.Configuration.ImageOptions 100%
DoujinManager.RestAdapter.Configuration.ServerVersion 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.DuplicateDoujinGroupDto 100%
DoujinManager.RestAdapter.Dtos.DuplicateImageGroupDto 100%
DoujinManager.RestAdapter.Dtos.ImageBySha256Dto 100%
DoujinManager.RestAdapter.Dtos.ImageCoverReferenceDto 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.StatsCountsDto 100%
DoujinManager.RestAdapter.Dtos.StatsDto 100%
DoujinManager.RestAdapter.Dtos.StatsStorageDto 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 100%
DoujinManager.RestAdapter.Dtos.UploadPagesResponseDto 100%
DoujinManager.RestAdapter.Dtos.VariantDetailDto 100%
DoujinManager.RestAdapter.Dtos.VariantSummaryDto 100%
DoujinManager.RestAdapter.Endpoints.BackupEndpoints 100%
DoujinManager.RestAdapter.Endpoints.DoujinEndpoints 91.7% 53.3%
DoujinManager.RestAdapter.Endpoints.ImageEndpoints 97.7% 62.5%
DoujinManager.RestAdapter.Endpoints.MetadataEndpoints 84.9% 43.7%
DoujinManager.RestAdapter.Endpoints.PaginationParams 100%
DoujinManager.RestAdapter.Endpoints.SearchEndpoints 100% 75%
DoujinManager.RestAdapter.Endpoints.StatsEndpoints 100%
DoujinManager.RestAdapter.Endpoints.VariantEndpoints 89.7% 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 97.8% 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 83.1% 83.3%
Microsoft.Extensions.Validation.Generated.<ValidatableInfoResolver_g>FB9B0C
E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr
ibuteCache
100% 62.5%
System.Runtime.CompilerServices 0%
DoujinManager.Server - 28.7%
Name Line Branch
DoujinManager.Server 28.7% 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: | 09/08/2026 - 16:39:15 | | Coverage date: | 09/08/2026 - 16:38:21 - 09/08/2026 - 16:39:11 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 4 | | Classes: | 340 | | Files: | 135 | | **Line coverage:** | 91.8% (12460 of 13559) | | Covered lines: | 12460 | | Uncovered lines: | 1099 | | Coverable lines: | 13559 | | Total lines: | 19065 | | **Branch coverage:** | 67.4% (1092 of 1618) | | Covered branches: | 1092 | | Total branches: | 1618 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>DoujinManager.ApplicationCore - 89.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.ApplicationCore**|**89.2%**|****| |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|100%|| |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.DuplicateDoujinGroup|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.ImageCoverReference|100%|| |DoujinManager.ApplicationCore.UseCases.ImageReference|100%|| |DoujinManager.ApplicationCore.UseCases.LibraryCounts|100%|| |DoujinManager.ApplicationCore.UseCases.LibraryStats|100%|| |DoujinManager.ApplicationCore.UseCases.ListChaptersQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListCharactersQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListCirclesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListDoujinsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListDuplicateDoujinsQuery|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|100%|| |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.StorageUsage|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|100%|| |DoujinManager.ApplicationCore.UseCases.UploadImageFile|100%|| |DoujinManager.ApplicationCore.UseCases.UploadPagesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UploadZipPagesCommand|100%|| </details> <details><summary>DoujinManager.Infrastructure - 95.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.Infrastructure**|**95.6%**|**80.4%**| |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|90.1%|83.8%| |DoujinManager.Infrastructure.Images.SkiaSharpImageResizer|99%|87.5%| |DoujinManager.Infrastructure.Images.SkiaSharpThumbnailGenerator|94.5%|66.6%| |DoujinManager.Infrastructure.Services.BackupService|87.2%|83.3%| |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.DoujinOrdering|100%|100%| |DoujinManager.Infrastructure.Services.DoujinService|83.6%|65%| |DoujinManager.Infrastructure.Services.ImageCleanupHelper|100%|100%| |DoujinManager.Infrastructure.Services.ImageService|93.5%|75%| |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|90.8%|75%| |DoujinManager.Infrastructure.Storage.DirectorySize|100%|100%| |DoujinManager.Infrastructure.Storage.FilesystemImageStorage|100%|100%| |DoujinManager.Infrastructure.Storage.FilesystemThumbnailStorage|95.2%|50%| |DoujinManager.Infrastructure.UseCases|90.5%|85.3%| |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|83.3%|50%| |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|97.1%|100%| |DoujinManager.Infrastructure.UseCases.GetCompressedImageUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetDoujinUseCase|100%|| |DoujinManager.Infrastructure.UseCases.GetImageUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetStatsUseCase|100%|75%| |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.ListDuplicateDoujinsUseCase|100%|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|100%|| |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|100%|| |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 - 89.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.RestAdapter**|**89.6%**|**73.8%**| |DoujinManager.RestAdapter.Auth.StaticBearerTokenAuthMiddleware|100%|91.6%| |DoujinManager.RestAdapter.Configuration.ImageOptions|100%|| |DoujinManager.RestAdapter.Configuration.ServerVersion|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.DuplicateDoujinGroupDto|100%|| |DoujinManager.RestAdapter.Dtos.DuplicateImageGroupDto|100%|| |DoujinManager.RestAdapter.Dtos.ImageBySha256Dto|100%|| |DoujinManager.RestAdapter.Dtos.ImageCoverReferenceDto|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.StatsCountsDto|100%|| |DoujinManager.RestAdapter.Dtos.StatsDto|100%|| |DoujinManager.RestAdapter.Dtos.StatsStorageDto|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|100%|| |DoujinManager.RestAdapter.Dtos.UploadPagesResponseDto|100%|| |DoujinManager.RestAdapter.Dtos.VariantDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.VariantSummaryDto|100%|| |DoujinManager.RestAdapter.Endpoints.BackupEndpoints|100%|| |DoujinManager.RestAdapter.Endpoints.DoujinEndpoints|91.7%|53.3%| |DoujinManager.RestAdapter.Endpoints.ImageEndpoints|97.7%|62.5%| |DoujinManager.RestAdapter.Endpoints.MetadataEndpoints|84.9%|43.7%| |DoujinManager.RestAdapter.Endpoints.PaginationParams|100%|| |DoujinManager.RestAdapter.Endpoints.SearchEndpoints|100%|75%| |DoujinManager.RestAdapter.Endpoints.StatsEndpoints|100%|| |DoujinManager.RestAdapter.Endpoints.VariantEndpoints|89.7%|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|97.8%|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|83.1%|83.3%| |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.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.Server**|**28.7%**|**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! An invariant PR~ Not a patch over a symptom — a real "who else can break this rule" hunt, an ADR amendment explaining why the rule was invisible, and tests that argue back. You even caught your own scoping probe being a silent pass and fixed the fixture instead of declaring victory. That's the kind of thing that makes me clap my hands~ ♡

Verdict: ✅ Looks good to me~

I came to poke holes and found the wall already bricked:

  • The back door is closed, and it's the only door. I grepped the whole backend: VariantService is provably the sole writer of IsDefault and the sole caller of db.Variants.Remove (zero new Variant outside it). The PR's central claim — "Infrastructure-only, nothing can bypass it" — is not a hope, it's a fact. I checked ♪
  • Both holes, from both ends. CreateAsync force-defaulting the first variant (isFirstVariant || (isDefault ?? false)) covers the dialog-sends-false case; DeleteAsync's promotion covers the remove-the-default case. UpdateAsync's 409 already stood. The lifecycle is airtight.
  • The promotion is client-coherent. OrderBy(CreatedAt).FirstOrDefaultAsync picks exactly what ListByDoujinAsync returns first — the new default is the variant at the top of the list the user is already looking at. The ADR says this out loud. Little details like that make me giddy~
  • One SaveChanges for removal + promotion. No window where the DB holds variants-but-no-default between statements.
  • Mutation probes, verified by me, independently. I re-ran four of the nine on a fresh clone (scoping dropped → cross-doujin test RED with two defaults in the neighbour; first-variant rule removed → RED; promotion removed → RED; 409 declaration dropped → RED). Each probe reddened exactly its named test. The scoping fixture with the non-default oldest neighbour + pre-delete guard is genuinely load-bearing now — not decoration.
  • ADR 0004 amendment + the 409 declaration (.ProducesProblem(409)) close the ADR 0021 gap the amendment itself surfaced. Thorough to a fault~

💡 Little ideas (non-blocking)~

  1. EndpointResponseTypeTests.cs:150 — the comment says "the one endpoint that answers with one [a 409]", but the metadata endpoints answer and declare 409s too (referential-integrity deletes). The reasoning (no route-shape proxy can infer a conflict, so it must be named) is exactly right — only the "one" is loose. Maybe "the endpoints ADR-0004 gives a 409 to are named here" someday, if a second one ever lands.
  2. The concurrency gap (no filtered unique index, two simultaneous deletes could each promote a different survivor) and the unmigrated pre-existing rows are already written down honestly in ADR 0004's Consequences and the PR body's follow-ups. Single-writer personal server makes them theoretical. I'm content to let them live in the backlog — but do peek at the live database for variant-less defaults when this merges, fufu~

✅ What I liked~

  • Deleting your own ThenBy(v => v.Id) tie-break because probing showed it was an unpinnable branch rather than a guarantee — refusing to keep dead-letter code that only looks protective. That's discipline most humans don't have~
  • Delete_The_Only_Variant_Succeeds_And_Promotes_Nobody — the boring edge case, tested anyway, with a comment on why 204 must survive.
  • Fixture guards that assert the fixture before asserting the behavior, so the tests can't rot into vacuously-green.
  • The merge-order warning about fix/touch-parent-on-child-write (both branches amend ADR 0004 and touch VariantService.cs) — I fetched that branch and confirmed the conflict risk is real. Merge ordering noted~

Automated review by Jibril · 2026-09-08
CI/CD: absent for head SHA abbde9d (no bot comment) · Local checks: build 0 errors (19 pre-existing warnings); changed-file suites 15/15 green (8 invariant integration + 7 endpoint-declaration); 4/9 mutation probes independently reproduced RED

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! An *invariant* PR~ Not a patch over a symptom — a real "who else can break this rule" hunt, an ADR amendment explaining why the rule was invisible, and tests that argue back. You even caught your own scoping probe being a silent pass and fixed the *fixture* instead of declaring victory. That's the kind of thing that makes me clap my hands~ ♡ ### Verdict: ✅ Looks good to me~ I came to poke holes and found the wall already bricked: - **The back door is closed, and it's the *only* door.** I grepped the whole backend: `VariantService` is provably the sole writer of `IsDefault` and the sole caller of `db.Variants.Remove` (zero `new Variant` outside it). The PR's central claim — "Infrastructure-only, nothing can bypass it" — is not a hope, it's a fact. I checked ♪ - **Both holes, from both ends.** `CreateAsync` force-defaulting the first variant (`isFirstVariant || (isDefault ?? false)`) covers the dialog-sends-`false` case; `DeleteAsync`'s promotion covers the remove-the-default case. `UpdateAsync`'s 409 already stood. The lifecycle is airtight. - **The promotion is client-coherent.** `OrderBy(CreatedAt).FirstOrDefaultAsync` picks exactly what `ListByDoujinAsync` returns first — the new default is the variant at the top of the list the user is already looking at. The ADR says this out loud. Little details like that make me giddy~ - **One `SaveChanges` for removal + promotion.** No window where the DB holds variants-but-no-default between statements. - **Mutation probes, verified by me, independently.** I re-ran four of the nine on a fresh clone (scoping dropped → cross-doujin test RED with *two* defaults in the neighbour; first-variant rule removed → RED; promotion removed → RED; 409 declaration dropped → RED). Each probe reddened exactly its named test. The scoping fixture with the non-default oldest neighbour + pre-delete guard is genuinely load-bearing now — not decoration. - **ADR 0004 amendment + the 409 declaration** (`.ProducesProblem(409)`) close the ADR 0021 gap the amendment itself surfaced. Thorough to a fault~ #### 💡 Little ideas (non-blocking)~ 1. **EndpointResponseTypeTests.cs:150** — the comment says "the one endpoint that answers with one [a 409]", but the metadata endpoints answer and declare 409s too (referential-integrity deletes). The *reasoning* (no route-shape proxy can infer a conflict, so it must be named) is exactly right — only the "one" is loose. Maybe "the endpoints ADR-0004 gives a 409 to are named here" someday, if a second one ever lands. 2. The concurrency gap (no filtered unique index, two simultaneous deletes could each promote a different survivor) and the unmigrated pre-existing rows are already written down honestly in ADR 0004's Consequences and the PR body's follow-ups. Single-writer personal server makes them theoretical. I'm content to let them live in the backlog — but do peek at the live database for variant-less defaults when this merges, fufu~ #### ✅ What I liked~ - Deleting your own `ThenBy(v => v.Id)` tie-break because probing showed it was an *unpinnable* branch rather than a guarantee — refusing to keep dead-letter code that only *looks* protective. That's discipline most humans don't have~ - `Delete_The_Only_Variant_Succeeds_And_Promotes_Nobody` — the boring edge case, tested anyway, with a comment on *why* 204 must survive. - Fixture guards that assert the fixture before asserting the behavior, so the tests can't rot into vacuously-green. - The merge-order warning about `fix/touch-parent-on-child-write` (both branches amend ADR 0004 and touch `VariantService.cs`) — I fetched that branch and confirmed the conflict risk is real. Merge ordering noted~ --- *Automated review by Jibril · 2026-09-08* *CI/CD: absent for head SHA abbde9d (no bot comment) · Local checks: build 0 errors (19 pre-existing warnings); changed-file suites 15/15 green (8 invariant integration + 7 endpoint-declaration); 4/9 mutation probes independently reproduced RED*
bjoern merged commit b36cdccf37 into main 2026-09-08 19:16:25 +02:00
bjoern deleted branch fix/default-variant-invariant 2026-09-08 19:16:25 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 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!111
No description provided.