diff --git a/.idea/libraries/gson_2_9_0.xml b/.idea/libraries/gson_2_9_0.xml new file mode 100644 index 0000000..2377008 --- /dev/null +++ b/.idea/libraries/gson_2_9_0.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index be7c42d..9e28842 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,147 @@ -# java-kanban -### Основа для менеджера задач +# Java Kanban - Менеджер задач ---- -## Содержание -- #### Main - пример добавления задачи -- #### TaskManager - основные методы работы программы -- #### Task - родитель всех задач -- #### Epic - задача, способная хранить другие задачи -- #### SubTask - задача, принадлежащая эпику -- #### Status - статусы задач +## Описание проекта ---- +**Java Kanban** - это бэкенд-система для управления задачами, реализующая менеджер задач и HTTP-сервер. -## Особенности задач -### Каждая задача обладает: +Система поддерживает три типа задач: обычные задачи(Task), эпики(Epic) и подзадачи(SubTask). -- #### уникальным id -- #### названием -- #### описанием -- #### статусом +## Функциональные возможности -#### Эпик хранит каждую подзадачу, которая в него входит -#### Подзадача хранит id эпика, которому принадлежит +### Управление задачами +- Создание, обновление и удаление задач всех типов +- Хранение истории просмотров задач +- Очистка всей истории просмотров +- Обработка ошибок при создании и обновлении задач ---- +### Получение информации +- Получение списков всех задач/эпиков/подзадач +- Поиск задач по ID +- Получение всех подзадач конкретного эпика +- Получение задач в порядке приоритета (по времени начала) -## TaskManager -Здесь хранятся основным методы работы с задачами и сами задачи -в таблицах (id=задача) +## Технологический стек +- **Java** - основной язык программирования +- **JUnit 5** - фреймворк для модульного тестирования +- **Gson** - библиотека для работы с JSON -- Добавление задачи в таблицу -- Получение задачи по id -- Удаление задачи из таблицы -- Удаление всех задач или всех задач из определенной задачи -- Печать задач в разных форматах -- Обновление задачи (задача меняет свое название или описание) +## API -Пользователь способен обновить статус задачи, обновив саму задачу. -При это обновление статуса эпика происходит независимо от пользователя, -а благодаря вычислениям +### Поддерживаемые форматы времени +Система поддерживает следующие форматы для полей `startTime`: +- `DateTimeFormatter.ISO_LOCAL_DATE_TIME` - `2023-10-15T14:30:00` +- `DateTimeFormatter.ISO_DATE_TIME` - `2023-10-15T14:30:00.000+03:00` +- `yyyy-MM-dd HH:mm:ss.SSS` - `2023-10-15 14:30:00.000` +- `yyyy-MM-dd HH:mm:ss` - `2023-10-15 14:30:00` +- `dd.MM.yyyy HH:mm:ss` - `15.10.2023 14:30:00` + +### Коды состояния HTTP + +Система использует следующие HTTP коды состояния для обработки запросов: + +| Код | Описание | +|---------|-----------------------------------------------------------------------------------------------------------| +| **200** | Успешный запрос (GET, HEAD, DELETE, OPTIONS) | +| **201** | Успешное создание или обновление ресурса (POST) | +| **400** | Неверный формат JSON или отсутствуют обязательные поля, неверный формат времени, id не число или меньше 0 | +| **404** | Задача не найдена или неверный путь | +| **405** | Метод не разрешен для ресурса | +| **406** | Пересечение временных интервалов с существующими задачами | +| **500** | Внутренняя ошибка сервера | + +### Задачи (Tasks) + +| Метод | Путь | Действие | Коды ответа | +|-------|------|----------|-------------| +| HEAD | `/tasks` или `/tasks/{id}` | Получить заголовки | 200, 404 | +| GET | `/tasks` | Получить все задачи | 200 | +| GET | `/tasks/{id}` | Получить задачу по ID | 200, 404 | +| POST | `/tasks` | Создать новую задачу | 201, 400, 406 | +| DELETE | `/tasks` | Удалить все задачи | 200 | +| DELETE | `/tasks/{id}` | Удалить задачу по ID | 200, 404 | +| OPTIONS | `/tasks` или `/tasks/{id}` | Получить разрешенные методы | 200 | + +#### Формат JSON для задачи +```json +{ + "taskId": 0, // 0 - для создания, >0 - для обновления (обязательное) + "title": "string", // обязательное поле + "description": "string", // обязательное поле + "status": "NEW|IN_PROGRESS|DONE", // обязательное поле + "duration": 10, // продолжительность в минутах (опционально) + "startTime": "1970-01-01T00:00:00.000" // дата и время ISO (опционально) +} +``` + +### Эпики (Epics) + +| Метод | Путь | Действие | Коды ответа | +|-------|------|----------|-------------| +| HEAD | любой путь | Получить заголовки | 200, 404 | +| GET | `/epics` | Получить все эпики | 200 | +| GET | `/epics/{id}` | Получить эпик по ID | 200, 404 | +| GET | `/epics/{id}/subtasks` | Получить все подзадачи эпика | 200, 404 | +| POST | `/epics` | Создать новый эпик | 201, 400, 406 | +| DELETE | `/epics` | Удалить все эпики | 200 | +| DELETE | `/epics/{id}` | Удалить эпик по ID | 200, 404 | +| DELETE | `/epics/{id}/subtasks` | Удалить все подзадачи эпика | 200, 404 | +| OPTIONS | любой путь | Получить разрешенные методы | 200 | + +#### Формат JSON для эпика +```json +{ + "taskId": 0, //0 - для создания, >0 - для обновления (обязательное) + "title": "string", // обязательное поле + "description": "string", // обязательное поле + "status": "NEW|IN_PROGRESS|DONE", // только для чтения + "subtasks": [1, 2, 3], // только для чтения + "duration": 10, // только для чтения + "startTime": "1970-01-01T00:00:00.000" // только для чтения +} +``` + +### Подзадачи (Subtasks) + +| Метод | Путь | Действие | Коды ответа | +|-------|------|----------|-------------| +| HEAD | `/subtasks` или `/subtasks/{id}` | Получить заголовки | 200, 404 | +| GET | `/subtasks` | Получить все подзадачи | 200 | +| GET | `/subtasks/{id}` | Получить подзадачу по ID | 200, 404 | +| POST | `/subtasks` | Создать новую подзадачу | 201, 400, 406 | +| DELETE | `/subtasks` | Удалить все подзадачи | 200 | +| DELETE | `/subtasks/{id}` | Удалить подзадачу по ID | 200, 404 | + +#### Формат JSON для подзадачи +```json +{ + "taskId": 0, // 0 - для создания, >0 - для обновления (обязательное) + "title": "string", // обязательное поле + "description": "string", // обязательное поле + "status": "NEW|IN_PROGRESS|DONE", // обязательное поле + "epicId": 123, // обязательное поле - ID родительского эпика + "duration": 10, // продолжительность в минутах (опционально) + "startTime": "1970-01-01T00:00:00.000" // дата и время ISO (опционально) +} +``` + +## История просмотров (History) + +| Метод | Путь | Действие | Коды ответа | +|-------|------|----------|-------------| +| HEAD | `/history` | Получить заголовки | 200 | +| GET | `/history` | Получить историю просмотров | 200 | +| OPTIONS | `/history` | Получить разрешенные методы | 200 | + +## Список приоритетов (Prioritized) + +| Метод | Путь | Действие | Коды ответа | +|-------|------|----------|-------------| +| HEAD | `/prioritized` | Получить заголовки | 200 | +| GET | `/prioritized` | Получить отсортированный список задач | 200 | +| OPTIONS | `/prioritized` | Получить разрешенные методы | 200 | + +## Тестирование + +Проект включает модульные тесты с использованием JUnit 5 для проверки функциональности менеджера задач и HTTP-сервера. \ No newline at end of file diff --git a/java-kanban.iml b/java-kanban.iml index 66c15d2..48a7097 100644 --- a/java-kanban.iml +++ b/java-kanban.iml @@ -24,5 +24,6 @@ + \ No newline at end of file diff --git a/src/http/HttpTaskServer.java b/src/http/HttpTaskServer.java new file mode 100644 index 0000000..0939d82 --- /dev/null +++ b/src/http/HttpTaskServer.java @@ -0,0 +1,67 @@ +package http; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.sun.net.httpserver.HttpServer; +import http.handlers.*; +import managers.InMemoryTaskManager; +import managers.TaskManager; +import util.gsonadapters.DurationAdapter; +import util.gsonadapters.LocalDateTimeAdapter; +import util.http.JsonBuilder; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.time.LocalDateTime; + +public class HttpTaskServer { + private final int port = 8080; + private HttpServer httpServer; + private final TaskManager manager; + + private final Gson gson; + private final JsonBuilder jsonBuilder; + + public static void main(String[] args) { + new HttpTaskServer(new InMemoryTaskManager()).start(); + } + + public HttpTaskServer(TaskManager manager) { + this.manager = manager; + this.gson = new GsonBuilder() + .registerTypeAdapter(LocalDateTime.class, new LocalDateTimeAdapter()) + .registerTypeAdapter(Duration.class, new DurationAdapter()) + .create(); + this.jsonBuilder = new JsonBuilder(gson); + createServer(); + } + + private void createServer() { + try { + httpServer = HttpServer.create(new InetSocketAddress(port), 0); + httpServer.createContext("/tasks", new TaskHandler(manager, gson, jsonBuilder)); + httpServer.createContext("/epics", new EpicsHandler(manager, gson, jsonBuilder)); + httpServer.createContext("/subtasks", new SubTasksHandler(manager, gson, jsonBuilder)); + httpServer.createContext("/history", new HistoryHandler(manager, gson, jsonBuilder)); + httpServer.createContext("/prioritized", new PrioritizedHandler(manager, gson, jsonBuilder)); + + } catch (IOException e) { + e.printStackTrace(); + } + } + + public void start() { + try { + httpServer.start(); + System.out.println("Server is running: " + httpServer.getAddress().getPort()); + } catch (IllegalStateException e) { + System.err.println("Server is already running"); + } + } + + public void stop() { + httpServer.stop(0); + System.out.println("Server has been stopped"); + } +} diff --git a/src/http/handlers/BaseHttpHandler.java b/src/http/handlers/BaseHttpHandler.java new file mode 100644 index 0000000..37a1f5e --- /dev/null +++ b/src/http/handlers/BaseHttpHandler.java @@ -0,0 +1,236 @@ +package http.handlers; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import managers.TaskManager; +import util.http.JsonBuilder; +import util.http.RequestSegments; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import static util.enums.Endpoint.*; +import static util.http.RequestSegments.getRequestSegments; + +/** + * Абстрактный базовый класс для обработки HTTP-запросов. + *

+ * Предоставляет общую функциональность для обработки HTTP-запросов, включая: + *

+ * + * + *

Жизненный цикл обработки запроса:

+ *
    + *
  1. Парсинг сегментов пути из URI
  2. + *
  3. Валидация сегментов и HTTP-метода
  4. + *
  5. Проверка ресурсов (абстрактный метод)
  6. + *
  7. Маршрутизация на соответствующий обработчик метода
  8. + *
  9. Обработка исключений и отправка ошибок
  10. + *
+ * + *

Поддерживаемые HTTP-методы: GET, POST, DELETE, OPTIONS, HEAD

+ * + *

Особенности обработки:

+ * + * + *

Наследование: Классы-наследники должны реализовать абстрактные методы + * для конкретной логики обработки ресурсов.

+ */ +public abstract class BaseHttpHandler implements HttpHandler { + private final Charset defaultCharset = StandardCharsets.UTF_8; + protected final TaskManager manager; + + protected Gson gson; + protected JsonBuilder jsonBuilder; + + public BaseHttpHandler(TaskManager manager, Gson gson, JsonBuilder jsonBuilder) { + this.manager = manager; + this.gson = gson; + + this.jsonBuilder = jsonBuilder; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + RequestSegments segments = getPreparedSegments(exchange); + + if (segments == null || validateResources(exchange, segments)) { + return; + } + + mapEndpoints(exchange, segments); + + } catch (JsonSyntaxException e) { + sendText(exchange, jsonBuilder.badRequest("Invalid Json"), 400); + } catch (IOException e) { + e.printStackTrace(); + } catch (Exception e) { + try { + sendServerError(exchange); + e.printStackTrace(); + } catch (IOException ex) { + System.err.println("Failed to send response"); + ex.printStackTrace(); + } + } + } + + /** + * Подготавливает и валидирует сегменты запроса. + * + * @param exchange HTTP-обмен для анализа + * @return подготовленные сегменты запроса или {@code null} если валидация не пройдена + * @throws IOException если возникает ошибка при обработке запроса + */ + private RequestSegments getPreparedSegments(HttpExchange exchange) throws IOException { + RequestSegments segments = getRequestSegments(exchange); + + jsonBuilder.setSegments(segments); + + if (!isSegmentsValid(exchange, segments)) { + return null; + } + + return segments; + } + + /** + * Маршрутизирует запрос на соответствующий обработчик метода. + * + * @param exchange HTTP-обмен для обработки + * @param segments разобранные сегменты запроса + * @throws IOException если возникает ошибка при обработке запроса + */ + private void mapEndpoints(HttpExchange exchange, RequestSegments segments) throws IOException { + switch (segments.endpoint()) { + case OPTIONS -> handleOptions(exchange, segments); + case GET -> handleGet(exchange, segments); + case POST -> handlePost(exchange, segments); + case DELETE -> handleDelete(exchange, segments); + default -> sendText(exchange, jsonBuilder.badRequest("No such endpoint"), 400); + } + } + + /** + * Проверяет валидность сегментов запроса. + * Отправляет ответ, если проверка не пройдена. + *

+ * Выполняет следующие проверки: + *

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты для валидации + * @return {@code true} если сегменты валидны, иначе {@code false} + * @throws IOException если возникает ошибка при отправке ответа об ошибке + */ + private boolean isSegmentsValid(HttpExchange exchange, RequestSegments segments) throws IOException { + + if (segments.endpoint() == INVALID_METHOD) { + exchange.getResponseHeaders().add("Allow", "GET, POST, DELETE, HEAD, OPTIONS"); + sendMethodNotAllowed(exchange); + return false; + } else if (segments.endpoint() == INVALID) { + sendText(exchange, jsonBuilder.badRequest("No such endpoint"), 400); + return false; + } else if (segments.endpoint() == INVALID_SUBRESOURCE) { + sendNotFound(exchange, jsonBuilder.tooMuchSubResources()); + return false; + } else if (segments.id() == -1) { + sendText(exchange, jsonBuilder.invalidId(), 400); + return false; + } else if (segments.subResource().isPresent() && !segments.resource().equals("epics")) { + sendNotFound(exchange, jsonBuilder.subresourceNotFound()); + return false; + } + + return true; + } + + + protected void sendText(HttpExchange exchange, String responseString, int responseCode) throws IOException { + byte[] resp = responseString.getBytes(defaultCharset); + exchange.getResponseHeaders().add("Content-Type", "application/json;charset=utf-8"); + exchange.getResponseHeaders().add("Content-Length", String.valueOf(resp.length)); + + if (!"HEAD".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(responseCode, resp.length); + exchange.getResponseBody().write(resp); + } else { + exchange.sendResponseHeaders(responseCode, -1); + } + + exchange.close(); + } + + protected void sendNotFound(HttpExchange exchange, String responseString) throws IOException { + sendText(exchange, responseString, 404); + } + + protected void sendHasOverlaps(HttpExchange exchange, String responseString) throws IOException { + sendText(exchange, responseString, 406); + } + + protected void sendMethodNotAllowed(HttpExchange exchange) throws IOException { + exchange.sendResponseHeaders(405, -1); + exchange.close(); + } + + protected void sendServerError(HttpExchange exchange) throws IOException { + exchange.sendResponseHeaders(500, -1); + exchange.close(); + } + + /** + * Проверяет запрет метода POST для ресурсов с идентификатором. + * Отправляет ответ, если метод запрещен + *

+ * POST запросы разрешены только для формата пути /resource. + * Для форматов /resource/id и /resource/id/subresource + * ресурсов разрешены только GET, DELETE, OPTIONS, HEAD. + *

+ * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @return true если операция POST запрещена, false если разрешена + * @throws IOException если возникает ошибка при отправке ответа об ошибке + */ + protected boolean isPostForbidden(HttpExchange exchange, RequestSegments segments) throws IOException { + if (segments.id() != 0) { + exchange.getResponseHeaders().add("Allow", "GET, DELETE, OPTIONS, HEAD"); + sendMethodNotAllowed(exchange); + return true; + } + return false; + } + + protected abstract void handleOptions(HttpExchange exchange, RequestSegments segments) throws IOException; + + protected abstract void handleGet(HttpExchange exchange, RequestSegments segments) throws IOException; + + protected abstract void handlePost(HttpExchange exchange, RequestSegments segments) throws IOException; + + protected abstract void handleDelete(HttpExchange exchange, RequestSegments segments) throws IOException; + + protected abstract boolean validateResources(HttpExchange exchange, RequestSegments segments) throws IOException; +} diff --git a/src/http/handlers/EpicsHandler.java b/src/http/handlers/EpicsHandler.java new file mode 100644 index 0000000..0eead44 --- /dev/null +++ b/src/http/handlers/EpicsHandler.java @@ -0,0 +1,269 @@ +package http.handlers; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.sun.net.httpserver.HttpExchange; +import managers.TaskManager; +import model.Epic; +import model.Task; +import util.enums.Endpoint; +import util.exceptions.TaskNotFound; +import util.http.JsonBuilder; +import util.http.RequestSegments; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.format.DateTimeParseException; + +/** + * Обработчик HTTP-запросов для управления эпиками (Epic) и их подзадачами. + *

+ * Расширяет функциональность {@link TaskHandler} для работы с эпиками. + * Обеспечивает REST API для операций CRUD над эпиками и их подзадачами. + *

+ * + *

Поддерживаемые эндпоинты для эпиков:

+ * + * + * + * + * + * + * + * + * + * + * + * + * + *
МетодПутьДействие
HEADлюбой путьПолучить заголовки
GET/epicsПолучить все эпики
GET/epics/{id}Получить эпик по Id
GET/epics/{id}/subtasksПолучить все подзадачи эпика
POST/epicsСоздать новый эпик
POST/epics/{id}Запрещено - метод не разрешен
POST/epics/{id}/subtasksЗапрещено - метод не разрешен
DELETE/epicsУдалить все эпики
DELETE/epics/{id}Удалить эпик по Id
DELETE/epics/{id}/subtasksУдалить все подзадачи эпика
OPTIONSлюбой путьПолучить разрешенные методы
+ * + *

Формат JSON для эпика:

+ *
+ * {
+ *   "taskId": 0,           // 0 для создания, >0 для обновления
+ *   "title": "string",     // обязательное поле
+ *   "description": "string", // обязательное поле
+ *   "status": "NEW|IN_PROGRESS|DONE", // (только для чтения)
+ *   "subtasks": [1, 2, 3], // (только для чтения)
+ *   "duration": 10,        // (только для чтения)
+ *   "startTime": "1970-01-01T00:00:00.000" //(только для чтения)
+ * }
+ * 
+ * + *

Обработка ошибок:

+ * + */ +public class EpicsHandler extends TaskHandler { + + public EpicsHandler(TaskManager manager, Gson gson, JsonBuilder jsonBuilder) { + super(manager, gson, jsonBuilder); + } + + /** + * Обрабатывает HTTP-метод OPTIONS для эпиков. + *

+ * Возвращает разрешенные методы в зависимости от контекста: + *

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handleOptions(HttpExchange exchange, RequestSegments segments) throws IOException { + if (segments.id() == 0 && segments.subResource().isEmpty()) { + exchange.getResponseHeaders().add("Allow", "GET, POST, DELETE, OPTIONS, HEAD"); + } else { + exchange.getResponseHeaders().add("Allow", "GET, DELETE, OPTIONS, HEAD"); + } + exchange.sendResponseHeaders(204, -1); + } + + /** + * Обрабатывает HTTP-метод GET для эпиков. + *

+ * В зависимости от пути запроса: + *

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handleGet(HttpExchange exchange, RequestSegments segments) throws IOException { + int id = segments.id(); + + if (segments.subResource().isPresent()) { + if (manager.getEpicWithoutHistory(id) != null) { + sendText(exchange, gson.toJson(manager.getSubTasksFromEpic(id)), 200); + } else { + sendNotFound(exchange, jsonBuilder.notFound("Epic with id '" + id + "'not found")); + } + } else if (id == 0) { + sendText(exchange, gson.toJson(manager.getEpics()), 200); + } else { + Epic epic = manager.getEpic(id); + if (epic != null) { + sendText(exchange, gson.toJson(epic), 200); + } else { + sendNotFound(exchange, jsonBuilder.notFound("Epic with id '" + id + "'not found")); + } + } + } + + /** + * Обрабатывает HTTP-метод POST для эпиков. + *

+ * Создает новый эпик или обновляет существующий: + *

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handlePost(HttpExchange exchange, RequestSegments segments) throws IOException { + if (isPostForbidden(exchange, segments)) { + return; + } + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + + if (body.isBlank()) { + sendText(exchange, jsonBuilder.badRequest("Body is required"), 400); + } + try { + Epic epic = gson.fromJson(body, Epic.class); + isTaskValid(epic); + + if (epic.getTaskId() == 0) { + //новый эпик для безопасного создания + epic = new Epic(epic.getTitle(), epic.getDescription(), epic.getStatus()); + manager.addEpic(epic); + } else { + manager.updateEpic(epic); + } + sendText(exchange, gson.toJson(manager.getEpicWithoutHistory(epic.getTaskId())), 201); + } catch (DateTimeParseException e) { + sendText(exchange, jsonBuilder.badRequest("Invalid date format"), 400); + } catch (TaskNotFound e) { + sendNotFound(exchange, jsonBuilder.notFound(e.getMessage())); + } + } + + /** + * Обрабатывает HTTP-метод DELETE для эпиков. + *

+ * В зависимости от пути запроса: + *

+ * + * + *

Удаление эпика автоматически удаляет все его подзадачи.

+ * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handleDelete(HttpExchange exchange, RequestSegments segments) throws IOException { + + if (segments.subResource().isPresent()) { + try { + manager.clearSubTasksFromEpic(segments.id()); + sendText(exchange, + jsonBuilder.message("Subtasks from epic: '" + segments.id() + "' were deleted"), + 200); + } catch (TaskNotFound e) { + sendNotFound(exchange, jsonBuilder.notFound(e.getMessage())); + } + } else if (segments.id() == 0) { + manager.clearEpics(); + sendText(exchange, jsonBuilder.message("Epics were deleted"), 200); + } else { + manager.deleteEpic(segments.id()); + sendText(exchange, jsonBuilder.message("Epic: '" + segments.id() + "' was deleted"), 200); + } + } + + /** + * Проверяет корректность ресурса и подресурсов в запросе. + *

+ * Выполняет следующие проверки: + *

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @return true если ресурс неверен или запрос запрещен, иначе false + * @throws IOException если возникает ошибка при отправке ответа об ошибке + */ + @Override + protected boolean validateResources(HttpExchange exchange, RequestSegments segments) throws IOException { + if (!"epics".equals(segments.resource())) { + sendNotFound(exchange, jsonBuilder.resourceNotFound()); + return true; + } + + if (segments.subResource().isPresent()) { + if (!segments.subResource().get().equals("subtasks")) { + sendNotFound(exchange, jsonBuilder.subresourceNotFound()); + return true; + } + if (segments.endpoint() == Endpoint.POST) { + exchange.getResponseHeaders().add("Allow", "GET, DELETE, HEAD, OPTIONS"); + sendMethodNotAllowed(exchange); + return true; + } + } + return false; + } + + /** + * Проверяет валидность объекта эпика. + *

+ * Проверяет наличие обязательных полей: + *

+ * + * + *

Отличие от TaskHandler: для эпиков статус не является обязательным полем, + * так как он рассчитывается автоматически на основе подзадач.

+ * + * @param task эпик для валидации + * @throws JsonSyntaxException если отсутствуют обязательные поля + */ + @Override + protected void isTaskValid(Task task) { + if (task.getTitle() == null || task.getDescription() == null) { + throw new JsonSyntaxException("Epic has invalid fields"); + } + } +} diff --git a/src/http/handlers/HistoryHandler.java b/src/http/handlers/HistoryHandler.java new file mode 100644 index 0000000..876980b --- /dev/null +++ b/src/http/handlers/HistoryHandler.java @@ -0,0 +1,152 @@ +package http.handlers; + +import com.google.gson.Gson; +import com.sun.net.httpserver.HttpExchange; +import managers.TaskManager; +import util.http.JsonBuilder; +import util.http.RequestSegments; + +import java.io.IOException; + +/** + * Обработчик HTTP-запросов для получения истории просмотров задач. + *

+ * Предоставляет доступ к истории последних просмотренных задач. + *

+ * + *

Поддерживаемые эндпоинты:

+ * + * + * + * + * + * + * + * + *
МетодПутьДействие
HEAD/historyПолучить заголовки
GET/historyПолучить историю просмотров
OPTIONS/historyПолучить разрешенные методы
POST/historyЗапрещено - метод не разрешен
DELETE/historyЗапрещено - метод не разрешен
GET/history/{id}Запрещено - метод не разрешен
+ * + *

Формат ответа:

+ *
+ * [
+ *   {
+ *     "taskId": 1,
+ *     "title": "Название задачи",
+ *     "description": "Описание задачи",
+ *     "status": "NEW",
+ *     "type": "TASK|EPIC|SUBTASK"
+ *     // ... другие поля задачи в зависимости от типа
+ *   },
+ *   {
+ *     "taskId": 2,
+ *     "title": "Другая задача",
+ *     // ...
+ *   }
+ * ]
+ * 
+ * + *

Обработка ошибок:

+ * + */ +public class HistoryHandler extends BaseHttpHandler { + + public HistoryHandler(TaskManager manager, Gson gson, JsonBuilder jsonBuilder) { + super(manager, gson, jsonBuilder); + } + + /** + * Обрабатывает HTTP-метод OPTIONS для истории. + *

+ * Возвращает разрешенные методы для ресурса истории: + * GET, OPTIONS, HEAD + *

+ * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handleOptions(HttpExchange exchange, RequestSegments segments) throws IOException { + exchange.getResponseHeaders().add("Allow", "GET, OPTIONS, HEAD"); + exchange.sendResponseHeaders(204, -1); + } + + /** + * Обрабатывает HTTP-метод GET для истории. + *

+ * Возвращает полную историю просмотров задач в формате JSON. + *

+ * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handleGet(HttpExchange exchange, RequestSegments segments) throws IOException { + sendText(exchange, gson.toJson(manager.getHistory()), 200); + } + + /** + * Обрабатывает HTTP-метод POST для истории или списка приоритетов. + *

+ * Ресурс только для чтения, поэтому POST запросы запрещены. + * Возвращает статус 405 Method Not Allowed с заголовком Allow. + *

+ * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handlePost(HttpExchange exchange, RequestSegments segments) throws IOException { + exchange.getResponseHeaders().add("Allow", "GET, HEAD, OPTIONS"); + sendMethodNotAllowed(exchange); + } + + /** + * Обрабатывает HTTP-метод DELETE для истории или списка приоритетов. + *

+ * Ресурс только для чтения, поэтому DELETE запросы запрещены. + * Возвращает статус 405 Method Not Allowed с заголовком Allow. + *

+ * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handleDelete(HttpExchange exchange, RequestSegments segments) throws IOException { + exchange.getResponseHeaders().add("Allow", "GET, HEAD, OPTIONS"); + sendMethodNotAllowed(exchange); + } + + /** + * Проверяет корректность ресурса и пути запроса для истории. + *

+ * Выполняет следующие проверки: + *

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @return true если путь неверен, иначе false + * @throws IOException если возникает ошибка при отправке ответа 404 + */ + @Override + protected boolean validateResources(HttpExchange exchange, RequestSegments segments) throws IOException { + if (!"history".equals(segments.resource())) { + sendNotFound(exchange, jsonBuilder.resourceNotFound()); + return true; + } else if (segments.id() != 0) { + sendNotFound(exchange, jsonBuilder.subresourceNotFound()); + return true; + } + return false; + } +} diff --git a/src/http/handlers/PrioritizedHandler.java b/src/http/handlers/PrioritizedHandler.java new file mode 100644 index 0000000..4b7e85d --- /dev/null +++ b/src/http/handlers/PrioritizedHandler.java @@ -0,0 +1,103 @@ +package http.handlers; + +import com.google.gson.Gson; +import com.sun.net.httpserver.HttpExchange; +import managers.TaskManager; +import util.http.JsonBuilder; +import util.http.RequestSegments; + +import java.io.IOException; + +/** + * Обработчик HTTP-запросов для получения отсортированного списка задач по приоритету. + *

+ * Расширяет функциональность {@link HistoryHandler} для обработки запрещенных методов (POST, DELETE). + *

+ * + *

Поддерживаемые эндпоинты:

+ * + * + * + * + * + * + * + * + *
МетодПутьДействие
HEAD/prioritizedПолучить заголовки
GET/prioritizedПолучить отсортированный список задач
OPTIONS/prioritizedПолучить разрешенные методы
POST/prioritizedЗапрещено - метод не разрешен
DELETE/prioritizedЗапрещено - метод не разрешен
GET/prioritized/{id}Запрещено - метод не разрешен
+ * + *

Формат ответа:

+ *
+ * [
+ *   {
+ *     "taskId": 3,
+ *     "title": "Ранняя задача",
+ *     "startTime": "1970-01-01T00:00:00.000",
+ *     "duration": 10,
+ *     // ... другие поля задачи
+ *   },
+ *   {
+ *     "taskId": 1,
+ *     "title": "Задача с временем",
+ *     "startTime": "2000-01-01T00:00:00.000",
+ *     "duration": 10,
+ *     // ...
+ *   }
+ * ]
+ * 
+ * + *

Обработка ошибок:

+ * + */ +public class PrioritizedHandler extends HistoryHandler { + + public PrioritizedHandler(TaskManager manager, Gson gson, JsonBuilder jsonBuilder) { + super(manager, gson, jsonBuilder); + } + + /** + * Обрабатывает HTTP-метод GET для приоритизированного списка. + *

+ * Возвращает задачи, отсортированные по времени начала (startTime). + *

+ * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + * @see util.TaskTimeController#getPrioritizedTasks() + */ + @Override + protected void handleGet(HttpExchange exchange, RequestSegments segments) throws IOException { + sendText(exchange, gson.toJson(manager.getPrioritizedTasks()), 200); + } + + /** + * Проверяет корректность ресурса и пути запроса для приоритизированного списка. + *

+ * Выполняет следующие проверки: + *

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @return true если путь неверен, иначе false + * @throws IOException если возникает ошибка при отправке ответа 404 + */ + @Override + protected boolean validateResources(HttpExchange exchange, RequestSegments segments) throws IOException { + if (!"prioritized".equals(segments.resource())) { + sendNotFound(exchange, jsonBuilder.resourceNotFound()); + return true; + } else if (segments.id() != 0) { + sendNotFound(exchange, jsonBuilder.subresourceNotFound()); + return true; + } + return false; + } +} diff --git a/src/http/handlers/SubTasksHandler.java b/src/http/handlers/SubTasksHandler.java new file mode 100644 index 0000000..4b43e5d --- /dev/null +++ b/src/http/handlers/SubTasksHandler.java @@ -0,0 +1,215 @@ +package http.handlers; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.sun.net.httpserver.HttpExchange; +import managers.TaskManager; +import model.SubTask; +import model.Task; +import util.exceptions.TaskNotFound; +import util.exceptions.TaskTimeOverlapException; +import util.http.JsonBuilder; +import util.http.RequestSegments; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.format.DateTimeParseException; + +/** + * Обработчик HTTP-запросов для управления подзадачами (SubTask). + *

+ * Расширяет функциональность {@link TaskHandler} для работы с подзадачами. + * Обеспечивает REST API для операций CRUD над подзадачами. + *

+ * + *

Поддерживаемые эндпоинты для подзадач:

+ * + * + * + * + * + * + * + * + * + *
МетодПутьДействие
HEAD/subtasks или /subtasks/{id}Получить заголовки
GET/subtasksПолучить все подзадачи
GET/subtasks/{id}Получить подзадачу по Id
POST/subtasksСоздать новую подзадачу
POST/subtasks/{id}Запрещено - метод не разрешен
DELETE/subtasksУдалить все подзадачи
DELETE/subtasks/{id}Удалить подзадачу по Id
+ * + *

Формат JSON для подзадачи:

+ *
+ * {
+ *   "taskId": 0,           // 0 для создания, >0 для обновления
+ *   "title": "string",     // обязательное поле
+ *   "description": "string", // обязательное поле
+ *   "status": "NEW|IN_PROGRESS|DONE", // обязательное поле
+ *   "epicId": 123,         // обязательное поле - ID родительского эпика
+ *   "duration": 10,        // продолжительность в минутах (опционально)
+ *   "startTime": "1970-01-01T00:00:00.000" // дата и время ISO (опционально)
+ * }
+ * 
+ * + *

Обработка ошибок:

+ * + */ +public class SubTasksHandler extends TaskHandler { + + public SubTasksHandler(TaskManager manager, Gson gson, JsonBuilder jsonBuilder) { + super(manager, gson, jsonBuilder); + } + + /** + * Обрабатывает HTTP-метод GET для подзадач. + *

+ * В зависимости от наличия идентификатора: + *

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handleGet(HttpExchange exchange, RequestSegments segments) throws IOException { + if (segments.id() == 0) { + sendText(exchange, gson.toJson(manager.getSubTasks()), 200); + } else { + SubTask subtask = manager.getSubTask(segments.id()); + if (subtask != null) { + sendText(exchange, gson.toJson(subtask), 200); + } else { + sendNotFound(exchange, jsonBuilder.notFound("SubTask with id '" + segments.id() + "'not found")); + } + } + } + + /** + * Обрабатывает HTTP-метод POST для подзадач. + *

+ * Создает новую подзадачу или обновляет существующую в зависимости от taskId: + *

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + * @throws TaskNotFound если родительский эпик или подзадача для обновления не существует + * @throws TaskTimeOverlapException если обнаружено пересечение временных интервалов + */ + @Override + protected void handlePost(HttpExchange exchange, RequestSegments segments) throws IOException { + if (isPostForbidden(exchange, segments)) { + return; + } + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + + if (body.isBlank()) { + sendText(exchange, jsonBuilder.badRequest("Body is required"), 400); + } + + try { + SubTask subTask = gson.fromJson(body, SubTask.class); + isTaskValid(subTask); + + if (subTask.getTaskId() == 0) { + manager.addSubTask(subTask); + } else { + manager.updateSubTask(subTask); + } + sendText(exchange, gson.toJson(manager.getSubTaskWithoutHistory(subTask.getTaskId())), 201); + } catch (TaskNotFound e) { + sendNotFound(exchange, jsonBuilder.notFound(e.getMessage())); + } catch (DateTimeParseException e) { + sendText(exchange, jsonBuilder.badRequest("Invalid date format"), 400); + } catch (TaskTimeOverlapException e) { + sendHasOverlaps(exchange, jsonBuilder.hasOverlaps()); + } + + } + + /** + * Обрабатывает HTTP-метод DELETE для подзадач. + *

+ * В зависимости от наличия идентификатора: + *

+ * + * + *

Удаление подзадачи автоматически обновляет статус и поля времени родительского эпика.

+ * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handleDelete(HttpExchange exchange, RequestSegments segments) throws IOException { + if (segments.id() == 0) { + manager.clearSubTasks(); + sendText(exchange, jsonBuilder.message("Subtasks were deleted"), 200); + } else { + manager.deleteSubTask(segments.id()); + sendText(exchange, jsonBuilder.message("Subtask: '" + segments.id() + "' was deleted"), 200); + } + } + + /** + * Проверяет корректность ресурса в запросе. + *

+ * Убеждается, что запрос адресован именно подзадачам ("subtasks"). + *

+ * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @return true если ресурс неверен (не "subtasks"), иначе false + * @throws IOException если возникает ошибка при отправке ответа 404 + */ + @Override + protected boolean validateResources(HttpExchange exchange, RequestSegments segments) throws IOException { + if (!"subtasks".equals(segments.resource())) { + sendNotFound(exchange, jsonBuilder.resourceNotFound()); + return true; + } + return false; + } + + /** + * Проверяет валидность объекта подзадачи. + *

+ * Проверяет наличие обязательных полей для подзадачи: + *

+ * + * + *

Отличие от TaskHandler: для подзадач дополнительно проверяется наличие epicId.

+ * + * @param task подзадача для валидации (приводится к SubTask) + * @throws JsonSyntaxException если отсутствуют обязательные поля или epicId = 0 + */ + @Override + protected void isTaskValid(Task task) { + SubTask subTask = (SubTask) task; + if (subTask.getTitle() == null || + subTask.getDescription() == null || + subTask.getStatus() == null || + subTask.getEpicId() == 0) { + throw new JsonSyntaxException("Subtask has invalid fields"); + } + } + +} diff --git a/src/http/handlers/TaskHandler.java b/src/http/handlers/TaskHandler.java new file mode 100644 index 0000000..84d011c --- /dev/null +++ b/src/http/handlers/TaskHandler.java @@ -0,0 +1,241 @@ +package http.handlers; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.sun.net.httpserver.HttpExchange; +import managers.TaskManager; +import model.Task; +import util.exceptions.TaskNotFound; +import util.exceptions.TaskTimeOverlapException; +import util.http.JsonBuilder; +import util.http.RequestSegments; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.format.DateTimeParseException; + +/** + * Обработчик HTTP-запросов для управления задачами (Task). + *

+ * Обеспечивает REST API для операций CRUD над задачами: + *

+ * + * + *

Поддерживаемые эндпоинты:

+ * + * + * + * + * + * + * + * + * + * + *
МетодПутьДействие
HEAD/tasks или /tasks/{id}Получить заголовки
GET/tasksПолучить все задачи
GET/tasks/{id}Получить задачу по Id
POST/tasksСоздать новую задачу
POST/tasks/{id}Запрещено - метод не разрешен
DELETE/tasksУдалить все задачи
DELETE/tasks/{id}Удалить задачу по Id
OPTIONS/tasks или /tasks/{id}Получить разрешенные методы
+ * + *

Формат JSON для задачи:

+ *
+ * {
+ *   "taskId": 0,           // 0 для создания, >0 для обновления
+ *   "title": "string",     // обязательное поле
+ *   "description": "string", // обязательное поле
+ *   "status": "NEW|IN_PROGRESS|DONE", // обязательное поле
+ *   "duration": 10,        // продолжительность в минутах (опционально)
+ *   "startTime": "1970-01-01T00:00:00.000" // дата и время ISO (опционально)
+ * }
+ * 
+ * + *

Обработка ошибок:

+ * + * + * @see java.time.format.DateTimeFormatter#ISO_LOCAL_DATE_TIME + */ +public class TaskHandler extends BaseHttpHandler { + + public TaskHandler(TaskManager manager, Gson gson, JsonBuilder jsonBuilder) { + super(manager, gson, jsonBuilder); + } + + /** + * Обрабатывает HTTP-метод OPTIONS для задач. + *

+ * Возвращает разрешенные методы в зависимости от контекста: + *

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handleOptions(HttpExchange exchange, RequestSegments segments) throws IOException { + if (segments.id() == 0) { + exchange.getResponseHeaders().add("Allow", "GET, POST, DELETE, OPTIONS, HEAD"); + } else { + exchange.getResponseHeaders().add("Allow", "GET, DELETE, OPTIONS, HEAD"); + } + exchange.sendResponseHeaders(204, -1); + } + + /** + * Обрабатывает HTTP-метод GET для задач. + *

+ * В зависимости от наличия идентификатора: + *

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handleGet(HttpExchange exchange, RequestSegments segments) throws IOException { + + if (segments.id() == 0) { + sendText(exchange, gson.toJson(manager.getTasks()), 200); + } else { + Task task = manager.getTask(segments.id()); + if (task != null) { + sendText(exchange, gson.toJson(task), 200); + } else { + sendNotFound(exchange, jsonBuilder.notFound("Task with id '" + segments.id() + "'not found")); + } + } + } + + /** + * Обрабатывает HTTP-метод POST для задач. + *

+ * Создает новую задачу или обновляет существующую в зависимости от taskId: + *

+ * + *

Возвращает задачу в теле ответа

+ * + *

Валидация:

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handlePost(HttpExchange exchange, RequestSegments segments) throws IOException { + if (isPostForbidden(exchange, segments)) { + return; + } + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + + if (body.isBlank()) { + sendText(exchange, jsonBuilder.badRequest("Body is required"), 400); + } + + try { + Task task = gson.fromJson(body, Task.class); + isTaskValid(task); + + if (task.getTaskId() == 0) { + manager.addTask(task); + } else { + manager.updateTask(task); + } + sendText(exchange, gson.toJson(manager.getTaskWithoutHistory(task.getTaskId())), 201); + } catch (TaskNotFound e) { + sendNotFound(exchange, jsonBuilder.notFound(e.getMessage())); + } catch (DateTimeParseException e) { + sendText(exchange, jsonBuilder.badRequest("Invalid date format"), 400); + } catch (TaskTimeOverlapException e) { + sendHasOverlaps(exchange, jsonBuilder.hasOverlaps()); + } + } + + /** + * Обрабатывает HTTP-метод DELETE для задач. + *

+ * В зависимости от наличия идентификатора: + *

+ * + * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @throws IOException если возникает ошибка при отправке ответа + */ + @Override + protected void handleDelete(HttpExchange exchange, RequestSegments segments) throws IOException { + if (segments.id() == 0) { + manager.clearTasks(); + sendText(exchange, jsonBuilder.message("Tasks were deleted"), 200); + } else { + manager.deleteTask(segments.id()); + sendText(exchange, jsonBuilder.message("Task: '" + segments.id() + "' was deleted"), 200); + } + } + + /** + * Проверяет корректность ресурса в запросе. + *

+ * Убеждается, что запрос адресован именно задачам ("tasks"). + *

+ * + * @param exchange HTTP-обмен + * @param segments сегменты запроса + * @return true если ресурс неверен (не "tasks"), false если корректно + * @throws IOException если возникает ошибка при отправке ответа 404 + */ + @Override + protected boolean validateResources(HttpExchange exchange, RequestSegments segments) throws IOException { + if (!"tasks".equals(segments.resource())) { + sendNotFound(exchange, jsonBuilder.resourceNotFound()); + return true; + } + return false; + } + + /** + * Проверяет валидность объекта задачи. + *

+ * Проверяет наличие обязательных полей: + *

+ * + * + * @param task задача для валидации + * @throws JsonSyntaxException если отсутствуют обязательные поля + */ + protected void isTaskValid(Task task) { + if (task.getTitle() == null || task.getDescription() == null || task.getStatus() == null) { + throw new JsonSyntaxException("Task has invalid fields"); + } + } +} diff --git a/src/managers/InMemoryTaskManager.java b/src/managers/InMemoryTaskManager.java index 56537ee..26984a8 100644 --- a/src/managers/InMemoryTaskManager.java +++ b/src/managers/InMemoryTaskManager.java @@ -4,8 +4,9 @@ import model.Epic; import model.SubTask; import model.Task; -import util.Status; import util.TaskTimeController; +import util.enums.Status; +import util.exceptions.TaskNotFound; import util.exceptions.TaskTimeOverlapException; import java.util.ArrayList; @@ -43,7 +44,7 @@ public class InMemoryTaskManager implements TaskManager { @Override public void addTask(Task task) throws TaskTimeOverlapException { if (taskTimeController.isTimeOverlapping(task)) { - throw new TaskTimeOverlapException("OVERLAP! Can`t add task: " + task); + throw new TaskTimeOverlapException("Time overlap! Can`t add task: " + task); } task.setTaskId(++idCount); tasks.put(task.getTaskId(), task); @@ -59,7 +60,7 @@ public void addEpic(Epic epic) { @Override public void addSubTask(SubTask subTask) throws TaskTimeOverlapException { if (taskTimeController.isTimeOverlapping(subTask)) { - throw new TaskTimeOverlapException("OVERLAP! Can`t add task: " + subTask); + throw new TaskTimeOverlapException("Time overlap! Can`t add subtask: " + subTask); } subTask.setTaskId(++idCount); subtasks.put(subTask.getTaskId(), subTask); @@ -90,6 +91,21 @@ public SubTask getSubTask(int id) { return subtasks.get(id); } + @Override + public Task getTaskWithoutHistory(int id) { + return tasks.get(id); + } + + @Override + public Epic getEpicWithoutHistory(int id) { + return epics.get(id); + } + + @Override + public SubTask getSubTaskWithoutHistory(int id) { + return subtasks.get(id); + } + @Override public List getTasks() { return List.copyOf(tasks.values()); @@ -122,30 +138,139 @@ public List getPrioritizedTasks() { return taskTimeController.getPrioritizedTasks(); } + /** + * Обновляет существующую задачу (Task) новыми данными. + * + *

Обновляемые поля:

+ *
    + *
  • Заголовок (title)
  • + *
  • Описание (description)
  • + *
  • Статус (status)
  • + *
  • Продолжительность (duration)
  • + *
  • Время начала (startTime)
  • + *
+ * + *

Временная логика:

+ *
    + *
  • Старая задача временно удаляется из временного контроллера
  • + *
  • Проверяется пересечение временных интервалов для новой версии задачи
  • + *
  • Обновленная задача добавляется обратно во временной контроллер
  • + *
  • Если найдено пересечение времени старая задача добавляется обратно во временной контроллер
  • + *
+ * + * @param task задача с обновленными данными (должна содержать корректный taskId) + * @throws TaskNotFound если задача с указанным Id не найдена + * @throws TaskTimeOverlapException если новое время задачи пересекается с существующими задачами + */ @Override public void updateTask(Task task) { - tasks.put(task.getTaskId(), task); + int id = task.getTaskId(); + if (!tasks.containsKey(id)) { + throw new TaskNotFound("Task with id: " + id + " not found"); + } + Task oldTask = tasks.get(id); + taskTimeController.remove(oldTask); + + if (taskTimeController.isTimeOverlapping(task)) { + taskTimeController.add(oldTask); + throw new TaskTimeOverlapException("Time overlap! Can`t add task: " + task); + } + + oldTask.setTitle(task.getTitle()); + oldTask.setDescription(task.getDescription()); + oldTask.setStatus(task.getStatus()); + oldTask.setDuration(task.getDuration()); + oldTask.setStartTime(task.getStartTime()); + + taskTimeController.add(oldTask); } + /** + * Обновляет существующий эпик (Epic) новыми данными. + *

Метод обновляет только основные данные эпика. + *

В отличие от обычных задач, для эпиков: + * + *

    + *
  • Не проверяются временные интервалы
  • + *
  • Не обновляется статус (статус эпика рассчитывается автоматически на основе подзадач)
  • + *
  • Не обновляются временные характеристики (duration, startTime)
  • + *
+ * + *

Обновляемые поля:

+ *
    + *
  • Заголовок (title)
  • + *
  • Описание (description)
  • + *
+ * + * @param epic эпик с обновленными данными (должен содержать корректный taskId) + * @throws TaskNotFound если эпик с указанным Id не найдена + */ @Override public void updateEpic(Epic epic) { int id = epic.getTaskId(); + if (!epics.containsKey(id)) { + throw new TaskNotFound("Epic with id: " + id + " not found"); + } Epic oldEpic = epics.get(id); oldEpic.setTitle(epic.getTitle()); oldEpic.setDescription(epic.getDescription()); } + /** + * Обновляет существующую подзадачу (SubTask) новыми данными. + * + *

Обновляемые поля:

+ *
    + *
  • Заголовок (title)
  • + *
  • Описание (description)
  • + *
  • Статус (status)
  • + *
  • Продолжительность (duration)
  • + *
  • Время начала (startTime)
  • + *
+ * + *

Вторичные эффекты:

+ *
    + *
  • Автоматическое обновление статуса родительского эпика
  • + *
  • Пересчет временных характеристик родительского эпика
  • + *
+ * + *

Временная логика:

+ *
    + *
  • Старая подзадача временно удаляется из временного контроллера
  • + *
  • Проверяется пересечение временных интервалов для новой версии задачи
  • + *
  • Обновленная подзадача добавляется обратно во временной контроллер
  • + *
  • Если найдено пересечение времени старая подзадача добавляется обратно во временной контроллер
  • + *
+ * + * @param subTask подзадача с обновленными данными (должна содержать корректный taskId) + * @throws TaskNotFound если подзадача с указанным Id не найдена + * @throws TaskTimeOverlapException если новое время подзадачи пересекается с существующими задачами + * @see #updateEpicStatus(Epic) + * @see TaskTimeController#isTimeOverlapping(Task) + */ @Override public void updateSubTask(SubTask subTask) { int id = subTask.getTaskId(); + if (!subtasks.containsKey(id)) { + throw new TaskNotFound("SubTask with id: " + id + " not found"); + } SubTask oldSubTask = subtasks.get(id); + taskTimeController.remove(oldSubTask); + + if (taskTimeController.isTimeOverlapping(subTask)) { + taskTimeController.add(oldSubTask); + throw new TaskTimeOverlapException("Time overlap! Can`t add subtask: " + subTask); + } oldSubTask.setTitle(subTask.getTitle()); oldSubTask.setDescription(subTask.getDescription()); oldSubTask.setStatus(subTask.getStatus()); + oldSubTask.setDuration(subTask.getDuration()); + oldSubTask.setStartTime(subTask.getStartTime()); - updateEpicStatus(epics.get(oldSubTask.getEpicId())); //обновляем статус эпика + taskTimeController.add(oldSubTask); + updateEpicStatus(epics.get(oldSubTask.getEpicId())); } /** @@ -304,6 +429,22 @@ public void clearSubTasks() { subtasks.clear(); } + @Override + public void clearSubTasksFromEpic(int id) { + if (!epics.containsKey(id)) { + throw new TaskNotFound("Epic with id: '" + id + "' not found"); + } + Epic epic = epics.get(id); + List subtasksFromEpic = epic.getSubtaskIds(); + + for (Integer key : subtasksFromEpic) { + taskTimeController.remove(subtasks.get(key)); + historyManager.remove(key); + subtasks.remove(key); + } + epic.clearSubtasks(); + } + public int getIdCount() { return idCount; } diff --git a/src/managers/TaskManager.java b/src/managers/TaskManager.java index 8450f89..fc81db6 100644 --- a/src/managers/TaskManager.java +++ b/src/managers/TaskManager.java @@ -24,6 +24,12 @@ public interface TaskManager { SubTask getSubTask(int id); + Task getTaskWithoutHistory(int id); + + Epic getEpicWithoutHistory(int id); + + SubTask getSubTaskWithoutHistory(int id); + List getTasks(); List getEpics(); @@ -52,5 +58,7 @@ public interface TaskManager { void clearSubTasks(); + void clearSubTasksFromEpic(int id); + List getHistory(); } diff --git a/src/managers/filedbacked/FileBackedTaskManager.java b/src/managers/filedbacked/FileBackedTaskManager.java index 7174fcc..8036425 100644 --- a/src/managers/filedbacked/FileBackedTaskManager.java +++ b/src/managers/filedbacked/FileBackedTaskManager.java @@ -4,8 +4,8 @@ import model.Epic; import model.SubTask; import model.Task; -import util.Status; -import util.Type; +import util.enums.Status; +import util.enums.Type; import util.exceptions.ManagerLoadException; import util.exceptions.ManagerSaveException; @@ -17,8 +17,8 @@ import java.util.List; import static managers.filedbacked.ParserHelper.*; -import static util.CsvField.*; -import static util.Type.*; +import static util.enums.CsvField.*; +import static util.enums.Type.*; /** * Менеджер задач с сохранением состояния в файл типа csv. @@ -277,4 +277,10 @@ public void clearSubTasks() { super.clearSubTasks(); save(); } + + @Override + public void clearSubTasksFromEpic(int id) { + super.clearSubTasks(); + save(); + } } diff --git a/src/managers/filedbacked/ParserHelper.java b/src/managers/filedbacked/ParserHelper.java index 6a80e9e..6823009 100644 --- a/src/managers/filedbacked/ParserHelper.java +++ b/src/managers/filedbacked/ParserHelper.java @@ -1,7 +1,7 @@ package managers.filedbacked; -import util.Status; -import util.Type; +import util.enums.Status; +import util.enums.Type; import util.exceptions.ManagerLoadException; import java.time.Duration; diff --git a/src/model/Epic.java b/src/model/Epic.java index f451675..5a8a095 100644 --- a/src/model/Epic.java +++ b/src/model/Epic.java @@ -1,7 +1,7 @@ package model; -import util.Status; -import util.Type; +import util.enums.Status; +import util.enums.Type; import java.time.Duration; import java.time.LocalDateTime; diff --git a/src/model/SubTask.java b/src/model/SubTask.java index 439a05f..5e406a7 100644 --- a/src/model/SubTask.java +++ b/src/model/SubTask.java @@ -1,7 +1,7 @@ package model; -import util.Status; -import util.Type; +import util.enums.Status; +import util.enums.Type; import java.time.LocalDateTime; diff --git a/src/model/Task.java b/src/model/Task.java index 7e31d1f..0aca7ca 100644 --- a/src/model/Task.java +++ b/src/model/Task.java @@ -1,7 +1,7 @@ package model; -import util.Status; -import util.Type; +import util.enums.Status; +import util.enums.Type; import java.time.Duration; import java.time.LocalDateTime; @@ -26,7 +26,7 @@ public Task(String title, String description, Status status, long durationInMinu this.title = title; this.description = description; this.status = status; - this.duration = durationInMinutes < 0 ? null : Duration.ofMinutes(durationInMinutes); + this.duration = durationInMinutes <= 0 ? null : Duration.ofMinutes(durationInMinutes); this.startTime = startTime; } @@ -34,7 +34,7 @@ public Task(String title, String description, Status status, long durationInMinu this.title = title; this.description = description; this.status = status; - this.duration = Duration.ofMinutes(durationInMinutes); + this.duration = durationInMinutes <= 0 ? null : Duration.ofMinutes(durationInMinutes); } public int getTaskId() { diff --git a/src/util/TaskTimeController.java b/src/util/TaskTimeController.java index 4194747..4643860 100644 --- a/src/util/TaskTimeController.java +++ b/src/util/TaskTimeController.java @@ -3,6 +3,7 @@ import model.Epic; import model.SubTask; import model.Task; +import util.enums.Type; import java.time.LocalDateTime; import java.util.Comparator; diff --git a/src/util/CsvField.java b/src/util/enums/CsvField.java similarity index 93% rename from src/util/CsvField.java rename to src/util/enums/CsvField.java index 3f3b79f..31370d2 100644 --- a/src/util/CsvField.java +++ b/src/util/enums/CsvField.java @@ -1,4 +1,4 @@ -package util; +package util.enums; public enum CsvField { ID(0), diff --git a/src/util/enums/Endpoint.java b/src/util/enums/Endpoint.java new file mode 100644 index 0000000..4dda49c --- /dev/null +++ b/src/util/enums/Endpoint.java @@ -0,0 +1,13 @@ +package util.enums; + +public enum Endpoint { + INVALID_SUBRESOURCE, + + INVALID, + INVALID_METHOD, + + OPTIONS, + GET, + POST, + DELETE +} diff --git a/src/util/Status.java b/src/util/enums/Status.java similarity index 74% rename from src/util/Status.java rename to src/util/enums/Status.java index 9e4a21d..1b22897 100644 --- a/src/util/Status.java +++ b/src/util/enums/Status.java @@ -1,4 +1,4 @@ -package util; +package util.enums; public enum Status { NEW, diff --git a/src/util/Type.java b/src/util/enums/Type.java similarity index 72% rename from src/util/Type.java rename to src/util/enums/Type.java index dff2b72..9259e98 100644 --- a/src/util/Type.java +++ b/src/util/enums/Type.java @@ -1,4 +1,4 @@ -package util; +package util.enums; public enum Type { TASK, diff --git a/src/util/exceptions/TaskNotFound.java b/src/util/exceptions/TaskNotFound.java new file mode 100644 index 0000000..59e0307 --- /dev/null +++ b/src/util/exceptions/TaskNotFound.java @@ -0,0 +1,16 @@ +package util.exceptions; + +public class TaskNotFound extends RuntimeException { + + public TaskNotFound() { + super(); + } + + public TaskNotFound(String message) { + super(message); + } + + public TaskNotFound(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/util/gsonadapters/DurationAdapter.java b/src/util/gsonadapters/DurationAdapter.java new file mode 100644 index 0000000..9bb25bd --- /dev/null +++ b/src/util/gsonadapters/DurationAdapter.java @@ -0,0 +1,22 @@ +package util.gsonadapters; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.time.Duration; + +public class DurationAdapter extends TypeAdapter { + + @Override + public void write(JsonWriter jsonWriter, Duration duration) throws IOException { + jsonWriter.value(duration == null ? null : duration.toMinutes()); + } + + @Override + public Duration read(JsonReader jsonReader) throws IOException { + String string = jsonReader.nextString(); + return Duration.ofMinutes(Long.parseLong(string)); + } +} diff --git a/src/util/gsonadapters/LocalDateTimeAdapter.java b/src/util/gsonadapters/LocalDateTimeAdapter.java new file mode 100644 index 0000000..532077a --- /dev/null +++ b/src/util/gsonadapters/LocalDateTimeAdapter.java @@ -0,0 +1,40 @@ +package util.gsonadapters; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; + +public class LocalDateTimeAdapter extends TypeAdapter { + private final List formats = + List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ISO_DATE_TIME, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss") + ); + + @Override + public void write(JsonWriter jsonWriter, LocalDateTime localDateTime) throws IOException { + jsonWriter.value(localDateTime == null ? null : localDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + } + + @Override + public LocalDateTime read(JsonReader jsonReader) throws IOException { + String date = jsonReader.nextString(); + for (DateTimeFormatter format : formats) { + try { + return LocalDateTime.parse(date, format); + } catch (DateTimeParseException e) { + continue; + } + } + + throw new DateTimeParseException("Cannot parse date: " + date, date, 0); + } +} diff --git a/src/util/http/ErrorResponse.java b/src/util/http/ErrorResponse.java new file mode 100644 index 0000000..3f0196a --- /dev/null +++ b/src/util/http/ErrorResponse.java @@ -0,0 +1,17 @@ +package util.http; + +import com.google.gson.Gson; + +public class ErrorResponse { + private final String error; + private final String message; + + private ErrorResponse(String error, String message) { + this.error = error; + this.message = message; + } + + public static String errorToJson(Gson gson, String error, String message) { + return gson.toJson(new ErrorResponse(error, message)); + } +} \ No newline at end of file diff --git a/src/util/http/JsonBuilder.java b/src/util/http/JsonBuilder.java new file mode 100644 index 0000000..32c730c --- /dev/null +++ b/src/util/http/JsonBuilder.java @@ -0,0 +1,67 @@ +package util.http; + +import com.google.gson.Gson; + +import java.util.HashMap; +import java.util.Map; + +public class JsonBuilder { + private final Gson gson; + private RequestSegments segments; + + public JsonBuilder(Gson gson) { + this.gson = gson; + } + + public void setSegments(RequestSegments segments) { + this.segments = segments; + } + + public String message(String message) { + Map map = new HashMap<>(); + map.put("message", message); + return gson.toJson(map); + } + + public String tooMuchSubResources() { + return notFound("Subresources do not exist"); + } + + public String badRequest(String message) { + return ErrorResponse.errorToJson( + gson, + "Bad Request", + message + ); + } + + public String notFound(String message) { + return ErrorResponse.errorToJson( + gson, + "Not Found", + message + ); + } + + public String resourceNotFound() { + return notFound("Resource '" + segments.resource() + "' does not exist"); + } + + public String subresourceNotFound() { + return notFound("Subresource '" + segments.subResource().orElse("") + "' does not exist"); + } + + public String hasOverlaps() { + return ErrorResponse.errorToJson(gson, "Task time is overlapping", + "This task cannot be added due to overlap" + ); + } + + public String invalidId() { + return ErrorResponse.errorToJson( + gson, + "Bad Request", + "Id must be a positive integer" + ); + } +} diff --git a/src/util/http/RequestSegments.java b/src/util/http/RequestSegments.java new file mode 100644 index 0000000..4bb51ab --- /dev/null +++ b/src/util/http/RequestSegments.java @@ -0,0 +1,145 @@ +package util.http; + +import com.sun.net.httpserver.HttpExchange; +import util.enums.Endpoint; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; + +/** + * Класс для парсинга сегментов HTTP-запроса. + *

+ * Разбирает URI пути запроса на составные части: endpoint, ресурс, + * идентификатор и опциональный подресурс. Также выполняет валидацию HTTP-метода. + *

+ * + *

Формат пути:

+ *
    + *
  • {@code /resource} - ресурс без идентификатора
  • + *
  • {@code /resource/123} - ресурс с числовым идентификатором
  • + *
  • {@code /resource/123/subresource} - ресурс с идентификатором и подресурсом
  • + *
+ * + *

Поддерживаемые HTTP-методы: GET, POST, DELETE, HEAD, OPTIONS

+ * + *

Особенности обработки:

+ *
    + *
  • Метод HEAD обрабатывается как GET
  • + *
  • Некорректные идентификаторы преобразуются в -1
  • + *
  • Неподдерживаемые методы возвращают соответствующие + * значения Endpoint.INVALID_METHOD
  • + *
  • Ошбика парсинга Endpoint возвращает Enpoint.INVALID
  • + *
+ * + * @param endpoint конечная точка запроса (на основе HTTP-метода) + * @param resource название основного ресурса (первый сегмент пути после /) + * @param id числовой идентификатор ресурса (второй сегмент пути) + * @param subResource опциональный подресурс (третий сегмент пути) + */ +public record RequestSegments( + Endpoint endpoint, + String resource, + int id, + Optional subResource) { + + /** + * Создает экземпляр RequestSegments на основе HttpExchange. + *

+ * Извлекает метод запроса и путь из переданного HttpExchange и делегирует + * парсинг методу {@link #parse(String, String)}. + *

+ * + * @param exchange HTTP-обмен для парсинга + * @return новый экземпляр RequestSegments с разобранными сегментами пути + * @see #parse(String, String) + */ + public static RequestSegments getRequestSegments(HttpExchange exchange) { + String path = exchange.getRequestURI().getPath(); + return RequestSegments.parse(exchange.getRequestMethod(), path); + } + + /** + * Парсит HTTP-метод и путь на составляющие сегменты. + *

+ * В зависимости от количества сегментов пути создает соответствующий экземпляр: + *

+ * + * + * + * + * + * + *
СегментовФорматРезультат
2/resourceresource, id=0, subResource=empty
3/resource/123resource, id=123, subResource=empty
4/resource/123/subresource, id=123, subResource=sub
другоелюбойEndpoint.INVALID_SUBRESOURCE
+ * + * @param method HTTP-метод запроса + * @param path путь URI запроса + * @return экземпляр RequestSegments с разобранными сегментами + */ + private static RequestSegments parse(String method, String path) { + String[] parts = path.split("/"); + + return switch (parts.length) { + case 2 -> new RequestSegments( + parseEndpoint(method), + parts[1], + 0, + Optional.empty()); + case 3 -> new RequestSegments( + parseEndpoint(method), + parts[1], + parseInt(parts[2]), + Optional.empty()); + case 4 -> new RequestSegments( + parseEndpoint(method), + parts[1], + parseInt(parts[2]), + Optional.of(parts[3])); + default -> new RequestSegments( + Endpoint.INVALID_SUBRESOURCE, + parts[1], + 0, + Optional.empty()); + }; + } + + private static int parseInt(String idString) { + try { + int id = Integer.parseInt(idString); + if (id < 0) { + throw new NumberFormatException(); + } else { + return id; + } + } catch (NumberFormatException e) { + return -1; + } + } + + private static Endpoint parseEndpoint(String method) { + HashSet validMethods = new HashSet<>(List.of( + "GET", + "POST", + "DELETE", + "HEAD", + "OPTIONS") + ); + if (!validMethods.contains(method)) { + return Endpoint.INVALID_METHOD; + } + + Endpoint endpoint; + + if (method.equals("HEAD")) { + method = "GET"; + } + + try { + endpoint = Endpoint.valueOf(method); + } catch (IllegalArgumentException e) { + endpoint = Endpoint.INVALID; + } + + return endpoint; + } +} diff --git a/test/http/HttpTaskServerTest.java b/test/http/HttpTaskServerTest.java new file mode 100644 index 0000000..31b14d7 --- /dev/null +++ b/test/http/HttpTaskServerTest.java @@ -0,0 +1,151 @@ +package http; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import managers.InMemoryTaskManager; +import managers.TaskManager; +import model.Epic; +import model.SubTask; +import model.Task; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import util.enums.Status; +import util.gsonadapters.DurationAdapter; +import util.gsonadapters.LocalDateTimeAdapter; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class HttpTaskServerTest { + + protected HttpClient client; + protected HttpTaskServer server; + protected TaskManager manager; + protected final Gson gson = new GsonBuilder() + .registerTypeAdapter(LocalDateTime.class, new LocalDateTimeAdapter()) + .registerTypeAdapter(Duration.class, new DurationAdapter()) + .create(); + + protected final LocalDateTime epochTime = + LocalDateTime.of(1970, 1, 1, 0, 0, 0); + + protected Task task; + protected Epic epic; + protected SubTask subTask; + + @BeforeEach + public void initServer() { + client = HttpClient.newHttpClient(); + manager = new InMemoryTaskManager(); + server = new HttpTaskServer(manager); + + task = new Task("task1", "demo", Status.NEW); + epic = new Epic("epic1", "demo", Status.NEW); + subTask = new SubTask("subtask1", "demo", Status.NEW, 1); + server.start(); + } + + @AfterEach + public void stopServer() { + server.stop(); + } + + protected HttpRequest getMethod(String method, String path) { + URI url = URI.create(path); + + HttpRequest.Builder builder = HttpRequest.newBuilder().uri(url); + + switch (method) { + case "HEAD" -> builder.HEAD(); + case "OPTIONS" -> builder.method("OPTIONS", HttpRequest.BodyPublishers.noBody()); + case "GET" -> builder.GET(); + case "DELETE" -> builder.DELETE(); + } + + return builder.build(); + } + + protected HttpRequest getPost(String value, String taskJson) { + URI url = URI.create(value); + + return HttpRequest + .newBuilder() + .POST(HttpRequest.BodyPublishers.ofString(taskJson)) + .uri(url) + .build(); + } + + @Nested + class BaseHandlerTest { + private HttpRequest getInvalidMethod() { + URI url = URI.create("http://localhost:8080/tasks"); + + return HttpRequest + .newBuilder() + .PUT(HttpRequest.BodyPublishers.ofString("")) + .uri(url) + .build(); + } + + @Test + public void shouldReturn405WhenForbiddenMethod() throws IOException, InterruptedException { + + HttpResponse response = client.send( + getInvalidMethod(), + HttpResponse.BodyHandlers.ofString() + ); + + assertEquals(405, response.statusCode()); + } + + @Test + public void shouldReturn404WhenTooManySubs() throws IOException, InterruptedException { + + HttpResponse response = client.send( + getMethod("GET", "http://localhost:8080/tasks/1/sub/sub"), + HttpResponse.BodyHandlers.ofString() + ); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn405WhenInvalidMethod() throws IOException, InterruptedException { + HttpRequest request = HttpRequest + .newBuilder() + .method("BlaBlaBla", HttpRequest.BodyPublishers.noBody()) + .uri(URI.create("http://localhost:8080/tasks")) + .build(); + + + HttpResponse response = client.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + + assertEquals(405, response.statusCode()); + } + } + + public class ListTaskTypeToken extends TypeToken> { + } + + public class ListEpicTypeToken extends TypeToken> { + } + + public class ListSubTaskTypeToken extends TypeToken> { + } +} + + diff --git a/test/http/handlers/EpicsHandlerTest.java b/test/http/handlers/EpicsHandlerTest.java new file mode 100644 index 0000000..1aac956 --- /dev/null +++ b/test/http/handlers/EpicsHandlerTest.java @@ -0,0 +1,393 @@ +package http.handlers; + +import http.HttpTaskServerTest; +import model.Epic; +import model.SubTask; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import util.enums.Status; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class EpicsHandlerTest extends HttpTaskServerTest { + + @Nested + class EpicHeadTest { + private HttpRequest getRequest(String value) { + URI url = URI.create(value); + + return HttpRequest + .newBuilder() + .HEAD() + .uri(url) + .build(); + } + + @Test + public void shouldReturn200AndRightHeadersWhenHeadWithEmptyEpics() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.headers().firstValue("Content-type")); + assertEquals(200, response.statusCode()); + + assertEquals("application/json;charset=utf-8", + response.headers().firstValue("Content-type").get()); + assertEquals("2", + response.headers().firstValue("Content-length").get()); + } + + @Test + public void shouldReturn404AndRightHeadersWhenHeadWithInvalidEpic() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/1"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.headers().firstValue("Content-type")); + assertEquals(404, response.statusCode()); + + assertEquals("application/json;charset=utf-8", + response.headers().firstValue("Content-type").get()); + assertEquals("69", + response.headers().firstValue("Content-length").get()); + } + + @Test + public void shouldReturn200AndRightHeadersWhenHeadWithSubResource() throws IOException, InterruptedException { + manager.addEpic(epic); + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/1/subtasks"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.headers().firstValue("Content-type")); + assertEquals(200, response.statusCode()); + + assertEquals("application/json;charset=utf-8", + response.headers().firstValue("Content-type").get()); + assertEquals("2", + response.headers().firstValue("Content-length").get()); + } + + @Test + public void shouldReturn400AndRightHeadersWhenHeadWithInvalidId() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/-1"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.headers().firstValue("Content-type")); + assertEquals(400, response.statusCode()); + + assertEquals("application/json;charset=utf-8", + response.headers().firstValue("Content-type").get()); + assertEquals("65", + response.headers().firstValue("Content-length").get()); + } + + } + + @Nested + class EpicGetTest { + + private HttpRequest getRequest(String value) { + return getMethod("GET", value); + } + + @Test + public void shouldReturn404WhenInvalidResource() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/epicsABC"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldBeEqualAndCode200() throws IOException, InterruptedException { + manager.addEpic(epic); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/1"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.body()); + assertEquals(200, response.statusCode()); + assertEquals(epic, gson.fromJson(response.body(), Epic.class)); + } + + @Test + public void shouldSameAmountAndCode200() throws IOException, InterruptedException { + manager.addEpic(epic); + manager.addEpic(new Epic("epic2", "demo", Status.NEW)); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics"), + HttpResponse.BodyHandlers.ofString()); + + List list = gson.fromJson(response.body(), new ListEpicTypeToken().getType()); + + assertEquals(200, response.statusCode()); + assertEquals(2, list.size()); + } + + @Test + public void shouldReturn404WhenNoId() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/1"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn404WhenSubResourcePresent() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/1/sub"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn404WhenIdInvalid() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/-2a"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(400, response.statusCode()); + } + + @Test + public void shouldReturn200AndSubtasks() throws IOException, InterruptedException { + manager.addEpic(epic); + manager.addSubTask(subTask); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/1/subtasks"), + HttpResponse.BodyHandlers.ofString()); + + List list = gson.fromJson(response.body(), new ListSubTaskTypeToken().getType()); + + assertEquals(200, response.statusCode()); + assertEquals(1, list.size()); + assertEquals(subTask, list.getFirst()); + + } + + @Test + public void shouldReturn404WhenNoIdAndSubTasks() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/1/subtasks"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + } + + @Nested + class EpicPostTest { + + @Test + public void shouldBeEqualAndCode201() throws IOException, InterruptedException { + String taskJson = gson.toJson(epic); + + HttpResponse response = client.send( + getPost("http://localhost:8080/epics", taskJson), + HttpResponse.BodyHandlers.ofString()); + + epic.setTaskId(1); + + assertEquals(201, response.statusCode()); + assertEquals(epic, manager.getEpic(1)); + } + + @Test + public void shouldBeUpdatedAndCode201() throws IOException, InterruptedException { + manager.addEpic(epic); + + epic.setTitle("new title"); + String taskJson = gson.toJson(epic); + + HttpResponse response = client.send( + getPost("http://localhost:8080/epics", taskJson), + HttpResponse.BodyHandlers.ofString()); + + + assertEquals(201, response.statusCode()); + assertEquals(epic, manager.getEpic(1)); + } + + @Test + public void shouldNotBeUpdatedAndCode404WhenNotFound() throws IOException, InterruptedException { + + epic.setTaskId(1); + String taskJson = gson.toJson(epic); + + HttpResponse response = client.send( + getPost("http://localhost:8080/epics", taskJson), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.body()); + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn405AndNotPostWhenIdAndSubIsPresent() throws IOException, InterruptedException { + String taskJson = gson.toJson(epic); + + HttpResponse response = client.send( + getPost("http://localhost:8080/epics/1/subtasks", taskJson), + HttpResponse.BodyHandlers.ofString() + ); + epic.setTaskId(1); + + assertEquals(405, response.statusCode()); + assertNull(manager.getEpic(1)); + } + + @Test + public void shouldReturn400WhenNoBody() throws IOException, InterruptedException { + + HttpResponse response = client.send( + getPost("http://localhost:8080/epics", ""), + HttpResponse.BodyHandlers.ofString() + ); + epic.setTaskId(1); + + assertEquals(400, response.statusCode()); + assertNull(manager.getEpic(1)); + } + + @Test + public void shouldReturn400WhenInvalidBody() throws IOException, InterruptedException { + String taskJson = "{blablabla:blabla}"; + + HttpResponse response = client.send( + getPost("http://localhost:8080/epics", taskJson), + HttpResponse.BodyHandlers.ofString() + ); + epic.setTaskId(1); + + assertEquals(400, response.statusCode()); + assertNull(manager.getEpic(1)); + } + + } + + @Nested + class EpicDeleteTest { + private HttpRequest getRequest(String value) { + return getMethod("DELETE", value); + } + + @Test + public void shouldReturn404WhenIdIsNotPresentAndDeletingSubtasks() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/1/subtasks"), + HttpResponse.BodyHandlers.ofString() + ); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn200AndDeleteAllEpics() throws IOException, InterruptedException { + manager.addEpic(epic); + manager.addEpic(new Epic("epic2", "demo", Status.NEW)); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics"), + HttpResponse.BodyHandlers.ofString() + ); + + assertEquals(200, response.statusCode()); + assertEquals(0, manager.getEpics().size()); + } + + @Test + public void shouldReturn200AndDeleteEpic() throws IOException, InterruptedException { + manager.addEpic(epic); + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/1"), + HttpResponse.BodyHandlers.ofString() + ); + + assertEquals(200, response.statusCode()); + assertNull(manager.getEpic(1)); + } + + @Test + public void shouldReturn200AndDeleteSubtasksFromEpic() throws IOException, InterruptedException { + manager.addEpic(epic); + manager.addSubTask(subTask); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/1/subtasks"), + HttpResponse.BodyHandlers.ofString() + ); + + assertEquals(200, response.statusCode()); + assertEquals(0, manager.getSubTasksFromEpic(1).size()); + } + + + } + + @Nested + class EpicOptionsTest { + + private HttpRequest getRequest(String value) { + return getMethod("OPTIONS", value); + } + + @Test + public void shouldReturn204AndValidMethods() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics"), + HttpResponse.BodyHandlers.ofString() + ); + List expected = List.of("GET, POST, DELETE, OPTIONS, HEAD"); + + List methods = response.headers().allValues("Allow"); + + assertEquals(204, response.statusCode()); + assertEquals(expected, methods); + } + + @Test + public void shouldReturn204AndValidMethodsWhenIdIsPresent() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks/1"), + HttpResponse.BodyHandlers.ofString() + ); + List expected = List.of("GET, DELETE, OPTIONS, HEAD"); + + List methods = response.headers().allValues("Allow"); + + assertEquals(204, response.statusCode()); + assertEquals(expected, methods); + } + + + @Test + public void shouldReturn204AndValidMethodsWhenIdAndSubTasksIsPresent() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/epics/1/subtasks"), + HttpResponse.BodyHandlers.ofString() + ); + List expected = List.of("GET, DELETE, OPTIONS, HEAD"); + + List methods = response.headers().allValues("Allow"); + + assertEquals(204, response.statusCode()); + assertEquals(expected, methods); + } + } +} \ No newline at end of file diff --git a/test/http/handlers/HistoryHandlerTest.java b/test/http/handlers/HistoryHandlerTest.java new file mode 100644 index 0000000..c7530df --- /dev/null +++ b/test/http/handlers/HistoryHandlerTest.java @@ -0,0 +1,203 @@ +package http.handlers; + +import http.HttpTaskServerTest; +import model.Task; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HistoryHandlerTest extends HttpTaskServerTest { + + @BeforeEach + public void addTasks() { + manager.addEpic(epic); + manager.addTask(task); + manager.addSubTask(subTask); + } + + @Nested + class HistoryHeadTest { + + private HttpRequest getRequest(String value) { + return getMethod("HEAD", value); + } + + @Test + public void shouldReturn200AndRightHeadersWhenHeadWithEmptyTasks() throws IOException, InterruptedException { + + HttpResponse response = client.send( + getRequest("http://localhost:8080/history"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.headers().firstValue("Content-type")); + assertEquals(200, response.statusCode()); + + assertEquals("application/json;charset=utf-8", + response.headers().firstValue("Content-type").get()); + assertEquals("2", + response.headers().firstValue("Content-length").get()); + } + + @Test + public void shouldReturn404AndRightHeadersWhenHeadWithInvalidId() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/history/1"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.headers().firstValue("Content-type")); + assertEquals(404, response.statusCode()); + + assertEquals("application/json;charset=utf-8", + response.headers().firstValue("Content-type").get()); + assertEquals("73", + response.headers().firstValue("Content-length").get()); + } + + @Test + public void shouldReturn404AndRightHeadersWhenHeadWithInvalidSubResource() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/history/1/sub"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.headers().firstValue("Content-type")); + assertEquals(404, response.statusCode()); + + assertEquals("application/json;charset=utf-8", + response.headers().firstValue("Content-type").get()); + assertEquals("76", + response.headers().firstValue("Content-length").get()); + } + + @Test + public void shouldReturn400AndRightHeadersWhenHeadWithInvalidId() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/history/-1"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.headers().firstValue("Content-type")); + assertEquals(400, response.statusCode()); + + assertEquals("application/json;charset=utf-8", + response.headers().firstValue("Content-type").get()); + assertEquals("65", + response.headers().firstValue("Content-length").get()); + } + + } + + @Nested + class HistoryGetTest { + + private HttpRequest getRequest(String value) { + return getMethod("GET", value); + } + + @Test + public void shouldBeEqualAndCode200() throws IOException, InterruptedException { + + manager.getEpic(1); + manager.getTask(2); + manager.getSubTask(3); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/history"), + HttpResponse.BodyHandlers.ofString()); + + + List list = gson.fromJson(response.body(), new ListTaskTypeToken().getType()); + assertEquals(200, response.statusCode()); + assertEquals(3, list.size()); + } + + @Test + public void shouldReturn404WithInvalidId() throws IOException, InterruptedException { + + HttpResponse response = client.send( + getRequest("http://localhost:8080/history/1"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn404WithInvalidIdAndSub() throws IOException, InterruptedException { + + HttpResponse response = client.send( + getRequest("http://localhost:8080/history/1/sub"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn404WithInvalidResource() throws IOException, InterruptedException { + + HttpResponse response = client.send( + getRequest("http://localhost:8080/historyABC"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + } + + @Nested + class HistoryPostTest { + + @Test + public void shouldReturn405() throws IOException, InterruptedException { + HttpResponse response = client.send( + getPost("http://localhost:8080/history", ""), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(405, response.statusCode()); + } + } + + @Nested + class HistoryDeleteTest { + + private HttpRequest getRequest(String value) { + return getMethod("DELETE", value); + } + + @Test + public void shouldReturn405() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/history"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(405, response.statusCode()); + } + } + + @Nested + class SubTaskOptionsTest { + + private HttpRequest getRequest(String value) { + return getMethod("OPTIONS", value); + } + + @Test + public void shouldReturn204AndValidMethods() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/history"), + HttpResponse.BodyHandlers.ofString() + ); + List expected = List.of("GET, OPTIONS, HEAD"); + + List methods = response.headers().allValues("Allow"); + + assertEquals(204, response.statusCode()); + assertEquals(expected, methods); + } + } + +} \ No newline at end of file diff --git a/test/http/handlers/PrioritizedHandlerTest.java b/test/http/handlers/PrioritizedHandlerTest.java new file mode 100644 index 0000000..cb9edd4 --- /dev/null +++ b/test/http/handlers/PrioritizedHandlerTest.java @@ -0,0 +1,73 @@ +package http.handlers; + +import http.HttpTaskServerTest; +import model.Task; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import util.enums.Status; + +import java.io.IOException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PrioritizedHandlerTest extends HttpTaskServerTest { + + @Nested + class HistoryGetTest { + + private HttpRequest getRequest(String value) { + return getMethod("GET", value); + } + + @Test + public void shouldBeEqualAndCode200() throws IOException, InterruptedException { + manager.addTask(new Task("task1", "demo", Status.NEW, 10, epochTime)); + manager.addTask(new Task("task2", "demo", Status.NEW, 10, epochTime.plusMinutes(10))); + manager.addTask(task); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/prioritized"), + HttpResponse.BodyHandlers.ofString()); + + + List list = gson.fromJson(response.body(), new ListTaskTypeToken().getType()); + assertEquals(200, response.statusCode()); + assertEquals(2, list.size()); + } + + @Test + public void shouldReturn404WithInvalidId() throws IOException, InterruptedException { + + HttpResponse response = client.send( + getRequest("http://localhost:8080/prioritized/1"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn404WithInvalidIdAndSub() throws IOException, InterruptedException { + + HttpResponse response = client.send( + getRequest("http://localhost:8080/prioritized/1/sub"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn404WithInvalidResource() throws IOException, InterruptedException { + + HttpResponse response = client.send( + getRequest("http://localhost:8080/prioritizedABC"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + } + +} \ No newline at end of file diff --git a/test/http/handlers/SubTasksHandlerTest.java b/test/http/handlers/SubTasksHandlerTest.java new file mode 100644 index 0000000..801bfa9 --- /dev/null +++ b/test/http/handlers/SubTasksHandlerTest.java @@ -0,0 +1,245 @@ +package http.handlers; + +import http.HttpTaskServerTest; +import model.SubTask; +import model.Task; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import util.enums.Status; + +import java.io.IOException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class SubTasksHandlerTest extends HttpTaskServerTest { + + @BeforeEach + public void addEpic() { + manager.addEpic(epic); + } + + @Nested + class SubTaskGetTest { + + private HttpRequest getRequest(String value) { + return getMethod("GET", value); + } + + @Test + public void shouldReturn404WhenInvalidResource() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/subtasksABC"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldBeEqualAndCode200() throws IOException, InterruptedException { + manager.addSubTask(subTask); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/subtasks/2"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode()); + assertEquals(subTask, gson.fromJson(response.body(), SubTask.class)); + } + + @Test + public void shouldSameAmountAndCode200() throws IOException, InterruptedException { + manager.addSubTask(subTask); + manager.addSubTask(new SubTask("subtask2", "demo", Status.NEW, 1)); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/subtasks"), + HttpResponse.BodyHandlers.ofString()); + + List list = gson.fromJson(response.body(), new ListSubTaskTypeToken().getType()); + + assertEquals(200, response.statusCode()); + assertEquals(2, list.size()); + } + + @Test + public void shouldReturn404WhenNoId() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/subtasks/2"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn404WhenSubResourcePresent() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/subtasks/2/sub"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn404WhenIdInvalid() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/subtasks/-2a"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(400, response.statusCode()); + } + + } + + @Nested + class SubTaskPostTest { + + @Test + public void shouldBeEqualAndCode201() throws IOException, InterruptedException { + String taskJson = gson.toJson(subTask); + + HttpResponse response = client.send( + getPost("http://localhost:8080/subtasks", taskJson), + HttpResponse.BodyHandlers.ofString()); + + subTask.setTaskId(2); + + assertEquals(201, response.statusCode()); + assertEquals(subTask, manager.getSubTask(2)); + } + + @Test + public void shouldBeUpdatedAndCode201() throws IOException, InterruptedException { + manager.addSubTask(subTask); + + subTask.setTitle("new title"); + String taskJson = gson.toJson(subTask); + + HttpResponse response = client.send( + getPost("http://localhost:8080/subtasks", taskJson), + HttpResponse.BodyHandlers.ofString()); + + + assertEquals(201, response.statusCode()); + assertEquals(subTask, manager.getSubTask(2)); + } + + @Test + public void shouldNotBeUpdatedAndCode404WhenNotFound() throws IOException, InterruptedException { + + subTask.setTaskId(2); + String taskJson = gson.toJson(subTask); + + HttpResponse response = client.send( + getPost("http://localhost:8080/subtasks", taskJson), + HttpResponse.BodyHandlers.ofString()); + + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn405AndNotPostWhenIdIsPresent() throws IOException, InterruptedException { + String taskJson = gson.toJson(subTask); + + HttpResponse response = client.send( + getPost("http://localhost:8080/subtasks/2", taskJson), + HttpResponse.BodyHandlers.ofString() + ); + subTask.setTaskId(2); + + assertEquals(405, response.statusCode()); + assertNull(manager.getSubTask(2)); + } + + @Test + public void shouldReturn400WhenNoBody() throws IOException, InterruptedException { + + HttpResponse response = client.send( + getPost("http://localhost:8080/subtasks", ""), + HttpResponse.BodyHandlers.ofString() + ); + subTask.setTaskId(2); + + assertEquals(400, response.statusCode()); + assertNull(manager.getSubTask(2)); + } + + @Test + public void shouldReturn400WhenInvalidBody() throws IOException, InterruptedException { + String taskJson = "{blablabla:blabla}"; + + HttpResponse response = client.send( + getPost("http://localhost:8080/subtasks", taskJson), + HttpResponse.BodyHandlers.ofString() + ); + subTask.setTaskId(2); + + assertEquals(400, response.statusCode()); + assertNull(manager.getSubTask(2)); + } + + @Test + public void shouldReturn406WhenTimeOverlap() throws IOException, InterruptedException { + + manager.addSubTask(new SubTask("subtask1", + "demo", + Status.NEW, + 1, + 10, + epochTime)); + + String taskJson = gson.toJson(new SubTask("subtask2", + "demo", + Status.NEW, + 1, + 5, + epochTime)); + + HttpResponse response = client.send( + getPost("http://localhost:8080/subtasks", taskJson), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(406, response.statusCode()); + } + } + + @Nested + class SubTaskDeleteTest { + + private HttpRequest getRequest(String value) { + return getMethod("DELETE", value); + } + + @Test + public void shouldReturn200AndDeleteAllTasks() throws IOException, InterruptedException { + manager.addSubTask(subTask); + manager.addSubTask(new SubTask("subtask2", "demo", Status.NEW, 1)); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/subtasks"), + HttpResponse.BodyHandlers.ofString() + ); + + assertEquals(200, response.statusCode()); + assertEquals(0, manager.getSubTasks().size()); + } + + @Test + public void shouldReturn200AndDeleteTask() throws IOException, InterruptedException { + manager.addSubTask(subTask); + HttpResponse response = client.send( + getRequest("http://localhost:8080/subtasks/2"), + HttpResponse.BodyHandlers.ofString() + ); + + assertEquals(200, response.statusCode()); + assertNull(manager.getSubTask(2)); + } + } + +} \ No newline at end of file diff --git a/test/http/handlers/TaskHandlerTest.java b/test/http/handlers/TaskHandlerTest.java new file mode 100644 index 0000000..29b1692 --- /dev/null +++ b/test/http/handlers/TaskHandlerTest.java @@ -0,0 +1,332 @@ +package http.handlers; + +import http.HttpTaskServerTest; +import model.Task; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import util.enums.Status; + +import java.io.IOException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + + +class TaskHandlerTest extends HttpTaskServerTest { + + @Nested + class TaskHeadTest { + + private HttpRequest getRequest(String value) { + return getMethod("HEAD", value); + } + + @Test + public void shouldReturn200AndRightHeadersWhenHeadWithEmptyTasks() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.headers().firstValue("Content-type")); + assertEquals(200, response.statusCode()); + + assertEquals("application/json;charset=utf-8", + response.headers().firstValue("Content-type").get()); + assertEquals("2", + response.headers().firstValue("Content-length").get()); + } + + @Test + public void shouldReturn404AndRightHeadersWhenHeadWithInvalidTask() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks/1"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.headers().firstValue("Content-type")); + assertEquals(404, response.statusCode()); + + assertEquals("application/json;charset=utf-8", + response.headers().firstValue("Content-type").get()); + assertEquals("69", + response.headers().firstValue("Content-length").get()); + } + + @Test + public void shouldReturn404AndRightHeadersWhenHeadWithInvalidSubResource() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks/1/sub"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.headers().firstValue("Content-type")); + assertEquals(404, response.statusCode()); + + assertEquals("application/json;charset=utf-8", + response.headers().firstValue("Content-type").get()); + assertEquals("76", + response.headers().firstValue("Content-length").get()); + } + + @Test + public void shouldReturn400AndRightHeadersWhenHeadWithInvalidId() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks/-1"), + HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.headers().firstValue("Content-type")); + assertEquals(400, response.statusCode()); + + assertEquals("application/json;charset=utf-8", + response.headers().firstValue("Content-type").get()); + assertEquals("65", + response.headers().firstValue("Content-length").get()); + } + } + + @Nested + class TaskGetTest { + + private HttpRequest getRequest(String value) { + return getMethod("GET", value); + } + + @Test + public void shouldReturn404WhenInvalidResource() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasksABC"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldBeEqualAndCode200() throws IOException, InterruptedException { + manager.addTask(task); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks/1"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode()); + assertEquals(task, gson.fromJson(response.body(), Task.class)); + } + + @Test + public void shouldSameAmountAndCode200() throws IOException, InterruptedException { + manager.addTask(task); + manager.addTask(new Task("task2", "demo", Status.NEW)); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks"), + HttpResponse.BodyHandlers.ofString()); + + List list = gson.fromJson(response.body(), new ListSubTaskTypeToken().getType()); + + assertEquals(200, response.statusCode()); + assertEquals(2, list.size()); + } + + @Test + public void shouldReturn404WhenNoId() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks/1"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn404WhenSubResourcePresent() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks/1/sub"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn404WhenIdInvalid() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks/-2a"), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(400, response.statusCode()); + } + + } + + @Nested + class TaskPostTest { + + @Test + public void shouldBeEqualAndCode201() throws IOException, InterruptedException { + String taskJson = gson.toJson(task); + + HttpResponse response = client.send( + getPost("http://localhost:8080/tasks", taskJson), + HttpResponse.BodyHandlers.ofString()); + + task.setTaskId(1); + + assertEquals(201, response.statusCode()); + assertEquals(task, manager.getTask(1)); + } + + @Test + public void shouldBeUpdatedAndCode201() throws IOException, InterruptedException { + manager.addTask(task); + + task.setTitle("new title"); + String taskJson = gson.toJson(task); + + HttpResponse response = client.send( + getPost("http://localhost:8080/tasks", taskJson), + HttpResponse.BodyHandlers.ofString()); + + + assertEquals(201, response.statusCode()); + assertEquals(task, manager.getTask(1)); + } + + @Test + public void shouldNotBeUpdatedAndCode404WhenNotFound() throws IOException, InterruptedException { + + task.setTaskId(1); + String taskJson = gson.toJson(task); + + HttpResponse response = client.send( + getPost("http://localhost:8080/tasks", taskJson), + HttpResponse.BodyHandlers.ofString()); + + + assertEquals(404, response.statusCode()); + } + + @Test + public void shouldReturn405AndNotPostWhenIdIsPresent() throws IOException, InterruptedException { + String taskJson = gson.toJson(task); + + HttpResponse response = client.send( + getPost("http://localhost:8080/tasks/1", taskJson), + HttpResponse.BodyHandlers.ofString() + ); + task.setTaskId(1); + + assertEquals(405, response.statusCode()); + assertNull(manager.getTask(1)); + } + + @Test + public void shouldReturn400WhenNoBody() throws IOException, InterruptedException { + + HttpResponse response = client.send( + getPost("http://localhost:8080/tasks", ""), + HttpResponse.BodyHandlers.ofString() + ); + task.setTaskId(1); + + assertEquals(400, response.statusCode()); + assertNull(manager.getTask(1)); + } + + @Test + public void shouldReturn400WhenInvalidBody() throws IOException, InterruptedException { + String taskJson = "{blablabla:blabla}"; + + HttpResponse response = client.send( + getPost("http://localhost:8080/tasks", taskJson), + HttpResponse.BodyHandlers.ofString() + ); + task.setTaskId(1); + + assertEquals(400, response.statusCode()); + assertNull(manager.getTask(1)); + } + + @Test + public void shouldReturn406WhenTimeOverlap() throws IOException, InterruptedException { + manager.addTask(new Task("task1", "demo", Status.NEW, 10, epochTime)); + + String taskJson = gson.toJson(new Task("task2", "demo", Status.NEW, 5, epochTime)); + + HttpResponse response = client.send( + getPost("http://localhost:8080/tasks", taskJson), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(406, response.statusCode()); + } + } + + @Nested + class TaskDeleteTest { + + private HttpRequest getRequest(String value) { + return getMethod("DELETE", value); + } + + @Test + public void shouldReturn200AndDeleteAllTasks() throws IOException, InterruptedException { + manager.addTask(task); + manager.addTask(new Task("task2", "demo", Status.NEW)); + + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks"), + HttpResponse.BodyHandlers.ofString() + ); + + assertEquals(200, response.statusCode()); + assertEquals(0, manager.getTasks().size()); + } + + @Test + public void shouldReturn200AndDeleteTask() throws IOException, InterruptedException { + manager.addTask(task); + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks/1"), + HttpResponse.BodyHandlers.ofString() + ); + + assertEquals(200, response.statusCode()); + assertNull(manager.getTask(1)); + } + } + + @Nested + class TaskOptionsTest { + + private HttpRequest getRequest(String value) { + return getMethod("OPTIONS", value); + } + + @Test + public void shouldReturn204AndValidMethods() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks"), + HttpResponse.BodyHandlers.ofString() + ); + List expected = List.of("GET, POST, DELETE, OPTIONS, HEAD"); + + List methods = response.headers().allValues("Allow"); + + assertEquals(204, response.statusCode()); + assertEquals(expected, methods); + } + + @Test + public void shouldReturn204AndValidMethodsWhenIdIsPresent() throws IOException, InterruptedException { + HttpResponse response = client.send( + getRequest("http://localhost:8080/tasks/1"), + HttpResponse.BodyHandlers.ofString() + ); + List expected = List.of("GET, DELETE, OPTIONS, HEAD"); + + List methods = response.headers().allValues("Allow"); + + assertEquals(204, response.statusCode()); + assertEquals(expected, methods); + } + + } +} \ No newline at end of file diff --git a/test/managers/FileBackedTaskManagerTest.java b/test/managers/FileBackedTaskManagerTest.java index eb4a9c7..0d2d48f 100644 --- a/test/managers/FileBackedTaskManagerTest.java +++ b/test/managers/FileBackedTaskManagerTest.java @@ -6,7 +6,7 @@ import model.Task; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import util.Status; +import util.enums.Status; import util.exceptions.ManagerLoadException; import util.exceptions.ManagerSaveException; diff --git a/test/managers/InMemoryHistoryManagerTest.java b/test/managers/InMemoryHistoryManagerTest.java index eb1f007..c4e8c6d 100644 --- a/test/managers/InMemoryHistoryManagerTest.java +++ b/test/managers/InMemoryHistoryManagerTest.java @@ -5,7 +5,7 @@ import model.SubTask; import model.Task; import org.junit.jupiter.api.Test; -import util.Status; +import util.enums.Status; import static org.junit.jupiter.api.Assertions.*; diff --git a/test/managers/InMemoryTaskManagerTest.java b/test/managers/InMemoryTaskManagerTest.java index 174f55f..8aa53cb 100644 --- a/test/managers/InMemoryTaskManagerTest.java +++ b/test/managers/InMemoryTaskManagerTest.java @@ -5,7 +5,7 @@ import model.Task; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import util.Status; +import util.enums.Status; import util.exceptions.TaskTimeOverlapException; import java.time.LocalDateTime; diff --git a/test/model/EpicTest.java b/test/model/EpicTest.java index 4dc9a59..eaa2377 100644 --- a/test/model/EpicTest.java +++ b/test/model/EpicTest.java @@ -1,7 +1,7 @@ package model; import org.junit.jupiter.api.Test; -import util.Status; +import util.enums.Status; import java.time.Duration; import java.time.LocalDateTime; diff --git a/test/model/SubTaskTest.java b/test/model/SubTaskTest.java index dce8d68..23b0160 100644 --- a/test/model/SubTaskTest.java +++ b/test/model/SubTaskTest.java @@ -2,7 +2,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import util.Status; +import util.enums.Status; public class SubTaskTest { diff --git a/test/model/TaskTest.java b/test/model/TaskTest.java index d0b0d98..5e2794a 100644 --- a/test/model/TaskTest.java +++ b/test/model/TaskTest.java @@ -1,8 +1,8 @@ package model; import org.junit.jupiter.api.Test; -import util.Status; -import util.Type; +import util.enums.Status; +import util.enums.Type; import java.time.Duration; import java.time.LocalDateTime; diff --git a/test/util/TaskTimeControllerTest.java b/test/util/TaskTimeControllerTest.java index b8e7a3b..74ce7ba 100644 --- a/test/util/TaskTimeControllerTest.java +++ b/test/util/TaskTimeControllerTest.java @@ -6,6 +6,8 @@ import model.SubTask; import model.Task; import org.junit.jupiter.api.Test; +import util.enums.Status; +import util.enums.Type; import java.time.Duration; import java.time.LocalDateTime;