Commit Graph

93 Commits

Author SHA1 Message Date
Marcel
9f90cc1a5f feat(service): create MAIDEN_NAME alias in findOrCreateByAlias
When split() returns a non-null maidenName, PersonService now
creates a PersonNameAlias with type MAIDEN_NAME. The maiden name
is stored as lastName on the alias (no firstName).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:50 +02:00
Marcel
8421d45c71 test(parser): add parseReceivers tests for widened geb pattern
Verify comma-prefix, no-dot, and multi-word maiden name variants
are correctly stripped in parseReceivers().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:53:03 +02:00
Marcel
c49cb345ca feat(parser): widen GEB_PATTERN and extract maiden name in stripMaidenName
Widen pattern from `\s+geb\.\s+\S+` to `,?\s*geb\.?\s+(.+)$` to
handle: optional comma, optional dot, multi-word maiden names.
stripMaidenName() now captures the maiden name instead of discarding
it. Handles all 5 input variants from the ODS data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:51:32 +02:00
Marcel
f11d8a38ed feat(frontend): replace all name concatenation with displayName
- Add displayName default method to PersonSummaryDTO
- Update native SQL queries to include title, person_type columns
- Add getInitials() utility to personFormat.ts
- Update abbreviateName/abbreviateCompact for nullable firstName
- Replace firstName+lastName concatenation with displayName in all
  person-displaying components and server load files
- Regenerate API types with displayName on Person and PersonSummaryDTO

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:22:30 +02:00
Marcel
de2cc677a9 fix(search): handle null firstName in all search queries
Use COALESCE to convert null firstName to empty string in:
- PersonRepository.searchByName (JPQL)
- PersonRepository.searchWithDocumentCount (native SQL)
- PersonRepository.findCorrespondentsWithFilter (native SQL)
- DocumentSpecifications.hasText (Criteria API, sender + receiver)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:59:41 +02:00
Marcel
9f14648dc3 feat(model): add title, personType, displayName to Person entity
- Add title (nullable VARCHAR) and personType (enum, default PERSON)
- Make firstName nullable for non-person entities
- Add @Transient getDisplayName() as single source of truth for
  name display, exposed via @Schema(READ_ONLY, REQUIRED)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:53:07 +02:00
