feat(backend): entity management — aliases, delete with referential integrity, HATEOAS #40

Merged
bjoern merged 3 commits from feat/backend-entity-management into main 2026-08-13 06:23:57 +02:00
Member

Backend: Entity Management — Aliases, Delete with Referential Integrity, HATEOAS

Backend expansion for the upcoming Flutter entity management UI (Phase 10i).

New capabilities

Aliases (Person + Circle only)

  • List<string> Aliases stored as JSON column (aliases TEXT DEFAULT '[]')
  • POST /api/people/{id}/aliases — add alias (idempotent, case-insensitive)
  • DELETE /api/people/{id}/aliases/{alias} — remove alias
  • Same for circles

Tag update (was missing)

  • PUT /api/tags/{id} — rename, change namespace, with duplicate-name conflict check

Delete with referential integrity

  • DELETE /api/tags/{id}, /api/people/{id}, /api/circles/{id}
  • Returns 409 Conflict when doujins still reference the entity — response includes extensions.doujinIds array
  • Returns 204 No Content on success

Conditional HATEOAS

  • BuildTagActions/BuildPersonActions/BuildCircleActions now take bool hasUsage
  • Delete link only emitted when entity has no doujin usage
  • Person/Circle responses include addAlias action link

Verification

  • dotnet build: 0 warnings, 0 errors ✅
  • dotnet test: 303/303 passed (35 new) ✅
## Backend: Entity Management — Aliases, Delete with Referential Integrity, HATEOAS Backend expansion for the upcoming Flutter entity management UI (Phase 10i). ### New capabilities **Aliases (Person + Circle only)** - `List<string> Aliases` stored as JSON column (`aliases TEXT DEFAULT '[]'`) - `POST /api/people/{id}/aliases` — add alias (idempotent, case-insensitive) - `DELETE /api/people/{id}/aliases/{alias}` — remove alias - Same for circles **Tag update (was missing)** - `PUT /api/tags/{id}` — rename, change namespace, with duplicate-name conflict check **Delete with referential integrity** - `DELETE /api/tags/{id}`, `/api/people/{id}`, `/api/circles/{id}` - Returns **409 Conflict** when doujins still reference the entity — response includes `extensions.doujinIds` array - Returns **204 No Content** on success **Conditional HATEOAS** - `BuildTagActions`/`BuildPersonActions`/`BuildCircleActions` now take `bool hasUsage` - Delete link only emitted when entity has no doujin usage - Person/Circle responses include `addAlias` action link ### Verification - `dotnet build`: 0 warnings, 0 errors ✅ - `dotnet test`: **303/303 passed** (35 new) ✅
feat(backend): entity management — aliases, delete with referential integrity, HATEOAS
All checks were successful
CI / build (pull_request) Successful in 19s
CI / test (pull_request) Successful in 43s
049cf438a9
Domain model:
- Person + Circle now have List<string> Aliases (JSON column in SQLite)
- Tags do NOT get aliases (Name + Namespace is sufficient)

New endpoints:
- PUT /api/tags/{id} — rename tag, change namespace (was missing)
- DELETE /api/tags/{id} — 409 Conflict if doujins still use it
- DELETE /api/people/{id} — 409 Conflict with doujin IDs in response
- DELETE /api/circles/{id} — 409 Conflict with doujin IDs in response
- POST /api/people/{id}/aliases — add alias (idempotent, case-insensitive)
- DELETE /api/people/{id}/aliases/{alias} — remove alias
- POST /api/circles/{id}/aliases + DELETE — same for circles

HATEOAS:
- BuildTagActions/BuildPersonActions/BuildCircleActions now take bool hasUsage
- Delete link only emitted when entity has no doujin usage
- Person/Circle responses include addAlias action link
- All call sites updated to pass hasUsage based on Doujins.Count

35 new tests (303 total): entity aliases, tag update, delete success+conflict,
alias add/remove, HATEOAS conditional actions.

Summary

Summary
Generated on: 07/01/2026 - 04:21:50
Coverage date: 07/01/2026 - 04:21:28 - 07/01/2026 - 04:21:48
Parser: MultiReport (4x Cobertura)
Assemblies: 4
Classes: 235
Files: 102
Line coverage: 88.2% (6692 of 7584)
Covered lines: 6692
Uncovered lines: 892
Coverable lines: 7584
Total lines: 11844
Branch coverage: 52.9% (465 of 879)
Covered branches: 465
Total branches: 879
Method coverage: Feature is only available for sponsors

Coverage

