From 090d8f3e8790ef0e747c23e330233d8aadf11a28 Mon Sep 17 00:00:00 2001 From: Vladimir Valekzhanin Date: Fri, 16 Oct 2026 19:18:51 +0300 Subject: [PATCH] =?UTF-8?q?test(=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B8):=20=D0=B4=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F=20=E2=80=94=20headless=20=D0=B8=20Test?= =?UTF-8?q?FX=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserDetailsControllerTest.java | 489 ++++++++++++++++++ .../ui/UserDetailsControllerUiTest.java | 334 ++++++++++++ 2 files changed, 823 insertions(+) create mode 100644 src/test/java/com/DesktopApplicationClientJava/controllers/UserDetailsControllerTest.java create mode 100644 src/test/java/com/DesktopApplicationClientJava/ui/UserDetailsControllerUiTest.java diff --git a/src/test/java/com/DesktopApplicationClientJava/controllers/UserDetailsControllerTest.java b/src/test/java/com/DesktopApplicationClientJava/controllers/UserDetailsControllerTest.java new file mode 100644 index 0000000..a0fbfc8 --- /dev/null +++ b/src/test/java/com/DesktopApplicationClientJava/controllers/UserDetailsControllerTest.java @@ -0,0 +1,489 @@ +package com.DesktopApplicationClientJava.controllers; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.DesktopApplicationClientJava.entities.Role; +import com.DesktopApplicationClientJava.entities.User; +import com.DesktopApplicationClientJava.navigation.Navigator; +import com.DesktopApplicationClientJava.services.AuthService; +import com.DesktopApplicationClientJava.services.ServiceException; +import com.DesktopApplicationClientJava.services.ServiceRegistry; +import com.DesktopApplicationClientJava.services.UsersService; +import com.DesktopApplicationClientJava.session.Session; +import com.DesktopApplicationClientJava.utils.ControllerWiring; +import io.qameta.allure.Epic; +import io.qameta.allure.Feature; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import javafx.application.Platform; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.testfx.util.WaitForAsyncUtils; + +/** + * Headless-тесты страницы пользователя: реальный FXML, диалоги подтверждаются стабом (без модалки + * {@code Alert}), асинхронная загрузка ожидается только через {@code WaitForAsyncUtils}. + */ +@Epic("Пользователи") +@Feature("Страница пользователя") +@DisplayName("Детали пользователя") +class UserDetailsControllerTest { + + private static final UUID USER_ID = UUID.randomUUID(); + + private AuthService authService; + private UsersService usersService; + private ServiceRegistry services; + private Navigator navigator; + private TestableUserDetailsController controller; + private Parent root; + + static class TestableUserDetailsController extends UserDetailsController { + boolean stubConfirm = true; + boolean confirmDeleteResult = true; + int confirmCalls = 0; + + @Override + protected boolean confirmDelete(User user) { + confirmCalls++; + if (stubConfirm) { + return confirmDeleteResult; + } + return super.confirmDelete(user); + } + } + + @BeforeAll + static void bootstrapFx() { + try { + Platform.startup(() -> {}); + Platform.setImplicitExit(false); + } catch (IllegalStateException alreadyStarted) { + // JVM-wide singleton; another test class already started the toolkit. + } + } + + @BeforeEach + void setUp() { + authService = mock(AuthService.class); + usersService = mock(UsersService.class); + services = mock(ServiceRegistry.class); + when(services.getAuthService()).thenReturn(authService); + when(services.getUsersService()).thenReturn(usersService); + navigator = mock(Navigator.class); + when(authService.isAdmin()).thenReturn(true); + when(authService.getCurrentUser()).thenReturn(null); + } + + @Test + @Tag("role:admin") + @DisplayName("Загрузка по id показывает все поля пользователя") + void loadByIdShowsAllFields() { + loadUser(user()); + + assertEquals("Иван Петров", label("#fullNameLabel").getText()); + assertEquals("ivan@example.com", label("#emailLabel").getText()); + assertEquals("ADMIN", label("#roleLabel").getText()); + assertEquals("Да", label("#enabledLabel").getText()); + assertEquals(USER_ID.toString(), label("#uuidLabel").getText()); + assertFalse(label("#errorLabel").isVisible()); + assertTrue(label("#fullNameLabel").isVisible()); + } + + @Test + @Tag("role:admin") + @DisplayName("Во время загрузки виден индикатор «Загрузка…»") + void loadingIndicatorShownDuringRequest() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(usersService.getById(USER_ID)) + .thenAnswer( + invocation -> { + entered.countDown(); + assertTrue(release.await(30, TimeUnit.SECONDS), "Не дождались разрешения"); + return user(); + }); + loadPage(); + fxSetUserId(USER_ID); + + assertTrue(entered.await(30, TimeUnit.SECONDS), "Сервис не вызван"); + assertEquals("Загрузка…", label("#statusLabel").getText()); + release.countDown(); + awaitLoaded(); + + assertEquals("Иван Петров", label("#fullNameLabel").getText()); + } + + @Test + @Tag("role:admin") + @DisplayName("setUserId(null) не обращается к сервису") + void nullIdDoesNotCallService() { + loadPage(); + fxSetUserId(null); + + verifyNoInteractions(usersService); + assertFalse(label("#errorLabel").isVisible()); + } + + @Test + @Tag("role:admin") + @DisplayName("Вызов setUserId до wiring не бросает NPE") + void setUserIdBeforeWiringDoesNotThrow() { + TestableUserDetailsController raw = new TestableUserDetailsController(); + + assertDoesNotThrow(() -> raw.setUserId(USER_ID)); + verifyNoInteractions(usersService, navigator); + } + + @Test + @Tag("role:admin") + @DisplayName("Пустое имя показывается как «Без имени»") + void blankNameShowsPlaceholder() { + loadUser(new User(USER_ID, "ivan@example.com", Role.USER, "", " ", true)); + + assertEquals("Без имени", label("#fullNameLabel").getText()); + } + + @Test + @Tag("role:admin") + @DisplayName("Null-поля отображаются пустыми, enabled null — «Нет»") + void nullFieldsShownEmpty() { + loadUser(new User(null, null, null, null, null, null)); + + assertEquals("Без имени", label("#fullNameLabel").getText()); + assertEquals("", label("#emailLabel").getText()); + assertEquals("", label("#roleLabel").getText()); + assertEquals("Нет", label("#enabledLabel").getText()); + assertEquals("", label("#uuidLabel").getText()); + } + + @Test + @Tag("role:admin") + @DisplayName("enabled false показывается как «Нет»") + void disabledFalseShowsNo() { + loadUser(new User(USER_ID, "ivan@example.com", Role.USER, "Иван", "Петров", false)); + + assertEquals("Нет", label("#enabledLabel").getText()); + } + + @Test + @Tag("role:admin") + @DisplayName("Null-ответ сервиса: «Пользователь не найден»") + void nullUserShowsNotFound() { + when(usersService.getById(USER_ID)).thenReturn(null); + loadPage(); + fxSetUserId(USER_ID); + awaitLoaded(); + + assertTrue(label("#errorLabel").isVisible()); + assertEquals("Пользователь не найден", label("#errorLabel").getText()); + verifyNoInteractions(navigator); + } + + @Test + @Tag("role:admin") + @DisplayName("ServiceException показывает её текст") + void serviceExceptionShowsItsMessage() { + when(usersService.getById(USER_ID)).thenThrow(new ServiceException("Нет доступа", 403)); + loadPage(); + fxSetUserId(USER_ID); + awaitLoaded(); + + assertTrue(label("#errorLabel").isVisible()); + assertEquals("Нет доступа", label("#errorLabel").getText()); + verifyNoInteractions(navigator); + } + + @Test + @Tag("role:admin") + @DisplayName("Runtime-исключение показывает общую ошибку загрузки") + void runtimeExceptionShowsGenericMessage() { + when(usersService.getById(USER_ID)).thenThrow(new RuntimeException("boom")); + loadPage(); + fxSetUserId(USER_ID); + awaitLoaded(); + + assertTrue(label("#errorLabel").isVisible()); + assertEquals("Не удалось загрузить пользователя", label("#errorLabel").getText()); + verifyNoInteractions(navigator); + } + + @Test + @Tag("role:admin") + @DisplayName("Кнопка «Назад» ведёт в список пользователей") + void backNavigatesToUserList() { + loadUser(user()); + fire("#backButton"); + + verify(navigator).goTo("/fxml/UserList.fxml"); + } + + @Test + @Tag("role:admin") + @DisplayName("Админ видит кнопки редактирования и удаления") + void adminSeesEditAndDeleteButtons() { + loadPage(); + + assertTrue(button("#editButton").isVisible()); + assertTrue(button("#editButton").isManaged()); + assertTrue(button("#deleteButton").isVisible()); + assertTrue(button("#deleteButton").isManaged()); + } + + @Test + @Tag("role:user") + @DisplayName("Пользователь не видит кнопок редактирования и удаления") + void plainUserSeesNeitherEditNorDeleteButtons() { + when(authService.isAdmin()).thenReturn(false); + loadPage(); + + assertFalse(button("#editButton").isVisible()); + assertFalse(button("#editButton").isManaged()); + assertFalse(button("#deleteButton").isVisible()); + assertFalse(button("#deleteButton").isManaged()); + } + + @Test + @Tag("role:moder") + @DisplayName("Модер не видит кнопок редактирования и удаления") + void moderSeesNeitherEditNorDeleteButtons() { + when(authService.isAdmin()).thenReturn(false); + loadPage(); + + assertFalse(button("#editButton").isVisible()); + assertFalse(button("#editButton").isManaged()); + assertFalse(button("#deleteButton").isVisible()); + assertFalse(button("#deleteButton").isManaged()); + } + + @Test + @Tag("role:admin") + @DisplayName("Null AuthService прячет кнопки без NPE") + void nullAuthServiceHidesButtonsWithoutNpe() { + when(services.getAuthService()).thenReturn(null); + when(usersService.getById(USER_ID)).thenReturn(user()); + loadPage(); + fxSetUserId(USER_ID); + awaitLoaded(); + + assertFalse(button("#editButton").isVisible()); + assertFalse(button("#deleteButton").isVisible()); + assertEquals("Иван Петров", label("#fullNameLabel").getText()); + } + + @Test + @Tag("role:admin") + @DisplayName("Своя запись: delete заблокирован даже при подтверждении") + void selfDeleteIsBlockedEvenWhenConfirmed() { + User me = new User(USER_ID, "me@example.com", Role.ADMIN, "Я", "Админ", true); + when(authService.getCurrentUser()).thenReturn(me); + when(usersService.getById(USER_ID)).thenReturn(me); + loadPage(); + fxSetUserId(USER_ID); + awaitLoaded(); + + assertTrue(button("#deleteButton").isDisable()); + controller.confirmDeleteResult = true; + // Кнопка disabled игнорирует fire(), поэтому эмулируем обход блокировки — + // контроллер обязан отклонить удаление себя guard-веткой. + runOnFx( + () -> { + button("#deleteButton").setDisable(false); + button("#deleteButton").fire(); + }); + + assertEquals(0, controller.confirmCalls); + assertTrue(label("#errorLabel").isVisible()); + assertEquals("Нельзя удалить собственную учётную запись", label("#errorLabel").getText()); + verify(usersService, never()).delete(any()); + verify(navigator, never()).goTo(anyString()); + } + + @Test + @Tag("role:admin") + @DisplayName("Редактирование ведёт на форму с userId") + void editNavigatesToFormWithUserId() { + loadUser(user()); + fire("#editButton"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Consumer.class); + verify(navigator).goTo(eq("/fxml/UserUpdateForm.fxml"), captor.capture()); + UserUpdateFormController form = mock(UserUpdateFormController.class); + captor.getValue().accept(form); + verify(form).setUserId(USER_ID); + } + + @Test + @Tag("role:admin") + @DisplayName("Редактирование без current ничего не делает") + void editWithoutCurrentDoesNothing() { + when(usersService.getById(USER_ID)).thenReturn(null); + loadPage(); + fxSetUserId(USER_ID); + awaitLoaded(); + fire("#editButton"); + + verifyNoInteractions(navigator); + } + + @Test + @Tag("role:admin") + @DisplayName("Отменённое удаление не вызывает сервис") + void cancelledDeleteDoesNotCallService() { + loadUser(user()); + controller.confirmDeleteResult = false; + fire("#deleteButton"); + + assertEquals(1, controller.confirmCalls); + verify(usersService, never()).delete(any()); + verify(navigator, never()).goTo(anyString()); + } + + @Test + @Tag("role:admin") + @DisplayName("Подтверждённое удаление вызывает сервис и ведёт в список") + void confirmedDeleteCallsServiceAndNavigatesToList() { + loadUser(user()); + controller.confirmDeleteResult = true; + fire("#deleteButton"); + awaitUnbusy(); + + verify(usersService).delete(USER_ID); + verify(navigator).goTo("/fxml/UserList.fxml"); + assertFalse(label("#errorLabel").isVisible()); + } + + @Test + @Tag("role:admin") + @DisplayName("Ошибка 403 показывает текст без перехода") + void deleteForbiddenShowsMessageWithoutNavigation() { + loadUser(user()); + controller.confirmDeleteResult = true; + doThrow(new ServiceException("Доступ запрещён", 403)).when(usersService).delete(USER_ID); + fire("#deleteButton"); + awaitUnbusy(); + + assertTrue(label("#errorLabel").isVisible()); + assertEquals("Доступ запрещён", label("#errorLabel").getText()); + verify(navigator, never()).goTo(anyString()); + } + + @Test + @Tag("role:admin") + @DisplayName("Ошибка 404 показывает текст без перехода") + void deleteMissingShowsMessageWithoutNavigation() { + loadUser(user()); + controller.confirmDeleteResult = true; + doThrow(new ServiceException("Пользователь не найден", 404)).when(usersService).delete(USER_ID); + fire("#deleteButton"); + awaitUnbusy(); + + assertTrue(label("#errorLabel").isVisible()); + assertEquals("Пользователь не найден", label("#errorLabel").getText()); + verify(navigator, never()).goTo(anyString()); + } + + private void loadUser(User user) { + when(usersService.getById(USER_ID)).thenReturn(user); + loadPage(); + fxSetUserId(USER_ID); + awaitLoaded(); + } + + private void loadPage() { + runOnFx( + () -> { + try { + controller = new TestableUserDetailsController(); + ControllerWiring.wire(controller, navigator, new Session(), services); + FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/UserDetails.fxml")); + loader.setControllerFactory(type -> controller); + root = loader.load(); + new Scene(root); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + private void fxSetUserId(UUID id) { + runOnFx(() -> controller.setUserId(id)); + } + + private void fire(String query) { + runOnFx(() -> button(query).fire()); + } + + private Label label(String query) { + return (Label) root.lookup(query); + } + + private Button button(String query) { + return (Button) root.lookup(query); + } + + private void awaitLoaded() { + try { + WaitForAsyncUtils.waitFor( + 30, + TimeUnit.SECONDS, + () -> + "".equals(label("#statusLabel").getText()) + && (!"...".equals(label("#fullNameLabel").getText()) + || label("#errorLabel").isVisible())); + } catch (TimeoutException e) { + throw new AssertionError("Не дождались загрузки пользователя", e); + } + } + + private void awaitUnbusy() { + try { + WaitForAsyncUtils.waitFor( + 30, + TimeUnit.SECONDS, + () -> !button("#deleteButton").isDisable() || label("#errorLabel").isVisible()); + } catch (TimeoutException e) { + throw new AssertionError("Не дождались завершения удаления", e); + } + } + + private static void runOnFx(Runnable action) { + try { + WaitForAsyncUtils.asyncFx(action).get(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("FX-поток прерван", e); + } catch (ExecutionException | TimeoutException e) { + throw new AssertionError("Не дождались FX-потока", e); + } + } + + private static User user() { + return new User(USER_ID, "ivan@example.com", Role.ADMIN, "Иван", "Петров", true); + } +} diff --git a/src/test/java/com/DesktopApplicationClientJava/ui/UserDetailsControllerUiTest.java b/src/test/java/com/DesktopApplicationClientJava/ui/UserDetailsControllerUiTest.java new file mode 100644 index 0000000..e791e6a --- /dev/null +++ b/src/test/java/com/DesktopApplicationClientJava/ui/UserDetailsControllerUiTest.java @@ -0,0 +1,334 @@ +package com.DesktopApplicationClientJava.ui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockingDetails; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.DesktopApplicationClientJava.controllers.UserDetailsController; +import com.DesktopApplicationClientJava.entities.Role; +import com.DesktopApplicationClientJava.entities.User; +import com.DesktopApplicationClientJava.navigation.Navigator; +import com.DesktopApplicationClientJava.services.AuthService; +import com.DesktopApplicationClientJava.services.ServiceException; +import com.DesktopApplicationClientJava.services.ServiceRegistry; +import com.DesktopApplicationClientJava.services.UsersService; +import com.DesktopApplicationClientJava.session.Session; +import com.DesktopApplicationClientJava.utils.ControllerWiring; +import io.qameta.allure.Epic; +import io.qameta.allure.Feature; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.DialogPane; +import javafx.scene.control.Label; +import javafx.stage.Stage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testfx.api.FxRobot; +import org.testfx.api.FxToolkit; +import org.testfx.util.WaitForAsyncUtils; + +/** + * Компонентные тесты страницы пользователя: удаление через настоящую модалку подтверждения (роль + * АДМИН). + */ +@Epic("Пользователи") +@Feature("Страница пользователя") +@DisplayName("Детали пользователя: удаление через модалку") +class UserDetailsControllerUiTest extends FxTestBase { + + private static final UUID USER_ID = UUID.randomUUID(); + private static final String EMAIL = "ivan@example.com"; + + private AuthService authService; + private UsersService usersService; + private ServiceRegistry services; + private Navigator navigator; + private TestableUserDetailsController controller; + private Stage stage; + + static class TestableUserDetailsController extends UserDetailsController { + boolean stubConfirm = true; + boolean confirmDeleteResult = false; + int confirmCalls = 0; + + @Override + protected boolean confirmDelete(User user) { + confirmCalls++; + if (stubConfirm) { + return confirmDeleteResult; + } + return super.confirmDelete(user); + } + } + + @BeforeEach + void resetScene() throws Exception { + FxToolkit.setupStage( + stage -> { + try { + start(stage); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + void start(Stage stage) throws Exception { + authService = mock(AuthService.class); + usersService = mock(UsersService.class); + services = mock(ServiceRegistry.class); + when(services.getAuthService()).thenReturn(authService); + when(services.getUsersService()).thenReturn(usersService); + navigator = mock(Navigator.class); + this.stage = stage; + when(authService.isAdmin()).thenReturn(true); + when(authService.getCurrentUser()).thenReturn(null); + when(usersService.getById(USER_ID)).thenReturn(user()); + loadPage(); + controller.setUserId(USER_ID); + } + + private void loadPage() { + try { + controller = new TestableUserDetailsController(); + FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/UserDetails.fxml")); + loader.setControllerFactory( + clazz -> { + ControllerWiring.wire(controller, navigator, new Session(), services); + return controller; + }); + Parent root = loader.load(); + stage.setScene(new Scene(root)); + stage.show(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Tag("role:admin") + @DisplayName("Админ видит кнопки редактирования и удаления") + void adminSeesEditAndDeleteButtons() { + awaitLoaded(); + + assertTrue(robot.lookup("#editButton").queryAs(Button.class).isVisible()); + assertTrue(robot.lookup("#deleteButton").queryAs(Button.class).isVisible()); + } + + @Test + @Tag("role:user") + @DisplayName("Пользователь не видит кнопок редактирования и удаления") + void plainUserSeesNeitherEditNorDeleteButtons() { + robot.interact( + () -> { + when(authService.isAdmin()).thenReturn(false); + loadPage(); + }); + + Button edit = robot.lookup("#editButton").queryAs(Button.class); + Button delete = robot.lookup("#deleteButton").queryAs(Button.class); + assertFalse(edit.isVisible()); + assertFalse(edit.isManaged()); + assertFalse(delete.isVisible()); + assertFalse(delete.isManaged()); + } + + @Test + @Tag("role:moder") + @DisplayName("Модер не видит кнопок редактирования и удаления") + void moderSeesNeitherEditNorDeleteButtons() { + robot.interact( + () -> { + when(authService.isAdmin()).thenReturn(false); + loadPage(); + }); + + Button edit = robot.lookup("#editButton").queryAs(Button.class); + Button delete = robot.lookup("#deleteButton").queryAs(Button.class); + assertFalse(edit.isVisible()); + assertFalse(edit.isManaged()); + assertFalse(delete.isVisible()); + assertFalse(delete.isManaged()); + } + + @Test + @Tag("role:admin") + @DisplayName("Клик админа открывает модалку с почтой и предупреждением") + void adminDeleteClickOpensModalWithEmailAndWarning() { + awaitLoaded(); + robot.interact(() -> controller.stubConfirm = false); + + Thread clicker = clickDeleteNoWait(); + DialogPane dialog = waitForDialog(robot); + + assertTrue(dialog.getScene().getWindow().isShowing(), "модалка удаления не открылась"); + assertEquals("Удаление пользователя", titleOf(dialog)); + String content = dialog.getContentText(); + assertTrue(content.contains(EMAIL), "в модалке нет почты пользователя"); + assertTrue(content.contains("Действие необратимо"), "в модалке нет предупреждения"); + + pressDialogButtonByText(robot, dialog, "Отмена"); + waitForClicker(clicker); + + assertModalClosed(robot); + assertEquals(1, controller.confirmCalls); + verify(usersService, never()).delete(USER_ID); + verify(navigator, never()).goTo(anyString()); + } + + @Test + @Tag("role:admin") + @DisplayName("Подтверждение удаляет пользователя и ведёт в список") + void confirmingModalDeletesAndNavigatesToList() { + awaitLoaded(); + robot.interact(() -> controller.stubConfirm = false); + + Thread clicker = clickDeleteNoWait(); + DialogPane dialog = waitForDialog(robot); + pressDialogButtonByText(robot, dialog, "Удалить"); + waitForClicker(clicker); + + assertModalClosed(robot); + awaitNavigation(); + verify(usersService).delete(USER_ID); + verify(navigator).goTo("/fxml/UserList.fxml"); + } + + @Test + @Tag("role:admin") + @DisplayName("Удаление без прав показывает ошибку без перехода") + void deleteForbiddenShowsErrorWithoutNavigation() { + awaitLoaded(); + doThrow(new ServiceException("Доступ запрещён", 403)).when(usersService).delete(USER_ID); + robot.interact(() -> controller.stubConfirm = false); + + Thread clicker = clickDeleteNoWait(); + DialogPane dialog = waitForDialog(robot); + pressDialogButtonByText(robot, dialog, "Удалить"); + waitForClicker(clicker); + + assertModalClosed(robot); + awaitError("Доступ запрещён"); + verify(navigator, never()).goTo(anyString()); + } + + @Test + @Tag("role:admin") + @DisplayName("Удаление отсутствующего показывает ошибку без перехода") + void deleteMissingShowsErrorWithoutNavigation() { + awaitLoaded(); + doThrow(new ServiceException("Пользователь не найден", 404)).when(usersService).delete(USER_ID); + robot.interact(() -> controller.stubConfirm = false); + + Thread clicker = clickDeleteNoWait(); + DialogPane dialog = waitForDialog(robot); + pressDialogButtonByText(robot, dialog, "Удалить"); + waitForClicker(clicker); + + assertModalClosed(robot); + awaitError("Пользователь не найден"); + verify(navigator, never()).goTo(anyString()); + } + + private User user() { + return new User(USER_ID, EMAIL, Role.ADMIN, "Иван", "Петров", true); + } + + private Label fullName() { + return robot.lookup("#fullNameLabel").queryAs(Label.class); + } + + private Label error() { + return robot.lookup("#errorLabel").queryAs(Label.class); + } + + private void awaitLoaded() { + try { + WaitForAsyncUtils.waitFor( + 30, TimeUnit.SECONDS, () -> !"...".equals(fullName().getText()) || error().isVisible()); + } catch (TimeoutException e) { + throw new AssertionError("Не дождались загрузки пользователя", e); + } + } + + private void awaitNavigation() { + try { + WaitForAsyncUtils.waitFor( + 30, TimeUnit.SECONDS, () -> !mockingDetails(navigator).getInvocations().isEmpty()); + } catch (TimeoutException e) { + throw new AssertionError("Не дождались навигации", e); + } + } + + private void awaitError(String expected) { + try { + WaitForAsyncUtils.waitFor( + 30, TimeUnit.SECONDS, () -> expected.equals(error().getText()) && error().isVisible()); + } catch (TimeoutException e) { + throw new AssertionError("Не дождались ошибки: " + expected, e); + } + } + + private static String titleOf(DialogPane dialog) { + return ((Stage) dialog.getScene().getWindow()).getTitle(); + } + + /** + * Нажимает кнопку удаления, не дожидаясь закрытия модального окна: настоящий {@code Alert} + * блокирует поток JavaFX через {@code showAndWait}, поэтому клик уходит в отдельный поток, а тест + * продолжает работать с диалогом. + */ + private Thread clickDeleteNoWait() { + Button delete = robot.lookup("#deleteButton").queryAs(Button.class); + Thread clicker = new Thread(() -> robot.interact(delete::fire), "user-details-delete-click"); + clicker.setDaemon(true); + clicker.start(); + return clicker; + } + + private static DialogPane waitForDialog(FxRobot robot) { + try { + WaitForAsyncUtils.waitFor( + 30, TimeUnit.SECONDS, () -> !robot.lookup(".dialog-pane").tryQuery().isEmpty()); + } catch (TimeoutException e) { + throw new AssertionError("Не дождались модального окна подтверждения удаления", e); + } + return robot.lookup(".dialog-pane").queryAs(DialogPane.class); + } + + private static void pressDialogButtonByText(FxRobot robot, DialogPane dialog, String text) { + ButtonType type = + dialog.getButtonTypes().stream() + .filter(buttonType -> text.equals(buttonType.getText())) + .findFirst() + .orElseThrow(() -> new AssertionError("Нет кнопки «" + text + "» в модалке")); + robot.interact(() -> ((Button) dialog.lookupButton(type)).fire()); + } + + private static void waitForClicker(Thread clicker) { + try { + WaitForAsyncUtils.waitFor(30, TimeUnit.SECONDS, () -> !clicker.isAlive()); + } catch (TimeoutException e) { + throw new AssertionError("Клик по удалению не завершился после закрытия диалога", e); + } + } + + private static void assertModalClosed(FxRobot robot) { + assertTrue(robot.lookup(".dialog-pane").tryQuery().isEmpty(), "модалка удаления не закрылась"); + } +}