Compare commits

...

4 Commits

Author SHA1 Message Date
Marcel
f0eb3a76be test(ui): add component tests for NameHistoryCard
Some checks failed
CI / Unit & Component Tests (push) Failing after 3s
CI / Backend Unit Tests (push) Failing after 1s
CI / Unit & Component Tests (pull_request) Failing after 2s
CI / Backend Unit Tests (pull_request) Failing after 2s
Verifies alias rendering, empty state, firstName fallback,
and type label display. 5 browser-based Svelte tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:43:09 +02:00
Marcel
6d837c518c fix(a11y): include alias name in delete button aria-label
Screen readers now announce which alias is being deleted, e.g.
"Entfernen de Gruyter" instead of just "Entfernen".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:42:08 +02:00
Marcel
97646a31df fix(ui): always show Namensverlauf card on detail page
Removes the {#if} guard so the card with empty state message is
always visible for feature discoverability.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:40:43 +02:00
6 changed files with 84 additions and 10 deletions

View File

@@ -104,7 +104,7 @@ public class PersonController {
@PostMapping("/{id}/aliases")
@RequirePermission(Permission.WRITE_ALL)
public PersonNameAlias addAlias(@PathVariable UUID id, @RequestBody PersonNameAliasDTO dto) {
public PersonNameAlias addAlias(@PathVariable UUID id, @Valid @RequestBody PersonNameAliasDTO dto) {
return personService.addAlias(id, dto);
}

View File

@@ -1,9 +1,12 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
public record PersonNameAliasDTO(
String lastName,
String firstName,
PersonNameAliasType type
@NotBlank @Size(max = 255) String lastName,
@Size(max = 255) String firstName,
@NotNull PersonNameAliasType type
) {}

View File

@@ -458,4 +458,22 @@ class PersonControllerTest {
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void addAlias_returns400_whenLastNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void addAlias_returns400_whenTypeIsNull() throws Exception {
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"de Gruyter\"}"))
.andExpect(status().isBadRequest());
}
}

View File

@@ -69,11 +69,9 @@ const coCorrespondents = $derived.by(() => {
<!-- Left column: Person card + name history -->
<div>
<PersonCard person={person} canWrite={data.canWrite} />
{#if data.aliases.length > 0}
<div class="mt-6">
<NameHistoryCard aliases={data.aliases} personFirstName={person.firstName} />
</div>
{/if}
<div class="mt-6">
<NameHistoryCard aliases={data.aliases} personFirstName={person.firstName} />
</div>
</div>
<!-- Right column: correspondents + documents -->

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import NameHistoryCard from './NameHistoryCard.svelte';
const aliases = [
{ id: 'a1', lastName: 'de Gruyter', firstName: null, type: 'BIRTH', sortOrder: 0 },
{ id: 'a2', lastName: 'Schmidt', firstName: 'Maria', type: 'WIDOWED', sortOrder: 1 }
];
describe('NameHistoryCard', () => {
it('should render one row per alias', async () => {
render(NameHistoryCard, { aliases, personFirstName: 'Clara' });
await expect.element(page.getByText('de Gruyter')).toBeInTheDocument();
await expect.element(page.getByText('Schmidt')).toBeInTheDocument();
});
it('should show empty state when no aliases', async () => {
render(NameHistoryCard, { aliases: [], personFirstName: 'Clara' });
const emptyText = document.querySelector('.italic');
expect(emptyText).not.toBeNull();
expect(emptyText!.textContent!.length).toBeGreaterThan(0);
});
it('should use personFirstName when alias firstName is null', async () => {
render(NameHistoryCard, {
aliases: [{ id: 'a1', lastName: 'de Gruyter', firstName: null, type: 'BIRTH', sortOrder: 0 }],
personFirstName: 'Clara'
});
await expect.element(page.getByText('Clara')).toBeInTheDocument();
});
it('should use alias firstName when provided', async () => {
render(NameHistoryCard, {
aliases: [
{ id: 'a1', lastName: 'Schmidt', firstName: 'Maria', type: 'WIDOWED', sortOrder: 0 }
],
personFirstName: 'Clara'
});
await expect.element(page.getByText('Maria')).toBeInTheDocument();
});
it('should show type labels', async () => {
render(NameHistoryCard, {
aliases: [{ id: 'a1', lastName: 'de Gruyter', firstName: null, type: 'BIRTH', sortOrder: 0 }],
personFirstName: 'Clara'
});
await expect.element(page.getByText('geborene/r')).toBeInTheDocument();
});
});

View File

@@ -125,7 +125,7 @@ async function addAlias() {
<button
type="button"
onclick={() => confirmDelete(alias.id)}
aria-label={m.person_alias_btn_delete()}
aria-label="{m.person_alias_btn_delete()} {alias.lastName}"
class="ml-4 inline-flex min-h-[44px] min-w-[44px] items-center justify-center text-red-400 transition-colors hover:text-red-600"
>
<svg