UI interaction (fill/save/navigate/error display/prefill) is covered by {@code + * ResourceFormControllerTest} with mocked services; these tests assert the backend contract only, + * plus one UI test for the authenticated-failure path that mocks cannot produce. + * + *
Requires a running backend at {@code localhost:8080} and {@code IT_ADMIN_PASSWORD} in {@code + * .env}; otherwise tests abort. Excluded from the default build and CI (see surefire {@code + * excludedGroups} in pom.xml). Run explicitly: {@code mvn test -Dgroups=integration + * -DexcludedGroups= -Dtest=ResourceFormIntegrationTest}. + */ +@ExtendWith(ApplicationExtension.class) +class ResourceFormIntegrationTest extends IntegrationTestCase { + + private Navigator navigator; + private Stage stage; + private Session session; + private ServiceRegistry services; + + @Start + void start(Stage stage) { + this.stage = stage; + this.navigator = mock(Navigator.class); + stage.setScene(new Scene(new StackPane())); + stage.show(); + } + + @Override + @BeforeEach + void setUpServices() { + assumeTrue(isBackendUp(), "Backend is not running at localhost:8080, skipping"); + session = new Session(); + ApiClient apiClient = new ApiClient(session); + authService = new AuthServiceImpl(apiClient, session); + resourceService = new ResourceServiceImpl(apiClient); + services = new ServiceRegistry(apiClient, session); + } + + @Test + void createValidRoundTrip() { + loginAsAdmin(); + String title = "IT ResourceForm " + UUID.randomUUID(); + Resource created = resourceService.create(title, "created by ResourceFormIntegrationTest"); + try { + Resource fetched = resourceService.getById(created.getUuid()); + assertEquals(created.getUuid(), fetched.getUuid()); + assertEquals(title, fetched.getTitle()); + } finally { + deleteQuietly(created.getUuid()); + } + } + + @Test + void createNullDescriptionRoundTrip() { + loginAsAdmin(); + String title = "IT ResourceForm " + UUID.randomUUID(); + Resource created = resourceService.create(title, null); + try { + assertNull(resourceService.getById(created.getUuid()).getDescription()); + } finally { + deleteQuietly(created.getUuid()); + } + } + + @Test + void updateValidRoundTrip() { + loginAsAdmin(); + String updatedTitle = "IT ResourceForm updated " + UUID.randomUUID(); + Resource created = resourceService.create("IT ResourceForm " + UUID.randomUUID(), null); + try { + resourceService.update(created.getUuid(), updatedTitle, "updated description"); + + Resource fetched = resourceService.getById(created.getUuid()); + assertEquals(updatedTitle, fetched.getTitle()); + assertEquals("updated description", fetched.getDescription()); + } finally { + deleteQuietly(created.getUuid()); + } + } + + @Test + void invalidTitlesAreRejectedWith400() { + loginAsAdmin(); + + assertStatus(400, () -> resourceService.create("", "blank title")); + assertStatus(400, () -> resourceService.create("ab", "short title")); + assertStatus(400, () -> resourceService.create("a".repeat(256), "long title")); + } + + @Test + void emptyDescriptionIsRejectedWith400() { + loginAsAdmin(); + + // The backend requires @Size(min = 1) on description: this is why the UI sends null + // for blank input instead of "". + assertStatus(400, () -> resourceService.create("IT ResourceForm " + UUID.randomUUID(), "")); + } + + @Test + void updateUnknownIdReturns404() { + loginAsAdmin(); + + assertStatus(404, () -> resourceService.update(UUID.randomUUID(), "IT ResourceForm", null)); + } + + @Test + void saveWithInvalidTokensShowsErrorAndDoesNotNavigate(FxRobot robot) { + // Garbage tokens (not a fresh session) force a real HTTP 401 round-trip instead of the + // client's offline "no refresh token" shortcut, proving server-side auth enforcement. + Session poisoned = new Session(); + poisoned.setAccessToken("invalid"); + poisoned.setRefreshToken("invalid"); + ServiceRegistry poisonedServices = new ServiceRegistry(new ApiClient(poisoned), poisoned); + loadFormPage(robot, poisoned, poisonedServices); + + robot.interact( + () -> { + robot.lookup("#titleField").queryAs(TextField.class).setText("IT ResourceForm"); + robot.lookup("#descriptionArea").queryAs(TextArea.class).setText("description"); + // Fire the action directly: synthetic clicks are unreliable under headless Monocle + // (silently missed, see ResourceListIntegrationTest helpers). + robot.lookup("#saveButton").queryAs(Button.class).getOnAction().handle(new ActionEvent()); + }); + + Label error = robot.lookup("#errorLabel").queryAs(Label.class); + assertTrue(error.isVisible()); + assertFalse(error.getText().isBlank()); + verify(navigator, never()).goTo(anyString()); + } + + private void loginAsAdmin() { + assumeTrue( + adminPassword != null && !adminPassword.isBlank(), + "IT_ADMIN_PASSWORD is not set, skipping"); + authService.login(adminEmail, adminPassword); + } + + private void loadFormPage(FxRobot robot, Session pageSession, ServiceRegistry pageServices) { + robot.interact( + () -> { + try { + ResourceFormController controller = new ResourceFormController(); + FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/ResourceForm.fxml")); + loader.setControllerFactory( + clazz -> { + ControllerWiring.wire(controller, navigator, pageSession, pageServices); + return controller; + }); + Parent root = loader.load(); + stage.setScene(new Scene(root)); + stage.show(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + private static void assertStatus(int expected, Runnable call) { + try { + call.run(); + } catch (ServiceException e) { + assertEquals(expected, e.getStatus(), "unexpected status: " + e.getMessage()); + return; + } + throw new AssertionError("Expected ServiceException with status " + expected); + } + + private void deleteQuietly(UUID id) { + try { + resourceService.delete(id); + } catch (Exception ignored) { + // Best-effort cleanup, never fail the test. + } + } +} diff --git a/src/test/java/ui/integration/ResourceListIntegrationTest.java b/src/test/java/ui/integration/ResourceListIntegrationTest.java new file mode 100644 index 0000000..bc2f2c3 --- /dev/null +++ b/src/test/java/ui/integration/ResourceListIntegrationTest.java @@ -0,0 +1,284 @@ +package ui.integration; + +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.junit.jupiter.api.Assumptions.assumeTrue; +import static org.mockito.Mockito.mock; + +import com.DesktopApplicationClientJava.Api.ApiClient; +import com.DesktopApplicationClientJava.controllers.ResourceListController; +import com.DesktopApplicationClientJava.entities.Resource; +import com.DesktopApplicationClientJava.navigation.Navigator; +import com.DesktopApplicationClientJava.services.ServiceRegistry; +import com.DesktopApplicationClientJava.services.filter.ResourceFilter; +import com.DesktopApplicationClientJava.services.impl.AuthServiceImpl; +import com.DesktopApplicationClientJava.services.impl.ResourceServiceImpl; +import com.DesktopApplicationClientJava.session.Session; +import com.DesktopApplicationClientJava.utils.ControllerWiring; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javafx.event.ActionEvent; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testfx.api.FxRobot; +import org.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; +import org.testfx.util.WaitForAsyncUtils; + +/** + * UI-level integration tests for the resource list page: real services with a real ApiClient + * against a live backend, no mocks (except Navigator). + * + *
Requires a running backend at {@code localhost:8080} (see README) and {@code
+ * IT_ADMIN_PASSWORD} in {@code .env} for the mutating tests; otherwise tests abort. Excluded from
+ * the default build and CI (see surefire {@code excludedGroups} in pom.xml). Run explicitly: {@code
+ * mvn test -Dgroups=integration -DexcludedGroups= -Dtest=ResourceListIntegrationTest}.
+ */
+@ExtendWith(ApplicationExtension.class)
+class ResourceListIntegrationTest extends IntegrationTestCase {
+
+ private Navigator navigator;
+ private Stage stage;
+ private Session session;
+ private ServiceRegistry services;
+
+ @Start
+ void start(Stage stage) {
+ this.stage = stage;
+ this.navigator = mock(Navigator.class);
+ stage.setScene(new Scene(new StackPane()));
+ stage.show();
+ }
+
+ @Override
+ @BeforeEach
+ void setUpServices() {
+ assumeTrue(isBackendUp(), "Backend is not running at localhost:8080, skipping");
+ session = new Session();
+ ApiClient apiClient = new ApiClient(session);
+ authService = new AuthServiceImpl(apiClient, session);
+ resourceService = new ResourceServiceImpl(apiClient);
+ services = new ServiceRegistry(apiClient, session);
+ }
+
+ @Test
+ void emptySearchShowsPlaceholderAndZeroCounter(FxRobot robot) {
+ loginAsAdmin();
+ loadListPage(robot);
+
+ searchFor(robot, "IT NoSuchResource " + UUID.randomUUID());
+ waitForLoadComplete(robot);
+
+ assertTrue(rows(robot).isEmpty());
+ assertEquals("Показано 0–0", textOf(robot, "#pageInfoLabel"));
+ assertFalse(robot.lookup("#errorLabel").queryAs(Label.class).isVisible());
+ }
+
+ @Test
+ void seededResourceAppearsInSearch(FxRobot robot) {
+ loginAsAdmin();
+ String title = "IT ResourceList " + UUID.randomUUID();
+ Resource created = resourceService.create(title, "created by ResourceListIntegrationTest");
+ try {
+ loadListPage(robot);
+
+ searchFor(robot, title);
+ waitForLoadComplete(robot);
+
+ List