Marcel
8101ddb697 feat(model): add PersonType enum and MAIDEN_NAME alias type
PersonType has 5 values: PERSON, INSTITUTION, GROUP, UNKNOWN, SKIP.
SKIP is intentionally excluded from the DB CHECK constraint (added
in migration) as defense-in-depth. MAIDEN_NAME added to
PersonNameAliasType for #209.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:50:19 +02:00
Marcel
dea1635d75 refactor(parser): extract split() pipeline into named methods
Extract stripMaidenName, normalizeDotCompressed, stripAnnotation,
stripTitle, and splitByKnownLastNameOrFallback as individually
testable pipeline steps. Each extraction method is a pass-through
until its feature issue fills in the logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:48:08 +02:00
Marcel
1e1921e0fa refactor(parser): expand SplitName record to 5 fields
Add title, maidenName, and annotation fields (all nullable) to
SplitName. All existing call sites pass null for new fields. Test
assertions updated to document the null-by-default contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:46:09 +02:00
Marcel
d6e74972eb test(parser): add regression and cross-feature interaction tests
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3s
CI / Backend Unit Tests (pull_request) Failing after 2s
CI / Unit & Component Tests (push) Failing after 4s
CI / Backend Unit Tests (push) Failing after 3s
Regression test confirms already-spaced dot names are not double-spaced.
Interaction test confirms // separator works with dot-compressed names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:35:30 +02:00
Marcel
0b57717586 feat(parser): normalize dot-compressed names in split()
Inserts spaces after dots when the cleaned name has no spaces but
contains dots, so the existing last-space fallback handles names
like "E.Rockstroh" and "Dr.Fr.Zarncke" correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:34:56 +02:00
Marcel
59475efbcb feat(parser): support // as multi-person separator in parseReceivers
Pre-splits input on "//" before existing logic so each segment is
processed independently through the full pipeline (und/u splitting,
last-name distribution, etc.).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:33:55 +02:00
Marcel
cfb3260e0e fix(api): add input validation to PersonNameAliasDTO
Adds @NotBlank @Size(max=255) on lastName, @NotNull on type,
@Valid on controller parameter. Blank/null input now returns
400 instead of reaching the DB constraint. 2 new controller tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:40:43 +02:00
Marcel
90c9ac9357 feat(search): extend document text search to match alias last names
Adds sender alias LEFT JOIN and receiver alias EXISTS subquery to
DocumentSpecifications.hasText(). Uses entity-graph navigation via
Person.nameAliases (@OneToMany) to avoid a separate DB roundtrip
while respecting domain boundaries. 2 new integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:18:31 +02:00
Marcel
db61d6b77f feat(search): extend person search to include alias last names
Adds LEFT JOIN to person_name_aliases in both searchByName (JPQL)
and searchWithDocumentCount (native SQL). Uses DISTINCT/GROUP BY
to prevent duplicate results. 4 new integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:12:54 +02:00
Marcel
a1d63bbc42 feat(api): add GET/POST/DELETE /api/persons/{id}/aliases endpoints
GET returns aliases (no permission required), POST requires
WRITE_ALL, DELETE requires WRITE_ALL. 5 new controller tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:09:58 +02:00
Marcel
0fc568dd9f feat(service): add alias CRUD methods to PersonService
getAliases (sorted by sort_order), addAlias (auto-incrementing
sort_order), removeAlias (with IDOR protection verifying alias
belongs to the given person). All TDD with 7 new unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:07:14 +02:00
Marcel
56f7282a9d test(search): add empty-receivers edge case for RECEIVER sort
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:45:01 +02:00
Marcel
972048d57d fix(search): treat null sender.lastName as empty in sort key
A sender with lastName=null produced sort key "null Bob" which sorted
before names starting with lowercase letters (n < s, t, u, v...).
Now returns "" for null lastName, which the comparator places at end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:39:30 +02:00
Marcel
1c1ab0c72a feat(search): reject invalid dir parameter with 400
Previously any value other than ASC/DESC silently defaulted to
DESC with no feedback. Now returns 400 Bad Request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:34:38 +02:00
Marcel
12023513b2 refactor(search): move DocumentSort from model/ to dto/
DocumentSort is a query parameter enum, not a JPA entity.
Placing it in model/ violated the layer boundary — model/ should
contain only domain entities.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:29:35 +02:00
Marcel
bc397048b7 fix(search): use in-memory sort for SENDER to include documents with null sender
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
INNER JOIN from Sort.by("sender.lastName") was excluding docs without a sender.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 14:15:03 +02:00
Marcel
879435c8d9 feat(search): wrap search response in { documents, total } envelope
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:17:08 +02:00
Marcel
c2b5008c66 feat(search): add sort param (DATE/TITLE/SENDER/RECEIVER/UPLOAD_DATE) and tagQ filter
- DocumentSort enum validated by Spring MVC (400 for unknown values)
- SENDER sort uses Spring Data Sort on sender.lastName/firstName
- RECEIVER sort uses in-memory sort by first receiver alphabetically
- UPLOAD_DATE sort uses createdAt; default sort is DATE DESC
- tagQ param wired to hasTagPartial specification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:13:06 +02:00
Marcel
beca2d463a feat(search): extend hasText to match sender/receiver/tag names, add hasTagPartial
- hasText now JOINs sender (LEFT JOIN) and uses EXISTS subqueries for
  receivers and tags to avoid duplicate rows
