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. // DigestIntervalForm all call into this module on mount.
const mockFetchEmailConfig = vi.fn(); const mockFetchEmailConfig = vi.fn();
const mockSaveEmailConfig = vi.fn(); const mockSaveEmailConfig = vi.fn();
const mockTestEmailConnection = vi.fn();
const mockListRssFeeds = vi.fn(); const mockListRssFeeds = vi.fn();
const mockCreateRssFeed = vi.fn(); const mockCreateRssFeed = vi.fn();
const mockDeleteRssFeed = vi.fn(); const mockDeleteRssFeed = vi.fn();
@@ -14,6 +15,7 @@ const mockSaveNotificationPref = vi.fn();
vi.mock('@/lib/tender-radar-api', () => ({ vi.mock('@/lib/tender-radar-api', () => ({
fetchEmailConfig: (...args: unknown[]) => mockFetchEmailConfig(...args), fetchEmailConfig: (...args: unknown[]) => mockFetchEmailConfig(...args),
saveEmailConfig: (...args: unknown[]) => mockSaveEmailConfig(...args), saveEmailConfig: (...args: unknown[]) => mockSaveEmailConfig(...args),
testEmailConnection: (...args: unknown[]) => mockTestEmailConnection(...args),
listRssFeeds: (...args: unknown[]) => mockListRssFeeds(...args), listRssFeeds: (...args: unknown[]) => mockListRssFeeds(...args),
createRssFeed: (...args: unknown[]) => mockCreateRssFeed(...args), createRssFeed: (...args: unknown[]) => mockCreateRssFeed(...args),
deleteRssFeed: (...args: unknown[]) => mockDeleteRssFeed(...args), deleteRssFeed: (...args: unknown[]) => mockDeleteRssFeed(...args),
@@ -75,6 +77,10 @@ vi.mock('next-intl', () => ({
'emailAlerts.saveSuccess': 'Einstellungen gespeichert.', 'emailAlerts.saveSuccess': 'Einstellungen gespeichert.',
'emailAlerts.errorSave': 'Einstellungen konnten nicht gespeichert werden', 'emailAlerts.errorSave': 'Einstellungen konnten nicht gespeichert werden',
'emailAlerts.notSaved': 'Noch kein Postfach hinterlegt.', '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.empty': 'Noch keine RSS-Feeds hinterlegt.',
'rssFeeds.errorLoad': 'RSS-Feeds konnten nicht geladen werden', 'rssFeeds.errorLoad': 'RSS-Feeds konnten nicht geladen werden',
'rssFeeds.urlLabel': 'Feed-URL *', 'rssFeeds.urlLabel': 'Feed-URL *',
@@ -119,6 +125,7 @@ afterEach(() => {
cleanup(); cleanup();
mockFetchEmailConfig.mockReset(); mockFetchEmailConfig.mockReset();
mockSaveEmailConfig.mockReset(); mockSaveEmailConfig.mockReset();
mockTestEmailConnection.mockReset();
mockListRssFeeds.mockReset(); mockListRssFeeds.mockReset();
mockCreateRssFeed.mockReset(); mockCreateRssFeed.mockReset();
mockDeleteRssFeed.mockReset(); mockDeleteRssFeed.mockReset();
@@ -4,16 +4,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock @/lib/tender-radar-api // Mock @/lib/tender-radar-api
const mockFetchEmailConfig = vi.fn(); const mockFetchEmailConfig = vi.fn();
const mockSaveEmailConfig = vi.fn(); const mockSaveEmailConfig = vi.fn();
const mockTestEmailConnection = vi.fn();
vi.mock('@/lib/tender-radar-api', () => ({ vi.mock('@/lib/tender-radar-api', () => ({
fetchEmailConfig: (...args: unknown[]) => mockFetchEmailConfig(...args), fetchEmailConfig: (...args: unknown[]) => mockFetchEmailConfig(...args),
saveEmailConfig: (...args: unknown[]) => mockSaveEmailConfig(...args), saveEmailConfig: (...args: unknown[]) => mockSaveEmailConfig(...args),
testEmailConnection: (...args: unknown[]) => mockTestEmailConnection(...args),
})); }));
// Mock next-intl useTranslations — mirrors the marketplace test mock // Mock next-intl useTranslations — mirrors the marketplace test mock
// convention (tenant-selector.test.tsx). // convention (tenant-selector.test.tsx).
vi.mock('next-intl', () => ({ vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => { useTranslations: () => (key: string, params?: Record<string, string>) => {
const translations: Record<string, string> = { const translations: Record<string, string> = {
'emailAlerts.notSaved': 'Noch nicht gespeichert — Postfach-Konfiguration ausfüllen und speichern.', 'emailAlerts.notSaved': 'Noch nicht gespeichert — Postfach-Konfiguration ausfüllen und speichern.',
'emailAlerts.protocolLabel': 'Protokoll *', 'emailAlerts.protocolLabel': 'Protokoll *',
@@ -45,8 +47,18 @@ vi.mock('next-intl', () => ({
'emailAlerts.save': 'Speichern', 'emailAlerts.save': 'Speichern',
'emailAlerts.saveSuccess': 'Einstellungen gespeichert.', 'emailAlerts.saveSuccess': 'Einstellungen gespeichert.',
'emailAlerts.errorSave': 'Einstellungen konnten nicht gespeichert werden', '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(); cleanup();
mockFetchEmailConfig.mockReset(); mockFetchEmailConfig.mockReset();
mockSaveEmailConfig.mockReset(); mockSaveEmailConfig.mockReset();
mockTestEmailConnection.mockReset();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -165,4 +178,46 @@ describe('EmailAlertConfigForm', () => {
expect(screen.getByLabelText(/Windows-Domäne/i)).toBeInTheDocument(); expect(screen.getByLabelText(/Windows-Domäne/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/^Port \*$/i)).not.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, type EmailAlertConfig,
fetchEmailConfig, fetchEmailConfig,
saveEmailConfig, saveEmailConfig,
testEmailConnection,
} from '@/lib/tender-radar-api'; } from '@/lib/tender-radar-api';
/** Inline eye SVG (16×16) for show/hide password toggle */ /** 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 * domain/username/password/isActive) — password blank on load, only sent
* when the admin types a new one (T-07-12). * when the admin types a new one (T-07-12).
* *
* Deliberately does NOT include a "Test Connection" button or an * Includes a "Verbindung testen" button (Quick 260907-let, WINDOWS #16)
* Abrufintervall field: this module's poll cadence is governed by the * that calls POST /modules/tender-radar/email-config/test against
* shared, platform-wide 'email-alert' TenderSourcePollConfig (D-15, * whatever is currently in the form, without saving. Deliberately does
* pollGranularity='tick'), not a per-tenant setting, and no * NOT include an Abrufintervall field, though: this module's poll cadence
* test-connection endpoint exists for this config (out of this plan's * is governed by the shared, platform-wide 'email-alert'
* scope). * TenderSourcePollConfig (D-15, pollGranularity='tick'), not a per-user
* setting.
* *
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* tenderRadar i18n namespace. * tenderRadar i18n namespace.
@@ -138,10 +140,15 @@ export function EmailAlertConfigForm() {
const [form, setForm] = useState<FormState>(DEFAULT_FORM); const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message?: string;
} | null>(null);
// Whether config exists in DB (null = not yet saved) // Whether config exists in DB (null = not yet saved)
const [configExists, setConfigExists] = useState(false); const [configExists, setConfigExists] = useState(false);
@@ -167,10 +174,27 @@ export function EmailAlertConfigForm() {
setForm((f) => ({ ...f, [key]: value })); setForm((f) => ({ ...f, [key]: value }));
setSaveError(null); setSaveError(null);
setSaveSuccess(false); 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 () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
setSaveError(null); setSaveError(null);
@@ -421,16 +445,39 @@ export function EmailAlertConfigForm() {
{/* Form actions */} {/* Form actions */}
<div className="flex gap-3 pt-4 border-t border-border mt-6"> <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 <button
type="button" type="button"
onClick={handleSave} 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" 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')} {isSaving ? t('emailAlerts.saving') : t('emailAlerts.save')}
</button> </button>
</div> </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 && ( {saveSuccess && (
<p className="text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}> <p className="text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
{t('emailAlerts.saveSuccess')} {t('emailAlerts.saveSuccess')}
+24
View File
@@ -569,3 +569,27 @@ export async function saveEmailConfig(
} }
return res.json(); return res.json();
} }
/**
* Test the connection to this user's own portal-alert mailbox WITHOUT
* saving anything (Quick 260907-let, WINDOWS #16). Same payload shape as
* `saveEmailConfig` — when `password`/`username` are left out, the server
* falls back to this user's stored, decrypted credentials.
* POST /modules/tender-radar/email-config/test
*/
export async function testEmailConnection(
payload: Partial<EmailAlertConfig> & { password?: string },
): Promise<{ success: boolean; message?: string }> {
const res = await fetch(`${API_URL}/modules/tender-radar/email-config/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(
await extractErrorMessage(res, 'Failed to test email-alert connection'),
);
}
return res.json();
}
+5 -1
View File
@@ -935,7 +935,11 @@
"saving": "Wird gespeichert...", "saving": "Wird gespeichert...",
"save": "Speichern", "save": "Speichern",
"saveSuccess": "Einstellungen gespeichert.", "saveSuccess": "Einstellungen gespeichert.",
"errorSave": "Einstellungen konnten nicht gespeichert werden" "errorSave": "Einstellungen konnten nicht gespeichert werden",
"testConnection": "Verbindung testen",
"testTesting": "Verbindung wird getestet...",
"testSuccess": "Verbindung erfolgreich",
"testFailed": "Verbindung fehlgeschlagen: {error}"
}, },
"mySources": { "mySources": {
"title": "Meine Quellen", "title": "Meine Quellen",
+5 -1
View File
@@ -935,7 +935,11 @@
"saving": "Saving...", "saving": "Saving...",
"save": "Save", "save": "Save",
"saveSuccess": "Settings saved.", "saveSuccess": "Settings saved.",
"errorSave": "Could not save settings" "errorSave": "Could not save settings",
"testConnection": "Test Connection",
"testTesting": "Testing connection...",
"testSuccess": "Connection successful",
"testFailed": "Connection failed: {error}"
}, },
"mySources": { "mySources": {
"title": "My sources", "title": "My sources",