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:
@@ -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();
|
||||
|
||||
+57
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+54
-7
@@ -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')}
|
||||
|
||||
@@ -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<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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user