diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx index 4bf3386..df7f957 100644 --- a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx @@ -22,6 +22,7 @@ vi.mock('next-intl', () => ({ 'form.cancelDialog': 'Abbrechen', 'form.save': 'Speichern', 'form.cancel': 'Bearbeitung abbrechen', + 'form.saveRow': 'Änderungen speichern', }, }; // Support dot-notation keys @@ -47,7 +48,14 @@ vi.mock('@/lib/dkv-api', () => ({ afterEach(() => { cleanup(); - vi.restoreAllMocks(); + // vi.restoreAllMocks() does not reset the call history of plain vi.fn() + // mocks (no "original" implementation to restore to) — mockDeleteVehicle's + // call count was leaking across tests, which the new double-click test + // below (toHaveBeenCalledTimes) exposed. vi.clearAllMocks() clears + // mock.calls/mock.results on every mock; each test sets its own + // mockResolvedValue*/mockImplementation before rendering, so leftover + // implementations from the previous test are harmless. + vi.clearAllMocks(); }); describe('VehicleTable', () => { @@ -113,8 +121,9 @@ describe('VehicleTable', () => { expect(screen.getByText(/wird gelöscht/i)).toBeInTheDocument(); }); - // Confirm the deletion - const confirmBtn = screen.getByRole('button', { name: 'Löschen' }); + // Confirm the deletion — button label comes from the (unmapped) key + // 'form.deleteConfirm', proving the text is no longer hardcoded. + const confirmBtn = screen.getByRole('button', { name: 'form.deleteConfirm' }); fireEvent.click(confirmBtn); await waitFor(() => { @@ -202,4 +211,86 @@ describe('VehicleTable', () => { expect(screen.getByRole('button', { name: 'Bearbeitung abbrechen' })).toBeInTheDocument(); }); }); + + it('double-clicking the delete confirm button triggers exactly one deleteVehicle call', async () => { + mockFetchVehicles.mockResolvedValue([ + { id: 'v1', kennzeichen: 'M-AB 123', marke: 'VW', modell: 'Golf', fahrer: 'Hans Müller' }, + ]); + // Deletion promise resolution is under the test's own control, so the + // in-flight state is actually observable between the two clicks. + let resolveDelete: () => void = () => {}; + mockDeleteVehicle.mockImplementation( + () => new Promise((resolve) => { resolveDelete = resolve; }), + ); + + const { VehicleTable } = await import( + '@/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable' + ); + render(); + + await waitFor(() => { + expect(screen.getByText('M-AB 123')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Fahrzeug löschen' })); + + await waitFor(() => { + expect(screen.getByText(/wird gelöscht/i)).toBeInTheDocument(); + }); + + const confirmBtn = screen.getByRole('button', { name: 'form.deleteConfirm' }); + fireEvent.click(confirmBtn); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(mockDeleteVehicle).toHaveBeenCalledTimes(1); + }); + + resolveDelete(); + }); + + it('confirm and cancel buttons of the delete dialog are disabled while the deletion is in flight', async () => { + mockFetchVehicles.mockResolvedValue([ + { id: 'v1', kennzeichen: 'M-AB 123', marke: 'VW', modell: 'Golf', fahrer: 'Hans Müller' }, + ]); + let resolveDelete: () => void = () => {}; + mockDeleteVehicle.mockImplementation( + () => new Promise((resolve) => { resolveDelete = resolve; }), + ); + + const { VehicleTable } = await import( + '@/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable' + ); + render(); + + await waitFor(() => { + expect(screen.getByText('M-AB 123')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Fahrzeug löschen' })); + + const confirmBtn = await screen.findByRole('button', { name: 'form.deleteConfirm' }); + const cancelBtn = screen.getByRole('button', { name: 'Abbrechen' }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(confirmBtn).toBeDisabled(); + expect(cancelBtn).toBeDisabled(); + }); + + resolveDelete(); + }); + + it('shows the load-error key when fetchVehicles fails', async () => { + mockFetchVehicles.mockRejectedValue(new Error('network down')); + + const { VehicleTable } = await import( + '@/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable' + ); + render(); + + await waitFor(() => { + expect(screen.getByText('errors.loadVehiclesFailed')).toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx index b4064cc..7c3a678 100644 --- a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx @@ -86,11 +86,12 @@ const editInputCls = // --- Delete confirm dialog --- interface DeleteDialogProps { plate: string; + isDeleting: boolean; onConfirm: () => void; onCancel: () => void; } -function DeleteDialog({ plate, onConfirm, onCancel }: DeleteDialogProps) { +function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogProps) { const t = useTranslations('dkvFleet'); return (
@@ -105,16 +106,18 @@ function DeleteDialog({ plate, onConfirm, onCancel }: DeleteDialogProps) {
@@ -161,7 +164,7 @@ export function VehicleTable() { // Delete confirm const [deleteTarget, setDeleteTarget] = useState(null); - const [, setIsDeleting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); // Server error below table const [tableError, setTableError] = useState(null); @@ -181,11 +184,13 @@ export function VehicleTable() { const data = await fetchVehicles(); setVehicles(data); } catch { - setTableError('Fahrzeuge konnten nicht geladen werden.'); + setTableError(t('errors.loadVehiclesFailed')); } finally { setIsLoading(false); } - }, []); + }, []); // `t` intentionally excluded: `load` is a dependency of the + // mount-time useEffect below, and an unstable `t` identity would turn + // that into a fetch-on-every-render loop instead of fetch-once-on-mount. useEffect(() => { load(); @@ -207,7 +212,7 @@ export function VehicleTable() { const saveEdit = async (id: string) => { if (!editState.kennzeichen.trim()) { - setEditError('Kennzeichen ist erforderlich.'); + setEditError(t('errors.licensePlateRequired')); return; } setIsSavingRow(true); @@ -223,7 +228,7 @@ export function VehicleTable() { setEditId(null); await load(); } catch { - setEditError('Speichern fehlgeschlagen.'); + setEditError(t('errors.saveVehicleFailed')); } finally { setIsSavingRow(false); } @@ -245,7 +250,7 @@ export function VehicleTable() { const saveNew = async () => { if (!newRow.kennzeichen.trim()) { - setNewRowError('Kennzeichen ist erforderlich.'); + setNewRowError(t('errors.licensePlateRequired')); return; } setIsSavingNew(true); @@ -262,7 +267,7 @@ export function VehicleTable() { setNewRow(EMPTY_EDIT); await load(); } catch { - setNewRowError('Fahrzeug konnte nicht hinzugefügt werden.'); + setNewRowError(t('errors.createVehicleFailed')); } finally { setIsSavingNew(false); } @@ -270,14 +275,14 @@ export function VehicleTable() { // --- Delete --- const confirmDelete = async () => { - if (!deleteTarget) return; + if (!deleteTarget || isDeleting) return; setIsDeleting(true); try { await deleteVehicle(deleteTarget.id); setDeleteTarget(null); await load(); } catch { - setTableError('Fahrzeug konnte nicht gelöscht werden.'); + setTableError(t('errors.deleteVehicleFailed')); setDeleteTarget(null); } finally { setIsDeleting(false); @@ -394,7 +399,7 @@ export function VehicleTable() {