/** * Tests des Backup-Assistenten. * * Geprüft wird der Ablauf durch die Maske: dass unvollständige Schritte * aufhalten, dass Rückwärtsgehen keine Eingaben verliert und dass eine * Fehlermeldung des Servers sichtbar wird statt zu verschwinden. */ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BackupWizard } from './BackupWizard'; import { ApiError } from '../../api/client'; import * as jobsApi from './jobsApi'; /** Ein Sicherungsziel, das Sicherungen annimmt. */ const writableRepository: jobsApi.BackupRepository = { id: '11111111-1111-1111-1111-111111111111', name: 'Produktiv', repository_type: 'local', location: '/backup/produktiv', status: 'active', accepts_backups: true, hardened: false, }; /** Ein Sicherungsziel im Nur-Lese-Zustand. */ const readOnlyRepository: jobsApi.BackupRepository = { id: '22222222-2222-2222-2222-222222222222', name: 'Archiv', repository_type: 'local', location: '/backup/archiv', status: 'read_only', accepts_backups: false, hardened: true, }; beforeEach(() => { vi.restoreAllMocks(); vi.spyOn(jobsApi, 'listRepositories').mockResolvedValue([writableRepository, readOnlyRepository]); }); /** Führt den Assistenten bis zu einem Schritt und füllt das Nötige aus. */ async function fillUntilRepository(user: ReturnType): Promise { await user.type(screen.getByLabelText('Name des Auftrags'), 'Nächtliche Sicherung'); await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.type(screen.getByLabelText('Pfad'), '/daten'); await user.click(screen.getByRole('button', { name: 'Weiter' })); // Zeitplan: der Standard "täglich 02:00" genügt. await user.click(screen.getByRole('button', { name: 'Weiter' })); } describe('Backup-Assistent', () => { it('zeigt alle zehn Schritte', async () => { render(); // Auf das Laden der Ziele warten: Sonst setzt der Effekt seinen Zustand // erst nach dem Test, und React meldet eine Aktualisierung außerhalb von act(). await waitFor(() => expect(jobsApi.listRepositories).toHaveBeenCalled()); for (const stepTitle of [ 'Name', 'Quelle', 'Zeitplan', 'Repository', 'Aufbewahrung', 'Sicherheit', 'Prüfung', 'Benachrichtigung', 'Übersicht', 'Anlegen', ]) { expect(screen.getByRole('button', { name: new RegExp(stepTitle) })).toBeInTheDocument(); } }); // Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang an // zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren. it('hält bei einem leeren Namen auf und erklärt warum', async () => { const user = userEvent.setup(); render(); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Weiter' })); expect(screen.getByRole('alert')).toHaveTextContent('braucht einen Namen'); expect(screen.getByText(/Schritt 1 von 10/)).toBeInTheDocument(); }); it('hält ohne Quelle auf', async () => { const user = userEvent.setup(); render(); await user.type(screen.getByLabelText('Name des Auftrags'), 'Test'); await user.click(screen.getByRole('button', { name: 'Weiter' })); expect(screen.getByText(/Schritt 2 von 10/)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Weiter' })); expect(screen.getByRole('alert')).toHaveTextContent('mindestens eine Quelle'); }); // Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst wäre // jeder Blick zurück ein Datenverlust. it('bewahrt die Eingaben beim Zurückgehen', async () => { const user = userEvent.setup(); render(); await user.type(screen.getByLabelText('Name des Auftrags'), 'Nächtliche Sicherung'); await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Zurück' })); expect(screen.getByLabelText('Name des Auftrags')).toHaveValue('Nächtliche Sicherung'); }); it('zeigt eine Vorschau des Zeitplans', async () => { const user = userEvent.setup(); render(); await user.type(screen.getByLabelText('Name des Auftrags'), 'Test'); await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.type(screen.getByLabelText('Pfad'), '/daten'); await user.click(screen.getByRole('button', { name: 'Weiter' })); expect(screen.getByText(/Ergibt: täglich um 02:00 Uhr/)).toBeInTheDocument(); }); // Ein Ziel, das keine Sicherungen annimmt, wird benannt statt stillschweigend // weggelassen: Sonst suchte der Anwender ein Repository, das er nicht findet. it('zeigt gesperrte Repositories, lässt sie aber nicht wählen', async () => { const user = userEvent.setup(); render(); await fillUntilRepository(user); await waitFor(() => expect(screen.getByText('Produktiv')).toBeInTheDocument()); expect(screen.getByText('Archiv')).toBeInTheDocument(); expect(screen.getByText(/nimmt derzeit keine Sicherungen an/)).toBeInTheDocument(); const radioButtons = screen.getAllByRole('radio'); expect(radioButtons[0]).toBeEnabled(); expect(radioButtons[1]).toBeDisabled(); }); // Eine Maske, die Werte sammelt, die niemand auswertet, ist ein // vorgetäuschtes Funktionsversprechen (PROMPT.md §138). it('kennzeichnet die nicht umgesetzten Schritte, statt Eingaben zu sammeln', async () => { const user = userEvent.setup(); render(); await fillUntilRepository(user); await waitFor(() => expect(screen.getByText('Produktiv')).toBeInTheDocument()); await user.click(screen.getAllByRole('radio')[0] as HTMLElement); await user.click(screen.getByRole('button', { name: 'Weiter' })); // Schritt 5: Aufbewahrung. expect(screen.getByText('Noch nicht verfügbar')).toBeInTheDocument(); expect(screen.getByText(/nicht automatisch gelöscht/)).toBeInTheDocument(); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); }); it('legt den Auftrag an und meldet ihn nach oben', async () => { const user = userEvent.setup(); const createdJob = { id: 'abc', name: 'Nächtliche Sicherung' } as jobsApi.BackupJob; const createSpy = vi.spyOn(jobsApi, 'createJob').mockResolvedValue(createdJob); const handleCreated = vi.fn(); render(); await fillUntilRepository(user); await waitFor(() => expect(screen.getByText('Produktiv')).toBeInTheDocument()); await user.click(screen.getAllByRole('radio')[0] as HTMLElement); // Von Repository bis Anlegen sind es sechs Schritte. for (let stepCounter = 0; stepCounter < 6; stepCounter++) { await user.click(screen.getByRole('button', { name: 'Weiter' })); } expect(screen.getByText(/Schritt 10 von 10/)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Auftrag anlegen' })); await waitFor(() => expect(handleCreated).toHaveBeenCalledWith(createdJob)); const sentRequest = createSpy.mock.calls[0]?.[0]; expect(sentRequest?.name).toBe('Nächtliche Sicherung'); expect(sentRequest?.sources[0]?.id).toBe('/daten'); expect(sentRequest?.repository_id).toBe(writableRepository.id); }); // Die Meldung des Servers wird wortgetreu gezeigt: Sie nennt den // eigentlichen Grund, den die Oberfläche nicht kennen kann. it('zeigt eine Fehlermeldung des Servers', async () => { const user = userEvent.setup(); vi.spyOn(jobsApi, 'createJob').mockRejectedValue( new ApiError({ code: 'VALIDATION_FAILED', message: 'der zeitplan läuft nur alle 1 Tage und kann den geforderten wiederherstellungspunkt nicht einhalten', statusCode: 422, requestId: 'req-1', }), ); render(); await fillUntilRepository(user); await waitFor(() => expect(screen.getByText('Produktiv')).toBeInTheDocument()); await user.click(screen.getAllByRole('radio')[0] as HTMLElement); for (let stepCounter = 0; stepCounter < 6; stepCounter++) { await user.click(screen.getByRole('button', { name: 'Weiter' })); } await user.click(screen.getByRole('button', { name: 'Auftrag anlegen' })); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('wiederherstellungspunkt'), ); // Der Assistent bleibt stehen, damit der Anwender zurückgehen und den // Zeitplan ändern kann. expect(screen.getByText(/Schritt 10 von 10/)).toBeInTheDocument(); }); it('meldet einen Fehler beim Laden der Ziele', async () => { const user = userEvent.setup(); vi.spyOn(jobsApi, 'listRepositories').mockRejectedValue( new ApiError({ code: 'PERMISSION_DENIED', message: 'Die Berechtigung repositories.read fehlt.', statusCode: 403, requestId: 'req-2', }), ); render(); await fillUntilRepository(user); await waitFor(() => expect(screen.getByText('Die Berechtigung repositories.read fehlt.')).toBeInTheDocument(), ); }); it('erlaubt den Sprung zurück über die Schrittleiste, nicht nach vorn', async () => { const user = userEvent.setup(); render(); await user.type(screen.getByLabelText('Name des Auftrags'), 'Test'); await user.click(screen.getByRole('button', { name: 'Weiter' })); // Ein noch nicht erreichter Schritt ist nicht anklickbar: Er würde eine // Prüfung überspringen, die der Assistent gerade führen soll. expect(screen.getByRole('button', { name: /Repository/ })).toBeDisabled(); await user.click(screen.getByRole('button', { name: /1\s*Name/ })); expect(screen.getByText(/Schritt 1 von 10/)).toBeInTheDocument(); }); });