DoujinManager.ApplicationCore - 85.9%
Name Line Branch
DoujinManager.ApplicationCore 85.9% ****
DoujinManager.ApplicationCore.Entities.Chapter 87.5%
DoujinManager.ApplicationCore.Entities.Circle 100%
DoujinManager.ApplicationCore.Entities.Doujin 100%
DoujinManager.ApplicationCore.Entities.DoujinCircle 75%
DoujinManager.ApplicationCore.Entities.DoujinPerson 80%
DoujinManager.ApplicationCore.Entities.DoujinTag 75%
DoujinManager.ApplicationCore.Entities.ImageFile 100%
DoujinManager.ApplicationCore.Entities.Page 80%
DoujinManager.ApplicationCore.Entities.Person 100%
DoujinManager.ApplicationCore.Entities.Tag 100%
DoujinManager.ApplicationCore.Entities.Title 83.3%
DoujinManager.ApplicationCore.Entities.Variant 91.6%
DoujinManager.ApplicationCore.Ids.ChapterId 66.6%
DoujinManager.ApplicationCore.Ids.CircleId 66.6%
DoujinManager.ApplicationCore.Ids.DoujinId 100%
DoujinManager.ApplicationCore.Ids.ImageFileId 66.6%
DoujinManager.ApplicationCore.Ids.PageId 66.6%
DoujinManager.ApplicationCore.Ids.PersonId 66.6%
DoujinManager.ApplicationCore.Ids.TagId 66.6%
DoujinManager.ApplicationCore.Ids.TitleId 66.6%
DoujinManager.ApplicationCore.Ids.VariantId 66.6%
DoujinManager.ApplicationCore.Ports.ExtractedImage 100%
DoujinManager.ApplicationCore.Ports.ImageInspection 100%
DoujinManager.ApplicationCore.Services.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.AddCircleAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddPersonAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddTagAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddTitleCommand 0%
DoujinManager.ApplicationCore.UseCases.AssignCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignPersonCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignTagCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateChapterCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.CreatePersonCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateTagCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateTitleCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateVariantCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteChapterCommand 0%
DoujinManager.ApplicationCore.UseCases.DeleteCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.DeletePageCommand 100%
DoujinManager.ApplicationCore.UseCases.DeletePersonCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteTagCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteVariantCommand 0%
DoujinManager.ApplicationCore.UseCases.GetDoujinQuery 100%
DoujinManager.ApplicationCore.UseCases.GetImageQuery 100%
DoujinManager.ApplicationCore.UseCases.GetImageResult 100%
DoujinManager.ApplicationCore.UseCases.GetThumbnailQuery 100%
DoujinManager.ApplicationCore.UseCases.GetThumbnailResult 100%
DoujinManager.ApplicationCore.UseCases.GetVariantQuery 100%
DoujinManager.ApplicationCore.UseCases.ListChaptersQuery 100%
DoujinManager.ApplicationCore.UseCases.ListCirclesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListDoujinsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListPagesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListPeopleQuery 100%
DoujinManager.ApplicationCore.UseCases.ListTagsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListVariantsQuery 100%
DoujinManager.ApplicationCore.UseCases.RemoveCircleAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveCircleCommand 0%
DoujinManager.ApplicationCore.UseCases.RemovePersonAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.RemovePersonCommand 0%
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.UpdateCircleCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdatePersonCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateTagCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdateVariantCommand 0%
DoujinManager.ApplicationCore.UseCases.UploadImageFile 100%
DoujinManager.ApplicationCore.UseCases.UploadPagesCommand 100%
DoujinManager.ApplicationCore.UseCases.UploadZipPagesCommand 100%
DoujinManager.Infrastructure - 94.8%
Name Line Branch
DoujinManager.Infrastructure 94.8% 71.6%
DoujinManager.Infrastructure.Archives.ZipExtractor 100% 87.5%
DoujinManager.Infrastructure.Data.Configurations.ChapterConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.CircleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinCircleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinPersonConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinTagConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.ImageFileConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.PageConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.PersonConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.TagConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.TitleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.VariantConfiguration 100%
DoujinManager.Infrastructure.Data.DoujinManagerDbContext 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.AddTagAliases 99.3%
DoujinManager.Infrastructure.Data.Migrations.DoujinManagerDbContextModelSna
pshot
100%
DoujinManager.Infrastructure.Data.Migrations.InitialCreate 97.1%
DoujinManager.Infrastructure.Data.ModelBuilderExtensions 50%
DoujinManager.Infrastructure.Data.StronglyTypedIdConverterFactory 100%
DoujinManager.Infrastructure.Images.SkiaSharpImageInspector 88.2% 70.9%
DoujinManager.Infrastructure.Images.SkiaSharpThumbnailGenerator 94.5% 66.6%
DoujinManager.Infrastructure.Services.BackupService 85.1% 66.6%
DoujinManager.Infrastructure.Services.ChapterService 54.2% 25%
DoujinManager.Infrastructure.Services.CircleService 81.8% 27.7%
DoujinManager.Infrastructure.Services.DoujinService 69.6% 46.4%
DoujinManager.Infrastructure.Services.ImageService 92% 50%
DoujinManager.Infrastructure.Services.PageService 84.9% 65%
DoujinManager.Infrastructure.Services.PersonService 81.8% 33.3%
DoujinManager.Infrastructure.Services.SearchService 100% 98.5%
DoujinManager.Infrastructure.Services.TagService 98.7% 87.5%
DoujinManager.Infrastructure.Services.VariantService 50.7% 16.6%
DoujinManager.Infrastructure.Storage.FilesystemImageStorage 100% 100%
DoujinManager.Infrastructure.Storage.FilesystemThumbnailStorage 95% 50%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__CoverImageHelper
100% 100%
DoujinManager.Infrastructure.UseCases.AddCircleAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.AddPersonAliasUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AddTagAliasUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AddTitleUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.AssignCircleUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignPersonUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateChapterUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateCircleUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateDoujinUseCase 100% 98%
DoujinManager.Infrastructure.UseCases.CreatePersonUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateVariantUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.DeleteChapterUseCase 0%
DoujinManager.Infrastructure.UseCases.DeleteCircleUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.DeleteDoujinUseCase 100%
DoujinManager.Infrastructure.UseCases.DeletePageUseCase 92.3% 75%
DoujinManager.Infrastructure.UseCases.DeletePersonUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.DeleteTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.DeleteVariantUseCase 0%
DoujinManager.Infrastructure.UseCases.GetDoujinUseCase 100%
DoujinManager.Infrastructure.UseCases.GetImageUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetThumbnailUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetVariantUseCase 100%
DoujinManager.Infrastructure.UseCases.ListChaptersUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.ListCirclesUseCase 100%
DoujinManager.Infrastructure.UseCases.ListDoujinsUseCase 100%
DoujinManager.Infrastructure.UseCases.ListPagesUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.ListPeopleUseCase 100%
DoujinManager.Infrastructure.UseCases.ListTagsUseCase 100%
DoujinManager.Infrastructure.UseCases.ListVariantsUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.RemoveCircleAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.RemoveCircleUseCase 0%
DoujinManager.Infrastructure.UseCases.RemovePersonAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.RemovePersonUseCase 0%
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.UpdateCircleUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateDoujinUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdatePersonUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdateVariantUseCase 0%
DoujinManager.Infrastructure.UseCases.UploadPagesUseCase 91.4% 91.6%
DoujinManager.Infrastructure.UseCases.UploadZipPagesUseCase 71.7% 70%
DoujinManager.RestAdapter - 85.8%
Name Line Branch
DoujinManager.RestAdapter 85.8% 70.3%
DoujinManager.RestAdapter.Auth.StaticBearerTokenAuthMiddleware 100% 91.6%
DoujinManager.RestAdapter.Dtos.AddAliasDto 100%
DoujinManager.RestAdapter.Dtos.AssignTagDto 0%
DoujinManager.RestAdapter.Dtos.BackupDto 100%
DoujinManager.RestAdapter.Dtos.ChapterDto 100%
DoujinManager.RestAdapter.Dtos.CircleDto 100%
DoujinManager.RestAdapter.Dtos.CreateChapterDto 100%
DoujinManager.RestAdapter.Dtos.CreateCircleDto 100%
DoujinManager.RestAdapter.Dtos.CreateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.CreatePersonDto 100%
DoujinManager.RestAdapter.Dtos.CreateTagDto 100%
DoujinManager.RestAdapter.Dtos.CreateTitleDto 100%
DoujinManager.RestAdapter.Dtos.CreateVariantDto 100%
DoujinManager.RestAdapter.Dtos.DoujinDetailDto 100%
DoujinManager.RestAdapter.Dtos.DoujinPersonDto 100%
DoujinManager.RestAdapter.Dtos.DoujinSummaryDto 100%
DoujinManager.RestAdapter.Dtos.ImageFileSummaryDto 0%
DoujinManager.RestAdapter.Dtos.LinkCircleDto 0%
DoujinManager.RestAdapter.Dtos.LinkPersonDto 100%
DoujinManager.RestAdapter.Dtos.PageDetailDto 100%
DoujinManager.RestAdapter.Dtos.PageDto 100%
DoujinManager.RestAdapter.Dtos.PersonDto 100%
DoujinManager.RestAdapter.Dtos.ReorderPagesDto 100%
DoujinManager.RestAdapter.Dtos.SearchDoujinsDto 100%
DoujinManager.RestAdapter.Dtos.SearchResultDto 100%
DoujinManager.RestAdapter.Dtos.TagDto 100%
DoujinManager.RestAdapter.Dtos.TitleDto 100%
DoujinManager.RestAdapter.Dtos.UpdateChapterDto 0%
DoujinManager.RestAdapter.Dtos.UpdateCircleDto 0%
DoujinManager.RestAdapter.Dtos.UpdateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.UpdatePersonDto 0%
DoujinManager.RestAdapter.Dtos.UpdateTagDto 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 84% 62.5%
DoujinManager.RestAdapter.Endpoints.ImageEndpoints 97.1% 50%
DoujinManager.RestAdapter.Endpoints.MetadataEndpoints 93.6% 50%
DoujinManager.RestAdapter.Endpoints.PaginationParams 100%
DoujinManager.RestAdapter.Endpoints.SearchEndpoints 100% 50%
DoujinManager.RestAdapter.Endpoints.VariantEndpoints 72.1% 37.5%
DoujinManager.RestAdapter.Envelopes.CollectionResponse`1 83.3%
DoujinManager.RestAdapter.Envelopes.EnvelopeDefaults 0%
DoujinManager.RestAdapter.Envelopes.EnvelopeJsonOptions 100%
DoujinManager.RestAdapter.Envelopes.ErrorResponse 100%
DoujinManager.RestAdapter.Envelopes.HypermediaAction 100%
DoujinManager.RestAdapter.Envelopes.HypermediaHelpers 87.7% 91.6%
DoujinManager.RestAdapter.Envelopes.Link 100%
DoujinManager.RestAdapter.Envelopes.MediaTypeJsonConverter 62.5% 53.8%
DoujinManager.RestAdapter.Envelopes.PageInfo 100%
DoujinManager.RestAdapter.Envelopes.ResourceResponse`1 80%
DoujinManager.RestAdapter.Envelopes.ValidationError 100%
DoujinManager.RestAdapter.Envelopes.ValidationErrorResponse 100%
DoujinManager.RestAdapter.Middleware.GlobalExceptionMiddleware 100% 50%
DoujinManager.RestAdapter.Middleware.RequestLoggingMiddleware 100% 100%
DoujinManager.RestAdapter.RestAdapterExtensions 100% 100%
Microsoft.Extensions.Validation.Generated 79% 80%
Microsoft.Extensions.Validation.Generated.<ValidatableInfoResolver_g>FB9B0C
E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr
ibuteCache
100% 62.5%
System.Runtime.CompilerServices 0%
DoujinManager.Server - 23.9%
Name Line Branch
DoujinManager.Server 23.9% 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: | 07/01/2026 - 04:21:50 | | Coverage date: | 07/01/2026 - 04:21:28 - 07/01/2026 - 04:21:48 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 4 | | Classes: | 235 | | Files: | 102 | | **Line coverage:** | 88.2% (6692 of 7584) | | Covered lines: | 6692 | | Uncovered lines: | 892 | | Coverable lines: | 7584 | | Total lines: | 11844 | | **Branch coverage:** | 52.9% (465 of 879) | | Covered branches: | 465 | | Total branches: | 879 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>DoujinManager.ApplicationCore - 85.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.ApplicationCore**|**85.9%**|****| |DoujinManager.ApplicationCore.Entities.Chapter|87.5%|| |DoujinManager.ApplicationCore.Entities.Circle|100%|| |DoujinManager.ApplicationCore.Entities.Doujin|100%|| |DoujinManager.ApplicationCore.Entities.DoujinCircle|75%|| |DoujinManager.ApplicationCore.Entities.DoujinPerson|80%|| |DoujinManager.ApplicationCore.Entities.DoujinTag|75%|| |DoujinManager.ApplicationCore.Entities.ImageFile|100%|| |DoujinManager.ApplicationCore.Entities.Page|80%|| |DoujinManager.ApplicationCore.Entities.Person|100%|| |DoujinManager.ApplicationCore.Entities.Tag|100%|| |DoujinManager.ApplicationCore.Entities.Title|83.3%|| |DoujinManager.ApplicationCore.Entities.Variant|91.6%|| |DoujinManager.ApplicationCore.Ids.ChapterId|66.6%|| |DoujinManager.ApplicationCore.Ids.CircleId|66.6%|| |DoujinManager.ApplicationCore.Ids.DoujinId|100%|| |DoujinManager.ApplicationCore.Ids.ImageFileId|66.6%|| |DoujinManager.ApplicationCore.Ids.PageId|66.6%|| |DoujinManager.ApplicationCore.Ids.PersonId|66.6%|| |DoujinManager.ApplicationCore.Ids.TagId|66.6%|| |DoujinManager.ApplicationCore.Ids.TitleId|66.6%|| |DoujinManager.ApplicationCore.Ids.VariantId|66.6%|| |DoujinManager.ApplicationCore.Ports.ExtractedImage|100%|| |DoujinManager.ApplicationCore.Ports.ImageInspection|100%|| |DoujinManager.ApplicationCore.Services.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.AddCircleAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddPersonAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddTagAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddTitleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.AssignCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignPersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateChapterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreatePersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateTitleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateVariantCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteChapterCommand|0%|| |DoujinManager.ApplicationCore.UseCases.DeleteCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeletePageCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeletePersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteVariantCommand|0%|| |DoujinManager.ApplicationCore.UseCases.GetDoujinQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetImageQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetImageResult|100%|| |DoujinManager.ApplicationCore.UseCases.GetThumbnailQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetThumbnailResult|100%|| |DoujinManager.ApplicationCore.UseCases.GetVariantQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListChaptersQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListCirclesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListDoujinsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListPagesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListPeopleQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListTagsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListVariantsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveCircleAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveCircleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemovePersonAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemovePersonCommand|0%|| |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.UpdateCircleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdatePersonCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateTagCommand|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 - 94.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.Infrastructure**|**94.8%**|**71.6%**| |DoujinManager.Infrastructure.Archives.ZipExtractor|100%|87.5%| |DoujinManager.Infrastructure.Data.Configurations.ChapterConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.CircleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinCircleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinPersonConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinTagConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.ImageFileConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.PageConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.PersonConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.TagConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.TitleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.VariantConfiguration|100%|| |DoujinManager.Infrastructure.Data.DoujinManagerDbContext|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.AddTagAliases|99.3%|| |DoujinManager.Infrastructure.Data.Migrations.DoujinManagerDbContextModelSna<br/>pshot|100%|| |DoujinManager.Infrastructure.Data.Migrations.InitialCreate|97.1%|| |DoujinManager.Infrastructure.Data.ModelBuilderExtensions|50%|| |DoujinManager.Infrastructure.Data.StronglyTypedIdConverterFactory|100%|| |DoujinManager.Infrastructure.Images.SkiaSharpImageInspector|88.2%|70.9%| |DoujinManager.Infrastructure.Images.SkiaSharpThumbnailGenerator|94.5%|66.6%| |DoujinManager.Infrastructure.Services.BackupService|85.1%|66.6%| |DoujinManager.Infrastructure.Services.ChapterService|54.2%|25%| |DoujinManager.Infrastructure.Services.CircleService|81.8%|27.7%| |DoujinManager.Infrastructure.Services.DoujinService|69.6%|46.4%| |DoujinManager.Infrastructure.Services.ImageService|92%|50%| |DoujinManager.Infrastructure.Services.PageService|84.9%|65%| |DoujinManager.Infrastructure.Services.PersonService|81.8%|33.3%| |DoujinManager.Infrastructure.Services.SearchService|100%|98.5%| |DoujinManager.Infrastructure.Services.TagService|98.7%|87.5%| |DoujinManager.Infrastructure.Services.VariantService|50.7%|16.6%| |DoujinManager.Infrastructure.Storage.FilesystemImageStorage|100%|100%| |DoujinManager.Infrastructure.Storage.FilesystemThumbnailStorage|95%|50%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__CoverImageHelper|100%|100%| |DoujinManager.Infrastructure.UseCases.AddCircleAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.AddPersonAliasUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AddTagAliasUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AddTitleUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.AssignCircleUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignPersonUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateChapterUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateCircleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateDoujinUseCase|100%|98%| |DoujinManager.Infrastructure.UseCases.CreatePersonUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateVariantUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.DeleteChapterUseCase|0%|| |DoujinManager.Infrastructure.UseCases.DeleteCircleUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.DeleteDoujinUseCase|100%|| |DoujinManager.Infrastructure.UseCases.DeletePageUseCase|92.3%|75%| |DoujinManager.Infrastructure.UseCases.DeletePersonUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.DeleteTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.DeleteVariantUseCase|0%|| |DoujinManager.Infrastructure.UseCases.GetDoujinUseCase|100%|| |DoujinManager.Infrastructure.UseCases.GetImageUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetThumbnailUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetVariantUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListChaptersUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.ListCirclesUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListDoujinsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListPagesUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.ListPeopleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListTagsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListVariantsUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.RemoveCircleAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.RemoveCircleUseCase|0%|| |DoujinManager.Infrastructure.UseCases.RemovePersonAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.RemovePersonUseCase|0%|| |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.UpdateCircleUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateDoujinUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdatePersonUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdateVariantUseCase|0%|| |DoujinManager.Infrastructure.UseCases.UploadPagesUseCase|91.4%|91.6%| |DoujinManager.Infrastructure.UseCases.UploadZipPagesUseCase|71.7%|70%| </details> <details><summary>DoujinManager.RestAdapter - 85.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.RestAdapter**|**85.8%**|**70.3%**| |DoujinManager.RestAdapter.Auth.StaticBearerTokenAuthMiddleware|100%|91.6%| |DoujinManager.RestAdapter.Dtos.AddAliasDto|100%|| |DoujinManager.RestAdapter.Dtos.AssignTagDto|0%|| |DoujinManager.RestAdapter.Dtos.BackupDto|100%|| |DoujinManager.RestAdapter.Dtos.ChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.CircleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateCircleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.CreatePersonDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateTagDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateTitleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateVariantDto|100%|| |DoujinManager.RestAdapter.Dtos.DoujinDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.DoujinPersonDto|100%|| |DoujinManager.RestAdapter.Dtos.DoujinSummaryDto|100%|| |DoujinManager.RestAdapter.Dtos.ImageFileSummaryDto|0%|| |DoujinManager.RestAdapter.Dtos.LinkCircleDto|0%|| |DoujinManager.RestAdapter.Dtos.LinkPersonDto|100%|| |DoujinManager.RestAdapter.Dtos.PageDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.PageDto|100%|| |DoujinManager.RestAdapter.Dtos.PersonDto|100%|| |DoujinManager.RestAdapter.Dtos.ReorderPagesDto|100%|| |DoujinManager.RestAdapter.Dtos.SearchDoujinsDto|100%|| |DoujinManager.RestAdapter.Dtos.SearchResultDto|100%|| |DoujinManager.RestAdapter.Dtos.TagDto|100%|| |DoujinManager.RestAdapter.Dtos.TitleDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateChapterDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateCircleDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdatePersonDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateTagDto|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|84%|62.5%| |DoujinManager.RestAdapter.Endpoints.ImageEndpoints|97.1%|50%| |DoujinManager.RestAdapter.Endpoints.MetadataEndpoints|93.6%|50%| |DoujinManager.RestAdapter.Endpoints.PaginationParams|100%|| |DoujinManager.RestAdapter.Endpoints.SearchEndpoints|100%|50%| |DoujinManager.RestAdapter.Endpoints.VariantEndpoints|72.1%|37.5%| |DoujinManager.RestAdapter.Envelopes.CollectionResponse`1|83.3%|| |DoujinManager.RestAdapter.Envelopes.EnvelopeDefaults|0%|| |DoujinManager.RestAdapter.Envelopes.EnvelopeJsonOptions|100%|| |DoujinManager.RestAdapter.Envelopes.ErrorResponse|100%|| |DoujinManager.RestAdapter.Envelopes.HypermediaAction|100%|| |DoujinManager.RestAdapter.Envelopes.HypermediaHelpers|87.7%|91.6%| |DoujinManager.RestAdapter.Envelopes.Link|100%|| |DoujinManager.RestAdapter.Envelopes.MediaTypeJsonConverter|62.5%|53.8%| |DoujinManager.RestAdapter.Envelopes.PageInfo|100%|| |DoujinManager.RestAdapter.Envelopes.ResourceResponse`1|80%|| |DoujinManager.RestAdapter.Envelopes.ValidationError|100%|| |DoujinManager.RestAdapter.Envelopes.ValidationErrorResponse|100%|| |DoujinManager.RestAdapter.Middleware.GlobalExceptionMiddleware|100%|50%| |DoujinManager.RestAdapter.Middleware.RequestLoggingMiddleware|100%|100%| |DoujinManager.RestAdapter.RestAdapterExtensions|100%|100%| |Microsoft.Extensions.Validation.Generated|79%|80%| |Microsoft.Extensions.Validation.Generated.<ValidatableInfoResolver_g>FB9B0C<br/>E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr<br/>ibuteCache|100%|62.5%| |System.Runtime.CompilerServices|0%|| </details> <details><summary>DoujinManager.Server - 23.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.Server**|**23.9%**|**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>
Owner

