feat(quick-260907-let): Knopf "Verbindung testen" im Postfach-Formular

- testEmailConnection im API-Klienten, POST email-config/test
- EmailAlertConfigForm: Testknopf vor Speichern, Wartezustand, gruene/rote
  Rueckmeldung, Formularaenderung raeumt vorherige Rueckmeldung weg
- Beschreibungsblock der Komponente korrigiert (Knopf existiert jetzt)
- Vier neue Schluessel unter tenderRadar.emailAlerts in de/en
- vi.mock-Fabriken in EmailAlertConfigForm.test.tsx und my-sources.test.tsx
  um testEmailConnection erweitert, zwei neue Testfaelle

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
2026-09-07 15:43:51 +02:00
parent 3bf550bc65
commit c4db3b2e65
6 changed files with 152 additions and 11 deletions
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
// DigestIntervalForm all call into this module on mount.
const mockFetchEmailConfig = vi.fn();
const mockSaveEmailConfig = vi.fn();
const mockTestEmailConnection = vi.fn();
const mockListRssFeeds = vi.fn();
const mockCreateRssFeed = vi.fn();
const mockDeleteRssFeed = vi.fn();
@@ -14,6 +15,7 @@ const mockSaveNotificationPref = vi.fn();
vi.mock('@/lib/tender-radar-api', () => ({
fetchEmailConfig: (...args: unknown[]) => mockFetchEmailConfig(...args),
saveEmailConfig: (...args: unknown[]) => mockSaveEmailConfig(...args),
testEmailConnection: (...args: unknown[]) => mockTestEmailConnection(...args),
listRssFeeds: (...args: unknown[]) => mockListRssFeeds(...args),
createRssFeed: (...args: unknown[]) => mockCreateRssFeed(...args),
deleteRssFeed: (...args: unknown[]) => mockDeleteRssFeed(...args),
@@ -75,6 +77,10 @@ vi.mock('next-intl', () => ({
'emailAlerts.saveSuccess': 'Einstellungen gespeichert.',
'emailAlerts.errorSave': 'Einstellungen konnten nicht gespeichert werden',
'emailAlerts.notSaved': 'Noch kein Postfach hinterlegt.',
'emailAlerts.testConnection': 'Verbindung testen',
'emailAlerts.testTesting': 'Verbindung wird getestet...',
'emailAlerts.testSuccess': 'Verbindung erfolgreich',
'emailAlerts.testFailed': 'Verbindung fehlgeschlagen: {error}',
'rssFeeds.empty': 'Noch keine RSS-Feeds hinterlegt.',
'rssFeeds.errorLoad': 'RSS-Feeds konnten nicht geladen werden',
'rssFeeds.urlLabel': 'Feed-URL *',
@@ -119,6 +125,7 @@ afterEach(() => {
cleanup();
mockFetchEmailConfig.mockReset();
mockSaveEmailConfig.mockReset();
mockTestEmailConnection.mockReset();
mockListRssFeeds.mockReset();
mockCreateRssFeed.mockReset();
mockDeleteRssFeed.mockReset();
@@ -4,16 +4,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock @/lib/tender-radar-api
const mockFetchEmailConfig = vi.fn();
const mockSaveEmailConfig = vi.fn();
const mockTestEmailConnection = vi.fn();
vi.mock('@/lib/tender-radar-api', () => ({
fetchEmailConfig: (...args: unknown[]) => mockFetchEmailConfig(...args),
saveEmailConfig: (...args: unknown[]) => mockSaveEmailConfig(...args),
testEmailConnection: (...args: unknown[]) => mockTestEmailConnection(...args),
}));
// Mock next-intl useTranslations — mirrors the marketplace test mock
// convention (tenant-selector.test.tsx).
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
useTranslations: () => (key: string, params?: Record<string, string>) => {
const translations: Record<string, string> = {
'emailAlerts.notSaved': 'Noch nicht gespeichert — Postfach-Konfiguration ausfüllen und speichern.',
'emailAlerts.protocolLabel': 'Protokoll *',
@@ -45,8 +47,18 @@ vi.mock('next-intl', () => ({
'emailAlerts.save': 'Speichern',
'emailAlerts.saveSuccess': 'Einstellungen gespeichert.',
'emailAlerts.errorSave': 'Einstellungen konnten nicht gespeichert werden',
'emailAlerts.testConnection': 'Verbindung testen',
'emailAlerts.testTesting': 'Verbindung wird getestet...',
'emailAlerts.testSuccess': 'Verbindung erfolgreich',
'emailAlerts.testFailed': 'Verbindung fehlgeschlagen: {error}',
};
return translations[key] ?? key;
let result = translations[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
result = result.replace(`{${k}}`, v);
}
}
return result;
},
}));
@@ -54,6 +66,7 @@ afterEach(() => {
cleanup();
mockFetchEmailConfig.mockReset();
mockSaveEmailConfig.mockReset();
mockTestEmailConnection.mockReset();
vi.restoreAllMocks();
});
@@ -165,4 +178,46 @@ describe('EmailAlertConfigForm', () => {
expect(screen.getByLabelText(/Windows-Domäne/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/^Port \*$/i)).not.toBeInTheDocument();
});
it('clicking "Verbindung testen" calls testEmailConnection once with a body carrying no password field (Quick 260907-let, WINDOWS #16)', async () => {
mockFetchEmailConfig.mockResolvedValue(EXISTING_CONFIG);
mockTestEmailConnection.mockResolvedValue({ success: true });
const { EmailAlertConfigForm } = await import('./EmailAlertConfigForm');
render(<EmailAlertConfigForm />);
await waitFor(() => {
expect(screen.getByDisplayValue('imap.example.test')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Verbindung testen/i }));
await waitFor(() => {
expect(mockTestEmailConnection).toHaveBeenCalledTimes(1);
});
const payload = mockTestEmailConnection.mock.calls[0][0];
expect(payload).not.toHaveProperty('password');
expect(screen.getByText(/Verbindung erfolgreich/i)).toBeInTheDocument();
});
it('a failed connection test shows the server\'s error message in the document', async () => {
mockFetchEmailConfig.mockResolvedValue(EXISTING_CONFIG);
mockTestEmailConnection.mockResolvedValue({
success: false,
message: '401 Unauthorized',
});
const { EmailAlertConfigForm } = await import('./EmailAlertConfigForm');
render(<EmailAlertConfigForm />);
await waitFor(() => {
expect(screen.getByDisplayValue('imap.example.test')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Verbindung testen/i }));
await waitFor(() => {
expect(screen.getByText(/401 Unauthorized/i)).toBeInTheDocument();
});
});
});
@@ -6,6 +6,7 @@ import {
type EmailAlertConfig,
fetchEmailConfig,
saveEmailConfig,
testEmailConnection,
} from '@/lib/tender-radar-api';
/** Inline eye SVG (16×16) for show/hide password toggle */
@@ -123,12 +124,13 @@ function formToPayload(
* domain/username/password/isActive) — password blank on load, only sent
* when the admin types a new one (T-07-12).
*
* Deliberately does NOT include a "Test Connection" button or an
* Abrufintervall field: this module's poll cadence is governed by the
* shared, platform-wide 'email-alert' TenderSourcePollConfig (D-15,
* pollGranularity='tick'), not a per-tenant setting, and no
* test-connection endpoint exists for this config (out of this plan's
* scope).
* Includes a "Verbindung testen" button (Quick 260907-let, WINDOWS #16)
* that calls POST /modules/tender-radar/email-config/test against
* whatever is currently in the form, without saving. Deliberately does
* NOT include an Abrufintervall field, though: this module's poll cadence
* is governed by the shared, platform-wide 'email-alert'
* TenderSourcePollConfig (D-15, pollGranularity='tick'), not a per-user
* setting.
*
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* tenderRadar i18n namespace.
@@ -138,10 +140,15 @@ export function EmailAlertConfigForm() {
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message?: string;
} | null>(null);
// Whether config exists in DB (null = not yet saved)
const [configExists, setConfigExists] = useState(false);
@@ -167,10 +174,27 @@ export function EmailAlertConfigForm() {
setForm((f) => ({ ...f, [key]: value }));
setSaveError(null);
setSaveSuccess(false);
setTestResult(null);
},
[],
);
const handleTestConnection = async () => {
setIsTesting(true);
setTestResult(null);
try {
const result = await testEmailConnection(formToPayload(form));
setTestResult(result);
} catch (err) {
setTestResult({
success: false,
message: err instanceof Error ? err.message : t('emailAlerts.errorSave'),
});
} finally {
setIsTesting(false);
}
};
const handleSave = async () => {
setIsSaving(true);
setSaveError(null);
@@ -421,16 +445,39 @@ export function EmailAlertConfigForm() {
{/* Form actions */}
<div className="flex gap-3 pt-4 border-t border-border mt-6">
<button
type="button"
onClick={handleTestConnection}
disabled={isTesting || isSaving}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
{isTesting ? t('emailAlerts.testTesting') : t('emailAlerts.testConnection')}
</button>
<button
type="button"
onClick={handleSave}
disabled={isSaving}
disabled={isSaving || isTesting}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? t('emailAlerts.saving') : t('emailAlerts.save')}
</button>
</div>
{testResult !== null && (
<p
className="text-sm"
style={{
color: testResult.success ? 'oklch(0.40 0.15 148)' : 'var(--destructive)',
}}
>
{testResult.success
? t('emailAlerts.testSuccess')
: t('emailAlerts.testFailed', {
error: testResult.message ?? 'Unbekannter Fehler',
})}
</p>
)}
{saveSuccess && (
<p className="text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
{t('emailAlerts.saveSuccess')}