- hasTagPartial added for live debounced tag text filter (ILIKE partial match)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:07:39 +02:00
Marcel
e89d8a4ca9 test: increase coverage 2026-04-06 11:20:57 +02:00
Marcel
da43cadb0a feat(comments): add block-level comment endpoints with TDD
RED/GREEN for CommentService:
- getCommentsForBlock(blockId): returns root comments filtered by blockId
- postBlockComment(documentId, blockId, content, mentions, author): creates
  comment with block_id set

RED/GREEN for CommentController:
- GET /api/documents/{docId}/transcription-blocks/{blockId}/comments
- POST /api/documents/{docId}/transcription-blocks/{blockId}/comments
- POST .../comments/{commentId}/replies (reuses existing replyToComment)

4 new tests: 2 service unit tests + 2 controller integration tests
All 25 CommentServiceTest + 24 CommentControllerTest green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:01:02 +02:00
Marcel
18c6bca2dd refactor(transcription): split reorderBlocks for command-query separation
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m46s
CI / Backend Unit Tests (push) Failing after 2m26s
CI / E2E Tests (push) Has started running
CI / Unit & Component Tests (pull_request) Failing after 1m11s
CI / Backend Unit Tests (pull_request) Failing after 2m24s
CI / E2E Tests (pull_request) Failing after 1h25m31s
TranscriptionService.reorderBlocks() now returns void (command).
Controller calls listBlocks() separately after reorder (query).
Updated test to match new void signature.

Fixes @Felix: "reorderBlocks violates command-query separation"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:32:44 +02:00
Marcel
51c799e20e test(transcription): add TranscriptionServiceTest with 13 unit tests
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m20s
CI / Backend Unit Tests (push) Failing after 2m32s
CI / E2E Tests (push) Failing after 1h22m5s
CI / Unit & Component Tests (pull_request) Failing after 1m35s
CI / Backend Unit Tests (pull_request) Failing after 2m46s
CI / E2E Tests (pull_request) Failing after 1h24m55s
Tests cover: getBlock (found, not found), createBlock (creates annotation +
block + version), updateBlock (text + label), deleteBlock (deletes block +
annotation, not found), reorderBlocks, getBlockHistory, sanitizeText (null,
max length, plain text preservation), listBlocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:46:16 +02:00
Marcel
154f859efc feat(korrespondenz): address PR #164 review – blockers and suggestions
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m36s
CI / Backend Unit Tests (pull_request) Failing after 2m36s
CI / E2E Tests (pull_request) Failing after 1h49m0s
Blockers (14):
- B1: fix senderName/receiverName to use $derived instead of $state + sync $effect
- B2: migrate all korrespondenz components from messages-extra shim to paraglide m.*
- B3: i18n CorrespondenzEmptyState (heading, subtext, search placeholder)
- B4: add response.ok checks to admin layout server load
- B5: add response.ok checks to korrespondenz page server load
- B6: add page.server.spec.ts with 5 test suites for korrespondenz load function
- B7: add axe-core accessibility checks to all e2e korrespondenz tests
- B8: add Testcontainers JPQL tests for findSinglePersonCorrespondence (DISTINCT + sender)
- B9: hide auth reset-token endpoint from OpenAPI spec; remove from generated api.ts
- B11: replace amber hardcoded hex colors in SinglePersonHintBar with brand tokens
- B12: replace clipboard emoji with Heroicons SVG in SinglePersonHintBar
- B13: create DateInput component (German dd.mm.yyyy); use it in CorrespondenzFilterControls
- B14: add Paraglide compile step to CI workflow before lint/test

Suggestions (11):
- S1: make CorrespondentSuggestionsDropdown a pure display component; lift fetch to PersonBar
- S2: fix leftover messages-extra import in ConversationTimeline; use brand tokens for status dots
- S3: add intent comment to EntityNav openFlyout behavior
- S4: rename canManageGroups → canManagePermissions throughout admin
- S6: remove domFlush helper from DateInput spec; use expect.poll instead
- S7: replace test.skip with throw new Error in bilateral e2e tests
- S8: add inverse aria-disabled test for filter strip
- S9: remove sm:min-h-0 from sort button to preserve 44px touch target
- S10: add title attributes to tablet trigger buttons in EntityNav
- S11: delete messages-extra.ts shim entirely