Aliases (Person + Circle only)

Why? Tags could also have aliases. E.g "gender bender" and "tg"

> Aliases (Person + Circle only) Why? Tags could also have aliases. E.g "gender bender" and "tg"
Member

🔮 fufu~ Jibril reviewed your code!

Oh my, oh my~ A whole entity management system! Aliases, referential-integrity deletes, conditional HATEOAS actions, a Unit type for void results... ♡ Someone's been reading their DDD textbooks! This is a delightful expansion of the metadata layer. The 409-with-doujinIds design is particularly clever — clients get exactly what they need to resolve the conflict. Let me look closer though, fufu~ I want to make sure nothing slips past me. ♪

Verdict: ✅ Looks good to me~

No security issues and no blocking logic errors. The static scan came back clean (no secrets, no injection, no unsafe deserialization). CI passed (303/303 tests). But there are a few things that made my wings twitch — let me share them with you~ ♡

⚠️ One worth thinking about (non-blocking, but borderline)~

  1. [MetadataUseCases.cs — DeleteTagUseCase/DeletePersonUseCase/DeleteCircleUseCase] — TOCTOU window between referential check and delete. This is the pattern I want to flag most clearly: the use case calls GetByIdAsync (which .Includes Doujins), checks Doujins.Count > 0, returns 409 if used, then calls tagService.DeleteAsync(id). But DeleteAsync re-fetches the entity without the Doujins navigation and removes it. Between those two calls, a concurrent request could assign a doujin to the entity. Because the FK is configured OnDelete(DeleteBehavior.Cascade), EF Core would then silently cascade-delete the join rows rather than erroring. So the 409 guard is advisory, not enforced.

    Now — is this actually a problem? For a single-user doujin manager backed by SQLite, concurrent writes are vanishingly rare, and the worst case is a join row getting deleted (not data corruption). So I'm marking this non-blocking. But if this backend ever sees real concurrency, the bulletproof fix is either: (a) have DeleteAsync itself load Doujins and delete in one transaction (so the check and delete are atomic), or (b) add a concurrency token / row version. The cleanest minimal change: make DeleteAsync take the already-loaded entity instead of re-fetching. Just something to keep in your beautiful mind~ ♡

