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