Also: fix admin pages revealing blank strip at bottom (-mb-6 on admin layout)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:57:48 +02:00
Marcel
252881b8d1 fix(backend): reject whitespace-only person search queries
A query of only spaces previously fell through to findAllWithDocumentCount,
exposing the full person list. Whitespace-only queries now return empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:27:57 +02:00
Marcel
f88371e9af feat(backend): extend conversation endpoint for optional receiverId
When receiverId is omitted, returns all documents where the person is
sender or receiver (single-person mode). Bilateral mode is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:25:04 +02:00
Marcel
8fc360a596 fix(admin): guard GET /api/users/{id} with @RequirePermission(ADMIN_USER)
Fixes IDOR: the endpoint was publicly accessible to any authenticated user.
Now requires ADMIN_USER permission, matching all other user management endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 01:09:40 +02:00
Marcel
169e6dc578 chore: merge main into feat/persons-redesign-concept-a
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Resolved conflicts in messages/de.json, en.json, es.json by keeping
both the persons-redesign keys (feature branch) and the notification
keys (main) in all three locale files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:30:54 +02:00
Marcel
707a7610f8 feat(stats): add GET /api/stats endpoint returning totalPersons + totalDocuments
New StatsController + StatsDTO; no WRITE_ALL required (read-only aggregates).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:45:25 +02:00
Marcel
593638482d feat(persons): add PersonSummaryDTO with document count to GET /api/persons
Native queries compute sender + receiver document count in one SQL call,
eliminating N+1. GET /api/persons now returns PersonSummaryDTO list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:44:16 +02:00
Marcel
a3d750822c feat(persons): accept PersonUpdateDTO for POST /api/persons (all 6 fields)
createPerson now takes PersonUpdateDTO, persisting birthYear, deathYear,
notes in addition to firstName, lastName, alias.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:40:53 +02:00
Marcel
3987bbc1f9 refactor(persons): replace ResponseStatusException with DomainException in PersonService
Added PERSON_NOT_FOUND to ErrorCode; getById, updatePerson, mergePersons
now throw DomainException.notFound for missing persons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:38:23 +02:00
Marcel
d1e506135b feat(persons): add year range bounds validation (> 0) to PersonService
birthYear and deathYear must be positive integers; extracted shared
validateYears() method for reuse in createPerson.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:37:08 +02:00
Marcel
ef9a85eee8 feat(persons): add @Size constraints to PersonUpdateDTO + @Valid to controller
firstName/lastName max 100, alias max 200, notes max 5000 chars.
PUT /api/persons/{id} returns 400 for oversized fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:35:59 +02:00
Marcel
93107e7c59 feat(persons): add @RequirePermission(WRITE_ALL) to write endpoints
POST /api/persons, PUT /api/persons/{id}, POST /api/persons/{id}/merge
now return 403 for READ_ALL-only users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:34:35 +02:00
Marcel
3b21aae44d feat(notifications): add documentTitle to NotificationDTO via DocumentService lookup
Notification rows in the history page need the document title. Added
findTitlesByIds(Collection<UUID>) to DocumentService (one query via a new
JPQL projection on DocumentRepository). NotificationService.getNotifications()
now fetches all titles for the page in a single extra query and maps them into
the DTO. documentTitle is null when the document has been deleted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:12:14 +02:00
Marcel
5ac7880a2b fix(notifications): add missing unread-only filter branch in service and repository
NullX Finding 1: GET /api/notifications?read=false with no type param fell through
to the all-notifications branch, silently ignoring the read filter. Added
findByRecipientIdAndReadFalseOrderByCreatedAtDesc to NotificationRepository and
the missing Boolean.FALSE.equals(read) branch in NotificationService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:12:14 +02:00
Marcel
5bdd26c792 fix(#145): address PR review — full-table scan, a11y, grid, tests
- DocumentService.getRecentActivity: replace findAll(Sort)+stream().limit()
  with findAll(PageRequest) so LIMIT is pushed to the database
- +page.svelte: collapse two-column grid to single column when mentions is empty
- DashboardNeedsMetadata: raise "show all" link from text-xs (12px) to text-sm
  (14px) and add hover:underline for WCAG 1.4.1
- DashboardRecentDocuments: add comment explaining why T12:00:00 noon-anchor
  is absent (updatedAt is a full ISO datetime, not a date-only string)
- DocumentServiceTest: update getRecentActivity tests to assert PageRequest
  usage instead of findAll(Sort)
- DocumentRepositoryTest: add @DataJpaTest verifying findAll(PageRequest)
  returns only size rows, not the full table
- DocumentControllerTest: add test for default size=5 when param is omitted
- NotificationServiceTest: add test documenting that type+read=true falls
  through to the type-only query (intentional)
- page.server.spec.ts: replace stale tests with full dashboard-mode coverage
- DashboardMentions.svelte.spec.ts: add tests for REPLY type and absent documentId
- DashboardResumeStrip.svelte.spec.ts: add corrupt localStorage test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:11:12 +02:00
Marcel
6976daa910 feat(#145): add recent-activity endpoint sorted by updatedAt
Add GET /api/documents/recent-activity?size=N endpoint that returns
the N most recently updated documents sorted by updatedAt DESC.
Includes TDD: failing tests written first, then production code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:29:34 +02:00
Marcel
08f3f92167 fix(#145): dashboard notification widget shows all recent notifications
- Add type-only filter to notification repo/service (previously only
  worked with type+read=false together)
- Dashboard widget now fetches all recent notifications (mentions +
  replies, both read and unread) instead of unread mentions only
- Update component heading and show type label per row

Root cause: Berit's mentions were read=true, so the unread-only filter
returned 0 results. The recent docs widget had no REVIEWED documents
because 'marking ready' sets metadata_complete, not status=REVIEWED.
Recent docs now shows all uploads without a status filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 09:41:28 +02:00
Marcel
147d1f2de5 feat(#145): add status filter to GET /api/documents/search
Dashboard "Recently Added" widget calls ?status=REVIEWED&size=5.
Null status is a no-op — existing callers without the param are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:21:48 +01:00
Marcel
968993c48e feat(#145): add IncompleteDocumentDTO and ?size= param to GET /api/documents/incomplete
Dashboard widget calls ?size=3 to cap the list. Response now returns
{id, title} DTO instead of full Document entity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:19:06 +01:00
Marcel
304359f67d feat(#145): add type and read filter params to GET /api/notifications
Dashboard widget uses ?type=MENTION&read=false to fetch unread mentions.
Also adds MethodArgumentTypeMismatchException → 400 handler so invalid
enum values in any @RequestParam return 400 instead of 500.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:16:04 +01:00
Marcel
3dd0ff94c6 test(#148): add controller tests and raise coverage gate to 88%
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m40s
CI / Backend Unit Tests (pull_request) Failing after 2m28s
CI / Unit & Component Tests (push) Successful in 3m44s
CI / Backend Unit Tests (push) Failing after 5m9s
CI / E2E Tests (pull_request) Failing after 3h13m37s
CI / E2E Tests (push) Failing after 3h9m10s
Add branch-coverage tests for DocumentController (getDocumentFile happy/error paths, quickUpload null files), UserController (getCurrentUser auth branches), AnnotationController (resolveUserId null/exception branches), CommentController (resolveUser exception branch), and PersonController (updatePerson blank lastName). Controller branch coverage: 62% → 80%. Overall: 87.8% → 89.4%. Raise JaCoCo gate from 0.42 to 0.88.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:56:47 +01:00