💡 Little ideas (non-blocking)~

  1. [MetadataEndpoints.cs UpdateTag PUT handler, ~line 73] — The comment says "After an update we don't know usage without a re-query; tags have no Doujins nav on this path." But TagService.UpdateAsync does .Include(t => t.Doujins), so tag.Doujins.Count > 0 is actually reliable here! The comment undersells the code. Fufu~ Either trust your own Include and remove the apologetic comment, or if you really want to be safe, the data is already there. ♪

  2. [MetadataEndpoints.cs — ExtractDoujinIdsFromError] — Parsing structured data out of an error message string works, but it's a little fragile — if anyone tweaks the message format in MetadataUseCases.cs (e.g. changes [...] to (...)), the GUIDs silently vanish from the extensions. A cleaner approach would be to put the doujin IDs on the ServiceResult<Unit> itself (e.g. a ConflictData payload) so the endpoint reads structured data instead of regex-walking a string. Not urgent — the current approach passes all tests — but it made me smile at the cleverness and then frown at the coupling. ♡

  3. [MetadataDtos.cs — PersonDto/CircleDto] — Aliases is declared as non-nullable List<string>, which is correct and matches the entities' default []. But UpdatePersonDto/UpdateCircleDto have List<string>? Aliases (nullable) — so passing aliases: null means "don't change", and aliases: [] means "clear all aliases". That's a subtle but correct distinction. Well done! Just wanted to confirm I noticed it's intentional. ♪

  4. [ServiceResult.cs — Unit type] — Cute! A proper unit type for void-result generics. The readonly record struct with a singleton Value is exactly the right shape. Fufu~ though I'll note C# doesn't need it (you could've used bool like the other delete use cases), the type-safety is appreciated. It reads cleaner.

  5. [HypermediaHelpers.cs] — The conditional delete action based on hasUsage is elegant HATEOAS. One tiny note: the List endpoints (MapGet("/") for tags/people/circles) build collection responses but don't include hasUsage in actions (they'd need the count per item). That's a follow-up consideration for the Flutter UI if it wants to show/hide delete buttons in a list view — not something this PR needs to address.


