feat: PersonNameParser enhancements and Person model refactor (#209-#213) #215

Merged
marcel merged 25 commits from feat/issues-209-213-person-parser-enhancements into main 2026-04-08 18:48:00 +02:00
7 changed files with 219 additions and 45 deletions
Showing only changes of commit 1aabd9826c - Show all commits

View File

@@ -5,10 +5,10 @@ import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
afterEach(cleanup); afterEach(cleanup);
const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller' }; const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller', displayName: 'Karl Müller' };
const receivers = [ const receivers = [
{ id: 'r1', firstName: 'Anna', lastName: 'Schmidt' }, { id: 'r1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
{ id: 'r2', firstName: 'Hans', lastName: 'Weber' } { id: 'r2', firstName: 'Hans', lastName: 'Weber', displayName: 'Hans Weber' }
]; ];
const tags = [ const tags = [
{ id: 't1', name: 'Familienbrief' }, { id: 't1', name: 'Familienbrief' },

View File

@@ -7,9 +7,21 @@ const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0)); const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [ const PERSONS = [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' }, {
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }, id: '1',
{ id: '3', firstName: 'Karl', lastName: 'König' } firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
},
{ id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' }
]; ];
function mockFetch(persons = PERSONS) { function mockFetch(persons = PERSONS) {
@@ -45,8 +57,20 @@ describe('PersonMultiSelect rendering', () => {
it('renders pre-selected persons as chips', async () => { it('renders pre-selected persons as chips', async () => {
render(PersonMultiSelect, { render(PersonMultiSelect, {
selectedPersons: [ selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' }, {
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' } id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
] ]
}); });
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument(); await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
@@ -57,8 +81,20 @@ describe('PersonMultiSelect rendering', () => {
it('renders hidden inputs for each selected person', async () => { it('renders hidden inputs for each selected person', async () => {
render(PersonMultiSelect, { render(PersonMultiSelect, {
selectedPersons: [ selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' }, {
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' } id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
] ]
}); });
await tick(); await tick();
@@ -70,7 +106,15 @@ describe('PersonMultiSelect rendering', () => {
it('hides the placeholder when persons are selected', async () => { it('hides the placeholder when persons are selected', async () => {
render(PersonMultiSelect, { render(PersonMultiSelect, {
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }] selectedPersons: [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]
}); });
await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument(); await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument();
}); });
@@ -85,7 +129,7 @@ describe('PersonMultiSelect selecting persons', () => {
const input = page.getByRole('textbox'); const input = page.getByRole('textbox');
await input.fill('Mu'); await input.fill('Mu');
await waitForDebounce(); await waitForDebounce();
await page.getByText('Mustermann, Max').click(); await page.getByText('Max Mustermann').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument(); await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(input).toHaveValue(''); await expect.element(input).toHaveValue('');
await page.screenshot({ await page.screenshot({
@@ -100,11 +144,11 @@ describe('PersonMultiSelect selecting persons', () => {
await input.fill('Mu'); await input.fill('Mu');
await waitForDebounce(); await waitForDebounce();
await page.getByText('Mustermann, Max').click(); await page.getByText('Max Mustermann').click();
await input.fill('Mu'); await input.fill('Mu');
await waitForDebounce(); await waitForDebounce();
await page.getByText('Musterfrau, Anna').click(); await page.getByText('Anna Musterfrau').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument(); await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument(); await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
@@ -116,22 +160,41 @@ describe('PersonMultiSelect selecting persons', () => {
it('filters already-selected persons from search results', async () => { it('filters already-selected persons from search results', async () => {
mockFetch(); mockFetch();
render(PersonMultiSelect, { render(PersonMultiSelect, {
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }] selectedPersons: [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]
}); });
const input = page.getByRole('textbox'); const input = page.getByRole('textbox');
await input.fill('Mu'); await input.fill('Mu');
await waitForDebounce(); await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument(); // Chip still shows "Max Mustermann" but the dropdown item (role=button) must be filtered out
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument(); await expect
.element(page.getByRole('button', { name: 'Max Mustermann' }))
.not.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Anna Musterfrau' })).toBeInTheDocument();
}); });
it('selects a result with Enter key', async () => { it('selects a result with Enter key', async () => {
mockFetch([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]); mockFetch([
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]);
render(PersonMultiSelect, { selectedPersons: [] }); render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox'); const input = page.getByRole('textbox');
await input.fill('Ma'); await input.fill('Ma');
await waitForDebounce(); await waitForDebounce();
await page.getByText('Mustermann, Max').click(); await page.getByText('Max Mustermann').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument(); await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
}); });
}); });
@@ -142,8 +205,20 @@ describe('PersonMultiSelect removing persons', () => {
it('removes a chip when its × button is clicked', async () => { it('removes a chip when its × button is clicked', async () => {
render(PersonMultiSelect, { render(PersonMultiSelect, {
selectedPersons: [ selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' }, {
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' } id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
] ]
}); });
// Buttons have aria-label="Entfernen" // Buttons have aria-label="Entfernen"
@@ -156,8 +231,20 @@ describe('PersonMultiSelect removing persons', () => {
it('removes the corresponding hidden input when a chip is removed', async () => { it('removes the corresponding hidden input when a chip is removed', async () => {
render(PersonMultiSelect, { render(PersonMultiSelect, {
selectedPersons: [ selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' }, {
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' } id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
] ]
}); });
await page.getByRole('button', { name: 'Entfernen' }).first().click(); await page.getByRole('button', { name: 'Entfernen' }).first().click();
@@ -177,9 +264,9 @@ describe('PersonMultiSelect click outside', () => {
const input = page.getByRole('textbox'); const input = page.getByRole('textbox');
await input.fill('Mu'); await input.fill('Mu');
await waitForDebounce(); await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument(); await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
document.body.click(); document.body.click();
await tick(); await tick();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument(); await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
}); });
}); });

View File

@@ -7,8 +7,20 @@ const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0)); const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [ const PERSONS = [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' }, {
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' } id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
]; ];
function mockFetchWithPersons(persons = PERSONS) { function mockFetchWithPersons(persons = PERSONS) {
@@ -76,8 +88,8 @@ describe('PersonTypeahead search', () => {
const input = page.getByPlaceholder('Namen tippen...'); const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu'); await input.fill('Mu');
await waitForDebounce(); await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument(); await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument(); await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' }); await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
}); });
@@ -105,7 +117,7 @@ describe('PersonTypeahead search', () => {
const input = page.getByPlaceholder('Namen tippen...'); const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma'); await input.fill('Ma');
await waitForDebounce(); await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument(); await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
}); });
}); });
@@ -122,7 +134,7 @@ describe('PersonTypeahead selection', () => {
await tick(); await tick();
await expect.element(input).toHaveValue('Max Mustermann'); await expect.element(input).toHaveValue('Max Mustermann');
await expect await expect
.element(page.getByRole('button', { name: 'Mustermann, Max' })) .element(page.getByRole('button', { name: 'Max Mustermann' }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' }); await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
}); });
@@ -152,7 +164,15 @@ describe('PersonTypeahead selection', () => {
}); });
it('selects a result with Enter key', async () => { it('selects a result with Enter key', async () => {
mockFetchWithPersons([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]); mockFetchWithPersons([
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]);
render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...'); const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma'); await input.fill('Ma');
@@ -218,7 +238,7 @@ describe('PersonTypeahead correspondent mode', () => {
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus(); (document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
await waitForDebounce(); await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument(); await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
}); });
it('uses correspondents endpoint with q param when typing', async () => { it('uses correspondents endpoint with q param when typing', async () => {
@@ -259,9 +279,9 @@ describe('PersonTypeahead click outside', () => {
const input = page.getByPlaceholder('Namen tippen...'); const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu'); await input.fill('Mu');
await waitForDebounce(); await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument(); await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
document.body.click(); document.body.click();
await tick(); await tick();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument(); await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
}); });
}); });

View File

@@ -54,7 +54,15 @@ describe('korrespondenz load — senderId set, no receiverId', () => {
const docs = [{ id: 'd1', title: 'Testbrief' }]; const docs = [{ id: 'd1', title: 'Testbrief' }];
const GET = mockApi([ const GET = mockApi([
{ ok: true, data: docs }, { ok: true, data: docs },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } } {
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
}
]); ]);
const result = await load({ const result = await load({
@@ -76,8 +84,24 @@ describe('korrespondenz load — senderId and receiverId set', () => {
it('calls conversation, sender person, and receiver person endpoints', async () => { it('calls conversation, sender person, and receiver person endpoints', async () => {
const GET = mockApi([ const GET = mockApi([
{ ok: true, data: [] }, { ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }, {
{ ok: true, data: { firstName: 'Anna', lastName: 'Schmidt' } } ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
},
{
ok: true,
data: {
firstName: 'Anna',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON'
}
}
]); ]);
const result = await load({ const result = await load({
@@ -98,7 +122,15 @@ describe('korrespondenz load — canWrite', () => {
it('derives canWrite true from WRITE_ALL permission', async () => { it('derives canWrite true from WRITE_ALL permission', async () => {
mockApi([ mockApi([
{ ok: true, data: [] }, { ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } } {
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
}
]); ]);
const result = await load({ const result = await load({
@@ -113,7 +145,15 @@ describe('korrespondenz load — canWrite', () => {
it('derives canWrite false when user lacks WRITE_ALL', async () => { it('derives canWrite false when user lacks WRITE_ALL', async () => {
mockApi([ mockApi([
{ ok: true, data: [] }, { ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } } {
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
}
]); ]);
const result = await load({ const result = await load({
@@ -132,7 +172,15 @@ describe('korrespondenz load — backend error', () => {
it('throws when the conversation endpoint returns non-ok', async () => { it('throws when the conversation endpoint returns non-ok', async () => {
mockApi([ mockApi([
{ ok: false, status: 500 }, { ok: false, status: 500 },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } } {
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
}
]); ]);
await expect( await expect(

View File

@@ -58,7 +58,9 @@ describe('New document page receiver prefill', () => {
it('shows a receiver chip when initialReceivers has a person', async () => { it('shows a receiver chip when initialReceivers has a person', async () => {
const data = { const data = {
...baseData, ...baseData,
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }] initialReceivers: [
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
]
}; };
render(Page, { data, form: null }); render(Page, { data, form: null });
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument(); await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
@@ -67,7 +69,9 @@ describe('New document page receiver prefill', () => {
it('renders a hidden receiverIds input for the prefilled receiver', async () => { it('renders a hidden receiverIds input for the prefilled receiver', async () => {
const data = { const data = {
...baseData, ...baseData,
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }] initialReceivers: [
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
]
}; };
render(Page, { data, form: null }); render(Page, { data, form: null });
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]'); const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');

View File

@@ -39,8 +39,22 @@ const makeDoc = (overrides = {}) => ({
status: 'UPLOADED' as const, status: 'UPLOADED' as const,
documentDate: '2024-03-15', documentDate: '2024-03-15',
location: 'Berlin', location: 'Berlin',
sender: { id: 'p1', firstName: 'Max', lastName: 'Mustermann' }, sender: {
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Musterfrau' }], id: 'p1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON' as const
},
receivers: [
{
id: 'p2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON' as const
}
],
tags: [{ id: 't1', name: 'Familie' }], tags: [{ id: 't1', name: 'Familie' }],
filePath: '/files/testbrief.pdf', filePath: '/files/testbrief.pdf',
createdAt: '2024-03-15T10:00:00Z', createdAt: '2024-03-15T10:00:00Z',

View File

@@ -11,6 +11,7 @@ const makePerson = (overrides = {}) => ({
id: '1', id: '1',
firstName: 'Max', firstName: 'Max',
lastName: 'Mustermann', lastName: 'Mustermann',
displayName: 'Max Mustermann',
documentCount: 0, documentCount: 0,
...overrides ...overrides
}); });