search(ResourceFilter filter, long offset, long count) {
+ return store.getResources().values().stream()
+ .filter(resource -> matches(resource, filter))
+ .skip(Math.max(0, offset))
+ .limit(count <= 0 ? Long.MAX_VALUE : count)
+ .toList();
+ }
+
+ @Override
+ public Resource create(String title, String description) {
+ LocalDateTime now = LocalDateTime.now();
+ Resource resource =
+ new Resource(UUID.randomUUID(), title, description, now, now, new ArrayList<>());
+ store.getResources().put(resource.getUuid(), resource);
+ return resource;
+ }
+
+ @Override
+ public Resource getById(UUID resourceId) {
+ Resource resource = store.getResources().get(resourceId);
+ if (resource == null) {
+ throw new ServiceException("Ресурс не найден", 404);
+ }
+ return resource;
+ }
+
+ @Override
+ public Resource update(UUID resourceId, String title, String description) {
+ Resource resource = getById(resourceId);
+ resource.setTitle(title);
+ resource.setDescription(description);
+ resource.setUpdatedAt(LocalDateTime.now());
+ return resource;
+ }
+
+ @Override
+ public void delete(UUID resourceId) {
+ if (store.getResources().remove(resourceId) == null) {
+ throw new ServiceException("Ресурс не найден", 404);
+ }
+ }
+
+ private static boolean matches(Resource resource, ResourceFilter filter) {
+ if (filter == null) {
+ return true;
+ }
+ return contains(resource.getTitle(), filter.getTitle())
+ && contains(resource.getDescription(), filter.getDescription());
+ }
+
+ private static boolean contains(String value, String query) {
+ if (query == null || query.isBlank()) {
+ return true;
+ }
+ return value != null && value.toLowerCase().contains(query.toLowerCase());
+ }
+}
diff --git a/src/main/java/com/DesktopApplicationClientJava/services/mock/MockService.java b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockService.java
new file mode 100644
index 0000000..be2fca6
--- /dev/null
+++ b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockService.java
@@ -0,0 +1,14 @@
+package com.DesktopApplicationClientJava.services.mock;
+
+/**
+ * Contract for in-memory demo services (see MOCK_DATA in Program).
+ *
+ * UI may check for this interface to enable demo conveniences that must never trigger against
+ * the real backend. Override {@link #doIfInitialPage()} when a mock needs to prepare demo state on
+ * page init.
+ */
+public interface MockService {
+
+ /** Demo hook, no-op by default. */
+ default void doIfInitialPage() {}
+}
diff --git a/src/main/java/com/DesktopApplicationClientJava/services/mock/MockStore.java b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockStore.java
new file mode 100644
index 0000000..3f205f6
--- /dev/null
+++ b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockStore.java
@@ -0,0 +1,96 @@
+package com.DesktopApplicationClientJava.services.mock;
+
+import com.DesktopApplicationClientJava.entities.FileInfo;
+import com.DesktopApplicationClientJava.entities.Resource;
+import com.DesktopApplicationClientJava.entities.Role;
+import com.DesktopApplicationClientJava.entities.User;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import lombok.Getter;
+
+/**
+ * In-memory data for mock services. Used only when the app runs with {@code MOCK_DATA=true}, for UI
+ * testing without a backend.
+ */
+@Getter
+public class MockStore {
+ private final Map users = new LinkedHashMap<>();
+ private final Map resources = new LinkedHashMap<>();
+ private final Map fileBytes = new LinkedHashMap<>();
+
+ public MockStore() {
+ seedUsers();
+ seedResources();
+ }
+
+ private void seedUsers() {
+ users.put(
+ UUID.fromString("11111111-1111-1111-1111-111111111111"),
+ new User(
+ UUID.fromString("11111111-1111-1111-1111-111111111111"),
+ "admin@example.com",
+ Role.ADMIN,
+ "Админ",
+ "Моковый",
+ true));
+ users.put(
+ UUID.fromString("22222222-2222-2222-2222-222222222222"),
+ new User(
+ UUID.fromString("22222222-2222-2222-2222-222222222222"),
+ "moder@example.com",
+ Role.MODER,
+ "Модер",
+ "Моковый",
+ true));
+ users.put(
+ UUID.fromString("33333333-3333-3333-3333-333333333333"),
+ new User(
+ UUID.fromString("33333333-3333-3333-3333-333333333333"),
+ "user@example.com",
+ Role.USER,
+ "Юзер",
+ "Моковый",
+ true));
+ }
+
+ private void seedResources() {
+ UUID resourceId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ FileInfo report =
+ new FileInfo(
+ UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
+ "report.pdf",
+ "application/pdf",
+ 25L);
+ FileInfo data =
+ new FileInfo(
+ UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"), "data.csv", "text/csv", 18L);
+ fileBytes.put(
+ report.getUuid(), "mock pdf content, not a real pdf".getBytes(StandardCharsets.UTF_8));
+ fileBytes.put(data.getUuid(), "id;name\n1;mock\n".getBytes(StandardCharsets.UTF_8));
+ resources.put(
+ resourceId,
+ new Resource(
+ resourceId,
+ "Годовой отчёт (мок)",
+ "Демонстрационный ресурс для проверки UI без бэкенда",
+ LocalDateTime.of(2024, 5, 6, 7, 8),
+ LocalDateTime.of(2024, 5, 7, 9, 10),
+ new ArrayList<>(List.of(report, data))));
+
+ UUID emptyId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
+ resources.put(
+ emptyId,
+ new Resource(
+ emptyId,
+ "Пустой ресурс (мок)",
+ "Ресурс без файлов",
+ LocalDateTime.of(2024, 6, 1, 12, 0),
+ LocalDateTime.of(2024, 6, 1, 12, 0),
+ new ArrayList<>()));
+ }
+}
diff --git a/src/main/java/com/DesktopApplicationClientJava/services/mock/MockUsersService.java b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockUsersService.java
new file mode 100644
index 0000000..189177b
--- /dev/null
+++ b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockUsersService.java
@@ -0,0 +1,94 @@
+package com.DesktopApplicationClientJava.services.mock;
+
+import com.DesktopApplicationClientJava.entities.User;
+import com.DesktopApplicationClientJava.services.ServiceException;
+import com.DesktopApplicationClientJava.services.UsersService;
+import com.DesktopApplicationClientJava.services.data.RegisterData;
+import com.DesktopApplicationClientJava.services.data.UpdateUserData;
+import com.DesktopApplicationClientJava.services.filter.UserFilter;
+import java.util.List;
+import java.util.UUID;
+
+/** In-memory {@link UsersService} over {@link MockStore}. */
+public class MockUsersService implements UsersService, MockService {
+
+ private final MockStore store;
+
+ public MockUsersService(MockStore store) {
+ this.store = store;
+ }
+
+ @Override
+ public List search(UserFilter filter, long offset, long count) {
+ return store.getUsers().values().stream()
+ .filter(user -> matches(user, filter))
+ .skip(Math.max(0, offset))
+ .limit(count <= 0 ? Long.MAX_VALUE : count)
+ .toList();
+ }
+
+ @Override
+ public User getById(UUID uuid) {
+ User user = store.getUsers().get(uuid);
+ if (user == null) {
+ throw new ServiceException("Пользователь не найден", 404);
+ }
+ return user;
+ }
+
+ @Override
+ public User create(RegisterData data) {
+ boolean duplicate =
+ store.getUsers().values().stream()
+ .anyMatch(user -> user.getEmail().equalsIgnoreCase(data.getEmail()));
+ if (duplicate) {
+ throw new ServiceException("Пользователь уже существует", 409);
+ }
+ User user =
+ new User(
+ UUID.randomUUID(),
+ data.getEmail(),
+ data.getRole(),
+ data.getFirstName(),
+ data.getLastName(),
+ true);
+ store.getUsers().put(user.getUuid(), user);
+ return user;
+ }
+
+ @Override
+ public User update(UUID uuid, UpdateUserData data) {
+ User user = getById(uuid);
+ user.setEmail(data.getEmail());
+ user.setRole(data.getRole());
+ user.setFirstName(data.getFirstName());
+ user.setLastName(data.getLastName());
+ user.setEnabled(data.getEnabled());
+ return user;
+ }
+
+ @Override
+ public void delete(UUID uuid) {
+ if (store.getUsers().remove(uuid) == null) {
+ throw new ServiceException("Пользователь не найден", 404);
+ }
+ }
+
+ private static boolean matches(User user, UserFilter filter) {
+ if (filter == null) {
+ return true;
+ }
+ return contains(user.getEmail(), filter.getEmail())
+ && (filter.getRole() == null || filter.getRole() == user.getRole())
+ && contains(user.getFirstName(), filter.getFirstName())
+ && contains(user.getLastName(), filter.getLastName())
+ && (filter.getEnabled() == null || filter.getEnabled().equals(user.getEnabled()));
+ }
+
+ private static boolean contains(String value, String query) {
+ if (query == null || query.isBlank()) {
+ return true;
+ }
+ return value != null && value.toLowerCase().contains(query.toLowerCase());
+ }
+}
diff --git a/src/main/java/com/DesktopApplicationClientJava/session/Session.java b/src/main/java/com/DesktopApplicationClientJava/session/Session.java
index cea4c73..19ffa97 100644
--- a/src/main/java/com/DesktopApplicationClientJava/session/Session.java
+++ b/src/main/java/com/DesktopApplicationClientJava/session/Session.java
@@ -1,8 +1,12 @@
package com.DesktopApplicationClientJava.session;
import com.DesktopApplicationClientJava.entities.User;
+import lombok.Getter;
import lombok.NoArgsConstructor;
+import lombok.Setter;
+@Getter
+@Setter
@NoArgsConstructor
public class Session {
@@ -10,34 +14,6 @@ public class Session {
private String accessToken;
private String refreshToken;
- // ===== user =====
-
- public User getCurrentUser() {
- return currentUser;
- }
-
- public void setCurrentUser(User user) {
- this.currentUser = user;
- }
-
- // ===== tokens =====
-
- public String getAccessToken() {
- return accessToken;
- }
-
- public void setAccessToken(String accessToken) {
- this.accessToken = accessToken;
- }
-
- public String getRefreshToken() {
- return refreshToken;
- }
-
- public void setRefreshToken(String refreshToken) {
- this.refreshToken = refreshToken;
- }
-
// ===== helpers =====
public boolean isLoggedIn() {
diff --git a/src/main/resources/fxml/ResourceDetails.fxml b/src/main/resources/fxml/ResourceDetails.fxml
new file mode 100644
index 0000000..395b680
--- /dev/null
+++ b/src/main/resources/fxml/ResourceDetails.fxml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/test/java/services/AuthServiceTest.java b/src/test/java/services/AuthServiceTest.java
new file mode 100644
index 0000000..9fb580d
--- /dev/null
+++ b/src/test/java/services/AuthServiceTest.java
@@ -0,0 +1,70 @@
+package services;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import com.DesktopApplicationClientJava.Api.ApiClient;
+import com.DesktopApplicationClientJava.entities.Role;
+import com.DesktopApplicationClientJava.entities.User;
+import com.DesktopApplicationClientJava.services.AuthService;
+import com.DesktopApplicationClientJava.services.impl.AuthServiceImpl;
+import com.DesktopApplicationClientJava.session.Session;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class AuthServiceTest {
+
+ private Session session;
+ private AuthService authService;
+
+ @BeforeEach
+ void setUp() {
+ session = new Session();
+ authService = new AuthServiceImpl(mock(ApiClient.class), session);
+ }
+
+ @Test
+ void guestHasNoRoles() {
+ assertFalse(authService.hasRole(Role.USER));
+ assertFalse(authService.hasRole(Role.MODER));
+ assertFalse(authService.hasRole(Role.ADMIN));
+ }
+
+ @Test
+ void userHasOnlyUserRole() {
+ loginAs(Role.USER);
+
+ assertTrue(authService.hasRole(Role.USER));
+ assertFalse(authService.hasRole(Role.MODER));
+ assertFalse(authService.hasRole(Role.ADMIN));
+ }
+
+ @Test
+ void moderHasUserAndModerRolesButIsNotAdmin() {
+ loginAs(Role.MODER);
+
+ assertTrue(authService.hasRole(Role.USER));
+ assertTrue(authService.hasRole(Role.MODER));
+ assertFalse(authService.hasRole(Role.ADMIN));
+ assertTrue(authService.isModerator());
+ assertFalse(authService.isAdmin());
+ }
+
+ @Test
+ void adminHasAllRoles() {
+ loginAs(Role.ADMIN);
+
+ assertTrue(authService.hasRole(Role.USER));
+ assertTrue(authService.hasRole(Role.MODER));
+ assertTrue(authService.hasRole(Role.ADMIN));
+ assertTrue(authService.isModerator());
+ assertTrue(authService.isAdmin());
+ }
+
+ private void loginAs(Role role) {
+ session.setCurrentUser(
+ new User(UUID.randomUUID(), "user@example.com", role, "Имя", "Фамилия", true));
+ }
+}
diff --git a/src/test/java/services/mock/MockServicesTest.java b/src/test/java/services/mock/MockServicesTest.java
new file mode 100644
index 0000000..34519e1
--- /dev/null
+++ b/src/test/java/services/mock/MockServicesTest.java
@@ -0,0 +1,130 @@
+package services.mock;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.DesktopApplicationClientJava.entities.FileInfo;
+import com.DesktopApplicationClientJava.entities.Resource;
+import com.DesktopApplicationClientJava.entities.Role;
+import com.DesktopApplicationClientJava.entities.User;
+import com.DesktopApplicationClientJava.services.ServiceException;
+import com.DesktopApplicationClientJava.services.filter.ResourceFilter;
+import com.DesktopApplicationClientJava.services.filter.UserFilter;
+import com.DesktopApplicationClientJava.services.mock.MockAuthService;
+import com.DesktopApplicationClientJava.services.mock.MockFileService;
+import com.DesktopApplicationClientJava.services.mock.MockResourceService;
+import com.DesktopApplicationClientJava.services.mock.MockStore;
+import com.DesktopApplicationClientJava.services.mock.MockUsersService;
+import com.DesktopApplicationClientJava.session.Session;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class MockServicesTest {
+
+ private Session session;
+ private MockAuthService authService;
+ private MockResourceService resourceService;
+ private MockFileService fileService;
+ private MockUsersService usersService;
+
+ @BeforeEach
+ void setUp() {
+ MockStore store = new MockStore();
+ session = new Session();
+ authService = new MockAuthService(store, session);
+ resourceService = new MockResourceService(store);
+ fileService = new MockFileService(store);
+ usersService = new MockUsersService(store);
+ }
+
+ @Test
+ void seededAdminKeepsAdminRole() {
+ User user = authService.login("admin@example.com", "whatever");
+
+ assertEquals(Role.ADMIN, user.getRole());
+ assertTrue(authService.isAdmin());
+ assertTrue(authService.isModerator());
+ }
+
+ @Test
+ void unknownEmailGetsRoleByPrefix() {
+ assertEquals(Role.MODER, authService.login("moder-ivan@example.com", "x").getRole());
+ assertEquals(Role.USER, authService.login("someone@example.com", "x").getRole());
+ }
+
+ @Test
+ void blankCredentialsAreRejected() {
+ assertThrows(ServiceException.class, () -> authService.login("", "x"));
+ assertThrows(ServiceException.class, () -> authService.login("a@b.c", ""));
+ }
+
+ @Test
+ void logoutClearsSession() {
+ authService.login("user@example.com", "x");
+ authService.logout();
+
+ assertNull(authService.getCurrentUser());
+ }
+
+ @Test
+ void seededResourceHasFiles() {
+ List found = resourceService.search(new ResourceFilter("отчёт", null), 0, 10);
+
+ assertEquals(1, found.size());
+ assertEquals(2, found.get(0).getFiles().size());
+ }
+
+ @Test
+ void unknownResourceThrows404() {
+ ServiceException e =
+ assertThrows(ServiceException.class, () -> resourceService.getById(UUID.randomUUID()));
+ assertEquals(404, e.getStatus());
+ }
+
+ @Test
+ void createAndDeleteRoundTrip() {
+ Resource created = resourceService.create("Временный", "удалится");
+ resourceService.delete(created.getUuid());
+
+ assertThrows(ServiceException.class, () -> resourceService.getById(created.getUuid()));
+ }
+
+ @Test
+ void downloadWritesFileWithOriginalName(@TempDir Path tempDir) throws Exception {
+ Resource resource = resourceService.search(ResourceFilter.empty(), 0, 10).get(0);
+ FileInfo file = resource.getFiles().get(0);
+
+ Path saved = fileService.downloadFile(resource.getUuid(), file.getUuid(), tempDir);
+
+ assertEquals(file.getName(), saved.getFileName().toString());
+ assertTrue(Files.size(saved) > 0);
+ }
+
+ @Test
+ void downloadUnknownFileThrows404(@TempDir Path tempDir) {
+ Resource resource = resourceService.search(ResourceFilter.empty(), 0, 10).get(0);
+
+ ServiceException e =
+ assertThrows(
+ ServiceException.class,
+ () -> fileService.downloadFile(resource.getUuid(), UUID.randomUUID(), tempDir));
+ assertEquals(404, e.getStatus());
+ }
+
+ @Test
+ void usersSearchFindsSeededAdmin() {
+ List found = usersService.search(new UserFilter("admin@", null, null, null, null), 0, 10);
+
+ assertEquals(1, found.size());
+ assertEquals(Role.ADMIN, found.get(0).getRole());
+ assertNotNull(usersService.getById(found.get(0).getUuid()));
+ }
+}
diff --git a/src/test/java/ui/LoginIntegrationTest.java b/src/test/java/ui/LoginIntegrationTest.java
deleted file mode 100644
index 152e18f..0000000
--- a/src/test/java/ui/LoginIntegrationTest.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package ui;
-
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assumptions.assumeTrue;
-
-import com.DesktopApplicationClientJava.Api.ApiClient;
-import com.DesktopApplicationClientJava.services.AuthService;
-import com.DesktopApplicationClientJava.services.ServiceException;
-import com.DesktopApplicationClientJava.services.impl.AuthServiceImpl;
-import com.DesktopApplicationClientJava.session.Session;
-import java.io.IOException;
-import java.net.InetSocketAddress;
-import java.net.Socket;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Tag;
-import org.junit.jupiter.api.Test;
-
-/**
- * Integration test for the login flow: real {@link AuthService} with a real {@link ApiClient}, no
- * mocks. Requires a running backend at {@code localhost:8080}; otherwise tests are aborted.
- *
- * Excluded from the default build and CI (see surefire {@code excludedGroups} in pom.xml). Run
- * explicitly: {@code mvn test -Dgroups=integration -DexcludedGroups=}
- */
-@Tag("integration")
-class LoginIntegrationTest {
-
- private AuthService authService;
-
- @BeforeEach
- void setUp() {
- assumeTrue(isBackendUp(), "Backend is not running at localhost:8080, skipping");
- Session session = new Session();
- authService = new AuthServiceImpl(new ApiClient(session), session);
- }
-
- @Test
- void invalidCredentialsAreRejectedByRealApi() {
- assertThrows(
- ServiceException.class,
- () -> authService.login("no-such-user@example.com", "wrong-password-123"));
- }
-
- private static boolean isBackendUp() {
- try (Socket socket = new Socket()) {
- socket.connect(new InetSocketAddress("localhost", 8080), 2000);
- return true;
- } catch (IOException e) {
- return false;
- }
- }
-}
diff --git a/src/test/java/ui/MockFlowTest.java b/src/test/java/ui/MockFlowTest.java
new file mode 100644
index 0000000..55d112d
--- /dev/null
+++ b/src/test/java/ui/MockFlowTest.java
@@ -0,0 +1,97 @@
+package ui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import com.DesktopApplicationClientJava.controllers.ResourceDetailsController;
+import com.DesktopApplicationClientJava.entities.FileInfo;
+import com.DesktopApplicationClientJava.navigation.Navigator;
+import com.DesktopApplicationClientJava.services.ServiceRegistry;
+import com.DesktopApplicationClientJava.session.Session;
+import com.DesktopApplicationClientJava.utils.ControllerWiring;
+import java.util.UUID;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Parent;
+import javafx.scene.Scene;
+import javafx.scene.control.Label;
+import javafx.scene.control.TableView;
+import javafx.stage.Stage;
+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;
+
+/**
+ * Mock-backed UI flow: real pages wired to real in-memory mock services (no Mockito on services, no
+ * backend). Proves that MOCK_DATA mode renders actual data.
+ */
+@ExtendWith(ApplicationExtension.class)
+class MockFlowTest {
+
+ private static final UUID SEEDED_RESOURCE_ID =
+ UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+
+ private ResourceDetailsController controller;
+
+ @Start
+ void start(Stage stage) throws Exception {
+ Navigator navigator = mockNavigator();
+ ServiceRegistry services = ServiceRegistry.mock(new Session());
+
+ FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/ResourceDetails.fxml"));
+ loader.setControllerFactory(
+ clazz -> {
+ try {
+ Object created = clazz.getDeclaredConstructor().newInstance();
+ ControllerWiring.wire(created, navigator, new Session(), services);
+ return created;
+ } catch (Exception e) {
+ throw new RuntimeException("Cannot create controller: " + clazz, e);
+ }
+ });
+ Parent root = loader.load();
+ controller = loader.getController();
+ stage.setScene(new Scene(root));
+ stage.show();
+ }
+
+ @Test
+ void mockLoginPutsUserIntoSession() {
+ Session session = new Session();
+ ServiceRegistry registry = ServiceRegistry.mock(session);
+
+ registry.getAuthService().login("admin@example.com", "any-password");
+
+ assertEquals("admin@example.com", session.getCurrentUser().getEmail());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void detailsPageRendersSeededMockResource(FxRobot robot) {
+ robot.interact(
+ () -> {
+ controller.setResourceId(SEEDED_RESOURCE_ID);
+ controller.load();
+ });
+
+ assertEquals("Годовой отчёт (мок)", robot.lookup("#titleLabel").queryAs(Label.class).getText());
+ TableView table = robot.lookup("#filesTable").queryAs(TableView.class);
+ assertEquals(2, table.getItems().size());
+ assertFalse(robot.lookup("#errorLabel").queryAs(Label.class).isVisible());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void detailsPageAutoSelectsFirstSeedWithoutId(FxRobot robot) {
+ robot.interact(() -> controller.load());
+
+ assertEquals("Годовой отчёт (мок)", robot.lookup("#titleLabel").queryAs(Label.class).getText());
+ TableView table = robot.lookup("#filesTable").queryAs(TableView.class);
+ assertEquals(2, table.getItems().size());
+ }
+
+ private static Navigator mockNavigator() {
+ return org.mockito.Mockito.mock(Navigator.class);
+ }
+}
diff --git a/src/test/java/ui/ResourceDetailsControllerTest.java b/src/test/java/ui/ResourceDetailsControllerTest.java
new file mode 100644
index 0000000..0aa92ad
--- /dev/null
+++ b/src/test/java/ui/ResourceDetailsControllerTest.java
@@ -0,0 +1,192 @@
+package 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.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.DesktopApplicationClientJava.controllers.ResourceDetailsController;
+import com.DesktopApplicationClientJava.entities.FileInfo;
+import com.DesktopApplicationClientJava.entities.Resource;
+import com.DesktopApplicationClientJava.navigation.Navigator;
+import com.DesktopApplicationClientJava.services.FileService;
+import com.DesktopApplicationClientJava.services.ResourceService;
+import com.DesktopApplicationClientJava.services.ServiceException;
+import com.DesktopApplicationClientJava.services.ServiceRegistry;
+import com.DesktopApplicationClientJava.session.Session;
+import com.DesktopApplicationClientJava.utils.ControllerWiring;
+import java.io.File;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Parent;
+import javafx.scene.Scene;
+import javafx.scene.control.Label;
+import javafx.scene.control.TableView;
+import javafx.stage.Stage;
+import javafx.stage.Window;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.testfx.api.FxRobot;
+import org.testfx.framework.junit5.ApplicationExtension;
+import org.testfx.framework.junit5.Start;
+
+@ExtendWith(ApplicationExtension.class)
+class ResourceDetailsControllerTest {
+
+ private static final UUID RESOURCE_ID = UUID.randomUUID();
+ private static final UUID FILE_ID_1 = UUID.randomUUID();
+ private static final UUID FILE_ID_2 = UUID.randomUUID();
+
+ private ResourceService resourceService;
+ private FileService fileService;
+ private Navigator navigator;
+ private TestableResourceDetailsController controller;
+
+ static class TestableResourceDetailsController extends ResourceDetailsController {
+ private File directory;
+
+ void setDirectory(File directory) {
+ this.directory = directory;
+ }
+
+ @Override
+ protected File chooseDirectory(Window owner) {
+ return directory;
+ }
+ }
+
+ @Start
+ void start(Stage stage) throws Exception {
+ resourceService = mock(ResourceService.class);
+ fileService = mock(FileService.class);
+ ServiceRegistry services = mock(ServiceRegistry.class);
+ when(services.getResourceService()).thenReturn(resourceService);
+ when(services.getFileService()).thenReturn(fileService);
+ navigator = mock(Navigator.class);
+
+ controller = new TestableResourceDetailsController();
+ FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/ResourceDetails.fxml"));
+ loader.setControllerFactory(clazz -> controller);
+ ControllerWiring.wire(controller, navigator, new Session(), services);
+ Parent root = loader.load();
+ stage.setScene(new Scene(root));
+ stage.show();
+ }
+
+ @Test
+ void successfulLoadShowsMetadataAndFiles(FxRobot robot) {
+ when(resourceService.getById(RESOURCE_ID)).thenReturn(resourceWithFiles());
+ loadPage(robot);
+
+ assertEquals("Годовой отчёт", textOf(robot, "#titleLabel"));
+ assertEquals("Описание ресурса", textOf(robot, "#descriptionLabel"));
+ assertEquals("06.05.2024 07:08", textOf(robot, "#createdLabel"));
+ assertEquals("07.05.2024 09:10", textOf(robot, "#updatedLabel"));
+ assertEquals(RESOURCE_ID.toString(), textOf(robot, "#uuidLabel"));
+
+ TableView table = filesTable(robot);
+ assertEquals(2, table.getItems().size());
+ assertEquals("report.pdf", table.getItems().get(0).getName());
+ assertEquals("photo.png", table.getItems().get(1).getName());
+ assertFalse(robot.lookup("#errorLabel").queryAs(Label.class).isVisible());
+ }
+
+ @Test
+ void resourceNotFoundShowsErrorAndLeavesTableEmpty(FxRobot robot) {
+ when(resourceService.getById(RESOURCE_ID)).thenThrow(new ServiceException("Ресурс не найден"));
+ loadPage(robot);
+
+ Label error = robot.lookup("#errorLabel").queryAs(Label.class);
+ assertTrue(error.isVisible());
+ assertEquals("Ресурс не найден", error.getText());
+ assertTrue(filesTable(robot).getItems().isEmpty());
+ verifyNoInteractions(navigator);
+ }
+
+ @Test
+ void downloadSavesFileAndShowsStatus(FxRobot robot, @TempDir Path tempDir) {
+ when(resourceService.getById(RESOURCE_ID)).thenReturn(resourceWithFiles());
+ loadPage(robot);
+
+ controller.setDirectory(tempDir.toFile());
+ Path saved = tempDir.resolve("report.pdf");
+ when(fileService.downloadFile(eq(RESOURCE_ID), eq(FILE_ID_1), any())).thenReturn(saved);
+
+ robot.clickOn("Скачать");
+
+ verify(fileService).downloadFile(RESOURCE_ID, FILE_ID_1, tempDir);
+ assertEquals("Сохранено: report.pdf", textOf(robot, "#statusLabel"));
+ }
+
+ @Test
+ void downloadErrorShowsInErrorLabel(FxRobot robot, @TempDir Path tempDir) {
+ when(resourceService.getById(RESOURCE_ID)).thenReturn(resourceWithFiles());
+ loadPage(robot);
+
+ controller.setDirectory(tempDir.toFile());
+ when(fileService.downloadFile(eq(RESOURCE_ID), eq(FILE_ID_1), any()))
+ .thenThrow(new ServiceException("Файл недоступен"));
+
+ robot.clickOn("Скачать");
+
+ Label error = robot.lookup("#errorLabel").queryAs(Label.class);
+ assertTrue(error.isVisible());
+ assertEquals("Файл недоступен", error.getText());
+ }
+
+ @Test
+ void cancelledDirectoryChooserDoesNotCallDownload(FxRobot robot) {
+ when(resourceService.getById(RESOURCE_ID)).thenReturn(resourceWithFiles());
+ loadPage(robot);
+
+ controller.setDirectory(null);
+ robot.clickOn("Скачать");
+
+ verifyNoInteractions(fileService);
+ }
+
+ @Test
+ void backNavigatesToResourceList(FxRobot robot) {
+ robot.clickOn("#backButton");
+
+ verify(navigator).goTo("/fxml/ResourceList.fxml");
+ }
+
+ private void loadPage(FxRobot robot) {
+ robot.interact(
+ () -> {
+ controller.setResourceId(RESOURCE_ID);
+ controller.load();
+ });
+ }
+
+ private Resource resourceWithFiles() {
+ FileInfo first = new FileInfo(FILE_ID_1, "report.pdf", "application/pdf", 12345L);
+ FileInfo second = new FileInfo(FILE_ID_2, "photo.png", "image/png", 678L);
+ return new Resource(
+ RESOURCE_ID,
+ "Годовой отчёт",
+ "Описание ресурса",
+ LocalDateTime.of(2024, 5, 6, 7, 8),
+ LocalDateTime.of(2024, 5, 7, 9, 10),
+ List.of(first, second));
+ }
+
+ private static String textOf(FxRobot robot, String query) {
+ return robot.lookup(query).queryAs(Label.class).getText();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static TableView filesTable(FxRobot robot) {
+ return robot.lookup("#filesTable").queryAs(TableView.class);
+ }
+}
diff --git a/src/test/java/ui/integration/IntegrationTestCase.java b/src/test/java/ui/integration/IntegrationTestCase.java
new file mode 100644
index 0000000..c3f0a22
--- /dev/null
+++ b/src/test/java/ui/integration/IntegrationTestCase.java
@@ -0,0 +1,67 @@
+package ui.integration;
+
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import com.DesktopApplicationClientJava.Api.ApiClient;
+import com.DesktopApplicationClientJava.services.AuthService;
+import com.DesktopApplicationClientJava.services.ResourceService;
+import com.DesktopApplicationClientJava.services.impl.AuthServiceImpl;
+import com.DesktopApplicationClientJava.services.impl.ResourceServiceImpl;
+import com.DesktopApplicationClientJava.session.Session;
+import io.github.cdimascio.dotenv.Dotenv;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.nio.file.Path;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+
+@Tag("integration")
+public abstract class IntegrationTestCase {
+ static int timeoutMs = 1000;
+
+ protected static Dotenv dotenv;
+ protected static String adminEmail;
+ protected static String adminPassword;
+
+ protected AuthService authService;
+ protected ResourceService resourceService;
+
+ @BeforeAll
+ static void loadEnv() {
+ Path dotenvPath = Path.of(System.getProperty("dotenv.path", ".env")).toAbsolutePath();
+ dotenv =
+ Dotenv.configure()
+ .directory(dotenvPath.getParent().toString())
+ .filename(dotenvPath.getFileName().toString())
+ .ignoreIfMissing()
+ .load();
+
+ adminEmail = var("IT_ADMIN_EMAIL", "admin@example.com");
+ adminPassword = var("IT_ADMIN_PASSWORD", null);
+ }
+
+ @BeforeEach
+ void setUpServices() {
+ assumeTrue(isBackendUp(), "Backend is not running at localhost:8080, skipping");
+ Session session = new Session();
+ ApiClient apiClient = new ApiClient(session);
+ authService = new AuthServiceImpl(apiClient, session);
+ resourceService = new ResourceServiceImpl(apiClient);
+ }
+
+ protected static String var(String key, String fallback) {
+ return dotenv.get(key, fallback);
+ }
+
+ protected static boolean isBackendUp() {
+ try (Socket socket = new Socket()) {
+ socket.connect(
+ new InetSocketAddress("localhost", Integer.parseInt(var("port", "8080"))), timeoutMs);
+ return true;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+}
diff --git a/src/test/java/ui/integration/LoginIntegrationTest.java b/src/test/java/ui/integration/LoginIntegrationTest.java
new file mode 100644
index 0000000..280963a
--- /dev/null
+++ b/src/test/java/ui/integration/LoginIntegrationTest.java
@@ -0,0 +1,24 @@
+package ui.integration;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.DesktopApplicationClientJava.services.ServiceException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration test for the login flow: real AuthService with a real ApiClient, no mocks. Requires a
+ * running backend at {@code localhost:8080}; otherwise tests are aborted.
+ *
+ * Excluded from the default build and CI (see surefire {@code excludedGroups} in pom.xml). Run
+ * explicitly: {@code mvn test -Dgroups=integration -DexcludedGroups=} ({@code @Tag} inherited from
+ * IntegrationTestCase).
+ */
+class LoginIntegrationTest extends IntegrationTestCase {
+
+ @Test
+ void invalidCredentialsAreRejectedByRealApi() {
+ assertThrows(
+ ServiceException.class,
+ () -> authService.login("no-such-user@example.com", "wrong-password-123"));
+ }
+}
diff --git a/src/test/java/ui/integration/ResourceDetailsIntegrationTest.java b/src/test/java/ui/integration/ResourceDetailsIntegrationTest.java
new file mode 100644
index 0000000..8080b51
--- /dev/null
+++ b/src/test/java/ui/integration/ResourceDetailsIntegrationTest.java
@@ -0,0 +1,44 @@
+package ui.integration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import com.DesktopApplicationClientJava.entities.Resource;
+import com.DesktopApplicationClientJava.services.ServiceException;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration test for the resource details flow: real services with a real ApiClient, no mocks.
+ * Requires a running backend at {@code localhost:8080}; otherwise tests are aborted.
+ *
+ *
Excluded from the default build and CI (see surefire {@code excludedGroups} in pom.xml). Run
+ * explicitly: {@code mvn test -Dgroups=integration -DexcludedGroups=} ({@code @Tag} inherited from
+ * IntegrationTestCase).
+ */
+class ResourceDetailsIntegrationTest extends IntegrationTestCase {
+ @Test
+ void unknownIdThrowsServiceException() {
+ assertThrows(ServiceException.class, () -> resourceService.getById(UUID.randomUUID()));
+ }
+
+ @Test
+ void createdResourceRoundTrip() {
+ assumeTrue(
+ adminPassword != null && !adminPassword.isBlank(),
+ "IT_ADMIN_PASSWORD is not set, skipping");
+ authService.login(adminEmail, adminPassword);
+ String title = "IT ResourceDetails " + UUID.randomUUID();
+ Resource created = resourceService.create(title, "created by integration test");
+ try {
+ Resource fetched = resourceService.getById(created.getUuid());
+ assertEquals(created.getUuid(), fetched.getUuid());
+ assertEquals(title, fetched.getTitle());
+ assertNotNull(fetched.getFiles());
+ } finally {
+ resourceService.delete(created.getUuid());
+ }
+ }
+}