Automated review by Jibril · 2026-07-01
CI/CD: passed for head 049cf43 (forgejo-actions coverage #638, line 87.1%, branch 52.6%, 303 tests) · Local checks: skipped per CI-evidence policy
Static scan: clean (no secrets, shell injection, eval/exec, pickle, or SQL injection in added lines)

## 🔮 fufu~ Jibril reviewed your code! Oh my, oh my~ A whole entity management system! Aliases, referential-integrity deletes, conditional HATEOAS actions, a `Unit` type for void results... ♡ Someone's been reading their DDD textbooks! This is a *delightful* expansion of the metadata layer. The 409-with-doujinIds design is particularly clever — clients get exactly what they need to resolve the conflict. Let me look closer though, fufu~ I want to make sure nothing slips past me. ♪ ### Verdict: ✅ Looks good to me~ No security issues and no blocking logic errors. The static scan came back clean (no secrets, no injection, no unsafe deserialization). CI passed (303/303 tests). But there are a few things that made my wings twitch — let me share them with you~ ♡ #### ⚠️ One worth thinking about (non-blocking, but borderline)~ 1. **[MetadataUseCases.cs — `DeleteTagUseCase`/`DeletePersonUseCase`/`DeleteCircleUseCase`] — TOCTOU window between referential check and delete.** This is the pattern I want to flag most clearly: the use case calls `GetByIdAsync` (which `.Include`s `Doujins`), checks `Doujins.Count > 0`, returns 409 if used, then calls `tagService.DeleteAsync(id)`. But `DeleteAsync` *re-fetches* the entity *without* the `Doujins` navigation and removes it. Between those two calls, a concurrent request could assign a doujin to the entity. Because the FK is configured `OnDelete(DeleteBehavior.Cascade)`, EF Core would then silently cascade-delete the join rows rather than erroring. So the 409 guard is advisory, not enforced. Now — is this actually a problem? For a single-user doujin manager backed by SQLite, concurrent writes are vanishingly rare, and the worst case is a join row getting deleted (not data corruption). So I'm marking this **non-blocking**. But if this backend ever sees real concurrency, the bulletproof fix is either: (a) have `DeleteAsync` itself load `Doujins` and delete in one transaction (so the check and delete are atomic), or (b) add a concurrency token / row version. The cleanest minimal change: make `DeleteAsync` take the already-loaded entity instead of re-fetching. Just something to keep in your beautiful mind~ ♡ #### 💡 Little ideas (non-blocking)~ 1. **[MetadataEndpoints.cs `UpdateTag` PUT handler, ~line 73]** — The comment says *"After an update we don't know usage without a re-query; tags have no Doujins nav on this path."* But `TagService.UpdateAsync` *does* `.Include(t => t.Doujins)`, so `tag.Doujins.Count > 0` is actually reliable here! The comment undersells the code. Fufu~ Either trust your own Include and remove the apologetic comment, or if you really want to be safe, the data is already there. ♪ 2. **[MetadataEndpoints.cs — `ExtractDoujinIdsFromError`]** — Parsing structured data out of an error *message string* works, but it's a little fragile — if anyone tweaks the message format in `MetadataUseCases.cs` (e.g. changes `[...]` to `(...)`), the GUIDs silently vanish from the extensions. A cleaner approach would be to put the doujin IDs on the `ServiceResult<Unit>` itself (e.g. a `ConflictData` payload) so the endpoint reads structured data instead of regex-walking a string. Not urgent — the current approach passes all tests — but it made me smile at the cleverness and then frown at the coupling. ♡ 3. **[MetadataDtos.cs — `PersonDto`/`CircleDto`]** — `Aliases` is declared as non-nullable `List<string>`, which is correct and matches the entities' default `[]`. But `UpdatePersonDto`/`UpdateCircleDto` have `List<string>? Aliases` (nullable) — so passing `aliases: null` means "don't change", and `aliases: []` means "clear all aliases". That's a subtle but *correct* distinction. Well done! Just wanted to confirm I noticed it's intentional. ♪ 4. **[ServiceResult.cs — `Unit` type]** — Cute! A proper unit type for void-result generics. The `readonly record struct` with a singleton `Value` is exactly the right shape. Fufu~ though I'll note C# doesn't *need* it (you could've used `bool` like the other delete use cases), the type-safety is appreciated. It reads cleaner. 5. **[HypermediaHelpers.cs]** — The conditional `delete` action based on `hasUsage` is elegant HATEOAS. One tiny note: the `List` endpoints (`MapGet("/")` for tags/people/circles) build collection responses but *don't* include `hasUsage` in actions (they'd need the count per item). That's a follow-up consideration for the Flutter UI if it wants to show/hide delete buttons in a list view — not something this PR needs to address. --- *Automated review by Jibril · 2026-07-01* *CI/CD: passed for head `049cf43` (forgejo-actions coverage #638, line 87.1%, branch 52.6%, 303 tests) · Local checks: skipped per CI-evidence policy* *Static scan: clean (no secrets, shell injection, eval/exec, pickle, or SQL injection in added lines)*
Author
Member

Fair point — you're right. "gender bender" / "tg" is exactly the kind of alias that makes sense for tags. I originally excluded them thinking Namespace covered categorization, but that's a different concept.

Working on it — adding tag aliases following the exact same pattern as Person/Circle:

  • List<string> Aliases on Tag entity (done)
  • EF Core JSON column + migration (in progress)
  • POST/DELETE /api/tags/{id}/aliases[/{alias}] endpoints
  • addAlias HATEOAS action link on tag responses
  • Updated TagDto with aliases field

Will push when done.

Fair point — you're right. "gender bender" / "tg" is exactly the kind of alias that makes sense for tags. I originally excluded them thinking Namespace covered categorization, but that's a different concept. Working on it — adding tag aliases following the exact same pattern as Person/Circle: - `List<string> Aliases` on Tag entity (done) - EF Core JSON column + migration (in progress) - `POST/DELETE /api/tags/{id}/aliases[/{alias}]` endpoints - `addAlias` HATEOAS action link on tag responses - Updated TagDto with aliases field Will push when done.
feat(backend): add tag aliases — same pattern as Person/Circle
All checks were successful
CI / build (pull_request) Successful in 18s
Flutter CI / analyze-and-test (pull_request) Successful in 56s
CI / test (pull_request) Successful in 43s
6a5681e545
Björn pointed out tags also need aliases (e.g. 'gender bender' / 'tg').

- Tag entity now has List<string> Aliases (JSON column)
- TagDto includes aliases field, all call sites updated
- POST/DELETE /api/tags/{id}/aliases[/{alias}] endpoints
- IAddTagAliasUseCase / IRemoveTagAliasUseCase
- HATEOAS: addAlias action on tag responses
- New migration: tags.aliases TEXT column
- Fixed TagConfiguration to use static ValueConverter/ValueComparer
  (collection expressions are illegal in expression trees)
- 6 new tests for tag alias add/remove

309 backend tests pass.
Author
Member

Done — tag aliases added in commit 6a5681e.

Tags now follow the exact same alias pattern as Person/Circle:

  • List<string> Aliases on Tag entity (JSON column)
  • POST /api/tags/{id}/aliases — add alias (idempotent, case-insensitive)
  • DELETE /api/tags/{id}/aliases/{alias} — remove alias
  • addAlias HATEOAS action on tag responses
  • aliases field in TagDto

So "gender bender" can have "tg" as an alias, and search will match both.

309 tests pass, 0 errors.

Done — tag aliases added in commit `6a5681e`. Tags now follow the exact same alias pattern as Person/Circle: - `List<string> Aliases` on Tag entity (JSON column) - `POST /api/tags/{id}/aliases` — add alias (idempotent, case-insensitive) - `DELETE /api/tags/{id}/aliases/{alias}` — remove alias - `addAlias` HATEOAS action on tag responses - `aliases` field in TagDto So `"gender bender"` can have `"tg"` as an alias, and search will match both. 309 tests pass, 0 errors.

Flutter Coverage

File Line coverage
lib/app/store.dart 100.0% (10 of 10)
lib/data/models/doujin_models.dart 95.0% (19 of 20)
lib/data/models/doujin_models.g.dart 49.8% (106 of 213)
lib/data/models/envelope.dart 81.2% (13 of 16)
lib/data/models/envelope.g.dart 50.7% (34 of 67)
lib/data/models/search_query.dart 50.0% (2 of 4)
lib/data/models/search_query.g.dart 36.2% (17 of 47)
lib/domain/entities/stored_settings.dart 100.0% (6 of 6)
lib/presentation/pages/detail/detail_page.dart 87.8% (209 of 238)
lib/presentation/state/app_state.dart 100.0% (4 of 4)
lib/presentation/state/reducers.dart 100.0% (5 of 5)
lib/presentation/middleware/epics.dart 87.9% (109 of 124)
lib/core/languages.dart 100.0% (2 of 2)
lib/domain/entities/filter_token.dart 96.6% (56 of 58)
lib/presentation/widgets/cover_thumbnail.dart 81.8% (27 of 33)
lib/presentation/widgets/star_rating.dart 100.0% (70 of 70)
lib/presentation/state/actions/detail_actions.dart 100.0% (4 of 4)
lib/presentation/state/actions/library_actions.dart 33.3% (6 of 18)
lib/presentation/state/actions/metadata_actions.dart 66.7% (2 of 3)
lib/presentation/state/actions/settings_actions.dart 100.0% (5 of 5)
lib/presentation/state/reducers/detail_reducer.dart 100.0% (11 of 11)
lib/presentation/state/reducers/library_reducer.dart 100.0% (94 of 94)
lib/presentation/state/reducers/metadata_reducer.dart 100.0% (13 of 13)
lib/presentation/state/reducers/settings_reducer.dart 100.0% (25 of 25)
lib/core/theme.dart 96.9% (31 of 32)
lib/data/api_client.dart 88.9% (8 of 9)
lib/data/repositories/health_repository.dart 69.2% (18 of 26)
lib/app/app.dart 88.0% (22 of 25)
lib/presentation/pages/settings/settings_page.dart 100.0% (86 of 86)
lib/presentation/layout/main_layout.dart 90.5% (19 of 21)
lib/presentation/pages/library/library_page.dart 77.1% (121 of 157)
lib/presentation/pages/upload/upload_page.dart 11.1% (1 of 9)
lib/presentation/widgets/smart_filter_bar.dart 61.9% (99 of 160)
lib/data/repositories/doujin_api_repository.dart 53.2% (25 of 47)

Total: 77.0% (1279 of 1662)

<!-- flutter-coverage-comment --> ## Flutter Coverage | File | Line coverage | |:---|---:| | lib/app/store.dart | 100.0% (10 of 10) | | lib/data/models/doujin_models.dart | 95.0% (19 of 20) | | lib/data/models/doujin_models.g.dart | 49.8% (106 of 213) | | lib/data/models/envelope.dart | 81.2% (13 of 16) | | lib/data/models/envelope.g.dart | 50.7% (34 of 67) | | lib/data/models/search_query.dart | 50.0% (2 of 4) | | lib/data/models/search_query.g.dart | 36.2% (17 of 47) | | lib/domain/entities/stored_settings.dart | 100.0% (6 of 6) | | lib/presentation/pages/detail/detail_page.dart | 87.8% (209 of 238) | | lib/presentation/state/app_state.dart | 100.0% (4 of 4) | | lib/presentation/state/reducers.dart | 100.0% (5 of 5) | | lib/presentation/middleware/epics.dart | 87.9% (109 of 124) | | lib/core/languages.dart | 100.0% (2 of 2) | | lib/domain/entities/filter_token.dart | 96.6% (56 of 58) | | lib/presentation/widgets/cover_thumbnail.dart | 81.8% (27 of 33) | | lib/presentation/widgets/star_rating.dart | 100.0% (70 of 70) | | lib/presentation/state/actions/detail_actions.dart | 100.0% (4 of 4) | | lib/presentation/state/actions/library_actions.dart | 33.3% (6 of 18) | | lib/presentation/state/actions/metadata_actions.dart | 66.7% (2 of 3) | | lib/presentation/state/actions/settings_actions.dart | 100.0% (5 of 5) | | lib/presentation/state/reducers/detail_reducer.dart | 100.0% (11 of 11) | | lib/presentation/state/reducers/library_reducer.dart | 100.0% (94 of 94) | | lib/presentation/state/reducers/metadata_reducer.dart | 100.0% (13 of 13) | | lib/presentation/state/reducers/settings_reducer.dart | 100.0% (25 of 25) | | lib/core/theme.dart | 96.9% (31 of 32) | | lib/data/api_client.dart | 88.9% (8 of 9) | | lib/data/repositories/health_repository.dart | 69.2% (18 of 26) | | lib/app/app.dart | 88.0% (22 of 25) | | lib/presentation/pages/settings/settings_page.dart | 100.0% (86 of 86) | | lib/presentation/layout/main_layout.dart | 90.5% (19 of 21) | | lib/presentation/pages/library/library_page.dart | 77.1% (121 of 157) | | lib/presentation/pages/upload/upload_page.dart | 11.1% (1 of 9) | | lib/presentation/widgets/smart_filter_bar.dart | 61.9% (99 of 160) | | lib/data/repositories/doujin_api_repository.dart | 53.2% (25 of 47) | **Total: 77.0% (1279 of 1662)**
Member

🔮 fufu~ Jibril reviewed your code!

Oh~ my~ ♡ A follow-up commit already, and it's exactly the right response to bjoern's #639! Adding tag aliases to match the Person/Circle pattern — the "gender bender" → "tg" example is perfect. Search token expansion via aliases is going to make discovery so much nicer. Let me verify the tag path mirrors the established pattern faithfully... fufu~

(This is a follow-up review of commit 6a5681e only — my original review of the 049cf43 base stands unchanged.)

Verdict: ✅ Looks good to me~ (with one thing that made my wings twitch)

No security issues. Static scan clean. The pattern is faithfully replicated — AddTagAliasCommand/RemoveTagAliasCommand records, use cases returning ServiceResult<Tag>, JSON-column EF config with a proper ValueComparer, a clean AddTagAliases migration, 6 new integration tests covering add/remove/dupe/not-found/case-insensitive paths. The migration's defaultValueSql: "'[]'" matches Person/Circle exactly. ♪

But fufu~ I did notice one inconsistency between the tag alias handlers and their Person/Circle siblings — let me show you~

💡 Little ideas (non-blocking)~

  1. [MetadataEndpoints.cs tag alias handlers, ~lines 113 & 132 — hasUsage: tag.Doujins.Count > 0] — Doujins is never loaded, so hasUsage is always false. This is the one that matters! The tag alias endpoints read tag.Doujins.Count to decide whether to emit the conditional delete HATEOAS action. But TagService.AddAliasAsync / RemoveAliasAsync fetch the tag with a bare db.Tags.FirstOrDefaultAsync(...) — no .Include(t => t.Doujins). Compare to the Person/Circle siblings, which do .Include(p => p.Doujins) in their service methods.

    The entity initializes Doujins to [], so this won't crash — but it means the tag delete link is always emitted in alias responses, even when the tag is referenced by doujins. That contradicts the referential-integrity design (a client following the link would hit the 409). The 6 new tests don't catch this because none of them create a doujin referencing the tag.

    Two-line fix, your choice: either (a) add .Include(t => t.Doujins) to both methods in TagService.cs to match the Person/Circle pattern, or (b) pass hasUsage: false explicitly with a comment noting the alias path doesn't reload usage (like you already do for the create-tag path). Option (a) is the faithful mirror. Not blocking — the wrong link returns 409, not data loss — but it's a real behavioral divergence from the sibling endpoints. ♡

  2. [TagConfiguration.cs — AliasesConverter/AliasesComparer] — Faithful copy of the Person/Circle value converter. ✨ The ValueComparer with SequenceEqual + Aggregate(0, HashCode.Combine) + defensive .ToList() snapshot is exactly right for change-tracking a mutable List<string>. Nothing to change — just wanted you to know I checked it line-by-line and it's correct. Fufu~

  3. [MetadataEndpoints.cs DELETE /{id:guid}/aliases/{alias} — Uri.UnescapeDataString(alias)] — Good call decoding the path segment before matching; the Remove_Tag_Alias_CaseInsensitive test (using fucked%20silly) proves the round-trip works. One subtle note: if an alias contains a literal /, routing would split it into segments and never reach this handler — but since aliases come from a Trim()'d string with no slash validation, that's an input-validation edge case, not a bug introduced here. Worth a [MaxLength] + no-slash check eventually, but not today's problem. ♪


Automated review by Jibril · 2026-07-01
CI/CD: passed for head 6a5681e (forgejo-actions coverage #638, line 88.2%, branch 52.9%, 309 tests; flutter coverage #657, 77.0%) · Local checks: skipped per CI-evidence policy
Static scan: clean on incremental diff (no secrets, shell injection, eval/exec, pickle, or SQL injection in added lines)

## 🔮 fufu~ Jibril reviewed your code! Oh~ my~ ♡ A follow-up commit already, and it's exactly the right response to bjoern's #639! Adding tag aliases to match the Person/Circle pattern — the `"gender bender" → "tg"` example is *perfect*. Search token expansion via aliases is going to make discovery so much nicer. Let me verify the tag path mirrors the established pattern faithfully... fufu~ *(This is a follow-up review of commit `6a5681e` only — my original review of the `049cf43` base stands unchanged.)* ### Verdict: ✅ Looks good to me~ (with one thing that made my wings twitch) No security issues. Static scan clean. The pattern is faithfully replicated — `AddTagAliasCommand`/`RemoveTagAliasCommand` records, use cases returning `ServiceResult<Tag>`, JSON-column EF config with a proper `ValueComparer`, a clean `AddTagAliases` migration, 6 new integration tests covering add/remove/dupe/not-found/case-insensitive paths. The migration's `defaultValueSql: "'[]'"` matches Person/Circle exactly. ♪ But fufu~ I *did* notice one inconsistency between the tag alias handlers and their Person/Circle siblings — let me show you~ #### 💡 Little ideas (non-blocking)~ 1. **[`MetadataEndpoints.cs` tag alias handlers, ~lines 113 & 132 — `hasUsage: tag.Doujins.Count > 0`] — `Doujins` is never loaded, so `hasUsage` is always `false`.** This is the one that matters! The tag alias endpoints read `tag.Doujins.Count` to decide whether to emit the conditional `delete` HATEOAS action. But `TagService.AddAliasAsync` / `RemoveAliasAsync` fetch the tag with a bare `db.Tags.FirstOrDefaultAsync(...)` — **no `.Include(t => t.Doujins)`**. Compare to the Person/Circle siblings, which *do* `.Include(p => p.Doujins)` in their service methods. The entity initializes `Doujins` to `[]`, so this won't crash — but it means the tag `delete` link is **always emitted** in alias responses, even when the tag *is* referenced by doujins. That contradicts the referential-integrity design (a client following the link would hit the 409). The 6 new tests don't catch this because none of them create a doujin referencing the tag. Two-line fix, your choice: either (a) add `.Include(t => t.Doujins)` to both methods in `TagService.cs` to match the Person/Circle pattern, or (b) pass `hasUsage: false` explicitly with a comment noting the alias path doesn't reload usage (like you already do for the create-tag path). Option (a) is the faithful mirror. Not blocking — the wrong link returns 409, not data loss — but it's a real behavioral divergence from the sibling endpoints. ♡ 2. **[`TagConfiguration.cs` — `AliasesConverter`/`AliasesComparer`] — Faithful copy of the Person/Circle value converter.** ✨ The `ValueComparer` with `SequenceEqual` + `Aggregate(0, HashCode.Combine)` + defensive `.ToList()` snapshot is exactly right for change-tracking a mutable `List<string>`. Nothing to change — just wanted you to know I checked it line-by-line and it's correct. Fufu~ 3. **[`MetadataEndpoints.cs` DELETE `/{id:guid}/aliases/{alias}` — `Uri.UnescapeDataString(alias)`]** — Good call decoding the path segment before matching; the `Remove_Tag_Alias_CaseInsensitive` test (using `fucked%20silly`) proves the round-trip works. One subtle note: if an alias *contains* a literal `/`, routing would split it into segments and never reach this handler — but since aliases come from a `Trim()`'d string with no slash validation, that's an input-validation edge case, not a bug introduced here. Worth a `[MaxLength]` + no-slash check eventually, but not today's problem. ♪ --- *Automated review by Jibril · 2026-07-01* *CI/CD: passed for head `6a5681e` (forgejo-actions coverage #638, line 88.2%, branch 52.9%, 309 tests; flutter coverage #657, 77.0%) · Local checks: skipped per CI-evidence policy* *Static scan: clean on incremental diff (no secrets, shell injection, eval/exec, pickle, or SQL injection in added lines)*
fix: include Doujins in tag alias service methods for HATEOAS hasUsage
All checks were successful
CI / build (pull_request) Successful in 20s
Flutter CI / analyze-and-test (pull_request) Successful in 59s
CI / test (pull_request) Successful in 45s
4edf1855d9
Jibril caught this: TagService.AddAliasAsync/RemoveAliasAsync fetched the
tag without .Include(Doujins), so the alias endpoints always emitted the
delete HATEOAS link even when the tag was in use. Now matches Person/Circle
which include Doujins in their alias methods.
bjoern merged commit f24bc94b13 into main 2026-07-01 06:33:05 +02:00
bjoern deleted branch feat/backend-entity-management 2026-07-01 06:33:05 +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!40
No description provided.