diff --git a/.env.example b/.env.example index 85b7177..53bf5f9 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,15 @@ MINIO_ROOT_PASSWORD=changeme-minio-root MINIO_BUCKET_NAME=local-bucket MINIO_ACCESS_TOKEN=changeme-access MINIO_SECRET_KEY=changeme-secret + +# --- App --- +# First page on startup (handy for manual UI checks, e.g. /fxml/ResourceDetails.fxml). +INITIAL_PAGE_PATH=/fxml/LoginForm.fxml +# Run without a backend, on in-memory mock data (for UI testing). Window title gets [MOCK]. +MOCK_DATA=false + +# --- Integration tests (mvn test -Dgroups=integration -DexcludedGroups=) --- +# Seed admin of the local dev backend; used by *IntegrationTest (skipped if unset). +# Never commit real values here. +IT_ADMIN_EMAIL=admin@example.com +IT_ADMIN_PASSWORD=changeme diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..301782b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["vscjava.vscode-lombok"] +} diff --git a/README.md b/README.md index 1f22d8a..af9c318 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ git pull --rebase ## Запуск -Требования: JDK 25, Maven, запущенный бэкенд (иначе вход покажет ошибку соединения). +Требования: JDK 25, Maven. + +### С реальным бэкендом ```bash # 1. Инфраструктура бэкенда (из корня этого репозитория): @@ -20,9 +22,32 @@ docker compose up -d postgres redis mvn javafx:run ``` -Откроется окно 800×600 со страницы входа (`LoginForm.fxml` — она же `INITIAL_PAGE_PATH` -в `Program.java`). Для проверки вёрстки отдельной страницы можно временно подменить -`INITIAL_PAGE_PATH` на нужный FXML. +### Без бэкенда (мок-данные для проверки UI) + +```bash +MOCK_DATA=true mvn javafx:run +``` + +Приложение стартует на in-memory данных (`services/mock`, окно помечено `[MOCK]`): +вход — любой непустой email/пароль, роль по префиксу email (`admin*` → ADMIN, +`moder*` → MODER, иначе USER). Засижены 3 пользователя и 2 ресурса с файлами, +скачивание пишет реальный файл-плейсхолдер. + +### Переменные окружения (.env) + +| Переменная | Дефолт | Назначение | +| ---------- | ------ | ---------- | +| `INITIAL_PAGE_PATH` | `/fxml/LoginForm.fxml` | Стартовая страница (удобно открывать страницу напрямую для проверки вёрстки) | +| `MOCK_DATA` | `false` | `true` — работать на моках без бэкенда | +| `IT_ADMIN_EMAIL` / `IT_ADMIN_PASSWORD` | — | Сид-админ локального бэкенда для интеграционных тестов (тесты сами читают `.env`; без пароля скипаются) | +| `POSTGRES_*`, `REDIS_PASSWORD`, `APP_PORT` | см. `.env.example` | Инфраструктура и бэкенд | +| `MINIO_*` | см. `.env.example` | Только с `docker compose --profile files up -d` | + +Свои значения — в `.env` (скопировать из `.env.example`): приложение читает +переменные из окружения (`set -a && source .env && set +a` перед запуском), +интеграционные тесты подхватывают `.env` сами. + +Откроется окно 800×600 со стартовой страницы (по умолчанию вход, `LoginForm.fxml`). ## Git hooks diff --git "a/docs/\321\201\321\202\321\200\320\260\320\275\320\270\321\206\321\213.md" "b/docs/\321\201\321\202\321\200\320\260\320\275\320\270\321\206\321\213.md" index 46d9969..5f78fe3 100644 --- "a/docs/\321\201\321\202\321\200\320\260\320\275\320\270\321\206\321\213.md" +++ "b/docs/\321\201\321\202\321\200\320\260\320\275\320\270\321\206\321\213.md" @@ -19,7 +19,7 @@ UI не ходит в HTTP напрямую. Контроллер вызывае | --- | -------------------------------------------- | ------------------------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------- | ------------- | | 1 | Вход (email + пароль) | `/fxml/LoginForm.fxml` | гость (публичная) | Аутентификация через `AuthService`, после успеха → №2 | @vladcheck | | 2 | Список ресурсов и сортировка (главная) | `/fxml/ResourceList.fxml` | `USER` | Поиск/фильтр/сортировка, пагинация `offset/count` через `ResourceService.search` | @miyagi18 | -| 3 | Просмотр метаданных документа | `/fxml/ResourceDetails.fxml` (новая) | `USER` | Только чтение: `ResourceService.getById` → `title, description, createdAt, updatedAt, uuid` | @vladcheck | +| 3 | Просмотр метаданных документа | `/fxml/ResourceDetails.fxml` | `USER` | Только чтение: `ResourceService.getById` → `title, description, createdAt, updatedAt, uuid` | @vladcheck | | 4 | Форма ресурса (создание/редактирование) | `/fxml/ResourceForm.fxml` (новая) | `MODER` | `create/update` через `ResourceService`; удаление — действием из №2/№3 с подтверждением, без отдельной страницы | @miyagi18 | | 5 | Список пользователей | `/fxml/UserList.fxml` (новая) | `ADMIN` | Поиск/фильтр через `UsersService.search` + `UserFilter`; удаление действием из списка | @deatheh | | 6 | Форма пользователя (создание/редактирование) | `/fxml/UserForm.fxml` (новая) | `ADMIN` | `create` через `RegisterData`, `update` через `UpdateUserData`, `getById` для предзаполнения | @deatheh | @@ -58,6 +58,11 @@ UI не ходит в HTTP напрямую. Контроллер вызывае * №1: ✅ готово — `LoginFormController` только email + пароль, вызов `AuthService`. * №2: ✅ переименование `Search.fxml → ResourceList.fxml` готово; наполнить список: `ResourceService.search` + пагинация, фильтры `ResourceFilter(title, description)`. +* №3: ✅ готово — `ResourceDetailsController` (read-only): метаданные + таблица файлов + со скачиванием (`FileService.downloadFile`), вход из №2 — `Navigator.goTo(path, c -> ...)` + с `setResourceId(uuid)` + `load()`. +* Роли: `AuthServiceImpl.hasRole` сравнивает по уровню (`USER=0 < MODER=1 < ADMIN=2`), + поэтому `isModerator()` возвращает true и для `ADMIN` — соответствует RBAC выше. * SoC: списки (№2, №5) не содержат логики форм; формы (№4, №6) работают в двух режимах `create/edit` по наличию `uuid`, валидация через существующие `validation/*Validator`. * Кнопки create/edit/delete рендерятся по роли из `Session.getCurrentUser().role`, а не отдельными страницами. diff --git a/pom.xml b/pom.xml index 9c44ee1..f37843e 100644 --- a/pom.xml +++ b/pom.xml @@ -73,6 +73,12 @@ 2.2 test + + io.github.cdimascio + dotenv-java + 3.2.0 + test + org.openjfx javafx-controls @@ -146,6 +152,11 @@ Run them explicitly: mvn test -Dgroups=integration -DexcludedGroups= --> ${excludedGroups} + + + ${project.basedir}/.env + diff --git a/src/main/java/com/DesktopApplicationClientJava/Api/ApiClient.java b/src/main/java/com/DesktopApplicationClientJava/Api/ApiClient.java index 83425e8..a2c7692 100644 --- a/src/main/java/com/DesktopApplicationClientJava/Api/ApiClient.java +++ b/src/main/java/com/DesktopApplicationClientJava/Api/ApiClient.java @@ -14,6 +14,7 @@ import com.DesktopApplicationClientJava.Api.Dto.response.ResourceDto; import com.DesktopApplicationClientJava.Api.Dto.response.UserDto; import com.DesktopApplicationClientJava.session.Session; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import java.io.ByteArrayOutputStream; @@ -49,6 +50,9 @@ public ApiClient(Session session) { .build(); this.objectMapper = new ObjectMapper(); this.objectMapper.registerModule(new JavaTimeModule()); + // FIXME: temporary — backend returns firstname/lastname instead of firstName/lastName. + // Remove once backend uses camelCase; then unknown properties should fail fast again. + this.objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); } // ======================== AUTH ======================== diff --git a/src/main/java/com/DesktopApplicationClientJava/Api/ApiException.java b/src/main/java/com/DesktopApplicationClientJava/Api/ApiException.java index 06ba69f..156910f 100644 --- a/src/main/java/com/DesktopApplicationClientJava/Api/ApiException.java +++ b/src/main/java/com/DesktopApplicationClientJava/Api/ApiException.java @@ -1,7 +1,9 @@ package com.DesktopApplicationClientJava.Api; import com.DesktopApplicationClientJava.Api.Dto.error.ErrorResponse; +import lombok.Getter; +@Getter public class ApiException extends RuntimeException { private final ErrorResponse errorResponse; @@ -10,10 +12,6 @@ public ApiException(ErrorResponse errorResponse) { this.errorResponse = errorResponse; } - public ErrorResponse getErrorResponse() { - return errorResponse; - } - public int getStatus() { return errorResponse.status(); } diff --git a/src/main/java/com/DesktopApplicationClientJava/Program.java b/src/main/java/com/DesktopApplicationClientJava/Program.java index b6750a0..9a02450 100644 --- a/src/main/java/com/DesktopApplicationClientJava/Program.java +++ b/src/main/java/com/DesktopApplicationClientJava/Program.java @@ -14,13 +14,16 @@ import org.kordamp.bootstrapfx.BootstrapFX; public class Program extends Application { - private static final String INITIAL_PAGE_PATH = "/fxml/LoginForm.fxml"; + private static final String INITIAL_PAGE_PATH = + System.getenv().getOrDefault("INITIAL_PAGE_PATH", "/fxml/LoginForm.fxml"); + private static final boolean MOCK_DATA = + Boolean.parseBoolean(System.getenv().getOrDefault("MOCK_DATA", "false")); private static final double WINDOW_WIDTH = 800.0; private static final double WINDOW_HEIGHT = 600.0; private final Session session = new Session(); private final ApiClient apiClient = new ApiClient(session); - private final ServiceRegistry services = new ServiceRegistry(apiClient, session); + private final ServiceRegistry services = createServices(); public static void main(String[] args) { launch(args); @@ -48,13 +51,20 @@ public void start(Stage stage) throws IOException { loadStyleSheet(scene, "/styles.css"); stage.setScene(scene); - stage.setTitle("JavaFX Application"); + stage.setTitle("JavaFX Application" + (MOCK_DATA ? " [MOCK]" : "")); stage.setWidth(WINDOW_WIDTH); stage.setHeight(WINDOW_HEIGHT); stage.centerOnScreen(); stage.show(); } + private ServiceRegistry createServices() { + if (MOCK_DATA) { + return ServiceRegistry.mock(session); + } + return new ServiceRegistry(apiClient, session); + } + private void loadStyleSheet(Scene scene, String path) { var url = getClass().getResource(path); if (url != null) { diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java new file mode 100644 index 0000000..7d7f92c --- /dev/null +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java @@ -0,0 +1,149 @@ +package com.DesktopApplicationClientJava.controllers; + +import com.DesktopApplicationClientJava.controllers.utils.Controller; +import com.DesktopApplicationClientJava.entities.FileInfo; +import com.DesktopApplicationClientJava.entities.Resource; +import com.DesktopApplicationClientJava.services.ServiceException; +import com.DesktopApplicationClientJava.services.filter.ResourceFilter; +import com.DesktopApplicationClientJava.services.mock.MockService; +import java.io.File; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.cell.PropertyValueFactory; +import javafx.stage.DirectoryChooser; +import javafx.stage.Window; +import lombok.Setter; + +public class ResourceDetailsController extends Controller { + + static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); + + @FXML private Label titleLabel; + @FXML private Label descriptionLabel; + @FXML private Label createdLabel; + @FXML private Label updatedLabel; + @FXML private Label uuidLabel; + @FXML private Label errorLabel; + @FXML private Label statusLabel; + @FXML private TableView filesTable; + @FXML private TableColumn nameColumn; + @FXML private TableColumn sizeColumn; + @FXML private TableColumn contentTypeColumn; + @FXML private TableColumn downloadColumn; + @FXML private Button backButton; + + @Setter private UUID resourceId; + + @FXML + private void initialize() { + hideError(); + nameColumn.setCellValueFactory(new PropertyValueFactory<>("name")); + sizeColumn.setCellValueFactory(new PropertyValueFactory<>("size")); + contentTypeColumn.setCellValueFactory(new PropertyValueFactory<>("contentType")); + downloadColumn.setCellFactory(col -> new DownloadCell(this::onDownload)); + autoSelectFirstInMockMode(); + } + + public void load() { + hideError(); + try { + showResource(services.getResourceService().getById(resourceId)); + } catch (ServiceException e) { + showError(e.getMessage()); + } + } + + @FXML + private void onBack(ActionEvent event) { + navigator.goTo("/fxml/ResourceList.fxml"); + } + + private void onDownload(FileInfo file) { + File dir = chooseDirectory(backButton.getScene().getWindow()); + if (dir == null) { + return; + } + try { + Path saved = services.getFileService().downloadFile(resourceId, file.getUuid(), dir.toPath()); + statusLabel.setText("Сохранено: " + saved.getFileName()); + } catch (ServiceException e) { + showError(e.getMessage()); + } + } + + protected File chooseDirectory(Window owner) { + return new DirectoryChooser().showDialog(owner); + } + + /** + * Dev-only fallback: when the page is opened without an id (e.g. straight via INITIAL_PAGE_PATH) + * against a mock service, show the first seeded resource so the UI is clickable without the + * resource list. Never triggers against the real backend. + */ + private void autoSelectFirstInMockMode() { + if (resourceId != null || !(services.getResourceService() instanceof MockService mock)) { + return; + } + mock.doIfInitialPage(); + services.getResourceService().search(ResourceFilter.empty(), 0, 1).stream() + .map(Resource::getUuid) + .findFirst() + .ifPresent( + id -> { + setResourceId(id); + load(); + }); + } + + private void showResource(Resource resource) { + titleLabel.setText(resource.getTitle()); + descriptionLabel.setText(resource.getDescription()); + createdLabel.setText(formatDateTime(resource.getCreatedAt())); + updatedLabel.setText(formatDateTime(resource.getUpdatedAt())); + uuidLabel.setText(resource.getUuid() == null ? "" : resource.getUuid().toString()); + List files = resource.getFiles() == null ? List.of() : resource.getFiles(); + filesTable.getItems().setAll(files); + } + + private static String formatDateTime(LocalDateTime value) { + return value == null ? "" : DATE_TIME_FORMAT.format(value); + } + + private void showError(String message) { + errorLabel.setText(message); + errorLabel.setVisible(true); + errorLabel.setManaged(true); + } + + private void hideError() { + errorLabel.setText(""); + errorLabel.setVisible(false); + errorLabel.setManaged(false); + } + + private static final class DownloadCell extends TableCell { + private final Button button = new Button("Скачать"); + + DownloadCell(Consumer onDownload) { + button.getStyleClass().setAll("btn", "btn-default"); + button.setOnAction(event -> onDownload.accept(getTableView().getItems().get(getIndex()))); + } + + @Override + protected void updateItem(Void item, boolean empty) { + super.updateItem(item, empty); + setGraphic(empty ? null : button); + } + } +} diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/utils/Controller.java b/src/main/java/com/DesktopApplicationClientJava/controllers/utils/Controller.java index e16b9c7..2e25530 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/utils/Controller.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/utils/Controller.java @@ -6,25 +6,12 @@ import com.DesktopApplicationClientJava.services.ServicesAware; import com.DesktopApplicationClientJava.session.Session; import com.DesktopApplicationClientJava.session.SessionAware; +import lombok.Setter; +@Setter public abstract class Controller implements SessionAware, NavigatorAware, ServicesAware { protected Session session; protected Navigator navigator; protected ServiceRegistry services; - - @Override - public void setSession(Session session) { - this.session = session; - } - - @Override - public void setNavigator(Navigator navigator) { - this.navigator = navigator; - } - - @Override - public void setServices(ServiceRegistry services) { - this.services = services; - } } diff --git a/src/main/java/com/DesktopApplicationClientJava/entities/Resource.java b/src/main/java/com/DesktopApplicationClientJava/entities/Resource.java index 8606d49..44d5411 100644 --- a/src/main/java/com/DesktopApplicationClientJava/entities/Resource.java +++ b/src/main/java/com/DesktopApplicationClientJava/entities/Resource.java @@ -1,6 +1,7 @@ package com.DesktopApplicationClientJava.entities; import java.time.LocalDateTime; +import java.util.List; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Getter; @@ -17,4 +18,5 @@ public class Resource { private String description; private LocalDateTime createdAt; private LocalDateTime updatedAt; + private List files; } diff --git a/src/main/java/com/DesktopApplicationClientJava/navigation/Navigator.java b/src/main/java/com/DesktopApplicationClientJava/navigation/Navigator.java index 1ddcfb3..87d0a59 100644 --- a/src/main/java/com/DesktopApplicationClientJava/navigation/Navigator.java +++ b/src/main/java/com/DesktopApplicationClientJava/navigation/Navigator.java @@ -5,6 +5,7 @@ import com.DesktopApplicationClientJava.session.SessionAware; import com.DesktopApplicationClientJava.utils.ControllerWiring; import java.io.IOException; +import java.util.function.Consumer; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.stage.Stage; @@ -22,6 +23,10 @@ public Navigator(Stage stage, Session session, ServiceRegistry services) { } public void goTo(String fxmlFile) { + goTo(fxmlFile, controller -> {}); + } + + public void goTo(String fxmlFile, Consumer withController) { var url = Navigator.class.getResource(fxmlFile); if (url == null) { throw new IllegalArgumentException("FXML not found on classpath: " + fxmlFile); @@ -49,6 +54,7 @@ public void goTo(String fxmlFile) { if (controller instanceof NavigatorAware na) { na.setNavigator(this); } + withController.accept(controller); stage.getScene().setRoot(root); } catch (IOException e) { diff --git a/src/main/java/com/DesktopApplicationClientJava/services/ServiceException.java b/src/main/java/com/DesktopApplicationClientJava/services/ServiceException.java index 07b5c3d..0445287 100644 --- a/src/main/java/com/DesktopApplicationClientJava/services/ServiceException.java +++ b/src/main/java/com/DesktopApplicationClientJava/services/ServiceException.java @@ -1,5 +1,8 @@ package com.DesktopApplicationClientJava.services; +import lombok.Getter; + +@Getter public class ServiceException extends RuntimeException { private final int status; @@ -11,8 +14,4 @@ public ServiceException(String message, int status) { public ServiceException(String message) { this(message, 0); } - - public int getStatus() { - return status; - } } diff --git a/src/main/java/com/DesktopApplicationClientJava/services/ServiceRegistry.java b/src/main/java/com/DesktopApplicationClientJava/services/ServiceRegistry.java index e5d2d7c..a9fc536 100644 --- a/src/main/java/com/DesktopApplicationClientJava/services/ServiceRegistry.java +++ b/src/main/java/com/DesktopApplicationClientJava/services/ServiceRegistry.java @@ -2,8 +2,18 @@ import com.DesktopApplicationClientJava.Api.ApiClient; import com.DesktopApplicationClientJava.services.impl.*; +import com.DesktopApplicationClientJava.services.mock.MockAuthService; +import com.DesktopApplicationClientJava.services.mock.MockExportService; +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 lombok.AllArgsConstructor; +import lombok.Getter; +@Getter +@AllArgsConstructor public class ServiceRegistry { private final AuthService authService; private final UsersService usersService; @@ -12,30 +22,22 @@ public class ServiceRegistry { private final ExportService exportService; public ServiceRegistry(ApiClient apiClient, Session session) { - this.authService = new AuthServiceImpl(apiClient, session); - this.usersService = new UsersServiceImpl(apiClient); - this.resourceService = new ResourceServiceImpl(apiClient); - this.fileService = new FileServiceImpl(apiClient); - this.exportService = new ExportServiceImpl(apiClient); + this( + new AuthServiceImpl(apiClient, session), + new UsersServiceImpl(apiClient), + new ResourceServiceImpl(apiClient), + new FileServiceImpl(apiClient), + new ExportServiceImpl(apiClient)); } - public AuthService getAuthService() { - return authService; - } - - public ExportService getExportService() { - return exportService; - } - - public FileService getFileService() { - return fileService; - } - - public ResourceService getResourceService() { - return resourceService; - } - - public UsersService getUsersService() { - return usersService; + /** In-memory services for UI testing without a backend (see MOCK_DATA in Program). */ + public static ServiceRegistry mock(Session session) { + MockStore store = new MockStore(); + return new ServiceRegistry( + new MockAuthService(store, session), + new MockUsersService(store), + new MockResourceService(store), + new MockFileService(store), + new MockExportService()); } } diff --git a/src/main/java/com/DesktopApplicationClientJava/services/impl/AuthServiceImpl.java b/src/main/java/com/DesktopApplicationClientJava/services/impl/AuthServiceImpl.java index 382de28..6538915 100644 --- a/src/main/java/com/DesktopApplicationClientJava/services/impl/AuthServiceImpl.java +++ b/src/main/java/com/DesktopApplicationClientJava/services/impl/AuthServiceImpl.java @@ -55,7 +55,9 @@ public User getCurrentUser() { @Override public boolean hasRole(Role required) { User current = session.getCurrentUser(); - return current != null && current.getRole() == required; + return current != null + && current.getRole() != null + && level(current.getRole()) >= level(required); } @Override @@ -72,4 +74,12 @@ private User toEntity(UserDto dto) { return new User( dto.uuid(), dto.email(), dto.role(), dto.firstName(), dto.lastName(), dto.enabled()); } + + private static int level(Role role) { + return switch (role) { + case USER -> 0; + case MODER -> 1; + case ADMIN -> 2; + }; + } } diff --git a/src/main/java/com/DesktopApplicationClientJava/services/impl/ResourceServiceImpl.java b/src/main/java/com/DesktopApplicationClientJava/services/impl/ResourceServiceImpl.java index 25d3725..b2473cf 100644 --- a/src/main/java/com/DesktopApplicationClientJava/services/impl/ResourceServiceImpl.java +++ b/src/main/java/com/DesktopApplicationClientJava/services/impl/ResourceServiceImpl.java @@ -6,6 +6,7 @@ import com.DesktopApplicationClientJava.Api.Dto.request.Resource.ResourceFilterRequest; import com.DesktopApplicationClientJava.Api.Dto.request.Resource.UpdateResourceRequest; import com.DesktopApplicationClientJava.Api.Dto.response.ResourceDto; +import com.DesktopApplicationClientJava.entities.FileInfo; import com.DesktopApplicationClientJava.entities.Resource; import com.DesktopApplicationClientJava.services.ResourceService; import com.DesktopApplicationClientJava.services.ServiceException; @@ -83,7 +84,13 @@ public void delete(UUID resourceId) { } private Resource toEntity(ResourceDto dto) { + List files = + dto.files() == null + ? List.of() + : dto.files().stream() + .map(f -> new FileInfo(f.uuid(), f.name(), f.contentType(), f.size())) + .toList(); return new Resource( - dto.uuid(), dto.title(), dto.description(), dto.createdAt(), dto.updatedAt()); + dto.uuid(), dto.title(), dto.description(), dto.createdAt(), dto.updatedAt(), files); } } diff --git a/src/main/java/com/DesktopApplicationClientJava/services/mock/MockAuthService.java b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockAuthService.java new file mode 100644 index 0000000..549a8a1 --- /dev/null +++ b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockAuthService.java @@ -0,0 +1,94 @@ +package com.DesktopApplicationClientJava.services.mock; + +import com.DesktopApplicationClientJava.entities.Role; +import com.DesktopApplicationClientJava.entities.User; +import com.DesktopApplicationClientJava.services.AuthService; +import com.DesktopApplicationClientJava.services.ServiceException; +import com.DesktopApplicationClientJava.session.Session; +import java.util.UUID; + +/** + * In-memory {@link AuthService}. Accepts any non-blank credentials; the role comes from the seeded + * user when the email matches, otherwise from the email prefix ({@code admin*} → ADMIN, {@code + * moder*} → MODER, anything else → USER). + */ +public class MockAuthService implements AuthService, MockService { + + private final MockStore store; + private final Session session; + + public MockAuthService(MockStore store, Session session) { + this.store = store; + this.session = session; + } + + @Override + public User login(String email, String password) { + if (email == null || email.isBlank() || password == null || password.isBlank()) { + throw new ServiceException("Неверный логин или пароль", 401); + } + User user = findByEmail(email); + if (user == null) { + user = new User(UUID.randomUUID(), email.trim(), roleByEmail(email), "Мок", "", true); + } + session.setCurrentUser(user); + return user; + } + + @Override + public void logout() { + session.clear(); + } + + @Override + public User getCurrentUser() { + return session.getCurrentUser(); + } + + @Override + public boolean hasRole(Role required) { + User current = session.getCurrentUser(); + return current != null + && current.getRole() != null + && level(current.getRole()) >= level(required); + } + + @Override + public boolean isAdmin() { + return hasRole(Role.ADMIN); + } + + @Override + public boolean isModerator() { + return hasRole(Role.MODER); + } + + private User findByEmail(String email) { + String normalized = email.trim(); + return store.getUsers().values().stream() + .filter(user -> user.getEmail().equalsIgnoreCase(normalized)) + .findFirst() + .orElse(null); + } + + private static Role roleByEmail(String email) { + String local = email.trim().toLowerCase(); + int at = local.indexOf('@'); + String prefix = at < 0 ? local : local.substring(0, at); + if (prefix.startsWith("admin")) { + return Role.ADMIN; + } + if (prefix.startsWith("moder")) { + return Role.MODER; + } + return Role.USER; + } + + private static int level(Role role) { + return switch (role) { + case USER -> 0; + case MODER -> 1; + case ADMIN -> 2; + }; + } +} diff --git a/src/main/java/com/DesktopApplicationClientJava/services/mock/MockExportService.java b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockExportService.java new file mode 100644 index 0000000..3f937eb --- /dev/null +++ b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockExportService.java @@ -0,0 +1,39 @@ +package com.DesktopApplicationClientJava.services.mock; + +import com.DesktopApplicationClientJava.services.ExportService; +import com.DesktopApplicationClientJava.services.ServiceException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +/** In-memory {@link ExportService}. Writes small placeholder files. */ +public class MockExportService implements ExportService, MockService { + + @Override + public Path exportUsers(Path destinationDir) { + return write(destinationDir, "export-users.txt", "mock users export\n"); + } + + @Override + public Path exportResources(Path destinationDir) { + return write(destinationDir, "export-resources.txt", "mock resources export\n"); + } + + @Override + public Path exportResourceFiles(UUID resourceUuid, Path destinationDir) { + return write(destinationDir, "export-resource-" + resourceUuid + ".txt", "mock files export\n"); + } + + private static Path write(Path destinationDir, String fileName, String content) { + try { + Files.createDirectories(destinationDir); + Path saved = destinationDir.resolve(fileName); + Files.write(saved, content.getBytes(StandardCharsets.UTF_8)); + return saved; + } catch (IOException e) { + throw new ServiceException("Ошибка записи файла"); + } + } +} diff --git a/src/main/java/com/DesktopApplicationClientJava/services/mock/MockFileService.java b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockFileService.java new file mode 100644 index 0000000..baadbd8 --- /dev/null +++ b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockFileService.java @@ -0,0 +1,82 @@ +package com.DesktopApplicationClientJava.services.mock; + +import com.DesktopApplicationClientJava.entities.FileInfo; +import com.DesktopApplicationClientJava.entities.Resource; +import com.DesktopApplicationClientJava.services.FileService; +import com.DesktopApplicationClientJava.services.ServiceException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +/** In-memory {@link FileService}. Downloads produce a real file with placeholder content. */ +public class MockFileService implements FileService, MockService { + + private final MockStore store; + + public MockFileService(MockStore store) { + this.store = store; + } + + @Override + public FileInfo uploadFile(UUID resourceId, Path file) { + Resource resource = findResource(resourceId); + try { + byte[] bytes = Files.readAllBytes(file); + String contentType = Files.probeContentType(file); + FileInfo info = + new FileInfo( + UUID.randomUUID(), + file.getFileName().toString(), + contentType == null ? "application/octet-stream" : contentType, + (long) bytes.length); + resource.getFiles().add(info); + store.getFileBytes().put(info.getUuid(), bytes); + return info; + } catch (IOException e) { + throw new ServiceException("Ошибка чтения файла"); + } + } + + @Override + public Path downloadFile(UUID resourceId, UUID fileId, Path destinationDir) { + Resource resource = findResource(resourceId); + FileInfo info = + resource.getFiles().stream() + .filter(file -> file.getUuid().equals(fileId)) + .findFirst() + .orElseThrow(() -> new ServiceException("Файл не найден", 404)); + byte[] bytes = + store + .getFileBytes() + .getOrDefault( + fileId, ("mock content of " + info.getName()).getBytes(StandardCharsets.UTF_8)); + try { + Files.createDirectories(destinationDir); + Path saved = destinationDir.resolve(info.getName()); + Files.write(saved, bytes); + return saved; + } catch (IOException e) { + throw new ServiceException("Ошибка записи файла"); + } + } + + @Override + public void deleteFile(UUID resourceId, UUID fileId) { + Resource resource = findResource(resourceId); + boolean removed = resource.getFiles().removeIf(file -> file.getUuid().equals(fileId)); + if (!removed) { + throw new ServiceException("Файл не найден", 404); + } + store.getFileBytes().remove(fileId); + } + + private Resource findResource(UUID resourceId) { + Resource resource = store.getResources().get(resourceId); + if (resource == null) { + throw new ServiceException("Ресурс не найден", 404); + } + return resource; + } +} diff --git a/src/main/java/com/DesktopApplicationClientJava/services/mock/MockResourceService.java b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockResourceService.java new file mode 100644 index 0000000..7689d38 --- /dev/null +++ b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockResourceService.java @@ -0,0 +1,78 @@ +package com.DesktopApplicationClientJava.services.mock; + +import com.DesktopApplicationClientJava.entities.Resource; +import com.DesktopApplicationClientJava.services.ResourceService; +import com.DesktopApplicationClientJava.services.ServiceException; +import com.DesktopApplicationClientJava.services.filter.ResourceFilter; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** In-memory {@link ResourceService} over {@link MockStore}. */ +public class MockResourceService implements ResourceService, MockService { + + private final MockStore store; + + public MockResourceService(MockStore store) { + this.store = store; + } + + @Override + public List 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + +