PR#51 Owners grid: paged and sorted by Name or City (#25)

Static5/10

Review

PR description on GitHub ↗

Closes #25.

Opened for the review demo, do not merge.

4 implementation assumptions · 14 open review issues · 25 auto-fixed

Show what will be posted

    Round I · while coding

    Implementation assumptions

    Where the ticket was ambiguous: the reading the coder chose.

    1. assumption60% confident Tie-breakers stay ascending whichever way the column sorts on GitHub ↗ in VS Code

      Read the other way: tie-breakers follow the clicked direction, one backward index walk

      Why 60%: the spec says owners within a city are ordered by name, read as A→Z (openspec/changes/paginate-owners-grid/specs/owner-list/spec.md:56↗). Nobody asked how a descending city should order its own names; a reviewer reading "descending" as the whole row reversed would be equally right.

      +118return Sort.by(direction, "city").and(Sort.by("firstName", "lastName", "id"));
    2. assumption65% confident The visit-date scenario leaves its own owner behind on GitHub ↗ in VS Code

      Read the other way: delete the owner in the After hook with the pet

      Why 65%: matches add-owner.spec.ts, which leaves its owners on purpose; the After hook already deletes the pet. Pulls down: each run adds one more pet-less owner to the dev database.

      +20const {headers: created} = await axios.post(`${API_BASE}/owners`, {
      +21  firstName: 'Ada', lastName: `Daterange${Date.now()}`,
      +22  address: '110 Analytical Engine Way', city: 'London', telephone: '6085551023',
      +23}, {timeout: 10_000});
    3. assumption70% confident Add Owner appears twice in the template, not once outside the table on GitHub ↗ in VS Code

      Read the other way: one footer row outside #ownersTable, paginator shown conditionally inside it

      Why 70%: the acceptance tests select #ownersTable mat-paginator, so the paginator must stay inside the table block; a second button in the empty state keeps that.

      +16<div *ngIf="page?.totalElements === 0 || errorMessage" class="owners-empty-actions">
    4. assumption75% confident Seed owner 1 is the add-visit scenario's owner with a pet on GitHub ↗ in VS Code

      Read the other way: take the first pet from GET /api/pets

      Why 75%: AGENTS.md promises owner 1 = Kevin McCallister to tests, and the DSL fails loudly if he loses his pet. It now books on Kevin every run rather than on whichever owner listed first.

      +17const owner = await new ApiClient().fetchOwner(SEEDED_OWNER_WITH_PET);

    Round II · code review

    Open review issues

    Left in the code, each with its reason. They stay open until you agree or disagree.

    1. must look/code-review Booking does not check the pet belongs to the owner on GitHub ↗ in VS Code

      Reviewer: POST /api/owners/1/pets/{another owner's pet}/visits books it and texts owner 1; the MCP path checks ownership.

      predates the audited range (visit-date commit b23c6d3a); worth its own ticket.

       213Pet pet = petRepository.findById(petId).orElseThrow();
    2. worth a look/code-review ListGetFirstTest input snippet has unbalanced braces on GitHub ↗ in VS Code

      Reviewer: 5190fd3c deleted the firstPet method line from the rewrite input, so the test's Java no longer parses.

      the human's own live-coding edit, committed as found at their request.

       49return owner.getPets().get(0);
    3. worth a look/code-review Visit-date rule skipped by the MCP tool and birth-date edits on GitHub ↗ in VS Code

      Reviewer: create_visit only requires a future date, and moving a pet's birthDate past its visits is never checked.

      the visit-date rule is b23c6d3a's, outside this change set.

       69requireFutureDate(visitDate);
    4. worth a look/code-review An undated visit skips the visit-date rule on GitHub ↗ in VS Code

      Reviewer: requireValidVisitDate returns on a null date, and VisitMapper overwrites the entity's default with null.

      the visit-date rule is b23c6d3a's, outside this change set.

       90if (visitDate == null) {
       91    return;
       92}
    5. nit/code-review Domain rule handled as the broad ValidationException on GitHub ↗ in VS Code

      Reviewer: Hibernate Validator's own failures (HV000030) now answer 400 at WARN instead of 500 at ERROR.

      the handler came with b23c6d3a, outside this change set.

      +59return badRequest(List.of(ex.getMessage()), ex.getMessage(), request);
    6. nit/code-review Visit form and server read "today" from different clocks on GitHub ↗ in VS Code

      Reviewer: the form's max date uses the browser's timezone, the server's LocalDate.now() the JVM's; they disagree near midnight.

      b23c6d3a's visit form, outside this change set.

       26readonly maxVisitDate = moment().add(1, 'year').format('YYYY-MM-DD');
    7. nit/code-review Visit booking saves the stub Pet, not the loaded one on GitHub ↗ in VS Code

      Reviewer: bookVisit loads the Pet only to validate it, then saves the Visit with the mapper's id-only stub.

      b23c6d3a's booking path, outside this change set.

       75petRepository.findById(visitDto.getPetId()).orElseThrow()
       76        .requireValidVisitDate(visitDto.getDate(), LocalDate.now());
    8. nit/simplify List payload still carries every pet's visits on GitHub ↗ in VS Code

      Reviewer: the grid shows pet names, yet each page loads and serializes all visits — one extra batch query and payload.

      a slim row is a contract change, a non-goal: openspec/changes/paginate-owners-grid/design.md:72↗

       27PetDto petDto = new PetDto()
       28        .setVisits(visitMapper.toVisitsDto(pet.getVisitsSortedByDate()))
       29        .setName(pet.getName())
       30        .setBirthDate(pet.getBirthDate())
       31        .setType(toPetTypeDto(pet.getType()))
       32        .setId(pet.getId());
    9. nit/simplify Count query repeated on every page turn on GitHub ↗ in VS Code

      Reviewer: Page re-runs count(*) on each click although the total did not change.

      accepted at 10 ms for 100k rows: openspec/changes/paginate-owners-grid/design.md:146↗

      +107Page<Owner> owners = ownerRepository.findByLastNameStartingWith(lastName,
      +108        PageRequest.of(page, size, toSort(sort)));
    10. nit/simplify Last page is a 100k-row offset on GitHub ↗ in VS Code

      Reviewer: showFirstLastButtons jumps to OFFSET 99990, which walks the whole index.

      keyset paging is a non-goal, 24 ms measured: openspec/changes/paginate-owners-grid/design.md:71↗

       50</table>
    11. context/code-review Descending sorts mix directions the indexes cannot walk on GitHub ↗ in VS Code

      Reviewer: city DESC with names ASC needs an incremental sort over each city's group.

      measured at 0.27 ms on 100k rows, openspec/changes/paginate-owners-grid/design.md:134↗

      +118return Sort.by(direction, "city").and(Sort.by("firstName", "lastName", "id"));
    12. context/simplify Hand-rolled paging params instead of Spring's Pageable on GitHub ↗ in VS Code

      Reviewer: Pageable, @PageableDefault and max-page-size exist for this.

      rejected for the sort whitelist and 400-not-clamp: openspec/changes/paginate-owners-grid/design.md:88↗

      +101public OwnerPageDto listOwners(
      +102        @RequestParam(defaultValue = "") String lastName,
      +103        @RequestParam(defaultValue = "0") @Min(0) @Max(MAX_PAGE) int page,
      +104        @RequestParam(defaultValue = "10") @Min(1) @Max(MAX_PAGE_SIZE) int size,
      +105        @RequestParam(defaultValue = "name,asc") @Pattern(regexp = "(name|city)(,(asc|desc))?",
      +106                message = "must be name or city, optionally followed by ,asc or ,desc") String sort) {
      +107    Page<Owner> owners = ownerRepository.findByLastNameStartingWith(lastName,
      +108            PageRequest.of(page, size, toSort(sort)));
      +109    return ownerMapper.toOwnerPageDto(owners);
      +110}
    13. context/simplify Overlapping OwnerListTest cases could be merged on GitHub ↗ in VS Code

      Reviewer: the default-page test already proves first-name order, so the Beatrix/Harry test adds nothing.

      each test pins one spec scenario: openspec/changes/paginate-owners-grid/specs/owner-list/spec.md:50↗

      138void sortByName_putsFirstNamesFirst() throws Exception {
      139    owner("Harry", "Pagerpotter", "London");
      140    owner("Beatrix", "Pagerpotter", "Near Sawrey");
      141
      142    OwnerPageDto page = list("?lastName=Pagerpotter&sort=name,asc");
      143
      144    assertThat(page.content()).extracting(OwnerDto::getFirstName).containsExactly("Beatrix", "Harry");
      145}
    14. context/simplify Shared aPage fixture builder for the specs on GitHub ↗ in VS Code

      Reviewer: two specs each build their own OwnerPage fixture.

      two uses; the reviewer itself said not worth it until a third.

       42schemas: [NO_ERRORS_SCHEMA],
      +43imports: [CommonModule, FormsModule, NoopAnimationsModule, OwnersModule, RouterTestingModule],
       44providers: [
      +45  {provide: OwnerService, useClass: OwnerServiceStub},
       46  {provide: ActivatedRoute, useClass: ActivatedRouteStub}
       47]

    Round III · fixing the review

    Auto-fixed

    Fixed by the review in 4 fix commits: 5190fd3c, 3ef70228, 1bdb4c61, d01c3776. Diffs against 0b99e29c, the implementation commit. 3 commits carry a Review-Points: trailer; the page reads them as one round of fixes, from the implementation to the last of them.

    1. fixed/code-review Add Owner vanished when a search matched nobody on GitHub ↗ in VS Code

      Reviewer: the button moved inside #ownersTable, rendered only when totalElements > 0; on no match, an error or an empty clinic there was no way to /owners/add.

       
      1313
      1414 <div *ngIf="errorMessage" id="ownersError" class="alert alert-danger">Could not load the owners: {{ errorMessage }}</div>
      1515 <div *ngIf="page?.totalElements === 0" id="noOwners">No owners with last name starting with "{{ query.lastName }}"</div>
      16+ <div *ngIf="page?.totalElements === 0 || errorMessage" class="owners-empty-actions">
      17+ <button class="btn btn-default" routerLink="/owners/add">Add Owner</button>
      18+ </div>
      1619 <div class="table-responsive" id="ownersTable" *ngIf="page && page.totalElements > 0">
      17- <table class="table table-striped"
      18- matSort [matSortActive]="sortColumn" [matSortDirection]="sortDirection" matSortDisableClear
      19- (matSortChange)="onSort($event)">
      20+ <table class="table table-striped">
      2021 <thead>
      2122 <tr>
      22- <th mat-sort-header="name">Name</th>
      23- <th>Address</th>
      24- <th mat-sort-header="city">City</th>
      25- <th>Telephone</th>
      26- <th>Pets</th>
      23+ <th class="col-name sortable" [attr.aria-sort]="ariaSort('name')">
      24+ <button type="button" class="sort-button" (click)="sortBy('name')">
      25+ Name<span class="sort-indicator" [ngClass]="indicator('name')"></span>
      26+ </button>
      27+ </th>
      28+ <th class="col-address">Address</th>
      29+ <th class="col-city sortable" [attr.aria-sort]="ariaSort('city')">
      30+ <button type="button" class="sort-button" (click)="sortBy('city')">
      31+ City<span class="sort-indicator" [ngClass]="indicator('city')"></span>
      32+ </button>
      33+ </th>
      34+ <th class="col-telephone">Telephone</th>
      35+ <th class="col-pets">Pets</th>
      2736 </tr>
      2837 </thead>
      2938
      describe('OwnerListComponent', () => {
      124115 });
      125116
      126117 it('a new sort goes back to the first page', () => {
      127- url.set({page: '3'});
      118+ route.setQueryParams({page: '3'});
      128119 fixture.detectChanges();
      129120
      130- component.onSort({active: 'city', direction: 'desc'});
      121+ component.sortBy('city');
      131122
      132- expect(navigatedTo()).toEqual({sort: 'city,desc'});
      123+ expect(navigatedTo()).toEqual({sort: 'city,asc'});
      133124 });
      134125
      135126 it('leaves the URL clean when everything is back to its default', () => {
      136- url.set({page: '2', sort: 'city,asc'});
      127+ route.setQueryParams({page: '2', sort: 'city,asc'});
      137128 fixture.detectChanges();
      138129
      139- component.onSort({active: 'name', direction: 'asc'});
      130+ component.sortBy('name');
      140131
      141132 expect(navigatedTo()).toEqual({});
      142133 });
      143134
      144- it('only Name and City can be sorted', () => {
      135+ it('clicking the sorted column flips its direction', () => {
      136+ route.setQueryParams({sort: 'city,asc'});
      137+ fixture.detectChanges();
      138+
      139+ component.sortBy('city');
      140+
      141+ expect(navigatedTo()).toEqual({sort: 'city,desc'});
      142+ });
      143+
      144+ it('only Name and City can be sorted, and the active one says which way', () => {
      145+ route.setQueryParams({sort: 'city,desc'});
      145146 fixture.detectChanges();
      146147
      147- const sortable = fixture.debugElement.queryAll(By.css('th[mat-sort-header]'))
      148- .map((th) => th.attributes['mat-sort-header']);
      149- expect(sortable).toEqual(['name', 'city']);
      148+ const sortable = fixture.debugElement.queryAll(By.css('th.sortable'));
      149+ expect(sortable.map((th) => th.nativeElement.textContent.trim())).toEqual(['Name', 'City']);
      150+ expect(sortable.map((th) => th.nativeElement.getAttribute('aria-sort'))).toEqual([null, 'descending']);
      150151 });
      151152
      152153 it('says no owner matched when the search finds none', () => {
      153154 getOwnersPage.and.returnValue(of(aPage([])));
      154- url.set({lastName: 'Zzzz'});
      155+ route.setQueryParams({lastName: 'Zzzz'});
      155156 fixture.detectChanges();
      156157
      157158 expect(text('#noOwners')).toBe('No owners with last name starting with "Zzzz"');
      158159 expect(fixture.debugElement.query(By.css('#ownersTable'))).toBeNull();
      159160 });
      160161
      162+ // An owner nobody found is exactly the one about to be added.
      163+ it('still offers Add Owner when the search finds none', () => {
      164+ getOwnersPage.and.returnValue(of(aPage([])));
      165+ route.setQueryParams({lastName: 'Zzzz'});
      166+ fixture.detectChanges();
      167+
      168+ expect(text('button[routerLink="/owners/add"]')).toBe('Add Owner');
      169+ });
      170+
      171+ it('searching again for the same name asks again, though the URL does not change', () => {
      172+ route.setQueryParams({lastName: 'Pot'});
      173+ fixture.detectChanges();
      174+ getOwnersPage.calls.reset();
      175+
      176+ component.lastName = 'Pot';
      177+ component.search();
      178+
      179+ expect(navigate).not.toHaveBeenCalled();
      180+ expect(getOwnersPage).toHaveBeenCalledWith({lastName: 'Pot', page: 0, size: 10, sort: 'name,asc'});
      181+ });
      182+
      161183 it('shows a failure as an error, not as "no owners"', () => {
      162184 getOwnersPage.and.returnValue(throwError('server returned code 500'));
      163185 fixture.detectChanges();

      Fix: the empty and error states show their own Add Owner under the message.

    2. fixed/code-review Bad page input answered 500 instead of 400 on GitHub ↗ in VS Code

      Reviewer: ?page=abc hit the catch-all handler as a type mismatch; ?page=30000000&size=100 overflowed Spring Data's int offset. Both logged at ERROR as 500.

      import jakarta.validation.constraints.Pattern;
      5757 public class OwnerRestController {
      5858
      5959 static final int MAX_PAGE_SIZE = 100;
      60+ // page * size must stay an int: Spring Data's offset overflows past it
      61+ static final int MAX_PAGE = Integer.MAX_VALUE / MAX_PAGE_SIZE;
      6062
      6163 private final OwnerRepository ownerRepository;
      6264 private final PetRepository petRepository;
      class OwnerListTest {
      120120 .andExpect(jsonPath("$.errors[0]").value(containsString("page")));
      121121 }
      122122
      123+ @Test
      124+ void pageThatIsNotANumber_isRejected() throws Exception {
      125+ mockMvc.perform(get("/api/owners?page=abc"))
      126+ .andExpect(status().isBadRequest())
      127+ .andExpect(jsonPath("$.errors[0]").value(containsString("page")));
      128+ }
      129+
      130+ @Test
      131+ void pageWhoseOffsetWouldOverflow_isRejected() throws Exception {
      132+ mockMvc.perform(get("/api/owners?page=30000000&size=100"))
      133+ .andExpect(status().isBadRequest())
      134+ .andExpect(jsonPath("$.errors[0]").value(containsString("page")));
      135+ }
      136+
      123137 @Test
      124138 void sortByName_putsFirstNamesFirst() throws Exception {
      125139 owner("Harry", "Pagerpotter", "London");
      public class OwnerRestController {
      98100 @GetMapping(produces = "application/json")
      99101 public OwnerPageDto listOwners(
      100102 @RequestParam(defaultValue = "") String lastName,
      101- @RequestParam(defaultValue = "0") @Min(0) int page,
      103+ @RequestParam(defaultValue = "0") @Min(0) @Max(MAX_PAGE) int page,
      102104 @RequestParam(defaultValue = "10") @Min(1) @Max(MAX_PAGE_SIZE) int size,
      103105 @RequestParam(defaultValue = "name,asc") @Pattern(regexp = "(name|city)(,(asc|desc))?",
      104106 message = "must be name or city, optionally followed by ,asc or ,desc") String sort) {
      105107 Page<Owner> owners = ownerRepository.findByLastNameStartingWith(lastName,
      106108 PageRequest.of(page, size, toSort(sort)));
      107- return new OwnerPageDto(ownerMapper.toOwnerDtoCollection(owners.getContent()),
      108- owners.getTotalElements(), owners.getTotalPages(), owners.getNumber(), owners.getSize());
      109+ return ownerMapper.toOwnerPageDto(owners);
      109110 }
      110111
      111112 // The direction applies to the clicked column only; the tie-breakers stay ascending and end
      public class ExceptionControllerAdvice {
      4647 }
      4748
      4849 @ExceptionHandler(ConstraintViolationException.class)
      49- @ResponseStatus(HttpStatus.BAD_REQUEST)
      50+ @ResponseStatus(HttpStatus.BAD_REQUEST) // springdoc documents the 400 on every operation from this
      5051 public ResponseEntity<ProblemDetail> handleConstraintViolation(ConstraintViolationException ex,
      5152 HttpServletRequest request) {
      52- List<String> errors = ValidationErrorExtractor.extract(ex);
      53- log.warn("Validation failed: {}", errors);
      54- ProblemDetail pd = buildProblemDetail("Validation Error",
      55- "Validation failed for request. See 'errors' for details.", HttpStatus.BAD_REQUEST, request);
      56- pd.setProperty("errors", errors);
      57- return ResponseEntity.badRequest().body(pd);
      53+ return badRequest(ValidationErrorExtractor.extract(ex), SEE_ERRORS, request);
      5854 }
      5955
      6056 @ExceptionHandler(ValidationException.class)
      6157 public ResponseEntity<ProblemDetail> handleValidationException(ValidationException ex,
      6258 HttpServletRequest request) {
      63- log.warn("Validation failed: {}", ex.getMessage());
      64- ProblemDetail pd = buildProblemDetail("Validation Error", ex.getMessage(), HttpStatus.BAD_REQUEST, request);
      65- pd.setProperty("errors", List.of(ex.getMessage()));
      66- return ResponseEntity.badRequest().body(pd);
      59+ return badRequest(List.of(ex.getMessage()), ex.getMessage(), request);
      6760 }
      6861
      6962 @ExceptionHandler(MethodArgumentNotValidException.class)
      7063 @ResponseStatus(HttpStatus.BAD_REQUEST)
      7164 public ResponseEntity<ProblemDetail> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex,
      7265 HttpServletRequest request) {
      73- BindingResult bindingResult = ex.getBindingResult();
      74- // reuse ValidationErrorExtractor style: build list of readable messages
      75- List<String> errors = ValidationErrorFieldExtractor.extract(bindingResult);
      66+ return badRequest(ValidationErrorFieldExtractor.extract(ex.getBindingResult()), SEE_ERRORS, request);
      67+ }
      68+
      69+ // A query or path parameter that does not parse (?page=abc) is the client's mistake, not a crash
      70+ @ExceptionHandler(MethodArgumentTypeMismatchException.class)
      71+ public ResponseEntity<ProblemDetail> handleTypeMismatch(MethodArgumentTypeMismatchException ex,
      72+ HttpServletRequest request) {
      73+ String type = ex.getRequiredType() == null ? "value" : ex.getRequiredType().getSimpleName();
      74+ return badRequest(List.of(ex.getName() + " must be a " + type + " (value: " + ex.getValue() + ")"),
      75+ SEE_ERRORS, request);
      76+ }
      77+
      78+ private ResponseEntity<ProblemDetail> badRequest(List<String> errors, String detail, HttpServletRequest request) {
      7679 log.warn("Validation failed: {}", errors);
      77- ProblemDetail pd = buildProblemDetail("Validation Error",
      78- "Validation failed for request. See 'errors' for details.", HttpStatus.BAD_REQUEST, request);
      80+ ProblemDetail pd = buildProblemDetail("Validation Error", detail, HttpStatus.BAD_REQUEST, request);
      7981 pd.setProperty("errors", errors);
      8082 return ResponseEntity.badRequest().body(pd);
      8183 }

      Fix: type mismatches map to 400 for every endpoint; page is capped at Integer.MAX_VALUE / MAX_PAGE_SIZE.

    3. fixed/code-review Find Owner with an unchanged name did not search again on GitHub ↗ in VS Code

      Reviewer: search() only navigates; an identical URL emits nothing, so a repeated search showed stale results where the old grid re-fetched.

      export class OwnerListComponent implements OnInit, OnDestroy {
      6056 }
      6157
      6258 search() {
      63- this.navigate({lastName: this.lastName, page: 1});
      59+ const searched = {...this.query, lastName: this.lastName, page: 1};
      60+ if (JSON.stringify(toUrl(searched)) === JSON.stringify(toUrl(this.query))) {
      61+ this.reload.next(searched);
      62+ return;
      63+ }
      64+ this.navigate(searched);
      6465 }
      6566
      6667 onPage(event: PageEvent) {
      export class OwnerListComponent implements OnInit, OnDestroy {
      3328 page: OwnerPage | undefined;
      3429 errorMessage: string | undefined;
      3530 private load: Subscription;
      31+ // Asks again for the query already in the URL: navigating to an identical URL emits nothing.
      32+ private readonly reload = new Subject<OwnerListQuery>();
      3633
      37- constructor(private router: Router, private route: ActivatedRoute, private ownerService: OwnerService) {
      34+ constructor(private readonly router: Router, private readonly route: ActivatedRoute,
      35+ private readonly ownerService: OwnerService) {
      3836 }
      3937
      4038 ngOnInit() {
      41- this.load = this.route.queryParamMap.pipe(
      42- map(fromUrl),
      39+ this.load = merge(this.route.queryParamMap.pipe(map(fromUrl)), this.reload).pipe(
      4340 tap((query) => {
      4441 this.query = query;
      4542 this.lastName = query.lastName;
      4643 }),
      4744 // switchMap: only the latest query may answer, however late an earlier one comes back
      48- switchMap((query) => this.ownerService.getOwnersPage(
      49- {lastName: query.lastName, page: query.page - 1, size: query.size, sort: query.sort}).pipe(
      45+ switchMap((query) => this.ownerService.getOwnersPage({...query, page: query.page - 1}).pipe(
      5046 catchError((error) => {
      5147 this.errorMessage = String(error);
      5248 this.page = undefined;

      owner-list.component.spec.ts: diff shown under Add Owner vanished when a search matched nobody.

      Fix: an unchanged query is pushed through a reload subject merged into the URL stream.

    4. fixed/code-review Hibernate statistics left switched on for the cached context on GitHub ↗ in VS Code

      Reviewer: the batch-fetch test enabled statistics on the shared SessionFactory and never disabled them, so later tests reusing the context paid for and inherited them.

      class OwnerListTest {
      201215 Statistics statistics = entityManager.getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics();
      202216 statistics.setStatisticsEnabled(true);
      203217 statistics.clear();
      204-
      205- list("?size=20");
      206-
      207- // page + count + one batch of pets + one batch of visits (+ pet types)
      208- assertThat(statistics.getPrepareStatementCount()).isLessThanOrEqualTo(5);
      218+ try {
      219+ list("?size=20");
      220+
      221+ // page + count + one batch of pets + one batch of visits (+ pet types)
      222+ assertThat(statistics.getPrepareStatementCount()).isLessThanOrEqualTo(5);
      223+ } finally {
      224+ statistics.setStatisticsEnabled(false); // the SessionFactory outlives this test
      225+ }
      209226 }
      210227
      211228 private Owner owner(String firstName, String lastName, String city) {

      Fix: try/finally switches them off.

    5. fixed/code-review Visit-date scenario still read the owner list as an array on GitHub ↗ in VS Code

      Reviewer: owners.reduce is not a function failed both visit-date-range scenarios in their Background once the list became a page.

      Given('today is {word}', async function (this: PlaywrightWorld, today: string) {
      1313 await this.page.clock.setFixedTime(new Date(`${today}T10:00:00`));
      1414 });
      1515
      16-/** A pet of its own, on the last owner — the seed has none born that day, and no other scenario looks there. */
      16+const idFrom = (location: string) => Number(location.split('/').pop());
      17+
      18+/** A pet of its own, on an owner of its own — no other scenario looks there. */
      1719 Given('a pet born on {word}', async function (this: PlaywrightWorld, birthDate: string) {
      18- const {data: owners} = await axios.get(`${API_BASE}/owners`, {timeout: 10_000});
      19- const owner = owners.reduce((a: any, b: any) => (a.id > b.id ? a : b));
      20+ const {headers: created} = await axios.post(`${API_BASE}/owners`, {
      21+ firstName: 'Ada', lastName: `Daterange${Date.now()}`,
      22+ address: '110 Analytical Engine Way', city: 'London', telephone: '6085551023',
      23+ }, {timeout: 10_000});
      24+ const ownerId = idFrom(created.location);
      2025 const name = `Born ${birthDate} ${Date.now()}`;
      21- const {headers} = await axios.post(`${API_BASE}/owners/${owner.id}/pets`,
      26+ const {headers} = await axios.post(`${API_BASE}/owners/${ownerId}/pets`,
      2227 {name, birthDate, type: {id: 1, name: 'cat'}}, {timeout: 10_000});
      23- this.ownerId = owner.id;
      24- this.createdPetId = this.petId = Number(headers.location.split('/').pop());
      28+ this.ownerId = ownerId;
      29+ this.createdPetId = this.petId = idFrom(headers.location);
      2530 this.petName = name;
      2631 });
      2732

      Fix: the scenario creates its own owner and puts the pet on it.

    6. fixed/code-review AddVisitApiTest sequence diagram showed the old list call on GitHub ↗ in VS Code

      Reviewer: the committed diagram still drew "List owners" with an array payload; only the browser suites' diagrams had been re-traced.

      Fix: re-traced with petclinic-backend/run-tests-with-tracing.sh.

      +16Test -> Backend: List one page of owners\nGET /api/owners
    7. fixedCI (SonarCloud java:S1192) Validation responses built from the same literals four times on GitHub ↗ in VS Code

      CI: "Validation failed: {}", "Validation Error" and "errors" were duplicated across the 400 handlers; the new type-mismatch handler made it a fourth copy.

      ExceptionControllerAdvice.java: diff shown under Bad page input answered 500 instead of 400.

      Fix: one badRequest helper builds every validation 400.

    8. fixed/simplify Dead stylesheet rules from an earlier pager layout on GitHub ↗ in VS Code

      Reviewer: about 80 lines (#nameGroup, .owners-pagination, .owner-search-label, …) matched nothing in the template, beside the live rules.

       
      1-#ownersByLastName
      2-{
      3-display:none;
      4-}
      5-
      6-#nameGroup {
      7- display: flex;
      8- align-items: center;
      9- gap: 16px;
      10- margin-left: 0;
      11- margin-right: 0;
      12- padding-left: 0;
      13-}
      14-
      15-#addressGroup {
      16- display: flex;
      17- align-items: center;
      18- gap: 16px;
      19- margin-left: 0;
      20- margin-right: 0;
      21- padding-left: 0;
      22-}
      23-
      24-.owner-search-label {
      25- margin: 0;
      26- text-align: left;
      27- min-width: 60px;
      28-}
      29-
      30-.owner-search-input {
      31- flex: 1;
      32-}
      33-
      34-.owners-controls {
      35- display: flex;
      36- align-items: center;
      37- justify-content: space-between;
      38- gap: 16px;
      39- margin-bottom: 16px;
      40-}
      41-
      42-.owners-pagination {
      43- display: flex;
      44- align-items: center;
      45- gap: 12px;
      46-}
      47-
      48-.owners-page-size {
      1+/* One row as wide as the table: label, stretching input, search button */
      2+.owner-search-row {
      493 display: flex;
      504 align-items: center;
      51- gap: 8px;
      5+ gap: 15px;
      6+ margin-bottom: 15px;
      527 }
      538
      54-.owners-page-size .form-control {
      55- width: auto;
      9+.owner-search-row label {
      10+ margin-bottom: 0;
      11+ white-space: nowrap;
      5612 }
      5713
      58-@media (max-width: 576px) {
      59- #nameGroup {
      60- flex-direction: column;
      61- align-items: flex-start;
      62- gap: 8px;
      63- }
      64-
      65- #addressGroup {
      66- flex-direction: column;
      67- align-items: flex-start;
      68- gap: 8px;
      69- }
      70-
      71- .owner-search-input {
      72- width: 100%;
      73- }
      74-
      75- .owners-controls {
      76- flex-direction: column;
      77- align-items: flex-start;
      78- }
      14+.owner-search-row button {
      15+ flex-shrink: 0;
      7916 }
      8017
      8118 /* Fixed widths: auto layout sized the columns to each page's rows, so the headers jumped on every sort. */
      8219 #ownersTable table {
      8320 table-layout: fixed;
      21+ margin-bottom: 0;
      8422 }
      8523
      86-#ownersTable th:nth-child(1) { width: 22%; }
      87-#ownersTable th:nth-child(2) { width: 26%; }
      88-#ownersTable th:nth-child(3) { width: 16%; }
      89-#ownersTable th:nth-child(4) { width: 18%; }
      90-#ownersTable th:nth-child(5) { width: 18%; }
      24+.col-name { width: 22%; }
      25+.col-address { width: 26%; }
      26+.col-city { width: 16%; }
      27+.col-telephone { width: 18%; }
      28+.col-pets { width: 18%; }
      9129
      9230 #ownersTable td {
      9331 overflow-wrap: anywhere;
      9432 }
      9533
      96-/* Paginator reads as the table's footer: no gap, no white panel */
      97-#ownersTable table {
      98- margin-bottom: 0;
      34+.owners-empty-actions {
      35+ margin-top: 15px;
      9936 }
      10037
      38+/* Paginator reads as the table's footer: no gap, no white panel */
      10139 .owners-footer {
      10240 display: flex;
      10341 align-items: center;

      Fix: deleted; the two #ownersTable table rules merged into one.

    9. fixed/simplify Label rules only undid form-horizontal on GitHub ↗ in VS Code

      Reviewer: the form kept form-horizontal only for its control-label styling, which the search-row rules then reset property by property.

       
      22 <div class="container xd-container">
      33 <h2>Owners</h2>
      44
      5- <form class="form-horizontal" id="search-owner-form" (ngSubmit)="search()">
      5+ <form id="search-owner-form" (ngSubmit)="search()">
      66 <div class="owner-search-row">
      7- <label class="control-label" for="lastName">Last name</label>
      7+ <label for="lastName">Last name</label>
      88 <input class="form-control" size="30"
      99 maxlength="80" id="lastName" name="lastName" [(ngModel)]="lastName"/>
      1010 <button type="submit" class="btn btn-default">Find Owner</button>

      owner-list.component.css: diff shown under Dead stylesheet rules from an earlier pager layout.

      Fix: dropped the class; only margin and nowrap remain.

    10. fixed/simplify Sort arrows overrode Material's private DOM with !important on GitHub ↗ in VS Code

      Reviewer: ::ng-deep rules hid .mat-sort-header-arrow's children and cancelled its animation styles; a Material upgrade breaks them silently.

      export class OwnerListComponent implements OnInit, OnDestroy {
      6869 this.navigate({size: event.pageSize, page: sizeChanged ? 1 : event.pageIndex + 1});
      6970 }
      7071
      71- onSort(sort: Sort) {
      72- this.navigate({sort: `${sort.active},${sort.direction || 'asc'}`, page: 1});
      72+ // A click on the sorted column flips its direction; on the other one it sorts ascending.
      73+ sortBy(column: SortColumn) {
      74+ const direction = this.sortColumn === column && this.sortDirection === 'asc' ? 'desc' : 'asc';
      75+ this.navigate({sort: `${column},${direction}`, page: 1});
      76+ }
      77+
      78+ ariaSort(column: SortColumn): 'ascending' | 'descending' | null {
      79+ if (this.sortColumn !== column) {
      80+ return null;
      81+ }
      82+ return this.sortDirection === 'asc' ? 'ascending' : 'descending';
      83+ }
      84+
      85+ indicator(column: SortColumn) {
      86+ const active = this.sortColumn === column;
      87+ return {active, asc: active && this.sortDirection === 'asc', desc: active && this.sortDirection === 'desc'};
      7388 }
      7489
      75- get sortColumn(): string {
      90+ private get sortColumn(): string {
      7691 return this.query.sort.split(',')[0];
      7792 }
      7893
      79- get sortDirection(): 'asc' | 'desc' {
      80- return this.query.sort.endsWith('desc') ? 'desc' : 'asc';
      94+ private get sortDirection(): string {
      95+ return this.query.sort.split(',')[1];
      8196 }
      8297
      8398 private show(page: OwnerPage) {
      display:none;
      10947 background: transparent;
      11048 }
      11149
      112-/* One row as wide as the table: label, stretching input, search button */
      113-.owner-search-row {
      114- display: flex;
      115- align-items: center;
      116- gap: 15px;
      117- margin-bottom: 15px;
      118-}
      119-
      120-.owner-search-row .control-label {
      121- padding-top: 0;
      122- margin-bottom: 0;
      123- text-align: left;
      124- white-space: nowrap;
      125-}
      126-
      127-.owner-search-row button {
      128- flex-shrink: 0;
      129-}
      130-
      13150 /* Sort indicator: a dim ▲▼ pair marks a sortable column, a bright ▲ or ▼ the active sort. */
      132-/* Replaces Material's stem+halves arrow; !important beats its animations' inline styles. */
      133-::ng-deep #ownersTable .mat-sort-header-arrow {
      51+/* A button, so the sort is reachable from the keyboard; styled to read as the header text */
      52+.sort-button {
      53+ padding: 0;
      54+ border: 0;
      55+ background: none;
      56+ color: inherit;
      57+ font: inherit;
      58+ cursor: pointer;
      59+}
      60+
      61+.sort-indicator {
      62+ display: inline-flex;
      13463 flex-direction: column;
      135- justify-content: center;
      136- align-items: center;
      13764 gap: 2px;
      138- width: 10px;
      139- min-width: 10px;
      140- color: #fff;
      141- opacity: 0.4 !important;
      142- transform: none !important;
      65+ margin-left: 6px;
      66+ vertical-align: middle;
      67+ opacity: 0.4;
      14368 }
      14469
      145-::ng-deep #ownersTable .mat-sort-header-arrow > * {
      146- display: none;
      70+.sort-indicator.active {
      71+ opacity: 1;
      14772 }
      14873
      149-::ng-deep #ownersTable .mat-sort-header-arrow::before,
      150-::ng-deep #ownersTable .mat-sort-header-arrow::after {
      74+.sort-indicator::before,
      75+.sort-indicator::after {
      15176 content: '';
      15277 border-left: 5px solid transparent;
      15378 border-right: 5px solid transparent;
      15479 }
      15580
      156-::ng-deep #ownersTable .mat-sort-header-arrow::before {
      81+.sort-indicator::before {
      15782 border-bottom: 5px solid currentColor;
      15883 }
      15984
      160-::ng-deep #ownersTable .mat-sort-header-arrow::after {
      85+.sort-indicator::after {
      16186 border-top: 5px solid currentColor;
      16287 }
      16388
      164-::ng-deep #ownersTable th[aria-sort="ascending"] .mat-sort-header-arrow,
      165-::ng-deep #ownersTable th[aria-sort="descending"] .mat-sort-header-arrow {
      166- opacity: 1 !important;
      167-}
      168-
      169-::ng-deep #ownersTable th[aria-sort="ascending"] .mat-sort-header-arrow::after,
      170-::ng-deep #ownersTable th[aria-sort="descending"] .mat-sort-header-arrow::before {
      89+.sort-indicator.asc::after,
      90+.sort-indicator.desc::before {
      17191 display: none;
      17292 }

      owner-list.component.html: diff shown under Add Owner vanished when a search matched nobody.

      Fix: the headers draw ▲/▼ from the component's own sort state; MatSort is gone, along with the dead || 'asc'.

    11. fixed/simplify Column widths tied to column order on GitHub ↗ in VS Code

      Reviewer: th:nth-child(1..5) widths go wrong silently when a column is added or moved.

      owner-list.component.css: diff shown under Dead stylesheet rules from an earlier pager layout.

      owner-list.component.html: diff shown under Add Owner vanished when a search matched nobody.

      Fix: one class per column.

    12. fixed/simplify Shell padding zeroed to undo its own container-fluid on GitHub ↗ in VS Code

      Reviewer: .main-wrapper padding was set to 0 to cancel the gutter of the container-fluid class on the same div.

       
      1-<div class="container-fluid main-wrapper">
      1+<div class="main-wrapper">
      22 <nav class="navbar navbar-default " role="navigation">
      33 <div class="container-fluid">
      44 <div class="navbar-header">
      body {
      88
      99 .main-wrapper {
      1010 flex: 1;
      11- /* the navbar runs edge to edge, not inside the container's gutter */
      12- padding-left: 0;
      13- padding-right: 0;
      1411 }
      1512
      1613 div.navbar-header {

      Fix: removed the class instead.

    13. fixed/simplify Query types written by hand beside the generated one on GitHub ↗ in VS Code

      Reviewer: OwnerQuery and OwnerListQuery restated listOwners' parameters, so a backend parameter change would not break the frontend build.

       
      11 import { Injectable } from '@angular/core';
      22 import { Owner } from './owner';
      33 import { OwnerPage } from './owner-page';
      4+import { operations } from '../generated/api-types';
      45 import { Observable } from 'rxjs';
      56 import { environment } from '../../environments/environment';
      67 import { HttpClient, HttpParams } from '@angular/common/http';
      78 import { catchError } from 'rxjs/operators';
      89 import { HandleError, HttpErrorHandler } from '../error.service';
      910
      10-export interface OwnerQuery {
      11- lastName?: string;
      12- page?: number;
      13- size?: number;
      14- sort?: string;
      15-}
      11+export type OwnerQuery = NonNullable<operations['listOwners']['parameters']['query']>;
      1612
      1713 @Injectable()
      1814 export class OwnerService {
       
      11 import {Component, OnDestroy, OnInit} from '@angular/core';
      22 import {ActivatedRoute, ParamMap, Params, Router} from '@angular/router';
      33 import {PageEvent} from '@angular/material/paginator';
      4-import {Sort} from '@angular/material/sort';
      5-import {EMPTY, Subscription} from 'rxjs';
      4+import {EMPTY, merge, Subject, Subscription} from 'rxjs';
      65 import {catchError, map, switchMap, tap} from 'rxjs/operators';
      7-import {OwnerService} from '../owner.service';
      6+import {OwnerQuery, OwnerService} from '../owner.service';
      87 import {OwnerPage} from '../owner-page';
      98
      10-/** What the grid shows, as kept in the URL: `page` is 1-based there, and `sort` is spelled as the API spells it. */
      11-interface OwnerListQuery {
      12- lastName: string;
      13- page: number;
      14- size: number;
      15- sort: string;
      16-}
      9+/** What the grid shows, as kept in the URL: the API's query, except that `page` is 1-based there. */
      10+type OwnerListQuery = Required<OwnerQuery>;
      11+type SortColumn = 'name' | 'city';
      1712
      1813 const DEFAULTS: OwnerListQuery = {lastName: '', page: 1, size: 10, sort: 'name,asc'};
      1914 const PAGE_SIZES = [5, 10, 20];

      owner-list.component.ts: diff shown under Find Owner with an unchanged name did not search again.

      Fix: OwnerQuery comes from api-types.ts; the component uses Required<OwnerQuery>.

    14. fixed/simplify Error fallback that is never returned on GitHub ↗ in VS Code

      Reviewer: handlerError always rethrows, so {} as OwnerPage suggested failures became an empty page.

      export class OwnerService {
      3733 }
      3834 return this.http
      3935 .get<OwnerPage>(this.entityUrl, {params})
      40- .pipe(catchError(this.handlerError('getOwnersPage', {} as OwnerPage)));
      36+ .pipe(catchError(this.handlerError<OwnerPage>('getOwnersPage')));
      4137 }
      4238
      4339 getOwnerById(ownerId: number): Observable<Owner> {

      Fix: only the type argument is kept.

    15. fixed/simplify Batch fetching added one association at a time on GitHub ↗ in VS Code

      Reviewer: @BatchSize fixed the N+1 only on the two collections the grid walks; Vet.specialties kept its own.

      spring.jpa.show-sql=true
      1111 # call a `select ... from owners` came from; the sequence diagrams label their DB arrows
      1212 # with it and keep the SQL itself behind a click.
      1313 spring.jpa.properties.hibernate.use_sql_comments=true
      14+# Lazy collections load in batches (one query per page of owners, not one per row)
      15+spring.jpa.properties.hibernate.default_batch_fetch_size=100
      1416
      1517 # Flyway
      1618 spring.flyway.enabled=true
      Owner.java+0 −1
      public class Owner {
      5553 private String telephone;
      5654
      5755 @OneToMany(cascade = CascadeType.ALL, mappedBy = "owner", fetch = FetchType.LAZY)
      58- @BatchSize(size = 100) // a page of owners loads its pets (and their visits) in one query, not one per row
      5956 private Set<Pet> pets = new HashSet<>();
      6057
      6158 public List<Pet> getPets() {
      Pet.java+0 −1
      public class Pet {
      5048 private Owner owner;
      5149
      5250 @OneToMany(cascade = CascadeType.ALL, mappedBy = "pet", fetch = FetchType.LAZY)
      53- @BatchSize(size = 100)
      5451 private Set<Visit> visits = new HashSet<>();
      5552
      5653 public List<Visit> getVisitsSortedByDate() {

      Fix: hibernate.default_batch_fetch_size=100 replaces both annotations.

    16. fixed/simplify Page-to-DTO conversion inlined in the controller on GitHub ↗ in VS Code

      Reviewer: every other entity↔DTO conversion lives in a mapper; this one was five accessor calls in listOwners.

      public class OwnerMapper {
      2729 .setPets(petMapper.toPetsDto(owner.getPets()));
      2830 }
      2931
      32+ public OwnerPageDto toOwnerPageDto(Page<Owner> owners) {
      33+ return new OwnerPageDto(toOwnerDtoCollection(owners.getContent()),
      34+ owners.getTotalElements(), owners.getTotalPages(), owners.getNumber(), owners.getSize());
      35+ }
      36+
      3037 public Owner toOwner(OwnerFieldsDto ownerDto) {
      3138 Owner owner = new Owner();
      3239 owner.setFirstName(ownerDto.getFirstName());

      OwnerRestController.java: diff shown under Bad page input answered 500 instead of 400.

      Fix: OwnerMapper.toOwnerPageDto.

    17. fixed/simplify Private route stub duplicating ActivatedRouteStub on GitHub ↗ in VS Code

      Reviewer: the spec carried its own BehaviorSubject stub beside the shared one 15 specs use.

      export class ActivatedRouteStub {
      4949 this.subject.next(params);
      5050 }
      5151
      52+ // ActivatedRoute.queryParamMap is Observable too
      53+ private querySubject = new BehaviorSubject(convertToParamMap({}));
      54+ queryParamMap = this.querySubject.asObservable();
      55+
      56+ setQueryParams(params: Params) {
      57+ this.querySubject.next(convertToParamMap(params));
      58+ }
      59+
      5260 // ActivatedRoute.snapshot.params
      5361 get snapshot() {
      5462 this.testParams = {id: 1};

      Fix: the shared stub gained queryParamMap and setQueryParams.

      +36const aPage = (content: Owner[], totalElements = content.length,
      +37                totalPages = Math.ceil(totalElements / 10)): OwnerPage =>
      +38  ({content, totalElements, totalPages, number: 0, size: 10});
    18. fixed/simplify Add-visit glue walked owner pages to find a pet on GitHub ↗ in VS Code

      Reviewer: the loop paged through owners because leftover pet-less "Ada Acceptance" owners sort first, one more round trip per hundred leftovers.

       
      11 import {expect, Page} from '@playwright/test';
      2-import axios from 'axios';
      2+import {ApiClient} from './support/api-client';
      33
      44 // The sentences of add-visit.spec.ts, as plain functions: named for what the
      55 // reader of a scenario wants to see, not for the widget being clicked. The
      66 // selectors live here so the spec never mentions one.
      77
      8-const API_BASE = process.env.API_BASE_URL || 'http://localhost:8080/api';
      9-
      108 export interface OwnerWithPet {
      119 ownerId: number;
      1210 petId: number;
      1311 }
      1412
      15-// Walks the pages: the pet-less owners other runs leave behind ("Ada Acceptance…") sort first by name.
      13+// The seed's owner 1 (Kevin McCallister) has a pet, so the list needs no searching.
      14+const SEEDED_OWNER_WITH_PET = 1;
      15+
      1616 export async function an_owner_with_at_least_one_pet_exists(): Promise<OwnerWithPet> {
      17- for (let page = 0; ; page++) {
      18- const {data} = await axios.get(`${API_BASE}/owners?size=100&page=${page}`, {timeout: 10_000});
      19- const ownerWithPet = data.content.find((o: any) => Array.isArray(o.pets) && o.pets.length > 0);
      20- if (ownerWithPet) {
      21- return {ownerId: ownerWithPet.id, petId: ownerWithPet.pets[0].id};
      22- }
      23- if (page + 1 >= data.totalPages) {
      24- throw new Error('No owner with a pet found in the system; cannot run add-visit scenario');
      25- }
      17+ const owner = await new ApiClient().fetchOwner(SEEDED_OWNER_WITH_PET);
      18+ if (owner.pets.length === 0) {
      19+ throw new Error(`Owner ${SEEDED_OWNER_WITH_PET} has no pet — did db/seed/R__seed.sql change?`);
      2620 }
      21+ return {ownerId: owner.id, petId: owner.pets[0].id};
      2722 }
      2823
      2924 export async function open_owner_detail_page(page: Page, ownerId: number): Promise<void> {
      export class ApiClient {
      2840 return response.data;
      2941 }
      3042
      43+ async fetchOwnersPage(query: string): Promise<OwnerPage> {
      44+ const response = await this.client.get<OwnerPage>(`/owners?${query}`);
      45+ return response.data;
      46+ }
      47+
      48+ async fetchOwner(ownerId: number): Promise<OwnerSummary> {
      49+ const response = await this.client.get<OwnerSummary>(`/owners/${ownerId}`);
      50+ return response.data;
      51+ }
      52+
      3153 static sortedByDate<T extends {date: string}>(rows: T[]): T[] {
      3254 return [...rows].sort((a, b) => a.date.localeCompare(b.date));
      3355 }

      Fix: one GET of seed owner 1, through ApiClient.

    19. fixed/simplify Owner-search glue repeated steps, locators and calls on GitHub ↗ in VS Code

      Reviewer: two steps differed by one word, the cell locator and poll lived in three places, and the Background asked for the Potters twice, sequentially, on localhost.

      import {PlaywrightWorld} from './support/world';
      1111 // Nothing below decides anything: the Background states the data, the Examples
      1212 // table states the search term and the expected result set.
      1313
      14-const API_BASE = process.env.API_BASE_URL || 'http://localhost:8080/api';
      14+const api = new ApiClient();
      1515
      1616 const fullName = (o: {firstName: string; lastName: string}) => `${o.firstName} ${o.lastName}`;
      1717 const namesIn = (cell: string) => cell.split(',').map((n) => n.trim()).filter(Boolean);
      1818
      19+const listedNames = async (world: PlaywrightWorld) =>
      20+ (await world.page.locator('#ownersTable td.ownerFullName').allTextContents()).map((t) => t.trim()).filter(Boolean);
      21+
      1922 /** Polls until the table has settled on exactly `expected` — order-insensitive. */
      2023 async function expectOwnersListed(world: PlaywrightWorld, expected: string[]): Promise<void> {
      21- const cells = world.page.locator('#ownersTable td.ownerFullName');
      22- const listed = async () => (await cells.allTextContents()).map((t) => t.trim()).filter(Boolean).sort();
      23-
      24- await expect.poll(listed, {timeout: 10_000}).toEqual([...expected].sort());
      24+ await expect.poll(async () => (await listedNames(world)).sort(), {timeout: 10_000}).toEqual([...expected].sort());
      2525 }
      2626
      2727 /** Polls until the table shows exactly `expected`, in that order. */
      2828 async function expectOwnersListedInOrder(world: PlaywrightWorld, expected: string[]): Promise<void> {
      29- const cells = world.page.locator('#ownersTable td.ownerFullName');
      30- await expect.poll(async () => (await cells.allTextContents()).map((t) => t.trim()), {timeout: 10_000})
      31- .toEqual(expected);
      29+ await expect.poll(() => listedNames(world), {timeout: 10_000}).toEqual(expected);
      3230 }
      3331
      3432 /** The full names the API lists first for this query — what the grid must show, in that order. */
      3533 async function firstPageFromApi(query: string): Promise<string[]> {
      36- const {data} = await axios.get(`${API_BASE}/owners?${query}`, {timeout: 10_000});
      37- return data.content.map(fullName);
      34+ return (await api.fetchOwnersPage(query)).content.map(fullName);
      3835 }
      3936
      4037 /**
      async function firstPageFromApi(query: string): Promise<string[]> {
      4340 * instead of looking like a broken search.
      4441 */
      4542 Given('the clinic has these owners', async function (this: PlaywrightWorld, owners: DataTable) {
      46- for (const [name] of owners.raw()) {
      47- const lastName = name.trim().split(' ').pop();
      48- const {data} = await axios.get(`${API_BASE}/owners?lastName=${lastName}&size=100`, {timeout: 10_000});
      49- expect(data.content.map(fullName), 'is the backend up and the DB seeded by Flyway?').toContain(name.trim());
      50- }
      51- const {data} = await axios.get(`${API_BASE}/owners?size=1`, {timeout: 10_000});
      52- this.ownerCount = data.totalElements;
      43+ const names = owners.raw().map(([name]) => name.trim());
      44+ const lastNames = [...new Set(names.map((name) => name.split(' ').pop()))];
      45+ const [all, ...matches] = await Promise.all([
      46+ api.fetchOwnersPage('size=1'),
      47+ ...lastNames.map((lastName) => api.fetchOwnersPage(`lastName=${lastName}&size=100`)),
      48+ ]);
      49+ const found = matches.flatMap((page) => page.content.map(fullName));
      50+ expect(found, 'is the backend up and the DB seeded by Flyway?').toEqual(expect.arrayContaining(names));
      51+ this.ownerCount = all.totalElements;
      5352 });
      5453
      5554 When('I open the owners page', async function (this: PlaywrightWorld) {
      When('I choose {int} rows per page', async function (this: PlaywrightWorld, size
      6867 });
      6968
      7069 When('I sort the owners by {string}', async function (this: PlaywrightWorld, column: string) {
      71- await this.page.locator(`#ownersTable th:has-text("${column}")`).click();
      70+ await this.page.locator(`#ownersTable th:has-text("${column}") button`).click();
      7271 });
      7372
      7473 Then('exactly these owners are listed: {string}', async function (this: PlaywrightWorld, owners: string) {
      7574 await expectOwnersListed(this, namesIn(owners));
      7675 });
      7776
      78-Then('the first 10 owners by name are listed', async function (this: PlaywrightWorld) {
      79- await expectOwnersListedInOrder(this, await firstPageFromApi('sort=name,asc&size=10'));
      80-});
      81-
      82-Then('the first 10 owners by city are listed', async function (this: PlaywrightWorld) {
      83- await expectOwnersListedInOrder(this, await firstPageFromApi('sort=city,asc&size=10'));
      77+Then('the first 10 owners by {word} are listed', async function (this: PlaywrightWorld, sortKey: string) {
      78+ await expectOwnersListedInOrder(this, await firstPageFromApi(`sort=${sortKey},asc&size=10`));
      8479 });
      8580
      8681 Then('{int} owners are listed', async function (this: PlaywrightWorld, count: number) {

      api-client.ts: diff shown under Add-visit glue walked owner pages to find a pet.

      Fix: one {word} step, a listedNames helper, deduped lookups in Promise.all through ApiClient.

    20. fixedpre-push hook New owner-creation line over the 119-character limit on GitHub ↗ in VS Code

      Hook: scripts/check-line-length.py blocked the push on a 122-character axios.post line.

      visit-date-range.feature.glue.ts: diff shown under Visit-date scenario still read the owner list as an array.

      Fix: the request body is wrapped one field group per line.

    21. fixedpre-push hook Dropped @ResponseStatus erased the 400 from the API contract on GitHub ↗ in VS Code

      Hook: the badRequest extraction removed @ResponseStatus(BAD_REQUEST); springdoc then dropped the 400 response from every operation in openapi.yaml.

      ExceptionControllerAdvice.java: diff shown under Bad page input answered 500 instead of 400.

      Fix: restored on the two validation handlers; openapi.yaml back to its committed state.

    22. fixed/code-review Accented names sorted after Z under CI's C collation on GitHub ↗ in VS Code

      Reviewer: zonky inherits the runner's locale; under C.UTF-8 'Łukasz' sorts after 'Mister' and accentedNames_sortAlphabetically failed CI.

       
      1+# Loaded beside the main application.properties (classpath:/config/ is read in addition, not instead).
      2+# The embedded test database sorts like a person would: ICU English, whatever locale the machine
      3+# runs under - CI's C.UTF-8 put 'Lukasz' with a stroke after 'Mister'.
      4+zonky.test.database.postgres.initdb.properties.locale-provider=icu
      5+zonky.test.database.postgres.initdb.properties.icu-locale=en
      pom.xml+9 −0
       
      3232 <type>pom</type>
      3333 <scope>import</scope>
      3434 </dependency>
      35+ <!-- Postgres 16 for the embedded test database on every platform (embedded-postgres
      36+ defaults to 14, which cannot make ICU the database's default collation) -->
      37+ <dependency>
      38+ <groupId>io.zonky.test.postgres</groupId>
      39+ <artifactId>embedded-postgres-binaries-bom</artifactId>
      40+ <version>16.2.0</version>
      41+ <type>pom</type>
      42+ <scope>import</scope>
      43+ </dependency>
      3544 </dependencies>
      3645 </dependencyManagement>
      3746

      Fix: Postgres 16 binaries on every platform and an ICU English default collation for the test database.

    23. fixedCI (SonarCloud Web:MouseEventWithoutKeyboardEquivalentCheck) Sort headers reachable only with a mouse on GitHub ↗ in VS Code

      CI: (click) on a <th> has no keyboard equivalent, so keyboard users could not sort; it failed the quality gate.

      owner-list.component.html: diff shown under Add Owner vanished when a search matched nobody.

      owner-search.feature.glue.ts: diff shown under Owner-search glue repeated steps, locators and calls.

      Fix: each sortable header holds a plain-styled <button>, focusable and Enter/Space-activated natively.

    24. fixedCI (SonarCloud typescript:S9383) Navigation promise left unhandled on GitHub ↗ in VS Code

      CI: router.navigate returns a promise nobody handled; a failed navigation was swallowed silently.

      export class OwnerListComponent implements OnInit, OnDestroy {
      92107
      93108 private navigate(change: Partial<OwnerListQuery>, replaceUrl = false) {
      94109 const queryParams = toUrl({...this.query, ...change});
      95- this.router.navigate([], {relativeTo: this.route, queryParams, replaceUrl});
      110+ this.router.navigate([], {relativeTo: this.route, queryParams, replaceUrl})
      111+ .catch((error) => this.errorMessage = String(error));
      96112 }
      97113 }
      98114

      Fix: a rejection now shows as the grid's error message.

    25. fixedCI (SonarCloud typescript:S2933) Injected and internal members not readonly on GitHub ↗ in VS Code

      CI: the router, route, service and reload subject are never reassigned.

      owner-list.component.ts: diff shown under Find Owner with an unchanged name did not search again.

      Fix: marked readonly.

    Other changes in the fix commits: 23 hunks in 11 files
    design.md+2 −2
    end and confirmed as written before implementation.
    141141
    142142 - **[The sort collation differs between dev, CI and prod]** The accented-name scenario passes
    143143 only under a linguistic collation, and zonky's `initdb` follows the machine locale.
    144- → The scenario runs in CI. If CI collates `C`, pin zonky's locale in the shared test
    145- config rather than weakening the scenario.
    144+ → Done: CI collated `C`, so the test database is pinned to ICU English
    145+ (`petclinic-backend/src/test/resources/config/application.properties`), not the scenario weakened.
    146146 - **[`count(*)` on every page request]** At 100k rows, a filtered or unfiltered count is in the low
    147147 milliseconds. → Accept it, because the total is what the paginator shows. Revisit at millions of rows.
    148148 - **[Deep offsets get slower]** `OFFSET 99990` still walks 99,990 index entries. → It stays acceptable at
    Owner.java+0 −2
    import org.springframework.beans.support.MutableSortDefinition;
    1111 import org.springframework.beans.support.PropertyComparator;
    1212 import org.springframework.core.style.ToStringCreator;
    1313
    14-import org.hibernate.annotations.BatchSize;
    15-
    1614 import jakarta.persistence.CascadeType;
    1715 import jakarta.persistence.Entity;
    1816 import jakarta.persistence.FetchType;
    Pet.java+0 −2
    import java.util.Set;
    1010 import org.springframework.beans.support.MutableSortDefinition;
    1111 import org.springframework.beans.support.PropertyComparator;
    1212
    13-import org.hibernate.annotations.BatchSize;
    14-
    1513 import jakarta.persistence.CascadeType;
    1614 import jakarta.persistence.Column;
    1715 import jakarta.persistence.Entity;
     
    11 package victor.training.petclinic.mapper;
    22
    3+import org.springframework.data.domain.Page;
    34 import org.springframework.stereotype.Component;
    45 import victor.training.petclinic.domain.Owner;
    56 import victor.training.petclinic.rest.dto.OwnerDto;
    67 import victor.training.petclinic.rest.dto.OwnerFieldsDto;
    8+import victor.training.petclinic.rest.dto.OwnerPageDto;
    79
    810 import java.util.ArrayList;
    911 import java.util.List;
    import jakarta.validation.ValidationException;
    88 import org.springframework.http.HttpStatus;
    99 import org.springframework.http.ProblemDetail;
    1010 import org.springframework.http.ResponseEntity;
    11-import org.springframework.validation.BindingResult;
    1211 import org.springframework.web.bind.MethodArgumentNotValidException;
    1312 import org.springframework.web.bind.annotation.ExceptionHandler;
    1413 import org.springframework.web.bind.annotation.ResponseStatus;
    1514 import org.springframework.web.bind.annotation.RestControllerAdvice;
    15+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
    1616
    1717 import java.time.Instant;
    1818 import java.util.List;
    import static org.springframework.http.HttpStatus.NOT_FOUND;
    3434 @RestControllerAdvice(basePackages = "victor.training.petclinic.rest")
    3535 public class ExceptionControllerAdvice {
    3636 private static final Logger log = LoggerFactory.getLogger(ExceptionControllerAdvice.class);
    37+ private static final String SEE_ERRORS = "Validation failed for request. See 'errors' for details.";
    3738
    3839 private ProblemDetail buildProblemDetail(String title, String detail, HttpStatus status,
    3940 HttpServletRequest request) {
     
    11 import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
    22 import {By} from '@angular/platform-browser';
    33 import {NO_ERRORS_SCHEMA} from '@angular/core';
    4-import {ActivatedRoute, convertToParamMap, Params, Router} from '@angular/router';
    4+import {ActivatedRoute, Router} from '@angular/router';
    55 import {RouterTestingModule} from '@angular/router/testing';
    66 import {CommonModule} from '@angular/common';
    77 import {FormsModule} from '@angular/forms';
    88 import {NoopAnimationsModule} from '@angular/platform-browser/animations';
    9-import {BehaviorSubject, Observable, of, Subject, throwError} from 'rxjs';
    9+import {Observable, of, Subject, throwError} from 'rxjs';
    1010
    1111 import {OwnerListComponent} from './owner-list.component';
    1212 import {OwnerQuery, OwnerService} from '../owner.service';
    1313 import {Owner} from '../owner';
    1414 import {OwnerPage} from '../owner-page';
    1515 import {OwnersModule} from '../owners.module';
    16+import {ActivatedRouteStub} from '../../testing/router-stubs';
    1617 import Spy = jasmine.Spy;
    1718
    1819 class OwnerServiceStub {
    class OwnerServiceStub {
    2122 }
    2223 }
    2324
    24-/** The URL's query string, as the component reads it. */
    25-class QueryParamsStub {
    26- private subject = new BehaviorSubject(convertToParamMap({}));
    27- queryParamMap = this.subject.asObservable();
    28-
    29- set(params: Params) {
    30- this.subject.next(convertToParamMap(params));
    31- }
    32-}
    33-
    3425 describe('OwnerListComponent', () => {
    3526 let component: OwnerListComponent;
    3627 let fixture: ComponentFixture<OwnerListComponent>;
    3728 let getOwnersPage: Spy;
    3829 let navigate: Spy;
    39- let url: QueryParamsStub;
    30+ let route: ActivatedRouteStub;
    4031
    4132 const george: Owner = {
    4233 id: 1, firstName: 'George', lastName: 'Franklin', address: '110 W. Liberty St.',
    describe('OwnerListComponent', () => {
    5243 imports: [CommonModule, FormsModule, NoopAnimationsModule, OwnersModule, RouterTestingModule],
    5344 providers: [
    5445 {provide: OwnerService, useClass: OwnerServiceStub},
    55- {provide: ActivatedRoute, useClass: QueryParamsStub}
    46+ {provide: ActivatedRoute, useClass: ActivatedRouteStub}
    5647 ]
    5748 }).compileComponents();
    5849 }));
    describe('OwnerListComponent', () => {
    6051 beforeEach(() => {
    6152 fixture = TestBed.createComponent(OwnerListComponent);
    6253 component = fixture.componentInstance;
    63- url = TestBed.inject(ActivatedRoute) as unknown as QueryParamsStub;
    54+ route = TestBed.inject(ActivatedRoute) as unknown as ActivatedRouteStub;
    6455 getOwnersPage = spyOn(TestBed.inject(OwnerService), 'getOwnersPage').and.returnValue(of(aPage([george])));
    6556 navigate = spyOn(TestBed.inject(Router), 'navigate').and.returnValue(Promise.resolve(true));
    6657 });
    describe('OwnerListComponent', () => {
    7566 });
    7667
    7768 it('asks for what the URL says, with its 1-based page turned 0-based', () => {
    78- url.set({lastName: 'Pot', page: '3', size: '5', sort: 'city,desc'});
    69+ route.setQueryParams({lastName: 'Pot', page: '3', size: '5', sort: 'city,desc'});
    7970 fixture.detectChanges();
    8071
    8172 expect(getOwnersPage).toHaveBeenCalledWith({lastName: 'Pot', page: 2, size: 5, sort: 'city,desc'});
    describe('OwnerListComponent', () => {
    8374 });
    8475
    8576 it('falls back to the defaults for values the URL gets wrong', () => {
    86- url.set({page: '-2', size: '7', sort: 'telephone,asc'});
    77+ route.setQueryParams({page: '-2', size: '7', sort: 'telephone,asc'});
    8778 fixture.detectChanges();
    8879
    8980 expect(getOwnersPage).toHaveBeenCalledWith({lastName: '', page: 0, size: 10, sort: 'name,asc'});
    describe('OwnerListComponent', () => {
    9687 });
    9788
    9889 it('a search goes back to the first page, keeping the sort and size', () => {
    99- url.set({page: '3', size: '5', sort: 'city,asc'});
    90+ route.setQueryParams({page: '3', size: '5', sort: 'city,asc'});
    10091 fixture.detectChanges();
    10192
    10293 component.lastName = 'Pot';
    describe('OwnerListComponent', () => {
    10697 });
    10798
    10899 it('moving to another page keeps the rest of the query', () => {
    109- url.set({lastName: 'Pot'});
    100+ route.setQueryParams({lastName: 'Pot'});
    110101 fixture.detectChanges();
    111102
    112103 component.onPage({pageIndex: 2, pageSize: 10, length: 26});
    describe('OwnerListComponent', () => {
    115106 });
    116107
    117108 it('a new page size goes back to the first page', () => {
    118- url.set({page: '3'});
    109+ route.setQueryParams({page: '3'});
    119110 fixture.detectChanges();
    120111
    121112 component.onPage({pageIndex: 1, pageSize: 5, length: 26});
    describe('OwnerListComponent', () => {
    168190
    169191 it('moves to the last page when the URL points past it', () => {
    170192 getOwnersPage.and.returnValue(of(aPage([], 26, 3)));
    171- url.set({page: '9'});
    193+ route.setQueryParams({page: '9'});
    172194 fixture.detectChanges();
    173195
    174196 expect(navigatedTo()).toEqual({page: 3});
    describe('OwnerListComponent', () => {
    182204 fixture.detectChanges();
    183205
    184206 getOwnersPage.and.returnValue(of(aPage([george])));
    185- url.set({lastName: 'Franklin'});
    207+ route.setQueryParams({lastName: 'Franklin'});
    186208 slowAnswer.next(aPage([{...george, id: 2, lastName: 'Davis'}]));
    187209
    188210 expect(component.page?.content).toEqual([george]);
    import {OwnerEditComponent} from './owner-edit/owner-edit.component';
    99 import {OwnersRoutingModule} from './owners-routing.module';
    1010 import {PetsModule} from '../pets/pets.module';
    1111 import {MatPaginatorModule} from '@angular/material/paginator';
    12-import {MatSortModule} from '@angular/material/sort';
    1312
    1413 @NgModule({
    1514 imports: [
    import {MatSortModule} from '@angular/material/sort';
    1716 FormsModule,
    1817 OwnersRoutingModule,
    1918 PetsModule,
    20- MatPaginatorModule,
    21- MatSortModule
    19+ MatPaginatorModule
    2220 ],
    2321 declarations: [
    2422 OwnerListComponent,
     
    22 export {ActivatedRoute, Router, RouterLink, RouterOutlet} from '@angular/router';
    33
    44 import {Component, Directive, HostListener, Injectable, Input} from '@angular/core';
    5-import {NavigationExtras} from '@angular/router';
    5+import {convertToParamMap, NavigationExtras, Params} from '@angular/router';
    66 // Only implements params and part of snapshot.params
    77 import {BehaviorSubject} from 'rxjs';
    88
     
    11 import {DataTable, Given, When, Then} from '@cucumber/cucumber';
    22 import {expect} from '@playwright/test';
    3-import axios from 'axios';
    3+import {ApiClient} from './support/api-client';
    44 import {PlaywrightWorld} from './support/world';
    55
    66 // Gherkin, bound directly: the steps do the work themselves, with no DSL layer
    export interface VisitDto {
    1111 ownerLastName?: string;
    1212 }
    1313
    14+export interface OwnerSummary {
    15+ id: number;
    16+ firstName: string;
    17+ lastName: string;
    18+ pets: {id: number}[];
    19+}
    20+
    21+export interface OwnerPage {
    22+ content: OwnerSummary[];
    23+ totalElements: number;
    24+}
    25+
    1426 export class ApiClient {
    1527 private client: AxiosInstance;
    1628
    class ListGetFirstTest implements RewriteTest {
    4646 import java.util.List;
    4747 class Owner { List<String> getPets() { return List.of(); } }
    4848 class A {
    49- String firstPet(Owner owner) {
    5049 return owner.getPets().get(0);
    5150 }
    5251 }

    8 generated files re-recorded in the fix commits (0b99e29c..d01c3776) — regenerated output, not a fix, so not drawn.

    Demo

    Running appchecking…
    DB Fixture:Defaultgreen

    Intro video

    1. 0:04The owners grid is now paged on the server: ten rows, and a total.
    2. 0:09Five rows per page, and the choice is kept in the URL.
    3. 0:14Name and City are sortable; click City to sort ascending.
    4. 0:19Click again for descending; the arrowhead shows the direction.
    5. 0:23Next page, still sorted by city.
    6. 0:28Back from an owner returns to the same page and sort.
    7. 0:33Searching starts again from page one.
    8. 0:37When nobody matches, a message and its own Add Owner button.

    API

    Breaking change · 1 endpoint broken · checked by oasdiff (report ↗)

    Data

    Domain ModelunchangedDomainModel.puml
    Domain ModelDomain ModelOwnerid : IntegerfirstName : StringlastName : Stringaddress : Stringcity : Stringtelephone : StringPetid : Integername : StringbirthDate : LocalDatePetTypeid : Integername : StringRoleid : Integername : StringSpecialtyid : Integername : Stringdescription : StringUserusername : Stringpassword : Stringenabled : BooleanVetid : IntegerfirstName : StringlastName : StringVisitid : Integerdate : LocalDatetime : LocalTimedescription : Stringowner* pets*typepet* visitsuser* roles** specialtiesDiagram generated from code using Java reflectiondomain/*.java -> petclinic-backend/docs/generated/DomainModel.puml
    DatabaseunchangedDB.puml
    Database Schema (ERD)Database Schema (ERD)ownersid : int «PK»first_name : textlast_name : textaddress : textcity : texttelephone : textpetsid : int «PK»name : textbirth_date : datetype_id : int «FK» not nullowner_id : int «FK»rolesid : int «PK»username : varchar(20) «FK» not nullrole : varchar(20) not nullspecialtiesid : int «PK»name : textdescription : texttypesid : int «PK»name : textusersusername : varchar(20) «PK»password : varchar(60) not nullenabled : boolean not nullvet_specialtiesvet_id : int «FK» not nullspecialty_id : int «FK» not nullvetsid : int «PK»first_name : textlast_name : textvisitsid : int «PK»pet_id : int «FK»visit_date : datedescription : textvisit_time : timeowner_idpet_idspecialty_idtype_idusernamevet_idDiagram generated from DB incremental scriptsdb/migration/*.sql -> DB -> dump to DB.sql -> converted to DB.puml

    Also changed, not drawn: indexes added on owners (city, first_name, last_name, id), owners (last_name text_pattern_ops), owners (first_name, last_name, id).

    Conceptual ModelunchangedConceptualModel.drawio.png

    Tests

    Issue #25: Add pagination to Owners grid

    victorrenteaon May 13, 2026
    • The grid should be sortable by any column
    • The grid should be paginated in pages of 5, 10, or 20 rows per page

    Requirements of the OpenSpec change paginate-owners-grid

    1. Owners are listed one page at a time — GET /api/owners SHALL return a single page of owners as {content, totalElements, totalPages, number, size}, where number is zero-based. Without paging parameters it SHALL return page 0 of size 10, sorted by name ascending. Each owner in content keeps its current shape, pets included.
    2. Page size is bounded — The API SHALL accept a size from 1 to 100 and SHALL reject any other value, and any negative page, with 400 and a message naming the parameter. It SHALL NOT silently adjust the value.
    3. Owners sort by name or by city only — The API SHALL accept sort as name or city, optionally followed by ,asc or ,desc (default asc). name orders by first name, then last name; city orders by city, then first name, then last name. Any other value SHALL be rejected with 400.
    4. Paging is stable across ties — Every sort order SHALL end with the owner id, so that walking all pages of one query returns each matching owner exactly once, even when owners share every sorted field.
    5. Last-name filter applies before paging — The lastName parameter SHALL keep its current meaning, a case-sensitive prefix of the last name, and totalElements SHALL count only the owners that match it.
    6. The Owners grid pages through owners — The Owners grid SHALL show one page at a time, offer 5, 10 and 20 rows per page (default 10), and show the total number of matching owners with controls to move between pages.
    7. The Owners grid sorts by Name and City headers — The Name and City column headers SHALL be clickable to sort ascending, then descending. The Address, Telephone and Pets headers SHALL NOT be sortable.
    8. Changing the query returns to the first page — Searching by last name, changing the page size, or changing the sort SHALL show the first page of the new result.
    9. The grid's position survives navigation — The current page, page size, sort and last-name filter SHALL be kept in the page URL, so that a browser refresh, or returning with Back from an owner's page, shows the same results. Parameters equal to their defaults SHALL be left out of the URL.
    10. Empty results are told apart from failures — The grid SHALL show "No owners with last name starting with …" only when the request succeeded and matched no owner. A failed request SHALL NOT show that message.
    fully coveredpartiallyexecutedmissingN/A

    +42 −12 ✍️5

    Sequence

    Sequence diagrams of tests

    These diagrams were captured from OpenTelemetry traces of 7 tests.
    1. The tests ran against the real app, with OpenTelemetry tracing switched on.
    2. Each HTTP call and database query was recorded as a span: who called whom, and for how long. The spans that one action set off form a trace.
    3. Each test's traces were drawn as its diagram below.

    The trace of OwnerListTest: unfilteredTotal_countsEveryOwner(), in Grafana Tempo. Compare it with that test's diagram below. Recorded while the e2e tests ran, 100% sampled: every request is in it.

    A trace in Grafana Tempo: OwnerListTest: unfilteredTotal_countsEveryOwner()
    API@AddVisitApiTest: adds a visit to an existing petAddVisitApiTest.java
    AddVisitApiTest: adds a visit to an existing petAddVisitApiTest: adds a visit to an existing petTestTestTestTestBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendNotificationServiceTestBackendDBNotificationService.module.SMS gatewayTestBackendDBNotificationService«module»CommonsSMS gatewayTestTestTestTestBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendNotificationServicegiven an owner with at least one pet exists ↗List one page of ownersGET /api/ownersOwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕200 ⊕when the owner detail page is opened ↗Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕and a visit is added for the first pet ↗Add a visit for an owner's petPOST /api/owners/{ownerId}/pets/{petId}/visits ⊕book-visit ↗PetRepository.findById ↗txselect pets ⊕VisitRepository.save ↗txinsert for victor.training.petclinic.domain.Visit ⊕OwnerRepository.findById ↗select pets ⊕PhoneNumbers.normalize ↗POST /api/notifications/visit-booked ⊕PhoneNumbers.normalize ↗send-sms ↗202201then the visit is listed under the pet ↗Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕@GenerateSequence in petclinic-backend/src/test/java/victor/training/petclinic/rest/AddVisitApiTest.java — generated from real traces of end-to-end test runs, do not edit ❗
    API+OwnerListTest: defaultPage_isTheFirstTenByName()OwnerListTest.java
    OwnerListTest: defaultPage_isTheFirstTenByName()OwnerListTest: defaultPage_isTheFirstTenByName()BackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendTestBackendDBTestBackendDBBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendOwnerRepository.save ↗insert for victor.training.petclinic.domain.Owner ⊕OwnerRepository.save ↗insert for victor.training.petclinic.domain.Owner ⊕OwnerRepository.save ↗insert for victor.training.petclinic.domain.Owner ⊕OwnerRepository.save ↗insert for victor.training.petclinic.domain.Owner ⊕OwnerRepository.save ↗insert for victor.training.petclinic.domain.Owner ⊕OwnerRepository.save ↗insert for victor.training.petclinic.domain.Owner ⊕OwnerRepository.save ↗insert for victor.training.petclinic.domain.Owner ⊕OwnerRepository.save ↗insert for victor.training.petclinic.domain.Owner ⊕OwnerRepository.save ↗insert for victor.training.petclinic.domain.Owner ⊕OwnerRepository.save ↗insert for victor.training.petclinic.domain.Owner ⊕OwnerRepository.save ↗insert for victor.training.petclinic.domain.Owner ⊕OwnerRepository.save ↗insert for victor.training.petclinic.domain.Owner ⊕List one page of ownersGET /api/ownersOwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕OwnerRepository.findByLastNameStartingWith ⊕200traced because this branch wrote it in petclinic-backend/src/test/java/victor/training/petclinic/rest/OwnerListTest.java — generated from real traces of end-to-end test runs, do not edit ❗
    API+OwnerListTest: unfilteredTotal_countsEveryOwner()OwnerListTest.java
    OwnerListTest: unfilteredTotal_countsEveryOwner()OwnerListTest: unfilteredTotal_countsEveryOwner()BackendBackendBackendTestBackendDBTestBackendDBBackendBackendBackendList one page of ownersGET /api/ownersOwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕200OwnerRepository.count ↗select count(*) from Owner x ⊕traced because this branch wrote it in petclinic-backend/src/test/java/victor/training/petclinic/rest/OwnerListTest.java — generated from real traces of end-to-end test runs, do not edit ❗
    E2E@Add a visit to an existing pet from the owner detail pageadd-visit.spec.ts
    Add a visit to an existing pet from the owner detail pageAdd a visit to an existing pet from the owner detail pageBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendNotificationService.module.BrowserBackendDBNotificationService.module.SMS gatewayBrowserBackendDBNotificationService«module»CommonsSMS gatewayBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendNotificationService.module.Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕getPetGET /api/pets/{petId}PetRepository.findById ↗txselect pets ⊕select visits ⊕200 ⊕Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕Add a visit for an owner's petPOST /api/owners/{ownerId}/pets/{petId}/visits ⊕book-visit ↗PetRepository.findById ↗txselect pets ⊕VisitRepository.save ↗txinsert for victor.training.petclinic.domain.Visit ⊕OwnerRepository.findById ↗select pets ⊕PhoneNumbers.normalize ↗NotificationServiceClient.homeDialCode ↗POST /api/notifications/visit-booked ⊕PhoneNumbers.normalize ↗send-sms ↗202201Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕@generate_sequence in src/add-visit.spec.ts — generated from real traces of end-to-end test runs, do not edit ❗
    E2E+Choosing 5 rows per pageowner-search.feature
    Choosing 5 rows per pageChoosing 5 rows per pageBackendBackendBackendBackendBrowserBackendDBBrowserBackendDBBackendBackendBackendBackendList one page of ownersGET /api/ownersOwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕200 ⊕List one page of ownersGET /api/ownersOwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕200 ⊕traced because this branch wrote it in src/owner-search.feature — generated from real traces of end-to-end test runs, do not edit ❗
    E2E@Searching with an empty last name shows the first page of every ownerowner-search.feature
    Searching with an empty last name shows the first page of every ownerSearching with an empty last name shows the first page of every ownerBackendBackendBackendBackendBrowserBackendDBBrowserBackendDBBackendBackendBackendBackendList one page of ownersGET /api/ownersOwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕200 ⊕List one page of ownersGET /api/ownersOwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕200 ⊕@generate_sequence in src/owner-search.feature — generated from real traces of end-to-end test runs, do not edit ❗
    E2E+Sorting by cityowner-search.feature
    Sorting by citySorting by cityBackendBackendBackendBackendBrowserBackendDBBrowserBackendDBBackendBackendBackendBackendList one page of ownersGET /api/ownersOwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕200 ⊕List one page of ownersGET /api/ownersOwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕200 ⊕traced because this branch wrote it in src/owner-search.feature — generated from real traces of end-to-end test runs, do not edit ❗

    Structure

    Structure diagrams

    Java packagesunchangedpackages.puml
    Backend Logical Architecture (java packages)Backend Logical Architecture (java packages)..rest..mcp..rest.error..security..mapper..repository..notification..rest.dto..domainAll boxes and lines are ArchUnit-tested vs codepetclinic-backend/docs/packages.puml
    Maven modulesunchangedMavenModules.puml
    Maven Module GraphMaven Module Graphnotification-servicepetclinic-backendpetclinic-chatbotpetclinic-commonspetclinic-databaserefactoring-toolsDiagram generated from `mvn dependency:tree`petclinic-backend/docs/generated/MavenModules.puml
    C2-ContainersunchangedC2-Containers.new.puml
    C2 ContainersC2 ContainersPetClinic[system]Backend[Java 21 / Spring Boot 3.5]Browser[Angular 16]DB[PostgreSQL]NotificationService[Java 21 / Spring Boot 3.5]SMS gateway SMS provider (simulated)«module»CommonsSQLHTTP ⊕[1 ops]calls ⊕[1 ops]calls ⊕[1 ops]HTTP ⊕[4 ops]calls ⊕[1 ops]calls ⊕[1 ops]Diagram generated from sequence diagrams of the test traces

    Op counts compare only the 2 tests traced on both sides.

    Deployment
    ✏️Edit on desktop↗✏️Edit on web↗
    Deployment.drawio.png

    Unit-tested against the traced sequence diagrams.

    walked by a testno test walks itcalled in the traces, missing from the drawing

    C2-Containers (Structurizr)Containers inside PetClinicunchangedc4model.c1+c2.dsl
    C2-Containers — drawn by StructurizrC2-Containers — drawn by Structurizr

    Hand-maintained: C3ArchTest.java parses this workspace but checks only its components; nothing on this view is compared with the code.

    C2-BackendOutbound (Structurizr)Backend and what it callsunchangedc4model.c1+c2.dsl
    C2-BackendOutbound — drawn by StructurizrC2-BackendOutbound — drawn by Structurizr

    Hand-maintained: C3ArchTest.java parses this workspace but checks only its components; nothing on this view is compared with the code.

    C3-Components-All (Structurizr)All components inside Backendunchangedc4model.c1+c2.dsl
    C3-Components-All — drawn by StructurizrC3-Components-All — drawn by Structurizr

    Its components and their arrows are checked against the code by C3ArchTest.java.

    C3-Repository (Structurizr)Repository Layer — nearest neighboursunchangedc4model.c1+c2.dsl
    C3-Repository — drawn by StructurizrC3-Repository — drawn by Structurizr

    Its components and their arrows are checked against the code by C3ArchTest.java.

    C3-Mapper (Structurizr)Mapper Layer — nearest neighboursunchangedc4model.c1+c2.dsl
    C3-Mapper — drawn by StructurizrC3-Mapper — drawn by Structurizr

    Its components and their arrows are checked against the code by C3ArchTest.java.

    C1-Context (Structurizr)Who uses PetClinicunchangedc4model.c1+c2.dsl
    C1-Context — drawn by StructurizrC1-Context — drawn by Structurizr

    Hand-maintained: C3ArchTest.java parses this workspace but checks only its components; nothing on this view is compared with the code.

    Code City

    Impact on code size, complexity, coupling, …

    Code City with the branch change set highlighted

    UX

    UX design system

    · ⚠ +1 gap

    ⚠️ Owners (/owners) · +1 gap
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2
    3
    ✗ new Angular Material paginator — the design system has none

    1 changed — nothing here to judge2 changed — nothing here to judge3 ✗ mat-paginator — added

    sideelementrolewhydeltachurn
    3gapDevoxx26Items per page: 10 1 – 10 of 26
    div#ownersTable>div.owners-footer:1>mat-paginator.mat-mdc-paginator:1
    paginatornot from the design system — <mat-paginator> is an Angular Material paginator, and the design system has no component for a paginator; its “Items per page” control is a <mat-select>, not combo
    brought in by this branch, from outside the design system
    added—
    why An Angular Material paginator (<mat-paginator>) is a control from outside the design system, which has no component for a paginator: either the design system gets one, or the team agrees this library is allowed here — otherwise every screen picks its own.
    <mat-paginator role="group" showfirstlastbuttons="" class="mat-mdc-paginator">
      <div class="mat-mdc-paginator-outer-container">
        <div class="mat-mdc-paginator-container">
          <!-- 2 more <div> -->
        </div>
      </div>
    </mat-paginator>
    1 control considered and deliberately not judged
    • Last name #lastName — role input[type=text], not covered
    ✅ Add A Pet (/owners/8/pets/add)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2
    ✓ combo3

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    3okDevoxx26Type
    app-combo[name="type"]
    design-system component combosame0%
    markup and template

    The design system’s combo component (<app-combo>) — the control this role should be.

    <app-combo inputid="type" name="type" required data-ds="combo">
      <select class="form-control pc-combo__control" id="type">
        <option value=""></option>
        <option value="0">cat</option>
        <option value="1">dog</option>
        <option value="2">lizard</option>
        <!-- 4 more <option> -->
      </select>
      <span aria-hidden="true" class="pc-combo__chevron"></span>
    </app-combo>
    3ok390f0e0eType
    app-combo[name="type"]
    design-system component combosame0%
    markup and template

    The design system’s combo component (<app-combo>) — the control this role should be.

    <app-combo inputid="type" name="type" required data-ds="combo">
      <select class="form-control pc-combo__control" id="type">
        <option value=""></option>
        <option value="0">cat</option>
        <option value="1">dog</option>
        <option value="2">lizard</option>
        <!-- 4 more <option> -->
      </select>
      <span aria-hidden="true" class="pc-combo__chevron"></span>
    </app-combo>
    3 controls considered and deliberately not judged
    • Owner #owner_name — role input[type=text], not covered
    • Name #name — role input[type=text], not covered
    • Birth Date input[name="birthDate"] — role input[type=text], not covered
    ✅ Edit A Pet (/pets/11/edit)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2
    ✓ combo3

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    3okDevoxx26Type
    app-combo[name="pettype"]
    design-system component combosame0%
    markup and template

    The design system’s combo component (<app-combo>) — the control this role should be.

    <app-combo inputid="type" name="pettype" required data-ds="combo">
      <select class="form-control pc-combo__control" id="type">
        <option value="0">cat</option>
        <option value="1">dog</option>
        <option value="2">lizard</option>
        <option value="3">snake</option>
        <!-- 3 more <option> -->
      </select>
      <span aria-hidden="true" class="pc-combo__chevron"></span>
    </app-combo>
    3ok390f0e0eType
    app-combo[name="pettype"]
    design-system component combosame0%
    markup and template

    The design system’s combo component (<app-combo>) — the control this role should be.

    <app-combo inputid="type" name="pettype" required data-ds="combo">
      <select class="form-control pc-combo__control" id="type">
        <option value="0">cat</option>
        <option value="1">dog</option>
        <option value="2">lizard</option>
        <option value="3">snake</option>
        <!-- 3 more <option> -->
      </select>
      <span aria-hidden="true" class="pc-combo__chevron"></span>
    </app-combo>
    4 controls considered and deliberately not judged
    • Owner #owner_name — role input[type=text], not covered
    • Name #name — role input[type=text], not covered
    • Birth Date input[name="birthDate"] — role input[type=text], not covered
    • Type #type1 — role input[type=text], not covered
    ✅ Add A Vet (/vets/add)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2
    ✓ combo3

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    3okDevoxx26Type
    app-combo[name="specialties"]
    design-system component combosame0%
    markup and template

    The design system’s combo component (<app-combo>) — the control this role should be.

    <app-combo inputid="specialties" name="specialties" data-ds="combo">
      <select class="form-control pc-combo__control" id="specialties">
        <option value=""></option>
        <option value="0">radiology</option>
        <option value="1">surgery</option>
        <option value="2">dentistry</option>
      </select>
      <span aria-hidden="true" class="pc-combo__chevron"></span>
    </app-combo>
    3ok390f0e0eType
    app-combo[name="specialties"]
    design-system component combosame0%
    markup and template

    The design system’s combo component (<app-combo>) — the control this role should be.

    <app-combo inputid="specialties" name="specialties" data-ds="combo">
      <select class="form-control pc-combo__control" id="specialties">
        <option value=""></option>
        <option value="0">radiology</option>
        <option value="1">surgery</option>
        <option value="2">dentistry</option>
      </select>
      <span aria-hidden="true" class="pc-combo__chevron"></span>
    </app-combo>
    2 controls considered and deliberately not judged
    • First Name #firstName — role input[type=text], not covered
    • Last Name #lastName — role input[type=text], not covered
    ✅ Welcome (/welcome)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    ✅ Add An Owner (/owners/add)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    5 controls considered and deliberately not judged
    • First Name #firstName — role input[type=text], not covered
    • Last Name #lastName — role input[type=text], not covered
    • Address #address — role input[type=text], not covered
    • City #city — role input[type=text], not covered
    • Telephone #telephone — role input[type=text], not covered
    ✅ Owner Details (/owners/8)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    ✅ Edit An Owner (/owners/8/edit)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    5 controls considered and deliberately not judged
    • First Name #firstName — role input[type=text], not covered
    • Last Name #lastName — role input[type=text], not covered
    • Address #address — role input[type=text], not covered
    • City #city — role input[type=text], not covered
    • Telephone #telephone — role input[type=text], not covered
    ✅ Pets (/pets)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    ✅ Book A Visit (/pets/11/visits/add)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    2 controls considered and deliberately not judged
    • Date input[name="date"] — role input[type=text], not covered
    • Description #description — role input[type=text], not covered
    ✅ Visits (/visits)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    ✅ Edit A Visit (/visits/1/edit)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    2 controls considered and deliberately not judged
    • Date input[name="date"] — role input[type=text], not covered
    • Description #description — role input[type=text], not covered
    ✅ Pet Types (/pettypes)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    7 controls considered and deliberately not judged
    • pettype_name #\30 — role input[type=text], not covered
    • pettype_name #\31 — role input[type=text], not covered
    • pettype_name #\32 — role input[type=text], not covered
    • pettype_name #\33 — role input[type=text], not covered
    • pettype_name #\34 — role input[type=text], not covered
    • pettype_name #\35 — role input[type=text], not covered
    • pettype_name #\36 — role input[type=text], not covered
    ✅ Add A Pet Type (/pettypes/add)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    1 control considered and deliberately not judged
    • Name #name — role input[type=text], not covered
    ✅ Edit A Pet Type (/pettypes/1/edit)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    1 control considered and deliberately not judged
    • Name #name — role input[type=text], not covered
    ✅ Specialties (/specialties)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    3 controls considered and deliberately not judged
    • spec_name #\30 — role input[type=text], not covered
    • spec_name #\31 — role input[type=text], not covered
    • spec_name #\32 — role input[type=text], not covered
    ✅ Edit A Specialty (/specialties/1/edit)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    2 controls considered and deliberately not judged
    • Name #name — role input[type=text], not covered
    • Description #description — role textarea, not covered
    ✅ Vets (/vets)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    ✅ Edit A Vet (/vets/1/edit)
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    1
    2

    1 changed — nothing here to judge2 changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    3 controls considered and deliberately not judged
    • First Name #firstName — role input[type=text], not covered
    • Last Name #lastName — role input[type=text], not covered
    • Specialties #spec — role select[multiple], not covered

    Complexity

    Cognitive Complexity per Entry Point

    Computed by endpoint-complexity.py with JavaParser and its symbol solver: a syntax tree of the Java sources, every call bound by type.

    REST APIs 45addedtotal
    GET/api/owners↗+213
    POST/api/users↗11
    GET/assistant↗11
    GET/api/owners/{ownerId}↗9
    GET/api/pets↗9
    POST/api/owners/{ownerId}/pets/{petId}/visits↗8
    GET/api/owners/{ownerId}/pets/{petId}↗7
    GET/api/pets/{petId}↗7
    GET/api/visits↗5
    GET/api/vets↗4
    PUT/api/vets/{vetId}↗4
    POST/api/visits↗4
    PUT/api/visits/{visitId}↗4
    POST/api/vets↗3
    GET/api/visits/{visitId}↗3
    GET/jev↗3
    GET/api/pettypes↗2
    GET/api/specialties↗2
    GET/api/vets/{vetId}↗2
    GET/history↗2
    POST/api/notifications/visit-booked↗1
    POST/api/owners/{ownerId}/pets↗1
    PUT/api/pets/{petId}↗1
    DELETE/api/pettypes/{petTypeId}↗1
    GET/api/specialties/feed↗1
    ANY/↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    POST/api/owners↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    GET/api/owners/count↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    DELETE/api/owners/{ownerId}↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    PUT/api/owners/{ownerId}↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    PUT/api/owners/{ownerId}/pets/{petId}↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    DELETE/api/pets/{petId}↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    POST/api/pettypes↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    GET/api/pettypes/{petTypeId}↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    PUT/api/pettypes/{petTypeId}↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    POST/api/specialties↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    DELETE/api/specialties/{specialtyId}↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    GET/api/specialties/{specialtyId}↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    PUT/api/specialties/{specialtyId}↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    DELETE/api/vets/{vetId}↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    DELETE/api/visits/{visitId}↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    GET/firefighter↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    DELETE/history↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    GET/model↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    GET/triage↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    MCP tools 7addedtotal
    MCPcall_vet_ambulance↗6
    MCPcreate_visit↗6
    MCPlist_visits↗3
    MCPLocalTools.sendEmail↗2
    MCPcancel_visit↗2
    MCPget_owner_profile↗1
    MCPLocalTools.currentDateTime↗0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    Logging

    Uses of common Java logging libraries

    Found by syntax-aware search over Java sources

    None. Not one logging statement was added or changed on the lines this change set touches.

    CODEOWNERS

    Needs approval as per .github/CODEOWNERS

    Cost

    Token costs

    stepyouagentcost
    ImplementationClaude Code 8782fda67 min36 min$15.98Opus 5.5
    ReviewClaude Code 8782fda6 · the reviewers and their brief19 s16 min$5.67Opus 5.5
    Auto-fixesClaude Code 8782fda6 · deciding and fixing2 min58 min$9.73Opus 5.5 · Sonnet 5.5
    claude -p on Sonnet 5.5 · requirements↔tests mappingplus 15 min of later refreshes, no model—2 min$0.39Sonnet 5.5
    VoicesFish Audio, 2 voices< $0.01s2.1-pro-free
    Total9 min1 h 52 min$31.77

    Building human-review itself — the tool, not this change

    $3,546token cost (subsidized)29 hVictor’s time, estimated161 hagent time

    186 sessions, 27 Aug 2026 → 8 Oct 2026