feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m22s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m10s

Administrator -> Willkommensmail: Betreff, Ueberschrift, Einleitung, Abschluss je
Mandant (Tabelle WelcomeMailTemplate, RLS je Mandant, Migration 20260930150000);
Platzhalter {{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}},
unbekannte -> 400 bzw. Hinweis beim Tippen; Werte escaped, Vorlage reiner Text.
Live-Vorschau per API gerendert, Testmail an die eigene Adresse ohne Token,
Zuruecksetzen auf Standard. Feste Bausteine (Kopf, Zugangsdaten, Anmeldehinweis,
Knoepfe, Fusszeile) bleiben immer drin.
Kopf: Wellenzelle dunkel statt weiss, Streifen 600x40, Inhalt 24 px naeher –
keine weisse Luecke, wenn OWA das CID-Bild nicht zeigt.
Lokal nachgewiesen: Hinweis/Sperre bei {{xyz}}, Speichern, Testmail (Link nur
/login), echte Mail mit eigener Vorlage und 7-Tage-Link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:49:17 +02:00
parent 31d514b7ca
commit e10da76259
27 changed files with 2471 additions and 82 deletions
@@ -0,0 +1,227 @@
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
// Namensraumfaehiger next-intl-Mock gegen die echten deutschen Texte:
// Punktschluessel werden aufgeloest, {param} wird eingesetzt.
vi.mock('next-intl', () => ({
useLocale: () => 'de',
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
let val: unknown = de;
for (const part of `${ns}.${key}`.split('.')) {
val = (val as Record<string, unknown> | undefined)?.[part];
}
if (typeof val !== 'string') return `${ns}.${key}`;
return params
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
: val;
},
}));
const api = vi.hoisted(() => ({
fetchWelcomeMailTemplate: vi.fn(),
saveWelcomeMailTemplate: vi.fn(),
resetWelcomeMailTemplate: vi.fn(),
previewWelcomeMail: vi.fn(),
sendWelcomeMailTest: vi.fn(),
}));
vi.mock('@/lib/welcome-mail-template-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/welcome-mail-template-api')>();
return { ...actual, ...api };
});
import { WelcomeMailTemplateRequestError } from '@/lib/welcome-mail-template-api';
import { WelcomeMailTemplateEditor } from './welcome-mail-template-editor';
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
const custom = {
subject: 'Hallo {{vorname}}',
heading: 'Willkommen, {{name}}!',
intro: 'Eigene Einleitung',
closing: 'Ihr IT-Team',
};
function state(isCustom: boolean) {
return {
custom: isCustom,
texts: isCustom ? { ...custom } : { ...defaults },
defaults: { ...defaults },
updatedAt: isCustom ? '2026-09-30T10:00:00.000Z' : null,
updatedBy: isCustom ? 'ada' : null,
};
}
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
api.fetchWelcomeMailTemplate.mockResolvedValue(state(false));
api.previewWelcomeMail.mockImplementation(async (texts: typeof defaults, account: string) => ({
subject: texts.subject,
html: `<html><body>Vorschau ${account}</body></html>`,
text: '',
}));
api.saveWelcomeMailTemplate.mockImplementation(async (texts: typeof defaults) => ({
...state(true),
texts: { ...texts },
}));
api.resetWelcomeMailTemplate.mockResolvedValue(state(false));
api.sendWelcomeMailTest.mockResolvedValue({ success: true, to: 'ada@example.invalid' });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
async function renderLoaded() {
render(<WelcomeMailTemplateEditor />);
return (await screen.findByLabelText('Betreff')) as HTMLInputElement;
}
describe('WelcomeMailTemplateEditor', () => {
it('laedt die Standardtexte ins Formular und zeigt Hinweis auf die festen Bausteine', async () => {
const subject = await renderLoaded();
expect(subject.value).toBe(defaults.subject);
expect((screen.getByLabelText('Überschrift') as HTMLInputElement).value).toBe(defaults.heading);
expect((screen.getByLabelText('Einleitungstext') as HTMLTextAreaElement).value).toBe(
defaults.intro,
);
expect((screen.getByLabelText('Abschlusstext') as HTMLTextAreaElement).value).toBe(
defaults.closing,
);
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
'Zurzeit gelten die Standardtexte.',
);
expect(
screen.getByText(/So kann eine eigene Vorlage die Anmeldung nicht kaputt machen/),
).toBeTruthy();
});
it('Hinweisbox Platzhalter: Tabelle mit allen sechs Platzhaltern, Bedeutung und Beispiel', async () => {
await renderLoaded();
const box = screen.getByRole('region', { name: 'Platzhalter' });
const rows = within(box).getAllByRole('row');
expect(rows).toHaveLength(7); // Kopf + 6
for (const token of [
'{{name}}',
'{{vorname}}',
'{{benutzername}}',
'{{email}}',
'{{adresse}}',
'{{firma}}',
]) {
expect(within(box).getByRole('button', { name: `${token} einfügen` })).toBeTruthy();
}
expect(within(box).getByText('Name Ihres Mandanten')).toBeTruthy();
expect(within(box).getByText('max.mustermann@example.com')).toBeTruthy();
});
it('Klick auf einen Platzhalter fuegt ihn an der Cursorposition des zuletzt fokussierten Feldes ein', async () => {
const subject = await renderLoaded();
fireEvent.change(subject, { target: { value: 'Hallo bei uns' } });
subject.focus();
subject.setSelectionRange(6, 6);
fireEvent.click(screen.getByRole('button', { name: '{{vorname}} einfügen' }));
expect(subject.value).toBe('Hallo {{vorname}} bei uns');
});
it('unbekannter Platzhalter wird benannt, Speichern und Testmail sind gesperrt', async () => {
const user = userEvent.setup();
await renderLoaded();
const heading = screen.getByLabelText('Überschrift');
await user.clear(heading);
fireEvent.change(heading, { target: { value: 'Hallo {{xyz}}' } });
expect(await screen.findByText('Unbekannter Platzhalter {{xyz}}')).toBeTruthy();
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
true,
);
expect(
(screen.getByRole('button', { name: 'Testmail an mich senden' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it('Speichern schickt die vier Texte und meldet Erfolg; Status zeigt die eigene Vorlage', async () => {
const user = userEvent.setup();
const subject = await renderLoaded();
fireEvent.change(subject, { target: { value: 'Neu für {{firma}}' } });
await user.click(screen.getByRole('button', { name: 'Speichern' }));
expect(api.saveWelcomeMailTemplate).toHaveBeenCalledWith({
...defaults,
subject: 'Neu für {{firma}}',
});
expect((await screen.findByRole('status')).textContent).toContain(
'Die Vorlage wurde gespeichert.',
);
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
'Eigene Vorlage aktiv',
);
expect(screen.getByTestId('welcome-template-status').textContent).toContain('von ada');
});
it('Fehler der API beim Speichern wird angezeigt (400 mit Meldung)', async () => {
const user = userEvent.setup();
api.saveWelcomeMailTemplate.mockRejectedValueOnce(
new WelcomeMailTemplateRequestError(400, ['Betreff: Unbekannter Platzhalter {{abc}}']),
);
await renderLoaded();
await user.click(screen.getByRole('button', { name: 'Speichern' }));
expect((await screen.findByRole('alert')).textContent).toBe(
'Betreff: Unbekannter Platzhalter {{abc}}',
);
});
it('Auf Standard zuruecksetzen: Rueckfrage, DELETE, danach Standardtexte im Formular', async () => {
const user = userEvent.setup();
api.fetchWelcomeMailTemplate.mockResolvedValue(state(true));
const subject = await renderLoaded();
expect(subject.value).toBe(custom.subject);
await user.click(screen.getByRole('button', { name: 'Auf Standard zurücksetzen' }));
const dialog = screen.getByRole('alertdialog');
expect(api.resetWelcomeMailTemplate).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole('button', { name: 'Zurücksetzen' }));
expect(api.resetWelcomeMailTemplate).toHaveBeenCalledTimes(1);
await waitFor(() => expect(subject.value).toBe(defaults.subject));
expect(screen.getByTestId('welcome-template-status').textContent).toContain('Standardtexte');
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('Vorschau: iframe mit dem von der API gerenderten HTML, Umschalter lokales Konto', async () => {
const user = userEvent.setup();
await renderLoaded();
const frame = screen.getByTitle('Vorschau der Willkommensmail') as HTMLIFrameElement;
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau directory'));
expect(frame.getAttribute('sandbox')).toBe('');
await user.click(screen.getByRole('button', { name: 'Lokales Konto' }));
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau local'));
expect(api.previewWelcomeMail).toHaveBeenLastCalledWith(defaults, 'local', expect.anything());
expect(screen.getByTestId('welcome-preview-subject').textContent).toBe(defaults.subject);
});
it('Testmail an mich: sendet den aktuellen (ungespeicherten) Inhalt und nennt die Adresse', async () => {
const user = userEvent.setup();
const subject = await renderLoaded();
fireEvent.change(subject, { target: { value: 'Ungespeichert' } });
await user.click(screen.getByRole('button', { name: 'Testmail an mich senden' }));
expect(api.sendWelcomeMailTest).toHaveBeenCalledWith({ ...defaults, subject: 'Ungespeichert' });
expect(api.saveWelcomeMailTemplate).not.toHaveBeenCalled();
expect((await screen.findByRole('status')).textContent).toBe(
'Die Testmail wurde an ada@example.invalid gesendet.',
);
});
it('Ladefehler wird angezeigt', async () => {
api.fetchWelcomeMailTemplate.mockRejectedValueOnce(new Error('down'));
render(<WelcomeMailTemplateEditor />);
expect((await screen.findByRole('alert')).textContent).toContain('konnte nicht geladen werden');
});
});