diff --git a/apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx index a356c87..4ed8a15 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx @@ -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(); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx index 051f404..952704b 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx @@ -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) => { const translations: Record = { '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(); + + 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(); + + 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(); + }); + }); }); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx index 71628b2..1e9f32f 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx @@ -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(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(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 */}
+
+ {testResult !== null && ( +

+ {testResult.success + ? t('emailAlerts.testSuccess') + : t('emailAlerts.testFailed', { + error: testResult.message ?? 'Unbekannter Fehler', + })} +

+ )} + {saveSuccess && (

{t('emailAlerts.saveSuccess')} diff --git a/apps/web/src/lib/tender-radar-api.ts b/apps/web/src/lib/tender-radar-api.ts index e11ace0..52ee65d 100644 --- a/apps/web/src/lib/tender-radar-api.ts +++ b/apps/web/src/lib/tender-radar-api.ts @@ -569,3 +569,27 @@ export async function saveEmailConfig( } 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 & { 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(); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 4bacfc0..06a41b4 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -935,7 +935,11 @@ "saving": "Wird gespeichert...", "save": "Speichern", "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": { "title": "Meine Quellen", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 7c1ff3e..536f921 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -935,7 +935,11 @@ "saving": "Saving...", "save": "Save", "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": { "title": "My sources",