From 0ca80e66ca74c143eed45bba2a9a6ac6f495586c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Fri, 16 Oct 2026 20:35:35 +0400 Subject: [PATCH 1/8] feat: implement task API with PostgreSQL concurrency and RabbitMQ --- .config/dotnet-tools.json | 10 + .dockerignore | 8 + .editorconfig | 12 + .gitattributes | 1 + .github/workflows/ci.yml | 25 ++ Directory.Build.props | 10 + Directory.Packages.props | 17 ++ PANiXiDA.TaskApi.sln | 114 +++++++++ README.md | 109 +++++++++ compose.test.yaml | 40 ++++ compose.yaml | 46 ++++ global.json | 6 + src/PANiXiDA.TaskApi.Host/Dockerfile | 12 + .../PANiXiDA.TaskApi.Host.csproj | 9 + src/PANiXiDA.TaskApi.Host/Program.cs | 26 ++ src/PANiXiDA.TaskApi.Host/appsettings.json | 9 + .../PANiXiDA.TaskApi.Tasks.Application.csproj | 5 + .../Tasks/CompleteTaskResult.cs | 12 + .../Tasks/ITaskEventPublisher.cs | 6 + .../Tasks/ITasksRepository.cs | 12 + .../Tasks/TaskCompleted.cs | 5 + .../Tasks/TasksService.cs | 45 ++++ .../PANiXiDA.TaskApi.Tasks.Domain.csproj | 1 + .../Tasks/Priority.cs | 8 + .../Tasks/TaskItem.cs | 50 ++++ .../ServiceCollectionExtensions.cs | 34 +++ .../Messaging/ITaskEventTransport.cs | 9 + .../Messaging/RabbitMqOptions.cs | 15 ++ .../Messaging/RabbitMqTransport.cs | 92 ++++++++ .../Messaging/TaskEventJson.cs | 12 + .../Messaging/TaskEventPublisher.cs | 96 ++++++++ ...NiXiDA.TaskApi.Tasks.Infrastructure.csproj | 11 + .../20261016162834_InitialCreate.Designer.cs | 73 ++++++ .../20261016162834_InitialCreate.cs | 42 ++++ .../Migrations/TasksDbContextModelSnapshot.cs | 70 ++++++ .../Persistence/TasksDbContext.cs | 24 ++ .../Persistence/TasksDbContextFactory.cs | 15 ++ .../Persistence/TasksRepository.cs | 37 +++ ...PANiXiDA.TaskApi.Tasks.Presentation.csproj | 7 + .../Tasks/CreateTaskRequest.cs | 5 + .../Tasks/TaskResponse.cs | 15 ++ .../Tasks/TasksEndpoints.cs | 67 ++++++ tests/Dockerfile | 8 + .../ApiFactory.cs | 32 +++ .../CompletionBarrier.cs | 28 +++ ...iXiDA.TaskApi.Tasks.FunctionalTests.csproj | 21 ++ .../RabbitMqProbe.cs | 69 ++++++ .../TaskEventPublisherTests.cs | 153 ++++++++++++ .../TasksEndpointsTests.cs | 222 ++++++++++++++++++ 49 files changed, 1755 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 PANiXiDA.TaskApi.sln create mode 100644 compose.test.yaml create mode 100644 compose.yaml create mode 100644 global.json create mode 100644 src/PANiXiDA.TaskApi.Host/Dockerfile create mode 100644 src/PANiXiDA.TaskApi.Host/PANiXiDA.TaskApi.Host.csproj create mode 100644 src/PANiXiDA.TaskApi.Host/Program.cs create mode 100644 src/PANiXiDA.TaskApi.Host/appsettings.json create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/PANiXiDA.TaskApi.Tasks.Application.csproj create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/CompleteTaskResult.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITaskEventPublisher.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITasksRepository.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TaskCompleted.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TasksService.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Domain/PANiXiDA.TaskApi.Tasks.Domain.csproj create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Domain/Tasks/Priority.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Domain/Tasks/TaskItem.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/ITaskEventTransport.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/RabbitMqOptions.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/RabbitMqTransport.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventJson.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventPublisher.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/PANiXiDA.TaskApi.Tasks.Infrastructure.csproj create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016162834_InitialCreate.Designer.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016162834_InitialCreate.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/TasksDbContextModelSnapshot.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContext.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContextFactory.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksRepository.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/PANiXiDA.TaskApi.Tasks.Presentation.csproj create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/CreateTaskRequest.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TaskResponse.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TasksEndpoints.cs create mode 100644 tests/Dockerfile create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/CompletionBarrier.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/PANiXiDA.TaskApi.Tasks.FunctionalTests.csproj create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TaskEventPublisherTests.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..5c1ae7a --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "9.0.21", + "commands": ["dotnet-ef"] + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f7b6259 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +**/bin +**/obj +**/TestResults +.git +.vs +.idea +.env +artifacts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..211695e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..280ece0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [development] + pull_request: + branches: [development] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Build and test in Docker + run: docker compose -p task-api-tests -f compose.test.yaml up --build --abort-on-container-exit --exit-code-from tests + - name: Build runtime image + run: docker build -f src/PANiXiDA.TaskApi.Host/Dockerfile -t task-api:ci . + - name: Clean up test containers + if: always() + run: docker compose -p task-api-tests -f compose.test.yaml down -v diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..3880be9 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + net9.0 + enable + enable + true + true + true + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..9521097 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,17 @@ + + + true + true + + + + + + + + + + + + + diff --git a/PANiXiDA.TaskApi.sln b/PANiXiDA.TaskApi.sln new file mode 100644 index 0000000..0d175bc --- /dev/null +++ b/PANiXiDA.TaskApi.sln @@ -0,0 +1,114 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PANiXiDA.TaskApi.Host", "src\PANiXiDA.TaskApi.Host\PANiXiDA.TaskApi.Host.csproj", "{21E61803-70F4-401C-A3F1-A87E9BF69383}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PANiXiDA.TaskApi.Tasks.Application", "src\Tasks\PANiXiDA.TaskApi.Tasks.Application\PANiXiDA.TaskApi.Tasks.Application.csproj", "{7B6A0997-93C6-42BD-B9F4-4A82842753C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PANiXiDA.TaskApi.Tasks.Domain", "src\Tasks\PANiXiDA.TaskApi.Tasks.Domain\PANiXiDA.TaskApi.Tasks.Domain.csproj", "{439A16D2-595A-44B4-8158-FA56F4707AA9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PANiXiDA.TaskApi.Tasks.Infrastructure", "src\Tasks\PANiXiDA.TaskApi.Tasks.Infrastructure\PANiXiDA.TaskApi.Tasks.Infrastructure.csproj", "{2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PANiXiDA.TaskApi.Tasks.Presentation", "src\Tasks\PANiXiDA.TaskApi.Tasks.Presentation\PANiXiDA.TaskApi.Tasks.Presentation.csproj", "{6FD423D9-4965-4450-8A17-FAD710C71EDB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PANiXiDA.TaskApi.Tasks.FunctionalTests", "tests\Tasks\PANiXiDA.TaskApi.Tasks.FunctionalTests\PANiXiDA.TaskApi.Tasks.FunctionalTests.csproj", "{8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {21E61803-70F4-401C-A3F1-A87E9BF69383}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21E61803-70F4-401C-A3F1-A87E9BF69383}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21E61803-70F4-401C-A3F1-A87E9BF69383}.Debug|x64.ActiveCfg = Debug|Any CPU + {21E61803-70F4-401C-A3F1-A87E9BF69383}.Debug|x64.Build.0 = Debug|Any CPU + {21E61803-70F4-401C-A3F1-A87E9BF69383}.Debug|x86.ActiveCfg = Debug|Any CPU + {21E61803-70F4-401C-A3F1-A87E9BF69383}.Debug|x86.Build.0 = Debug|Any CPU + {21E61803-70F4-401C-A3F1-A87E9BF69383}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21E61803-70F4-401C-A3F1-A87E9BF69383}.Release|Any CPU.Build.0 = Release|Any CPU + {21E61803-70F4-401C-A3F1-A87E9BF69383}.Release|x64.ActiveCfg = Release|Any CPU + {21E61803-70F4-401C-A3F1-A87E9BF69383}.Release|x64.Build.0 = Release|Any CPU + {21E61803-70F4-401C-A3F1-A87E9BF69383}.Release|x86.ActiveCfg = Release|Any CPU + {21E61803-70F4-401C-A3F1-A87E9BF69383}.Release|x86.Build.0 = Release|Any CPU + {7B6A0997-93C6-42BD-B9F4-4A82842753C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B6A0997-93C6-42BD-B9F4-4A82842753C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B6A0997-93C6-42BD-B9F4-4A82842753C7}.Debug|x64.ActiveCfg = Debug|Any CPU + {7B6A0997-93C6-42BD-B9F4-4A82842753C7}.Debug|x64.Build.0 = Debug|Any CPU + {7B6A0997-93C6-42BD-B9F4-4A82842753C7}.Debug|x86.ActiveCfg = Debug|Any CPU + {7B6A0997-93C6-42BD-B9F4-4A82842753C7}.Debug|x86.Build.0 = Debug|Any CPU + {7B6A0997-93C6-42BD-B9F4-4A82842753C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B6A0997-93C6-42BD-B9F4-4A82842753C7}.Release|Any CPU.Build.0 = Release|Any CPU + {7B6A0997-93C6-42BD-B9F4-4A82842753C7}.Release|x64.ActiveCfg = Release|Any CPU + {7B6A0997-93C6-42BD-B9F4-4A82842753C7}.Release|x64.Build.0 = Release|Any CPU + {7B6A0997-93C6-42BD-B9F4-4A82842753C7}.Release|x86.ActiveCfg = Release|Any CPU + {7B6A0997-93C6-42BD-B9F4-4A82842753C7}.Release|x86.Build.0 = Release|Any CPU + {439A16D2-595A-44B4-8158-FA56F4707AA9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {439A16D2-595A-44B4-8158-FA56F4707AA9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {439A16D2-595A-44B4-8158-FA56F4707AA9}.Debug|x64.ActiveCfg = Debug|Any CPU + {439A16D2-595A-44B4-8158-FA56F4707AA9}.Debug|x64.Build.0 = Debug|Any CPU + {439A16D2-595A-44B4-8158-FA56F4707AA9}.Debug|x86.ActiveCfg = Debug|Any CPU + {439A16D2-595A-44B4-8158-FA56F4707AA9}.Debug|x86.Build.0 = Debug|Any CPU + {439A16D2-595A-44B4-8158-FA56F4707AA9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {439A16D2-595A-44B4-8158-FA56F4707AA9}.Release|Any CPU.Build.0 = Release|Any CPU + {439A16D2-595A-44B4-8158-FA56F4707AA9}.Release|x64.ActiveCfg = Release|Any CPU + {439A16D2-595A-44B4-8158-FA56F4707AA9}.Release|x64.Build.0 = Release|Any CPU + {439A16D2-595A-44B4-8158-FA56F4707AA9}.Release|x86.ActiveCfg = Release|Any CPU + {439A16D2-595A-44B4-8158-FA56F4707AA9}.Release|x86.Build.0 = Release|Any CPU + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}.Debug|x64.ActiveCfg = Debug|Any CPU + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}.Debug|x64.Build.0 = Debug|Any CPU + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}.Debug|x86.ActiveCfg = Debug|Any CPU + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}.Debug|x86.Build.0 = Debug|Any CPU + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}.Release|Any CPU.Build.0 = Release|Any CPU + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}.Release|x64.ActiveCfg = Release|Any CPU + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}.Release|x64.Build.0 = Release|Any CPU + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}.Release|x86.ActiveCfg = Release|Any CPU + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF}.Release|x86.Build.0 = Release|Any CPU + {6FD423D9-4965-4450-8A17-FAD710C71EDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6FD423D9-4965-4450-8A17-FAD710C71EDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6FD423D9-4965-4450-8A17-FAD710C71EDB}.Debug|x64.ActiveCfg = Debug|Any CPU + {6FD423D9-4965-4450-8A17-FAD710C71EDB}.Debug|x64.Build.0 = Debug|Any CPU + {6FD423D9-4965-4450-8A17-FAD710C71EDB}.Debug|x86.ActiveCfg = Debug|Any CPU + {6FD423D9-4965-4450-8A17-FAD710C71EDB}.Debug|x86.Build.0 = Debug|Any CPU + {6FD423D9-4965-4450-8A17-FAD710C71EDB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6FD423D9-4965-4450-8A17-FAD710C71EDB}.Release|Any CPU.Build.0 = Release|Any CPU + {6FD423D9-4965-4450-8A17-FAD710C71EDB}.Release|x64.ActiveCfg = Release|Any CPU + {6FD423D9-4965-4450-8A17-FAD710C71EDB}.Release|x64.Build.0 = Release|Any CPU + {6FD423D9-4965-4450-8A17-FAD710C71EDB}.Release|x86.ActiveCfg = Release|Any CPU + {6FD423D9-4965-4450-8A17-FAD710C71EDB}.Release|x86.Build.0 = Release|Any CPU + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}.Debug|x64.Build.0 = Debug|Any CPU + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}.Debug|x86.ActiveCfg = Debug|Any CPU + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}.Debug|x86.Build.0 = Debug|Any CPU + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}.Release|Any CPU.Build.0 = Release|Any CPU + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}.Release|x64.ActiveCfg = Release|Any CPU + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}.Release|x64.Build.0 = Release|Any CPU + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}.Release|x86.ActiveCfg = Release|Any CPU + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {21E61803-70F4-401C-A3F1-A87E9BF69383} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {7B6A0997-93C6-42BD-B9F4-4A82842753C7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {439A16D2-595A-44B4-8158-FA56F4707AA9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {6FD423D9-4965-4450-8A17-FAD710C71EDB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 716fa34..0f3235f 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,112 @@ Тестовое задание: ASP.NET Core Minimal API на .NET 9, PostgreSQL / EF Core и RabbitMQ. Структура основана на [dotnet-backend-template](https://github.com/panixida-templates/dotnet-backend-template/tree/development); зависимости от PANiXiDA.Core не используются. + +## Запуск + +Требуется только Docker с Compose. SDK .NET на компьютере не нужен. + +```sh +docker compose up --build -d +``` + +API: `http://localhost:8080`. Проверка запуска: `GET /health`. +При занятом порте задайте переменную окружения `TASK_API_PORT`. +Миграции EF Core применяются перед запуском HTTP-сервера. PostgreSQL сохраняет данные в volume. + +```sh +docker compose logs -f api +docker compose down +``` + +Пароли в Compose — демонстрационные значения только для локальных контейнеров. Порты PostgreSQL и RabbitMQ не публикуются, API доступен только через loopback. + +## API + +| Метод | Путь | Результат | +| --- | --- | --- | +| POST | `/tasks` | `201`, созданная задача; неверный JSON, Title или Priority → `400` | +| GET | `/tasks` | `200`, все задачи в порядке CreatedAt / Id | +| PUT | `/tasks/{id}/complete` | `200`, завершённая задача; отсутствует → `404`; уже завершена или конкурентно изменена → `409` | +| DELETE | `/tasks/{id}` | `204`, физическое удаление; отсутствует → `404` | + +Некорректный GUID возвращает `400`. Пустой GUID корректен синтаксически, но такой задачи нет: `404`. +Ошибки оформляются как Problem Details. Пагинации и аутентификации нет, согласно объёму задания. + +Создание: + +```sh +curl -i http://localhost:8080/tasks \ + -H 'Content-Type: application/json' \ + -d '{"title":"Buy milk","priority":"High"}' +``` + +`title` обязателен, содержит хотя бы один непробельный символ, максимум 200 символов; исходный текст сохраняется. +`priority` по умолчанию `Medium`; принимаются `Low`, `Medium`, `High` или соответствующие числа `0`, `1`, `2`. +Ответы и события содержат строковое значение приоритета. `IsCompleted=false`, `CompletedAt=null` при создании. +Временные метки записываются в UTC с точностью PostgreSQL до микросекунды, одинаковой для API, БД и события. Повторное завершение не изменяет `CompletedAt` и не публикует событие. + +```sh +curl -i -X PUT http://localhost:8080/tasks//complete +curl http://localhost:8080/tasks +curl -i -X DELETE http://localhost:8080/tasks/ +``` + +В Windows используйте `curl.exe` или `Invoke-RestMethod`. + +## Устройство + +```text +src/ + PANiXiDA.TaskApi.Host/ # startup, конфигурация, Dockerfile + Tasks/ + PANiXiDA.TaskApi.Tasks.Domain/ # TaskItem, Priority, инварианты + PANiXiDA.TaskApi.Tasks.Application/ # сценарии, контракты хранилища и событий + PANiXiDA.TaskApi.Tasks.Infrastructure/ # EF Core, миграции, RabbitMQ + PANiXiDA.TaskApi.Tasks.Presentation/ # Minimal API, DTO, HTTP-валидация +tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ +``` + +Host связывает слои через встроенный DI. Application зависит только от Domain; Presentation не обращается к EF или RabbitMQ. Версии NuGet централизованы в `Directory.Packages.props`. + +`TaskItem.RowVersion` имеет тип `uint` и настроен через `IsRowVersion()`: Npgsql использует системную колонку PostgreSQL `xmin`. EF добавляет исходную версию в условие UPDATE; `DbUpdateConcurrencyException` преобразуется в `409`. Для двух запросов, прочитавших одну версию строки, сохраняется только один результат. Подробнее: [Npgsql concurrency tokens](https://www.npgsql.org/efcore/modeling/concurrency.html). + +## RabbitMQ и остановка + +После успешного `SaveChangesAsync` неизменяемое событие добавляется в ограниченную очередь в памяти. HTTP-запрос не ждёт RabbitMQ. Один BackgroundService последовательно публикует события через асинхронный клиент, поэтому канал не используется параллельно. + +- Exchange: `task.events`, тип `topic`, durable. +- Routing key: `task.completed`. +- JSON: `taskId`, `title`, `completedAt`, `priority`. +- Сообщение persistent, `Content-Type=application/json`, `MessageId=taskId`. +- Попытка публикации ограничена 2 секундами; ошибка логируется вместе с JSON события. Следующее событие может заново открыть соединение. +- При переполнении очереди или остановке publisher событие также записывается в лог. +- При SIGTERM/Ctrl+C очередь закрывается для новых событий. На завершение текущих и ожидающих публикаций отводится 4 секунды, затем до 1 секунды на отмену и закрытие канала/соединения. + +Очередь получателя и binding создаёт получатель; приложение только объявляет exchange и публикует. Для проверки тест создаёт временную очередь с binding `task.completed` и читает реальное сообщение. + +Это best-effort доставка, как в ТЗ: нет outbox, повторной доставки и publisher confirms. При падении процесса между сохранением и публикацией или недоступном брокере событие может потеряться. Если у exchange нет подходящей очереди, RabbitMQ удалит сообщение. Завершение задачи при этом сохраняется. + +## Тесты + +```sh +docker compose -p task-api-tests -f compose.test.yaml up --build --abort-on-container-exit --exit-code-from tests +docker compose -p task-api-tests -f compose.test.yaml down -v +``` + +Команда сборки и тестов работает полностью в контейнере .NET 9. PostgreSQL и RabbitMQ настоящие, изолированы от обычного запуска; тестовая БД находится в tmpfs. + +Проверяются создание/завершение/удаление, значения по умолчанию, валидация, получение события из RabbitMQ, отсутствие повторного события, отсутствие публикации при ошибке сохранения, недоступный брокер, гонка двух запросов с одной версией строки, drain/таймаут остановки, переполнение очереди и логирование потерянных сообщений. Гонка воспроизводится барьером перед UPDATE, чтобы оба запроса гарантированно прочитали одну версию. + +## Конфигурация + +| Переменная | Назначение | +| --- | --- | +| `ConnectionStrings__Tasks` | Строка подключения PostgreSQL, обязательна | +| `RabbitMq__HostName` | Адрес RabbitMQ | +| `RabbitMq__Port` | AMQP-порт, по умолчанию 5672 | +| `RabbitMq__UserName`, `RabbitMq__Password` | Учётные данные, обязательны | +| `RabbitMq__VirtualHost` | Virtual host, по умолчанию `/` | +| `RabbitMq__QueueCapacity` | Размер очереди публикаций, по умолчанию 1024 | + +В production секреты передаются через окружение/секрет-хранилище, а миграции обычно выносятся в отдельный шаг развёртывания. `/health` показывает работоспособность HTTP-процесса, а не доступность внешних зависимостей. diff --git a/compose.test.yaml b/compose.test.yaml new file mode 100644 index 0000000..095a469 --- /dev/null +++ b/compose.test.yaml @@ -0,0 +1,40 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: tasks_tests + POSTGRES_USER: tasks + POSTGRES_PASSWORD: isolated-test-only + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tasks -d tasks_tests"] + interval: 2s + timeout: 3s + retries: 30 + + rabbitmq: + image: rabbitmq:4.1-alpine + environment: + RABBITMQ_DEFAULT_USER: tasks + RABBITMQ_DEFAULT_PASS: isolated-test-only + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 3s + timeout: 5s + retries: 30 + + tests: + build: + context: . + dockerfile: tests/Dockerfile + environment: + ConnectionStrings__Tasks: Host=postgres;Database=tasks_tests;Username=tasks;Password=isolated-test-only + RabbitMq__HostName: rabbitmq + RabbitMq__UserName: tasks + RabbitMq__Password: isolated-test-only + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..a48f483 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,46 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: tasks + POSTGRES_USER: tasks + POSTGRES_PASSWORD: local-development-only + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tasks -d tasks"] + interval: 2s + timeout: 3s + retries: 30 + + rabbitmq: + image: rabbitmq:4.1-alpine + environment: + RABBITMQ_DEFAULT_USER: tasks + RABBITMQ_DEFAULT_PASS: local-development-only + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 3s + timeout: 5s + retries: 30 + + api: + build: + context: . + dockerfile: src/PANiXiDA.TaskApi.Host/Dockerfile + ports: + - "127.0.0.1:${TASK_API_PORT:-8080}:8080" + environment: + ConnectionStrings__Tasks: Host=postgres;Database=tasks;Username=tasks;Password=local-development-only + RabbitMq__HostName: rabbitmq + RabbitMq__UserName: tasks + RabbitMq__Password: local-development-only + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + stop_grace_period: 10s + +volumes: + postgres-data: diff --git a/global.json b/global.json new file mode 100644 index 0000000..ece9a7b --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.319", + "rollForward": "latestPatch" + } +} diff --git a/src/PANiXiDA.TaskApi.Host/Dockerfile b/src/PANiXiDA.TaskApi.Host/Dockerfile new file mode 100644 index 0000000..95d6cbc --- /dev/null +++ b/src/PANiXiDA.TaskApi.Host/Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /source +COPY Directory.Build.props Directory.Packages.props global.json ./ +COPY src/ src/ +RUN dotnet publish src/PANiXiDA.TaskApi.Host/PANiXiDA.TaskApi.Host.csproj -c Release -o /app /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final +WORKDIR /app +COPY --from=build /app ./ +USER $APP_UID +EXPOSE 8080 +ENTRYPOINT ["dotnet", "PANiXiDA.TaskApi.Host.dll"] diff --git a/src/PANiXiDA.TaskApi.Host/PANiXiDA.TaskApi.Host.csproj b/src/PANiXiDA.TaskApi.Host/PANiXiDA.TaskApi.Host.csproj new file mode 100644 index 0000000..25207fe --- /dev/null +++ b/src/PANiXiDA.TaskApi.Host/PANiXiDA.TaskApi.Host.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/PANiXiDA.TaskApi.Host/Program.cs b/src/PANiXiDA.TaskApi.Host/Program.cs new file mode 100644 index 0000000..e682ae6 --- /dev/null +++ b/src/PANiXiDA.TaskApi.Host/Program.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using PANiXiDA.TaskApi.Tasks.Infrastructure.DependencyInjection; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; +using PANiXiDA.TaskApi.Tasks.Presentation.Tasks; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddProblemDetails(); +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); +builder.Services.AddInfrastructure(builder.Configuration); + +var app = builder.Build(); +app.UseExceptionHandler(); +app.UseStatusCodePages(); +app.MapTasks(); +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); + +await using (var scope = app.Services.CreateAsyncScope()) +{ + await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync(); +} + +await app.RunAsync(); + +public partial class Program; diff --git a/src/PANiXiDA.TaskApi.Host/appsettings.json b/src/PANiXiDA.TaskApi.Host/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/PANiXiDA.TaskApi.Host/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/PANiXiDA.TaskApi.Tasks.Application.csproj b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/PANiXiDA.TaskApi.Tasks.Application.csproj new file mode 100644 index 0000000..c9ee78b --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/PANiXiDA.TaskApi.Tasks.Application.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/CompleteTaskResult.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/CompleteTaskResult.cs new file mode 100644 index 0000000..f4aa583 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/CompleteTaskResult.cs @@ -0,0 +1,12 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks; + +public enum CompletionStatus +{ + Completed, + NotFound, + Conflict +} + +public sealed record CompleteTaskResult(CompletionStatus Status, TaskItem? Task = null); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITaskEventPublisher.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITaskEventPublisher.cs new file mode 100644 index 0000000..078eda9 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITaskEventPublisher.cs @@ -0,0 +1,6 @@ +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks; + +public interface ITaskEventPublisher +{ + void Publish(TaskCompleted message); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITasksRepository.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITasksRepository.cs new file mode 100644 index 0000000..c062567 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITasksRepository.cs @@ -0,0 +1,12 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks; + +public interface ITasksRepository +{ + Task AddAsync(TaskItem task, CancellationToken cancellationToken); + Task> GetAllAsync(CancellationToken cancellationToken); + Task FindAsync(Guid id, CancellationToken cancellationToken); + Task TrySaveAsync(CancellationToken cancellationToken); + Task DeleteAsync(Guid id, CancellationToken cancellationToken); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TaskCompleted.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TaskCompleted.cs new file mode 100644 index 0000000..6576690 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TaskCompleted.cs @@ -0,0 +1,5 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks; + +public sealed record TaskCompleted(Guid TaskId, string Title, DateTimeOffset CompletedAt, Priority Priority); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TasksService.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TasksService.cs new file mode 100644 index 0000000..8b4d3e8 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TasksService.cs @@ -0,0 +1,45 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks; + +public sealed class TasksService( + ITasksRepository repository, + ITaskEventPublisher publisher, + TimeProvider timeProvider) +{ + public async Task CreateAsync(string title, Priority priority, CancellationToken cancellationToken) + { + var task = TaskItem.Create(title, priority, GetTimestamp()); + await repository.AddAsync(task, cancellationToken); + return task; + } + + public Task> GetAllAsync(CancellationToken cancellationToken) => + repository.GetAllAsync(cancellationToken); + + public async Task CompleteAsync(Guid id, CancellationToken cancellationToken) + { + var task = await repository.FindAsync(id, cancellationToken); + if (task is null) + { + return new CompleteTaskResult(CompletionStatus.NotFound); + } + + if (!task.TryComplete(GetTimestamp()) || !await repository.TrySaveAsync(cancellationToken)) + { + return new CompleteTaskResult(CompletionStatus.Conflict); + } + + publisher.Publish(new TaskCompleted(task.Id, task.Title, task.CompletedAt!.Value, task.Priority)); + return new CompleteTaskResult(CompletionStatus.Completed, task); + } + + public Task DeleteAsync(Guid id, CancellationToken cancellationToken) => + repository.DeleteAsync(id, cancellationToken); + + private DateTimeOffset GetTimestamp() + { + var now = timeProvider.GetUtcNow(); + return now.AddTicks(-(now.Ticks % TimeSpan.TicksPerMicrosecond)); + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Domain/PANiXiDA.TaskApi.Tasks.Domain.csproj b/src/Tasks/PANiXiDA.TaskApi.Tasks.Domain/PANiXiDA.TaskApi.Tasks.Domain.csproj new file mode 100644 index 0000000..6b512ec --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Domain/PANiXiDA.TaskApi.Tasks.Domain.csproj @@ -0,0 +1 @@ + diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Domain/Tasks/Priority.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Domain/Tasks/Priority.cs new file mode 100644 index 0000000..edeb002 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Domain/Tasks/Priority.cs @@ -0,0 +1,8 @@ +namespace PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +public enum Priority +{ + Low, + Medium, + High +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Domain/Tasks/TaskItem.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Domain/Tasks/TaskItem.cs new file mode 100644 index 0000000..fa36484 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Domain/Tasks/TaskItem.cs @@ -0,0 +1,50 @@ +namespace PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +public sealed class TaskItem +{ + public const int MaxTitleLength = 200; + + private TaskItem() { } + + public Guid Id { get; private set; } + public string Title { get; private set; } = string.Empty; + public bool IsCompleted { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset? CompletedAt { get; private set; } + public Priority Priority { get; private set; } + public uint RowVersion { get; private set; } + + public static TaskItem Create(string title, Priority priority, DateTimeOffset createdAt) + { + ArgumentException.ThrowIfNullOrWhiteSpace(title); + if (title.Length > MaxTitleLength) + { + throw new ArgumentOutOfRangeException(nameof(title)); + } + + if (!Enum.IsDefined(priority)) + { + throw new ArgumentOutOfRangeException(nameof(priority)); + } + + return new TaskItem + { + Id = Guid.NewGuid(), + Title = title, + Priority = priority, + CreatedAt = createdAt.ToUniversalTime() + }; + } + + public bool TryComplete(DateTimeOffset completedAt) + { + if (IsCompleted) + { + return false; + } + + IsCompleted = true; + CompletedAt = completedAt.ToUniversalTime(); + return true; + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0d647ac --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("Tasks") + ?? throw new InvalidOperationException("ConnectionStrings:Tasks is required."); + services.AddDbContext(options => options.UseNpgsql(connectionString)); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(TimeProvider.System); + services.AddOptions() + .Bind(configuration.GetSection(RabbitMqOptions.SectionName)) + .Validate(x => !string.IsNullOrWhiteSpace(x.HostName) && x.Port is > 0 and <= 65535, + "RabbitMQ host and port are required.") + .Validate(x => !string.IsNullOrWhiteSpace(x.UserName) && !string.IsNullOrEmpty(x.Password), + "RabbitMQ credentials are required.") + .Validate(x => x.QueueCapacity > 0, "RabbitMQ queue capacity must be positive.") + .ValidateOnStart(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddHostedService(provider => provider.GetRequiredService()); + return services; + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/ITaskEventTransport.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/ITaskEventTransport.cs new file mode 100644 index 0000000..2eba0eb --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/ITaskEventTransport.cs @@ -0,0 +1,9 @@ +using PANiXiDA.TaskApi.Tasks.Application.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; + +public interface ITaskEventTransport +{ + Task SendAsync(TaskCompleted message, CancellationToken cancellationToken); + Task CloseAsync(CancellationToken cancellationToken); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/RabbitMqOptions.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/RabbitMqOptions.cs new file mode 100644 index 0000000..38df54f --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/RabbitMqOptions.cs @@ -0,0 +1,15 @@ +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; + +public sealed class RabbitMqOptions +{ + public const string SectionName = "RabbitMq"; + public const string Exchange = "task.events"; + public const string RoutingKey = "task.completed"; + + public string HostName { get; set; } = "localhost"; + public int Port { get; set; } = 5672; + public string UserName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string VirtualHost { get; set; } = "/"; + public int QueueCapacity { get; set; } = 1024; +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/RabbitMqTransport.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/RabbitMqTransport.cs new file mode 100644 index 0000000..6c3ef09 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/RabbitMqTransport.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using RabbitMQ.Client; + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; + +internal sealed class RabbitMqTransport(IOptions options) : ITaskEventTransport +{ + private IConnection? _connection; + private IChannel? _channel; + + public async Task SendAsync(TaskCompleted message, CancellationToken cancellationToken) + { + if (_channel is not { IsOpen: true }) + { + await CloseAsync(cancellationToken); + var configuration = options.Value; + var factory = new ConnectionFactory + { + HostName = configuration.HostName, + Port = configuration.Port, + UserName = configuration.UserName, + Password = configuration.Password, + VirtualHost = configuration.VirtualHost, + AutomaticRecoveryEnabled = false, + RequestedConnectionTimeout = TimeSpan.FromSeconds(2), + ContinuationTimeout = TimeSpan.FromSeconds(2), + ClientProvidedName = "task-api-publisher" + }; + _connection = await factory.CreateConnectionAsync(cancellationToken); + _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); + } + + await _channel.ExchangeDeclareAsync(RabbitMqOptions.Exchange, ExchangeType.Topic, + durable: true, autoDelete: false, cancellationToken: cancellationToken); + + var properties = new BasicProperties + { + ContentType = "application/json", + Persistent = true, + MessageId = message.TaskId.ToString(), + Type = RabbitMqOptions.RoutingKey + }; + await _channel.BasicPublishAsync(RabbitMqOptions.Exchange, RabbitMqOptions.RoutingKey, + mandatory: false, properties, JsonSerializer.SerializeToUtf8Bytes(message, TaskEventJson.Options), + cancellationToken); + } + + public async Task CloseAsync(CancellationToken cancellationToken) + { + var channel = _channel; + var connection = _connection; + _channel = null; + _connection = null; + + try + { + if (channel is not null) + { + try + { + if (channel.IsOpen) + { + await channel.CloseAsync(cancellationToken); + } + } + finally + { + await channel.DisposeAsync().AsTask().WaitAsync(cancellationToken); + } + } + } + finally + { + if (connection is not null) + { + try + { + if (connection.IsOpen) + { + await connection.CloseAsync(cancellationToken); + } + } + finally + { + await connection.DisposeAsync().AsTask().WaitAsync(cancellationToken); + } + } + } + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventJson.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventJson.cs new file mode 100644 index 0000000..b77a7cf --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventJson.cs @@ -0,0 +1,12 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; + +internal static class TaskEventJson +{ + internal static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventPublisher.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventPublisher.cs new file mode 100644 index 0000000..ce85646 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventPublisher.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PANiXiDA.TaskApi.Tasks.Application.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; + +public sealed class TaskEventPublisher( + ITaskEventTransport transport, + IOptions options, + ILogger logger) : BackgroundService, ITaskEventPublisher +{ + private readonly Channel _queue = Channel.CreateBounded( + new BoundedChannelOptions(options.Value.QueueCapacity) + { + SingleReader = true, + FullMode = BoundedChannelFullMode.Wait, + AllowSynchronousContinuations = false + }); + + public void Publish(TaskCompleted message) + { + if (!_queue.Writer.TryWrite(message)) + { + LogUnsent(message, "Publisher queue is full or stopping"); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await foreach (var message in _queue.Reader.ReadAllAsync(stoppingToken)) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + timeout.CancelAfter(TimeSpan.FromSeconds(2)); + try + { + await transport.SendAsync(message, timeout.Token); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Task completion event was not sent: {Message}", + JsonSerializer.Serialize(message, TaskEventJson.Options)); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + finally + { + while (_queue.Reader.TryRead(out var message)) + { + LogUnsent(message, "Publisher shutdown timeout"); + } + + using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + try + { + await transport.CloseAsync(closeTimeout.Token); + } + catch (Exception exception) + { + logger.LogWarning(exception, "RabbitMQ connection cleanup did not complete normally"); + } + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + _queue.Writer.TryComplete(); + using var drainTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + drainTimeout.CancelAfter(TimeSpan.FromSeconds(4)); + try + { + if (ExecuteTask is not null) + { + await ExecuteTask.WaitAsync(drainTimeout.Token); + } + } + catch (OperationCanceledException) when (drainTimeout.IsCancellationRequested) + { + logger.LogWarning("Task event publisher drain deadline reached"); + } + + using var stopTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await base.StopAsync(stopTimeout.Token); + } + + private void LogUnsent(TaskCompleted message, string reason) => + logger.LogWarning("{Reason}. Task completion event was not sent: {Message}", reason, + JsonSerializer.Serialize(message, TaskEventJson.Options)); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/PANiXiDA.TaskApi.Tasks.Infrastructure.csproj b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/PANiXiDA.TaskApi.Tasks.Infrastructure.csproj new file mode 100644 index 0000000..11910ad --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/PANiXiDA.TaskApi.Tasks.Infrastructure.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016162834_InitialCreate.Designer.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016162834_InitialCreate.Designer.cs new file mode 100644 index 0000000..287b6c7 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016162834_InitialCreate.Designer.cs @@ -0,0 +1,73 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +#nullable disable + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(TasksDbContext))] + [Migration("20261016162834_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.21") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PANiXiDA.TaskApi.Tasks.Domain.Tasks.TaskItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("character varying(6)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("tasks", null, t => + { + t.HasCheckConstraint("ck_tasks_completion", "\"IsCompleted\" = (\"CompletedAt\" IS NOT NULL)"); + + t.HasCheckConstraint("ck_tasks_priority", "\"Priority\" IN ('Low', 'Medium', 'High')"); + + t.HasCheckConstraint("ck_tasks_title", "length(btrim(\"Title\")) > 0"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016162834_InitialCreate.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016162834_InitialCreate.cs new file mode 100644 index 0000000..6a57b1a --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016162834_InitialCreate.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "tasks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + IsCompleted = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + Priority = table.Column(type: "character varying(6)", maxLength: 6, nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_tasks", x => x.Id); + table.CheckConstraint("ck_tasks_completion", "\"IsCompleted\" = (\"CompletedAt\" IS NOT NULL)"); + table.CheckConstraint("ck_tasks_priority", "\"Priority\" IN ('Low', 'Medium', 'High')"); + table.CheckConstraint("ck_tasks_title", "length(btrim(\"Title\")) > 0"); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "tasks"); + } + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/TasksDbContextModelSnapshot.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/TasksDbContextModelSnapshot.cs new file mode 100644 index 0000000..9b515e4 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/TasksDbContextModelSnapshot.cs @@ -0,0 +1,70 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +#nullable disable + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(TasksDbContext))] + partial class TasksDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.21") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PANiXiDA.TaskApi.Tasks.Domain.Tasks.TaskItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("character varying(6)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("tasks", null, t => + { + t.HasCheckConstraint("ck_tasks_completion", "\"IsCompleted\" = (\"CompletedAt\" IS NOT NULL)"); + + t.HasCheckConstraint("ck_tasks_priority", "\"Priority\" IN ('Low', 'Medium', 'High')"); + + t.HasCheckConstraint("ck_tasks_title", "length(btrim(\"Title\")) > 0"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContext.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContext.cs new file mode 100644 index 0000000..0b6d1a2 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContext.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +public sealed class TasksDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Tasks => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var task = modelBuilder.Entity(); + task.ToTable("tasks", table => + { + table.HasCheckConstraint("ck_tasks_title", "length(btrim(\"Title\")) > 0"); + table.HasCheckConstraint("ck_tasks_priority", "\"Priority\" IN ('Low', 'Medium', 'High')"); + table.HasCheckConstraint("ck_tasks_completion", "\"IsCompleted\" = (\"CompletedAt\" IS NOT NULL)"); + }); + task.HasKey(x => x.Id); + task.Property(x => x.Title).IsRequired().HasMaxLength(TaskItem.MaxTitleLength); + task.Property(x => x.Priority).HasConversion().HasMaxLength(6); + task.Property(x => x.RowVersion).IsRowVersion(); + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContextFactory.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContextFactory.cs new file mode 100644 index 0000000..c62e482 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContextFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +public sealed class TasksDbContextFactory : IDesignTimeDbContextFactory +{ + public TasksDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__Tasks") + ?? "Host=localhost;Database=tasks"; + return new TasksDbContext(new DbContextOptionsBuilder() + .UseNpgsql(connectionString).Options); + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksRepository.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksRepository.cs new file mode 100644 index 0000000..7851c61 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksRepository.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +internal sealed class TasksRepository(TasksDbContext dbContext) : ITasksRepository +{ + public async Task AddAsync(TaskItem task, CancellationToken cancellationToken) + { + dbContext.Tasks.Add(task); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task> GetAllAsync(CancellationToken cancellationToken) => + await dbContext.Tasks.AsNoTracking().OrderBy(x => x.CreatedAt).ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + + public Task FindAsync(Guid id, CancellationToken cancellationToken) => + dbContext.Tasks.SingleOrDefaultAsync(x => x.Id == id, cancellationToken); + + public async Task TrySaveAsync(CancellationToken cancellationToken) + { + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + catch (DbUpdateConcurrencyException) + { + return false; + } + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) => + await dbContext.Tasks.Where(x => x.Id == id).ExecuteDeleteAsync(cancellationToken) != 0; +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/PANiXiDA.TaskApi.Tasks.Presentation.csproj b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/PANiXiDA.TaskApi.Tasks.Presentation.csproj new file mode 100644 index 0000000..19a7d10 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/PANiXiDA.TaskApi.Tasks.Presentation.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/CreateTaskRequest.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/CreateTaskRequest.cs new file mode 100644 index 0000000..efd0722 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/CreateTaskRequest.cs @@ -0,0 +1,5 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Tasks; + +public sealed record CreateTaskRequest(string? Title, Priority Priority = Priority.Medium); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TaskResponse.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TaskResponse.cs new file mode 100644 index 0000000..850a0c6 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TaskResponse.cs @@ -0,0 +1,15 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Tasks; + +public sealed record TaskResponse( + Guid Id, + string Title, + bool IsCompleted, + DateTimeOffset CreatedAt, + DateTimeOffset? CompletedAt, + Priority Priority) +{ + internal static TaskResponse From(TaskItem task) => + new(task.Id, task.Title, task.IsCompleted, task.CreatedAt, task.CompletedAt, task.Priority); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TasksEndpoints.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TasksEndpoints.cs new file mode 100644 index 0000000..87a4e43 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TasksEndpoints.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Tasks; + +public static class TasksEndpoints +{ + public static IEndpointRouteBuilder MapTasks(this IEndpointRouteBuilder endpoints) + { + var tasks = endpoints.MapGroup("/tasks"); + tasks.MapPost("/", CreateAsync).WithName("CreateTask"); + tasks.MapGet("/", GetAllAsync).WithName("GetTasks"); + tasks.MapPut("/{id}/complete", CompleteAsync).WithName("CompleteTask"); + tasks.MapDelete("/{id}", DeleteAsync).WithName("DeleteTask"); + return endpoints; + } + + private static async Task, ValidationProblem>> CreateAsync( + CreateTaskRequest request, TasksService service, CancellationToken cancellationToken) + { + var errors = new Dictionary(); + if (string.IsNullOrWhiteSpace(request.Title) || request.Title.Length > TaskItem.MaxTitleLength) + { + errors["title"] = ["Title must contain non-whitespace characters and be at most 200 characters long."]; + } + + if (!Enum.IsDefined(request.Priority)) + { + errors["priority"] = ["Priority must be Low, Medium or High."]; + } + + if (errors.Count > 0) + { + return TypedResults.ValidationProblem(errors); + } + + var task = await service.CreateAsync(request.Title!, request.Priority, cancellationToken); + return TypedResults.Created("/tasks", TaskResponse.From(task)); + } + + private static async Task> GetAllAsync(TasksService service, CancellationToken cancellationToken) + { + var tasks = await service.GetAllAsync(cancellationToken); + return TypedResults.Ok(tasks.Select(TaskResponse.From).ToArray()); + } + + private static async Task, NotFound, ProblemHttpResult>> CompleteAsync( + Guid id, TasksService service, CancellationToken cancellationToken) + { + var result = await service.CompleteAsync(id, cancellationToken); + return result.Status switch + { + CompletionStatus.Completed => TypedResults.Ok(TaskResponse.From(result.Task!)), + CompletionStatus.NotFound => TypedResults.NotFound(), + _ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, + title: "The task is already completed or was modified concurrently.") + }; + } + + private static async Task> DeleteAsync( + Guid id, TasksService service, CancellationToken cancellationToken) => + await service.DeleteAsync(id, cancellationToken) ? TypedResults.NoContent() : TypedResults.NotFound(); +} diff --git a/tests/Dockerfile b/tests/Dockerfile new file mode 100644 index 0000000..14aeee9 --- /dev/null +++ b/tests/Dockerfile @@ -0,0 +1,8 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 +WORKDIR /source +COPY Directory.Build.props Directory.Packages.props global.json PANiXiDA.TaskApi.sln ./ +COPY src/ src/ +COPY tests/ tests/ +RUN dotnet restore PANiXiDA.TaskApi.sln +RUN dotnet build PANiXiDA.TaskApi.sln -c Release --no-restore +CMD ["dotnet", "test", "PANiXiDA.TaskApi.sln", "-c", "Release", "--no-build", "--logger", "console;verbosity=normal"] diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs new file mode 100644 index 0000000..be6a771 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; + +public sealed class ApiFactory(SaveChangesInterceptor? interceptor = null, int? rabbitMqPort = null) + : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + if (rabbitMqPort is not null) + { + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["RabbitMq:Port"] = rabbitMqPort.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) + })); + } + + if (interceptor is not null) + { + builder.ConfigureServices(services => services.AddDbContext(options => + options.AddInterceptors(interceptor))); + } + } +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/CompletionBarrier.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/CompletionBarrier.cs new file mode 100644 index 0000000..9cff6a9 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/CompletionBarrier.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; + +internal sealed class CompletionBarrier : SaveChangesInterceptor +{ + private readonly TaskCompletionSource _bothReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _arrived; + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (eventData.Context!.ChangeTracker.Entries() + .Any(x => x.State == EntityState.Modified && x.Entity.IsCompleted)) + { + if (Interlocked.Increment(ref _arrived) == 2) + { + _bothReady.TrySetResult(); + } + + await _bothReady.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); + } + + return result; + } +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/PANiXiDA.TaskApi.Tasks.FunctionalTests.csproj b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/PANiXiDA.TaskApi.Tasks.FunctionalTests.csproj new file mode 100644 index 0000000..21aeb76 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/PANiXiDA.TaskApi.Tasks.FunctionalTests.csproj @@ -0,0 +1,21 @@ + + + true + false + + + + + + + + + + + + + + + + + diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs new file mode 100644 index 0000000..119383d --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; +using RabbitMQ.Client; + +namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; + +internal sealed class RabbitMqProbe(IConnection connection, IChannel channel, string queue) : IAsyncDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + public static async Task CreateAsync() + { + var factory = new ConnectionFactory + { + HostName = Environment.GetEnvironmentVariable("RabbitMq__HostName")!, + UserName = Environment.GetEnvironmentVariable("RabbitMq__UserName")!, + Password = Environment.GetEnvironmentVariable("RabbitMq__Password")!, + AutomaticRecoveryEnabled = false + }; + var connection = await factory.CreateConnectionAsync(); + var channel = await connection.CreateChannelAsync(); + await channel.ExchangeDeclareAsync(RabbitMqOptions.Exchange, ExchangeType.Topic, durable: true); + var queue = await channel.QueueDeclareAsync(); + await channel.QueueBindAsync(queue.QueueName, RabbitMqOptions.Exchange, RabbitMqOptions.RoutingKey); + return new RabbitMqProbe(connection, channel, queue.QueueName); + } + + public async Task ReadAsync(Guid id) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (true) + { + var delivery = await channel.BasicGetAsync(queue, autoAck: true, timeout.Token); + if (delivery is not null) + { + Assert.Equal(RabbitMqOptions.RoutingKey, delivery.RoutingKey); + Assert.Equal("application/json", delivery.BasicProperties.ContentType); + using var json = JsonDocument.Parse(delivery.Body); + Assert.Equal(JsonValueKind.String, json.RootElement.GetProperty("priority").ValueKind); + var message = JsonSerializer.Deserialize(delivery.Body.Span, JsonOptions)!; + if (message.TaskId == id) + { + return message; + } + } + + await Task.Delay(25, timeout.Token); + } + } + + public async Task AssertNoMessageAsync() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + Assert.Null(await channel.BasicGetAsync(queue, autoAck: true, timeout.Token)); + } + + public async ValueTask DisposeAsync() + { + await channel.CloseAsync(); + await channel.DisposeAsync(); + await connection.CloseAsync(); + await connection.DisposeAsync(); + } +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TaskEventPublisherTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TaskEventPublisherTests.cs new file mode 100644 index 0000000..93bcfb9 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TaskEventPublisherTests.cs @@ -0,0 +1,153 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; + +namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; + +public sealed class TaskEventPublisherTests +{ + [Fact] + public async Task Stop_Should_DrainStartedAndQueuedPublications_Before_ClosingTransport() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var transport = new RecordingTransport(async (_, token) => + { + entered.TrySetResult(); + await release.Task.WaitAsync(token); + }); + using var publisher = CreatePublisher(transport); + await publisher.StartAsync(CancellationToken.None); + var first = Message(); + var second = Message(); + publisher.Publish(first); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + publisher.Publish(second); + + var stopping = publisher.StopAsync(CancellationToken.None); + Assert.False(stopping.IsCompleted); + Assert.False(transport.Closed); + release.SetResult(); + await stopping.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal([first, second], transport.Messages.ToArray()); + Assert.True(transport.Closed); + } + + [Fact] + public async Task Publish_Should_LogPayloadAndContinue_When_TransportFails() + { + var first = Message(); + var second = Message(); + var logger = new RecordingLogger(); + var transport = new RecordingTransport((message, _) => + message == first ? Task.FromException(new IOException("Broker unavailable")) : Task.CompletedTask); + using var publisher = CreatePublisher(transport, logger); + await publisher.StartAsync(CancellationToken.None); + + publisher.Publish(first); + publisher.Publish(second); + await publisher.StopAsync(CancellationToken.None); + + Assert.Equal(second, Assert.Single(transport.Messages)); + Assert.Contains(logger.Messages, x => x.Contains(first.TaskId.ToString(), StringComparison.Ordinal) + && x.Contains(first.Title, StringComparison.Ordinal)); + Assert.True(transport.Closed); + } + + [Fact] + public async Task Publish_Should_LogDroppedEvent_When_QueueIsFullOrStopped() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var logger = new RecordingLogger(); + var transport = new RecordingTransport(async (_, token) => + { + entered.TrySetResult(); + await release.Task.WaitAsync(token); + }); + using var publisher = CreatePublisher(transport, logger, capacity: 1); + await publisher.StartAsync(CancellationToken.None); + publisher.Publish(Message()); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + publisher.Publish(Message()); + var overflow = Message(); + + publisher.Publish(overflow); + release.SetResult(); + await publisher.StopAsync(CancellationToken.None); + var afterStop = Message(); + publisher.Publish(afterStop); + + Assert.Equal(2, transport.Messages.Count); + Assert.Contains(logger.Messages, x => x.Contains(overflow.TaskId.ToString(), StringComparison.Ordinal)); + Assert.Contains(logger.Messages, x => x.Contains(afterStop.TaskId.ToString(), StringComparison.Ordinal)); + } + + [Fact] + public async Task Stop_Should_CancelHungPublicationsAndClose_Within_ShutdownBudget() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var transport = new RecordingTransport(async (_, token) => + { + entered.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + }); + var logger = new RecordingLogger(); + using var publisher = CreatePublisher(transport, logger); + await publisher.StartAsync(CancellationToken.None); + var events = Enumerable.Range(0, 5).Select(_ => Message()).ToArray(); + foreach (var message in events) + { + publisher.Publish(message); + } + + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + var elapsed = Stopwatch.StartNew(); + await publisher.StopAsync(CancellationToken.None); + + Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(5), $"Shutdown took {elapsed.Elapsed}."); + Assert.True(transport.Closed); + Assert.Empty(transport.Messages); + Assert.All(events, message => Assert.Contains(logger.Messages, + x => x.Contains(message.TaskId.ToString(), StringComparison.Ordinal))); + } + + private static TaskEventPublisher CreatePublisher( + ITaskEventTransport transport, RecordingLogger? logger = null, int capacity = 16) => + new(transport, Options.Create(new RabbitMqOptions { QueueCapacity = capacity }), logger ?? new RecordingLogger()); + + private static TaskCompleted Message() => new(Guid.NewGuid(), "Buy milk", DateTimeOffset.UtcNow, Priority.High); + + private sealed class RecordingTransport(Func send) : ITaskEventTransport + { + public ConcurrentQueue Messages { get; } = new(); + public bool Closed { get; private set; } + + public async Task SendAsync(TaskCompleted message, CancellationToken cancellationToken) + { + Assert.False(Closed); + await send(message, cancellationToken); + Messages.Enqueue(message); + } + + public Task CloseAsync(CancellationToken cancellationToken) + { + Closed = true; + return Task.CompletedTask; + } + } + + private sealed class RecordingLogger : ILogger + { + public ConcurrentQueue Messages { get; } = new(); + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => Messages.Enqueue(formatter(state, exception)); + } +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs new file mode 100644 index 0000000..9a266d9 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs @@ -0,0 +1,222 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; +using PANiXiDA.TaskApi.Tasks.Presentation.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; + +public sealed class TasksEndpointsTests +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + [Fact] + public async Task Complete_Should_PublishPersistedTask_When_TaskExists() + { + await using var probe = await RabbitMqProbe.CreateAsync(); + await using var factory = new ApiFactory(); + using var client = factory.CreateClient(); + var created = await CreateAsync(client, "Buy milk", "High"); + Assert.False(created.IsCompleted); + Assert.Null(created.CompletedAt); + Assert.Equal(Priority.High, created.Priority); + + using var completedResponse = await client.PutAsync($"/tasks/{created.Id}/complete", null); + Assert.Equal(HttpStatusCode.OK, completedResponse.StatusCode); + var completed = (await completedResponse.Content.ReadFromJsonAsync(JsonOptions))!; + var message = await probe.ReadAsync(created.Id); + var tasks = (await client.GetFromJsonAsync("/tasks", JsonOptions))!; + var saved = Assert.Single(tasks, x => x.Id == created.Id); + + Assert.True(saved.IsCompleted); + Assert.NotNull(saved.CompletedAt); + Assert.True(saved.CompletedAt >= saved.CreatedAt); + Assert.Equal(completed.CompletedAt, saved.CompletedAt); + Assert.Equal(saved.CompletedAt, message.CompletedAt); + Assert.Equal(saved.Title, message.Title); + Assert.Equal(saved.Priority, message.Priority); + + using var repeated = await client.PutAsync($"/tasks/{created.Id}/complete", null); + Assert.Equal(HttpStatusCode.Conflict, repeated.StatusCode); + var unchanged = Assert.Single((await client.GetFromJsonAsync("/tasks", JsonOptions))!, + x => x.Id == created.Id); + Assert.Equal(saved.CompletedAt, unchanged.CompletedAt); + await factory.DisposeAsync(); + await probe.AssertNoMessageAsync(); + } + + [Fact] + public async Task Create_Should_UseDefaults_When_OnlyTitleIsProvided() + { + await using var factory = new ApiFactory(); + using var client = factory.CreateClient(); + using var response = await client.PostAsJsonAsync("/tasks", new { title = new string('a', 200) }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var task = (await response.Content.ReadFromJsonAsync(JsonOptions))!; + Assert.Equal(Priority.Medium, task.Priority); + Assert.False(task.IsCompleted); + Assert.Null(task.CompletedAt); + Assert.NotEqual(Guid.Empty, task.Id); + Assert.NotEqual(default, task.CreatedAt); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\r\n")] + public async Task Create_Should_Return400_When_TitleIsBlank(string? title) + { + await using var factory = new ApiFactory(); + using var client = factory.CreateClient(); + using var response = await client.PostAsJsonAsync("/tasks", new { title }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Create_Should_Return400_When_TitleIsTooLong() + { + await using var factory = new ApiFactory(); + using var client = factory.CreateClient(); + using var response = await client.PostAsJsonAsync("/tasks", new { title = new string('a', 201) }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [InlineData("{\"title\":\"test\",\"priority\":99}")] + [InlineData("{\"title\":\"test\",\"priority\":\"Urgent\"}")] + [InlineData("{\"title\":\"test\",\"priority\":null}")] + [InlineData("{}")] + [InlineData("null")] + [InlineData("{")] + public async Task Create_Should_Return400_When_BodyIsInvalid(string json) + { + await using var factory = new ApiFactory(); + using var client = factory.CreateClient(); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync("/tasks", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Complete_Should_ReturnOneConflict_When_BothRequestsReadTheSameRowVersion() + { + await using var probe = await RabbitMqProbe.CreateAsync(); + await using var factory = new ApiFactory(new CompletionBarrier()); + using var client = factory.CreateClient(); + var task = await CreateAsync(client); + + var responses = await Task.WhenAll( + client.PutAsync($"/tasks/{task.Id}/complete", null), + client.PutAsync($"/tasks/{task.Id}/complete", null)); + + Assert.Single(responses, x => x.StatusCode == HttpStatusCode.OK); + Assert.Single(responses, x => x.StatusCode == HttpStatusCode.Conflict); + await probe.ReadAsync(task.Id); + await factory.DisposeAsync(); + await probe.AssertNoMessageAsync(); + foreach (var response in responses) + { + response.Dispose(); + } + } + + [Fact] + public async Task Complete_Should_PersistTask_When_RabbitMqIsUnavailable() + { + await using var factory = new ApiFactory(rabbitMqPort: 1); + using var client = factory.CreateClient(); + var task = await CreateAsync(client); + + using var response = await client.PutAsync($"/tasks/{task.Id}/complete", null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var scope = factory.Services.CreateScope(); + var saved = await scope.ServiceProvider.GetRequiredService().Tasks + .AsNoTracking().SingleAsync(x => x.Id == task.Id); + Assert.True(saved.IsCompleted); + Assert.NotNull(saved.CompletedAt); + } + + [Fact] + public async Task Complete_Should_NotPublish_When_DatabaseSaveFails() + { + await using var probe = await RabbitMqProbe.CreateAsync(); + await using var factory = new ApiFactory(new RejectCompletion()); + using var client = factory.CreateClient(); + var task = await CreateAsync(client); + + using var response = await client.PutAsync($"/tasks/{task.Id}/complete", null); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + using var scope = factory.Services.CreateScope(); + var saved = await scope.ServiceProvider.GetRequiredService().Tasks + .AsNoTracking().SingleAsync(x => x.Id == task.Id); + Assert.False(saved.IsCompleted); + Assert.Null(saved.CompletedAt); + await factory.DisposeAsync(); + await probe.AssertNoMessageAsync(); + } + + [Fact] + public async Task Delete_Should_RemoveTaskPermanently_When_TaskExists() + { + await using var factory = new ApiFactory(); + using var client = factory.CreateClient(); + var task = await CreateAsync(client); + + using var response = await client.DeleteAsync($"/tasks/{task.Id}"); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var tasks = (await client.GetFromJsonAsync("/tasks", JsonOptions))!; + Assert.DoesNotContain(tasks, x => x.Id == task.Id); + using var missing = await client.DeleteAsync($"/tasks/{task.Id}"); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + using var complete = await client.PutAsync($"/tasks/{task.Id}/complete", null); + Assert.Equal(HttpStatusCode.NotFound, complete.StatusCode); + } + + [Theory] + [InlineData("not-a-guid", HttpStatusCode.BadRequest)] + [InlineData("00000000-0000-0000-0000-000000000000", HttpStatusCode.NotFound)] + public async Task Endpoints_Should_ReturnExpectedStatus_When_IdIsInvalidOrMissing( + string id, HttpStatusCode expected) + { + await using var factory = new ApiFactory(); + using var client = factory.CreateClient(); + using var complete = await client.PutAsync($"/tasks/{id}/complete", null); + using var delete = await client.DeleteAsync($"/tasks/{id}"); + Assert.Equal(expected, complete.StatusCode); + Assert.Equal(expected, delete.StatusCode); + } + + private static async Task CreateAsync(HttpClient client, string title = "Test task", string priority = "Medium") + { + using var response = await client.PostAsJsonAsync("/tasks", new { title, priority }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + private sealed class RejectCompletion : SaveChangesInterceptor + { + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (eventData.Context!.ChangeTracker.Entries().Any(x => x.State == EntityState.Modified)) + { + throw new InvalidOperationException("Simulated database write failure."); + } + + return ValueTask.FromResult(result); + } + } +} From 613bd46a8bbafe8c5dca6db394becc7d9ed75c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Fri, 16 Oct 2026 20:58:57 +0400 Subject: [PATCH 2/8] fix: make Docker startup reliable and add customer walkthrough --- README.md | 125 +++++++++++++++++++++++++++++++++++++++++++--- compose.test.yaml | 2 +- compose.yaml | 2 +- 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0f3235f..1503872 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,132 @@ Структура основана на [dotnet-backend-template](https://github.com/panixida-templates/dotnet-backend-template/tree/development); зависимости от PANiXiDA.Core не используются. -## Запуск +## Проверка для заказчика: без установки .NET и знания кода -Требуется только Docker с Compose. SDK .NET на компьютере не нужен. +Это серверное API без отдельного сайта с кнопками. Самый простой способ проверить задание — запустить готовые автотесты одной командой. Они сами создают и завершают задачи, проверяют БД и получают настоящее сообщение из RabbitMQ. -```sh +### 1. Подготовить компьютер и скачать проект + +1. Установите [Docker Desktop](https://docs.docker.com/desktop/setup/install/windows-install/) и запустите его. На Windows используйте Linux containers; если установщик попросит настроить WSL или перезагрузиться, выполните его инструкции. Docker Compose входит в Docker Desktop. +2. Дождитесь, когда Docker Desktop сообщит, что Docker Engine запущен. Для первой сборки нужен интернет: Docker скачает необходимые компоненты. +3. Распакуйте полученный от разработчика ZIP-архив проекта. Если скачиваете с GitHub, откройте [ветку с реализацией](https://github.com/PANiXiDA/task-api/tree/feature/implement-task-api), затем **Code → Download ZIP**. Пока [PR №1](https://github.com/PANiXiDA/task-api/pull/1) не слит, скачивать нужно именно эту ветку, а не `development`. Репозиторий приватный: для скачивания через GitHub нужен выданный доступ; переданный ZIP такого доступа не требует. +4. Откройте распакованную папку, в которой лежат `README.md`, `compose.yaml` и `compose.test.yaml`. В Проводнике Windows нажмите на адресную строку, введите `powershell` и нажмите Enter. Все команды ниже выполняйте в открывшемся окне. + +Проверьте, что Docker доступен: + +```powershell +docker compose version +``` + +Должна появиться строка с версией Docker Compose. Устанавливать .NET, PostgreSQL, RabbitMQ, Git или Postman отдельно не нужно. + +### 2. Запустить автоматическую проверку + +Скопируйте в PowerShell всю команду и нажмите Enter: + +```powershell +docker compose -p task-api-tests -f compose.test.yaml up --build --abort-on-container-exit --exit-code-from tests --attach tests +``` + +Первый запуск может занять несколько минут. Дождитесь окончания: команда сама соберёт приложение, запустит отдельные PostgreSQL и RabbitMQ, выполнит проверки и остановит тестовые контейнеры. Обычный сервис для этого запускать не нужно. + +**Успешный результат в конце вывода:** + +```text +Test Run Successful. +Total tests: 23 + Passed: 23 +``` + +В логах могут встречаться ошибки БД и недоступности RabbitMQ: некоторые тесты специально воспроизводят эти ситуации. Ориентируйтесь на итог `Test Run Successful.` и количество прошедших тестов. Если в конце написано `Test Run Failed`, есть упавшие тесты или сборка оборвалась, проверка не пройдена — передайте разработчику вывод окна. + +После проверки удалите её временные контейнеры: + +```powershell +docker compose -p task-api-tests -f compose.test.yaml down -v +``` + +Команда очистки относится только к отдельному тестовому окружению и не удаляет задачи из обычного сервиса. + +### 3. Запустить сервис для ручной проверки + +В том же окне PowerShell выполните: + +```powershell docker compose up --build -d ``` -API: `http://localhost:8080`. Проверка запуска: `GET /health`. -При занятом порте задайте переменную окружения `TASK_API_PORT`. -Миграции EF Core применяются перед запуском HTTP-сервера. PostgreSQL сохраняет данные в volume. +Команда поднимает всё необходимое: **API + PostgreSQL + RabbitMQ**. Подключения уже настроены, таблицы создаются автоматически миграциями. Проверить контейнеры можно командой `docker compose ps`: у API ожидается состояние `Up`, у PostgreSQL и RabbitMQ — `Up (healthy)`. -```sh -docker compose logs -f api +Откройте в браузере [http://localhost:8080/health](http://localhost:8080/health). Ожидаемый ответ: + +```json +{"status":"ok"} +``` + +Если страница не открылась сразу, подождите несколько секунд после запуска и обновите её. Список задач доступен по адресу [http://localhost:8080/tasks](http://localhost:8080/tasks); в новой БД он пустой: `[]`. + +### 4. Проверить создание, завершение и удаление вручную + +Копируйте блоки по порядку в **одно и то же окно PowerShell**. ID задачи сохраняется автоматически: ничего подставлять вместо `` не нужно. + +Создайте задачу: + +```powershell +$baseUrl = "http://localhost:8080" +$task = Invoke-RestMethod -Method Post -Uri "$baseUrl/tasks" -ContentType "application/json" -Body '{"title":"Buy milk","priority":"High"}' +$task | ConvertTo-Json +``` + +В ответе должны быть `title: "Buy milk"`, `priority: "High"`, `isCompleted: false` и `completedAt: null`. + +Завершите её: + +```powershell +Invoke-RestMethod -Method Put -Uri "$baseUrl/tasks/$($task.id)/complete" | ConvertTo-Json +``` + +Теперь `isCompleted` равен `true`, а `completedAt` содержит дату и время завершения. Если выполнить эту команду повторно, ожидается ошибка **409 Conflict**: повторное завершение запрещено. + +Посмотрите список задач: + +```powershell +Invoke-RestMethod -Uri "$baseUrl/tasks" | ConvertTo-Json +``` + +Созданная задача должна присутствовать в списке и быть завершённой. Удалите её и снова откройте список: + +```powershell +Invoke-RestMethod -Method Delete -Uri "$baseUrl/tasks/$($task.id)" +Invoke-RestMethod -Uri "$baseUrl/tasks" | ConvertTo-Json +``` + +Успешное удаление не выводит текст. Задача исчезнет из списка; если других задач нет, список пустой (PowerShell может не вывести ничего). + +Получение события из RabbitMQ проверяют автотесты из шага 2. В обычном запуске нет интерфейса брокера и очереди получателя: они не нужны для работы API, а тест создаёт свою очередь автоматически. Успешное ручное завершение задачи само по себе не подтверждает доставку события. + +### 5. Остановить сервис + +```powershell docker compose down ``` +Созданные вручную задачи сохраняются в Docker volume и будут доступны после следующего запуска. + +### Если что-то не получилось + +| Что видно | Что сделать | +| --- | --- | +| `docker` не найден | Установите Docker Desktop и заново откройте PowerShell. | +| `Cannot connect` / `error during connect` к Docker | Запустите Docker Desktop и дождитесь запуска Engine. | +| `no configuration file provided` | Откройте PowerShell в папке, где лежит `compose.yaml`. | +| Ошибка скачивания образов или пакетов | Проверьте интернет и доступ Docker к сети, затем повторите команду. | +| `port is already allocated` / занят 8080 | Выполните `$env:TASK_API_PORT = "8081"`, затем повторите `docker compose up --build -d`. В адресах браузера и `$baseUrl` используйте порт 8081. | +| API не отвечает после запуска | Выполните `docker compose ps` и `docker compose logs --tail=100 api`, передайте вывод разработчику. | +| При скачивании с GitHub ошибка 404 | Проверьте доступ к приватному репозиторию или используйте ZIP, полученный от разработчика. | + +Команды Docker работают и на macOS/Linux с установленным Docker Compose; блоки `Invoke-RestMethod` выше предназначены для PowerShell. + Пароли в Compose — демонстрационные значения только для локальных контейнеров. Порты PostgreSQL и RabbitMQ не публикуются, API доступен только через loopback. ## API diff --git a/compose.test.yaml b/compose.test.yaml index 095a469..739a9d0 100644 --- a/compose.test.yaml +++ b/compose.test.yaml @@ -19,7 +19,7 @@ services: RABBITMQ_DEFAULT_USER: tasks RABBITMQ_DEFAULT_PASS: isolated-test-only healthcheck: - test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + test: ["CMD-SHELL", "test -s /var/lib/rabbitmq/.erlang.cookie && su-exec rabbitmq rabbitmq-diagnostics -q check_running"] interval: 3s timeout: 5s retries: 30 diff --git a/compose.yaml b/compose.yaml index a48f483..72541c0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -19,7 +19,7 @@ services: RABBITMQ_DEFAULT_USER: tasks RABBITMQ_DEFAULT_PASS: local-development-only healthcheck: - test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + test: ["CMD-SHELL", "test -s /var/lib/rabbitmq/.erlang.cookie && su-exec rabbitmq rabbitmq-diagnostics -q check_running"] interval: 3s timeout: 5s retries: 30 From 7849b90b74b52cf4b4f6170babbe1f9122f6572b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Fri, 16 Oct 2026 21:06:08 +0400 Subject: [PATCH 3/8] feat: enable RabbitMQ management UI for local testing --- README.md | 24 +++++++++++++++++++++--- compose.yaml | 4 +++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1503872..fb1cd0b 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ docker compose -p task-api-tests -f compose.test.yaml down -v docker compose up --build -d ``` -Команда поднимает всё необходимое: **API + PostgreSQL + RabbitMQ**. Подключения уже настроены, таблицы создаются автоматически миграциями. Проверить контейнеры можно командой `docker compose ps`: у API ожидается состояние `Up`, у PostgreSQL и RabbitMQ — `Up (healthy)`. +Команда поднимает всё необходимое: **API + PostgreSQL + RabbitMQ с веб-панелью управления**. Подключения уже настроены, таблицы создаются автоматически миграциями. Проверить контейнеры можно командой `docker compose ps`: у API ожидается состояние `Up`, у PostgreSQL и RabbitMQ — `Up (healthy)`. Откройте в браузере [http://localhost:8080/health](http://localhost:8080/health). Ожидаемый ответ: @@ -69,6 +69,23 @@ docker compose up --build -d Если страница не открылась сразу, подождите несколько секунд после запуска и обновите её. Список задач доступен по адресу [http://localhost:8080/tasks](http://localhost:8080/tasks); в новой БД он пустой: `[]`. +#### Веб-панель RabbitMQ + +Панель запускается автоматически той же командой `docker compose up --build -d`. Отдельно включать плагины не нужно. + +| Параметр | Значение | +| --- | --- | +| Адрес панели | [http://localhost:15672](http://localhost:15672) | +| Порт панели | `15672` | +| Логин | `tasks` | +| Пароль | `local-development-only` | + +Откройте адрес в браузере и введите эти логин и пароль. Если панель ещё не отвечает сразу после запуска, подождите несколько секунд и обновите страницу. + +**Учётные данные уже указаны в `compose.yaml`**, заказчику ничего дописывать не нужно: `RABBITMQ_DEFAULT_USER` и `RABBITMQ_DEFAULT_PASS` создают пользователя брокера, а `RabbitMq__UserName` и `RabbitMq__Password` в сервисе `api` задают подключение приложения. Если меняете логин или пароль, меняйте их в обеих секциях одинаково. Переменные `RABBITMQ_DEFAULT_*` применяются при первоначальном создании данных брокера; пароль существующего пользователя меняется через панель управления. + +Порт `5672` используется приложением для AMQP внутри Docker и не открывается на компьютере. Порт `15672` предназначен для браузера и доступен только с этого компьютера. В отдельном `compose.test.yaml` веб-панель не запускается: автотесты подключаются к брокеру напрямую. + ### 4. Проверить создание, завершение и удаление вручную Копируйте блоки по порядку в **одно и то же окно PowerShell**. ID задачи сохраняется автоматически: ничего подставлять вместо `` не нужно. @@ -106,7 +123,7 @@ Invoke-RestMethod -Uri "$baseUrl/tasks" | ConvertTo-Json Успешное удаление не выводит текст. Задача исчезнет из списка; если других задач нет, список пустой (PowerShell может не вывести ничего). -Получение события из RabbitMQ проверяют автотесты из шага 2. В обычном запуске нет интерфейса брокера и очереди получателя: они не нужны для работы API, а тест создаёт свою очередь автоматически. Успешное ручное завершение задачи само по себе не подтверждает доставку события. +Получение события из RabbitMQ проверяют автотесты из шага 2. В обычном запуске доступна веб-панель брокера, но очередь получателя автоматически не создаётся; тест создаёт свою очередь сам. Чтобы увидеть сообщение вручную в панели, до завершения задачи создайте очередь в virtual host `/` и привяжите её к exchange `task.events` с ключом `task.completed`. Если exchange ещё нет, сначала создайте его с типом `topic` и параметром `Durable = true`. Успешное ручное завершение задачи само по себе не подтверждает доставку события. ### 5. Остановить сервис @@ -125,12 +142,13 @@ docker compose down | `no configuration file provided` | Откройте PowerShell в папке, где лежит `compose.yaml`. | | Ошибка скачивания образов или пакетов | Проверьте интернет и доступ Docker к сети, затем повторите команду. | | `port is already allocated` / занят 8080 | Выполните `$env:TASK_API_PORT = "8081"`, затем повторите `docker compose up --build -d`. В адресах браузера и `$baseUrl` используйте порт 8081. | +| Занят порт панели RabbitMQ 15672 | Выполните `$env:RABBITMQ_UI_PORT = "15673"`, затем повторите `docker compose up --build -d`. Панель будет доступна на `http://localhost:15673`. | | API не отвечает после запуска | Выполните `docker compose ps` и `docker compose logs --tail=100 api`, передайте вывод разработчику. | | При скачивании с GitHub ошибка 404 | Проверьте доступ к приватному репозиторию или используйте ZIP, полученный от разработчика. | Команды Docker работают и на macOS/Linux с установленным Docker Compose; блоки `Invoke-RestMethod` выше предназначены для PowerShell. -Пароли в Compose — демонстрационные значения только для локальных контейнеров. Порты PostgreSQL и RabbitMQ не публикуются, API доступен только через loopback. +Пароли в Compose — демонстрационные значения только для локальных контейнеров. Порты PostgreSQL и AMQP RabbitMQ не публикуются. API и веб-панель RabbitMQ доступны только через loopback. ## API diff --git a/compose.yaml b/compose.yaml index 72541c0..975a992 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,7 +14,9 @@ services: retries: 30 rabbitmq: - image: rabbitmq:4.1-alpine + image: rabbitmq:4.1-management-alpine + ports: + - "127.0.0.1:${RABBITMQ_UI_PORT:-15672}:15672" environment: RABBITMQ_DEFAULT_USER: tasks RABBITMQ_DEFAULT_PASS: local-development-only From 2ca63d2639c3cd9525f3ea1f3825c44ed90a06b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Fri, 16 Oct 2026 21:17:34 +0400 Subject: [PATCH 4/8] test: split test suites and run dotnet test with shared CI --- .github/workflows/ci.yml | 15 ++- .gitignore | 1 + Directory.Packages.props | 9 +- PANiXiDA.TaskApi.sln | 49 ++++++- README.md | 68 +++++----- compose.test.yaml | 40 ------ global.json | 5 +- src/PANiXiDA.TaskApi.Host/Dockerfile | 2 +- tests/CodeCoverage.settings.xml | 19 +++ tests/Directory.Build.props | 15 +++ tests/Dockerfile | 8 -- .../PANiXiDA.TaskApi.Testing.csproj | 6 + .../PostgreSqlTestDatabase.cs | 18 +++ .../RabbitMqTestBroker.cs | 30 +++++ .../ApiFactory.cs | 3 +- .../FunctionalTestFixture.cs | 50 +++++++ ...iXiDA.TaskApi.Tasks.FunctionalTests.csproj | 9 +- .../RabbitMqProbe.cs | 24 ++-- .../TasksEndpointsTests.cs | 100 +++++++------- .../Messaging/RabbitMqTransportTests.cs | 69 ++++++++++ .../Persistence/TasksMigrationTests.cs | 45 +++++++ .../Persistence/TasksRepositoryTests.cs | 111 +++++++++++++++ .../IntegrationTestBase.cs | 11 ++ .../IntegrationTestCollectionDefinition.cs | 7 + .../IntegrationTestFixture.cs | 71 ++++++++++ ...XiDA.TaskApi.Tasks.IntegrationTests.csproj | 12 ++ .../Application/Tasks/TasksServiceTests.cs | 127 ++++++++++++++++++ .../Domain/Tasks/TaskItemTests.cs | 68 ++++++++++ .../Messaging}/TaskEventPublisherTests.cs | 34 ++--- .../PANiXiDA.TaskApi.Tasks.UnitTests.csproj | 9 ++ 30 files changed, 857 insertions(+), 178 deletions(-) delete mode 100644 compose.test.yaml create mode 100644 tests/CodeCoverage.settings.xml create mode 100644 tests/Directory.Build.props delete mode 100644 tests/Dockerfile create mode 100644 tests/PANiXiDA.TaskApi.Testing/PANiXiDA.TaskApi.Testing.csproj create mode 100644 tests/PANiXiDA.TaskApi.Testing/PostgreSqlTestDatabase.cs create mode 100644 tests/PANiXiDA.TaskApi.Testing/RabbitMqTestBroker.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/FunctionalTestFixture.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Messaging/RabbitMqTransportTests.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksRepositoryTests.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/IntegrationTestBase.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/IntegrationTestCollectionDefinition.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/IntegrationTestFixture.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/PANiXiDA.TaskApi.Tasks.IntegrationTests.csproj create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/TasksServiceTests.cs create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Domain/Tasks/TaskItemTests.cs rename tests/Tasks/{PANiXiDA.TaskApi.Tasks.FunctionalTests => PANiXiDA.TaskApi.Tasks.UnitTests/Infrastructure/Messaging}/TaskEventPublisherTests.cs (76%) create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/PANiXiDA.TaskApi.Tasks.UnitTests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 280ece0..bbf45a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,17 +9,20 @@ on: permissions: contents: read + checks: write jobs: - test: + tests: + uses: PANiXiDA-Infrastructure/ci-cd/.github/workflows/dotnet-tests.yml@main + secrets: + registry-user: ${{ github.actor }} + registry-token: ${{ secrets.GITHUB_TOKEN }} + + build: + needs: tests runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - name: Build and test in Docker - run: docker compose -p task-api-tests -f compose.test.yaml up --build --abort-on-container-exit --exit-code-from tests - name: Build runtime image run: docker build -f src/PANiXiDA.TaskApi.Host/Dockerfile -t task-api:ci . - - name: Clean up test containers - if: always() - run: docker compose -p task-api-tests -f compose.test.yaml down -v diff --git a/.gitignore b/.gitignore index bda197b..28290d5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ *.user *.suo artifacts/ +tests-report/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 9521097..5f72479 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,8 +10,11 @@ - - - + + + + + + diff --git a/PANiXiDA.TaskApi.sln b/PANiXiDA.TaskApi.sln index 0d175bc..5800c14 100644 --- a/PANiXiDA.TaskApi.sln +++ b/PANiXiDA.TaskApi.sln @@ -17,6 +17,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PANiXiDA.TaskApi.Tasks.Pres EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PANiXiDA.TaskApi.Tasks.FunctionalTests", "tests\Tasks\PANiXiDA.TaskApi.Tasks.FunctionalTests\PANiXiDA.TaskApi.Tasks.FunctionalTests.csproj", "{8F65B1F0-79C4-46AC-BA4F-31F9A59D310D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{75C100DC-8B11-45CF-9AB3-F62C88C951ED}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PANiXiDA.TaskApi.Testing", "tests\PANiXiDA.TaskApi.Testing\PANiXiDA.TaskApi.Testing.csproj", "{BD357DE7-BF5C-44B1-8758-B06457B221DE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PANiXiDA.TaskApi.Tasks.UnitTests", "tests\Tasks\PANiXiDA.TaskApi.Tasks.UnitTests\PANiXiDA.TaskApi.Tasks.UnitTests.csproj", "{38C28A25-05A6-4AD7-A364-00472F44554D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PANiXiDA.TaskApi.Tasks.IntegrationTests", "tests\Tasks\PANiXiDA.TaskApi.Tasks.IntegrationTests\PANiXiDA.TaskApi.Tasks.IntegrationTests.csproj", "{BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +35,42 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BD357DE7-BF5C-44B1-8758-B06457B221DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD357DE7-BF5C-44B1-8758-B06457B221DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD357DE7-BF5C-44B1-8758-B06457B221DE}.Debug|x64.ActiveCfg = Debug|Any CPU + {BD357DE7-BF5C-44B1-8758-B06457B221DE}.Debug|x64.Build.0 = Debug|Any CPU + {BD357DE7-BF5C-44B1-8758-B06457B221DE}.Debug|x86.ActiveCfg = Debug|Any CPU + {BD357DE7-BF5C-44B1-8758-B06457B221DE}.Debug|x86.Build.0 = Debug|Any CPU + {BD357DE7-BF5C-44B1-8758-B06457B221DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD357DE7-BF5C-44B1-8758-B06457B221DE}.Release|Any CPU.Build.0 = Release|Any CPU + {BD357DE7-BF5C-44B1-8758-B06457B221DE}.Release|x64.ActiveCfg = Release|Any CPU + {BD357DE7-BF5C-44B1-8758-B06457B221DE}.Release|x64.Build.0 = Release|Any CPU + {BD357DE7-BF5C-44B1-8758-B06457B221DE}.Release|x86.ActiveCfg = Release|Any CPU + {BD357DE7-BF5C-44B1-8758-B06457B221DE}.Release|x86.Build.0 = Release|Any CPU + {38C28A25-05A6-4AD7-A364-00472F44554D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38C28A25-05A6-4AD7-A364-00472F44554D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38C28A25-05A6-4AD7-A364-00472F44554D}.Debug|x64.ActiveCfg = Debug|Any CPU + {38C28A25-05A6-4AD7-A364-00472F44554D}.Debug|x64.Build.0 = Debug|Any CPU + {38C28A25-05A6-4AD7-A364-00472F44554D}.Debug|x86.ActiveCfg = Debug|Any CPU + {38C28A25-05A6-4AD7-A364-00472F44554D}.Debug|x86.Build.0 = Debug|Any CPU + {38C28A25-05A6-4AD7-A364-00472F44554D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38C28A25-05A6-4AD7-A364-00472F44554D}.Release|Any CPU.Build.0 = Release|Any CPU + {38C28A25-05A6-4AD7-A364-00472F44554D}.Release|x64.ActiveCfg = Release|Any CPU + {38C28A25-05A6-4AD7-A364-00472F44554D}.Release|x64.Build.0 = Release|Any CPU + {38C28A25-05A6-4AD7-A364-00472F44554D}.Release|x86.ActiveCfg = Release|Any CPU + {38C28A25-05A6-4AD7-A364-00472F44554D}.Release|x86.Build.0 = Release|Any CPU + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}.Debug|x64.ActiveCfg = Debug|Any CPU + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}.Debug|x64.Build.0 = Debug|Any CPU + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}.Debug|x86.ActiveCfg = Debug|Any CPU + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}.Debug|x86.Build.0 = Debug|Any CPU + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}.Release|Any CPU.Build.0 = Release|Any CPU + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}.Release|x64.ActiveCfg = Release|Any CPU + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}.Release|x64.Build.0 = Release|Any CPU + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}.Release|x86.ActiveCfg = Release|Any CPU + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7}.Release|x86.Build.0 = Release|Any CPU {21E61803-70F4-401C-A3F1-A87E9BF69383}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {21E61803-70F4-401C-A3F1-A87E9BF69383}.Debug|Any CPU.Build.0 = Debug|Any CPU {21E61803-70F4-401C-A3F1-A87E9BF69383}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -104,11 +148,14 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {BD357DE7-BF5C-44B1-8758-B06457B221DE} = {75C100DC-8B11-45CF-9AB3-F62C88C951ED} + {38C28A25-05A6-4AD7-A364-00472F44554D} = {75C100DC-8B11-45CF-9AB3-F62C88C951ED} + {BB7E45AD-D51D-4424-B37F-2B2396F7E0D7} = {75C100DC-8B11-45CF-9AB3-F62C88C951ED} {21E61803-70F4-401C-A3F1-A87E9BF69383} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {7B6A0997-93C6-42BD-B9F4-4A82842753C7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {439A16D2-595A-44B4-8158-FA56F4707AA9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {2990EC8F-FEC8-45B8-8B14-4E0F4E6C37FF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {6FD423D9-4965-4450-8A17-FAD710C71EDB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {8F65B1F0-79C4-46AC-BA4F-31F9A59D310D} = {75C100DC-8B11-45CF-9AB3-F62C88C951ED} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index fb1cd0b..b8048b2 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ ## Проверка для заказчика: без установки .NET и знания кода -Это серверное API без отдельного сайта с кнопками. Самый простой способ проверить задание — запустить готовые автотесты одной командой. Они сами создают и завершают задачи, проверяют БД и получают настоящее сообщение из RabbitMQ. +Это серверное API без отдельного сайта с кнопками. Готовность автотестов можно проверить в GitHub Actions, а сам сервис — запустить через Docker по инструкции ниже. Для ручной проверки .NET устанавливать не нужно. ### 1. Подготовить компьютер и скачать проект 1. Установите [Docker Desktop](https://docs.docker.com/desktop/setup/install/windows-install/) и запустите его. На Windows используйте Linux containers; если установщик попросит настроить WSL или перезагрузиться, выполните его инструкции. Docker Compose входит в Docker Desktop. 2. Дождитесь, когда Docker Desktop сообщит, что Docker Engine запущен. Для первой сборки нужен интернет: Docker скачает необходимые компоненты. 3. Распакуйте полученный от разработчика ZIP-архив проекта. Если скачиваете с GitHub, откройте [ветку с реализацией](https://github.com/PANiXiDA/task-api/tree/feature/implement-task-api), затем **Code → Download ZIP**. Пока [PR №1](https://github.com/PANiXiDA/task-api/pull/1) не слит, скачивать нужно именно эту ветку, а не `development`. Репозиторий приватный: для скачивания через GitHub нужен выданный доступ; переданный ZIP такого доступа не требует. -4. Откройте распакованную папку, в которой лежат `README.md`, `compose.yaml` и `compose.test.yaml`. В Проводнике Windows нажмите на адресную строку, введите `powershell` и нажмите Enter. Все команды ниже выполняйте в открывшемся окне. +4. Откройте распакованную папку, в которой лежат `README.md` и `compose.yaml`. В Проводнике Windows нажмите на адресную строку, введите `powershell` и нажмите Enter. Все команды ниже выполняйте в открывшемся окне. Проверьте, что Docker доступен: @@ -23,33 +23,13 @@ docker compose version Должна появиться строка с версией Docker Compose. Устанавливать .NET, PostgreSQL, RabbitMQ, Git или Postman отдельно не нужно. -### 2. Запустить автоматическую проверку +### 2. Проверить результат автотестов в GitHub -Скопируйте в PowerShell всю команду и нажмите Enter: +Откройте [PR №1 → Checks](https://github.com/PANiXiDA/task-api/pull/1/checks) или [GitHub Actions](https://github.com/PANiXiDA/task-api/actions/workflows/ci.yml) и выберите запуск для последнего коммита ветки с реализацией. Зелёные проверки означают, что тесты и сборка прошли; красные — что есть ошибка, жёлтые — что проверка ещё выполняется. -```powershell -docker compose -p task-api-tests -f compose.test.yaml up --build --abort-on-container-exit --exit-code-from tests --attach tests -``` - -Первый запуск может занять несколько минут. Дождитесь окончания: команда сама соберёт приложение, запустит отдельные PostgreSQL и RabbitMQ, выполнит проверки и остановит тестовые контейнеры. Обычный сервис для этого запускать не нужно. - -**Успешный результат в конце вывода:** - -```text -Test Run Successful. -Total tests: 23 - Passed: 23 -``` - -В логах могут встречаться ошибки БД и недоступности RabbitMQ: некоторые тесты специально воспроизводят эти ситуации. Ориентируйтесь на итог `Test Run Successful.` и количество прошедших тестов. Если в конце написано `Test Run Failed`, есть упавшие тесты или сборка оборвалась, проверка не пройдена — передайте разработчику вывод окна. - -После проверки удалите её временные контейнеры: - -```powershell -docker compose -p task-api-tests -f compose.test.yaml down -v -``` +В CI отдельно запускаются проекты `UnitTests`, `IntegrationTests` и `FunctionalTests`. В отчёте **Tests** названия проверок указаны на русском. Интеграционные и функциональные тесты сами запускают временные PostgreSQL и RabbitMQ, проверяют сохранение данных и получают настоящее сообщение из брокера. -Команда очистки относится только к отдельному тестовому окружению и не удаляет задачи из обычного сервиса. +Для просмотра приватного репозитория нужен доступ GitHub. Если у вас только ZIP, переходите к ручной проверке ниже; результаты CI можно запросить у разработчика. Команда запуска тестов для разработчика описана в разделе [Тесты](#тесты). ### 3. Запустить сервис для ручной проверки @@ -84,7 +64,7 @@ docker compose up --build -d **Учётные данные уже указаны в `compose.yaml`**, заказчику ничего дописывать не нужно: `RABBITMQ_DEFAULT_USER` и `RABBITMQ_DEFAULT_PASS` создают пользователя брокера, а `RabbitMq__UserName` и `RabbitMq__Password` в сервисе `api` задают подключение приложения. Если меняете логин или пароль, меняйте их в обеих секциях одинаково. Переменные `RABBITMQ_DEFAULT_*` применяются при первоначальном создании данных брокера; пароль существующего пользователя меняется через панель управления. -Порт `5672` используется приложением для AMQP внутри Docker и не открывается на компьютере. Порт `15672` предназначен для браузера и доступен только с этого компьютера. В отдельном `compose.test.yaml` веб-панель не запускается: автотесты подключаются к брокеру напрямую. +Порт `5672` используется приложением для AMQP внутри Docker и не открывается на компьютере. Порт `15672` предназначен для браузера и доступен только с этого компьютера. Автотесты используют отдельные временные контейнеры без веб-панели и подключаются к брокеру напрямую. ### 4. Проверить создание, завершение и удаление вручную @@ -123,7 +103,7 @@ Invoke-RestMethod -Uri "$baseUrl/tasks" | ConvertTo-Json Успешное удаление не выводит текст. Задача исчезнет из списка; если других задач нет, список пустой (PowerShell может не вывести ничего). -Получение события из RabbitMQ проверяют автотесты из шага 2. В обычном запуске доступна веб-панель брокера, но очередь получателя автоматически не создаётся; тест создаёт свою очередь сам. Чтобы увидеть сообщение вручную в панели, до завершения задачи создайте очередь в virtual host `/` и привяжите её к exchange `task.events` с ключом `task.completed`. Если exchange ещё нет, сначала создайте его с типом `topic` и параметром `Durable = true`. Успешное ручное завершение задачи само по себе не подтверждает доставку события. +Получение события из RabbitMQ проверяют автотесты в CI из шага 2. В обычном запуске доступна веб-панель брокера, но очередь получателя автоматически не создаётся; тест создаёт свою очередь сам. Чтобы увидеть сообщение вручную в панели, до завершения задачи создайте очередь в virtual host `/` и привяжите её к exchange `task.events` с ключом `task.completed`. Если exchange ещё нет, сначала создайте его с типом `topic` и параметром `Durable = true`. Успешное ручное завершение задачи само по себе не подтверждает доставку события. ### 5. Остановить сервис @@ -193,7 +173,12 @@ src/ PANiXiDA.TaskApi.Tasks.Application/ # сценарии, контракты хранилища и событий PANiXiDA.TaskApi.Tasks.Infrastructure/ # EF Core, миграции, RabbitMQ PANiXiDA.TaskApi.Tasks.Presentation/ # Minimal API, DTO, HTTP-валидация -tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ +tests/ + PANiXiDA.TaskApi.Testing/ # общие Testcontainers для БД и брокера + Tasks/ + PANiXiDA.TaskApi.Tasks.UnitTests/ # домен, сценарии, publisher с подменами + PANiXiDA.TaskApi.Tasks.IntegrationTests/ # репозиторий, миграции, RabbitMQ transport + PANiXiDA.TaskApi.Tasks.FunctionalTests/ # HTTP API через WebApplicationFactory ``` Host связывает слои через встроенный DI. Application зависит только от Domain; Presentation не обращается к EF или RabbitMQ. Версии NuGet централизованы в `Directory.Packages.props`. @@ -218,14 +203,31 @@ Host связывает слои через встроенный DI. Application ## Тесты +Тесты запускаются обычной командой из корня репозитория: + +```sh +dotnet test +``` + +Для самостоятельного запуска разработчику нужны SDK из `global.json` (.NET 10), ASP.NET Core Runtime 9 и работающий Docker. Приложение и все тестовые проекты имеют target framework `net9.0`; SDK 10 нужен для команды `dotnet test` с Microsoft.Testing.Platform, которую использует общий CI-шаблон. Контейнер приложения по-прежнему работает на runtime .NET 9. + +Предварительно запускать `docker compose up` не нужно. Testcontainers создаёт отдельные PostgreSQL и RabbitMQ со случайными портами и удаляет их после проверки. Строки подключения и тестовые учётные данные передаются автоматически; обычная база сервиса не используется. + +| Проект | Что проверяет | +| --- | --- | +| `UnitTests` | Инварианты `TaskItem`, время и порядок сохранения/публикации в `TasksService`, конфликты, ошибки и отмена, очередь publisher и остановка. Внешние сервисы не нужны. | +| `IntegrationTests` | Репозиторий и миграции на настоящем PostgreSQL: сортировка, ограничения, `xmin`, удаление. Контракт события и повторное соединение транспорта с настоящим RabbitMQ. | +| `FunctionalTests` | HTTP API через `WebApplicationFactory`: статусы, валидация, значения по умолчанию, CRUD, реальное событие, недоступный брокер и конкурентное завершение. | + +Запуск только unit-тестов без Docker: + ```sh -docker compose -p task-api-tests -f compose.test.yaml up --build --abort-on-container-exit --exit-code-from tests -docker compose -p task-api-tests -f compose.test.yaml down -v +dotnet test --project tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/PANiXiDA.TaskApi.Tasks.UnitTests.csproj ``` -Команда сборки и тестов работает полностью в контейнере .NET 9. PostgreSQL и RabbitMQ настоящие, изолированы от обычного запуска; тестовая БД находится в tmpfs. +Все `[Fact]` и `[Theory]` имеют русские `DisplayName`. Для интеграционных тестов общий fixture создаёт окружение, а перед каждой проверкой очищаются тестовые задачи. Гонка HTTP-запросов воспроизводится барьером перед UPDATE, чтобы оба запроса гарантированно прочитали одну версию строки. -Проверяются создание/завершение/удаление, значения по умолчанию, валидация, получение события из RabbitMQ, отсутствие повторного события, отсутствие публикации при ошибке сохранения, недоступный брокер, гонка двух запросов с одной версией строки, drain/таймаут остановки, переполнение очереди и логирование потерянных сообщений. Гонка воспроизводится барьером перед UPDATE, чтобы оба запроса гарантированно прочитали одну версию. +CI использует общий [dotnet-tests.yml](https://github.com/PANiXiDA-Infrastructure/ci-cd/blob/main/.github/workflows/dotnet-tests.yml): обнаруживает три тестовых проекта, запускает `dotnet test` на GitHub runner, публикует TRX и отчёт покрытия. Переменная репозитория `PROJECT_FOLDER` равна `.`. Приватные NuGet-пакеты и токены к ним не нужны. После успешных тестов отдельная job собирает рабочий Docker-образ API. ## Конфигурация diff --git a/compose.test.yaml b/compose.test.yaml deleted file mode 100644 index 739a9d0..0000000 --- a/compose.test.yaml +++ /dev/null @@ -1,40 +0,0 @@ -services: - postgres: - image: postgres:17-alpine - environment: - POSTGRES_DB: tasks_tests - POSTGRES_USER: tasks - POSTGRES_PASSWORD: isolated-test-only - tmpfs: - - /var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U tasks -d tasks_tests"] - interval: 2s - timeout: 3s - retries: 30 - - rabbitmq: - image: rabbitmq:4.1-alpine - environment: - RABBITMQ_DEFAULT_USER: tasks - RABBITMQ_DEFAULT_PASS: isolated-test-only - healthcheck: - test: ["CMD-SHELL", "test -s /var/lib/rabbitmq/.erlang.cookie && su-exec rabbitmq rabbitmq-diagnostics -q check_running"] - interval: 3s - timeout: 5s - retries: 30 - - tests: - build: - context: . - dockerfile: tests/Dockerfile - environment: - ConnectionStrings__Tasks: Host=postgres;Database=tasks_tests;Username=tasks;Password=isolated-test-only - RabbitMq__HostName: rabbitmq - RabbitMq__UserName: tasks - RabbitMq__Password: isolated-test-only - depends_on: - postgres: - condition: service_healthy - rabbitmq: - condition: service_healthy diff --git a/global.json b/global.json index ece9a7b..c412d6b 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,9 @@ { "sdk": { - "version": "9.0.319", + "version": "10.0.401", "rollForward": "latestPatch" + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } diff --git a/src/PANiXiDA.TaskApi.Host/Dockerfile b/src/PANiXiDA.TaskApi.Host/Dockerfile index 95d6cbc..9153aa3 100644 --- a/src/PANiXiDA.TaskApi.Host/Dockerfile +++ b/src/PANiXiDA.TaskApi.Host/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /source COPY Directory.Build.props Directory.Packages.props global.json ./ COPY src/ src/ diff --git a/tests/CodeCoverage.settings.xml b/tests/CodeCoverage.settings.xml new file mode 100644 index 0000000..bbe62a5 --- /dev/null +++ b/tests/CodeCoverage.settings.xml @@ -0,0 +1,19 @@ + + + false + MissingAll + + + + .*[\\/]PANiXiDA\.TaskApi\..*Tests\.dll$ + .*[\\/]PANiXiDA\.TaskApi\.Testing\.dll$ + + + + + .*[\\/]obj[\\/].* + .*[\\/]Migrations[\\/].* + + + + diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..84d47a4 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,15 @@ + + + + false + true + Exe + $(TestingPlatformCommandLineArguments) --coverage-settings "$(MSBuildThisFileDirectory)CodeCoverage.settings.xml" + + + + + + + + diff --git a/tests/Dockerfile b/tests/Dockerfile deleted file mode 100644 index 14aeee9..0000000 --- a/tests/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:9.0 -WORKDIR /source -COPY Directory.Build.props Directory.Packages.props global.json PANiXiDA.TaskApi.sln ./ -COPY src/ src/ -COPY tests/ tests/ -RUN dotnet restore PANiXiDA.TaskApi.sln -RUN dotnet build PANiXiDA.TaskApi.sln -c Release --no-restore -CMD ["dotnet", "test", "PANiXiDA.TaskApi.sln", "-c", "Release", "--no-build", "--logger", "console;verbosity=normal"] diff --git a/tests/PANiXiDA.TaskApi.Testing/PANiXiDA.TaskApi.Testing.csproj b/tests/PANiXiDA.TaskApi.Testing/PANiXiDA.TaskApi.Testing.csproj new file mode 100644 index 0000000..56ad0c7 --- /dev/null +++ b/tests/PANiXiDA.TaskApi.Testing/PANiXiDA.TaskApi.Testing.csproj @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/PANiXiDA.TaskApi.Testing/PostgreSqlTestDatabase.cs b/tests/PANiXiDA.TaskApi.Testing/PostgreSqlTestDatabase.cs new file mode 100644 index 0000000..ddca653 --- /dev/null +++ b/tests/PANiXiDA.TaskApi.Testing/PostgreSqlTestDatabase.cs @@ -0,0 +1,18 @@ +using Testcontainers.PostgreSql; + +namespace PANiXiDA.TaskApi.Testing; + +public sealed class PostgreSqlTestDatabase : IAsyncDisposable +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("tasks_tests") + .WithUsername("tasks") + .WithPassword("isolated-test-only") + .Build(); + + public string ConnectionString => _container.GetConnectionString(); + + public Task InitializeAsync(CancellationToken cancellationToken) => _container.StartAsync(cancellationToken); + + public ValueTask DisposeAsync() => _container.DisposeAsync(); +} diff --git a/tests/PANiXiDA.TaskApi.Testing/RabbitMqTestBroker.cs b/tests/PANiXiDA.TaskApi.Testing/RabbitMqTestBroker.cs new file mode 100644 index 0000000..c0c19b0 --- /dev/null +++ b/tests/PANiXiDA.TaskApi.Testing/RabbitMqTestBroker.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using Testcontainers.RabbitMq; + +namespace PANiXiDA.TaskApi.Testing; + +public sealed class RabbitMqTestBroker : IAsyncDisposable +{ + public const string UserName = "tasks"; + public const string Password = "isolated-test-only"; + + private readonly RabbitMqContainer _container = new RabbitMqBuilder("rabbitmq:4.1-alpine") + .WithUsername(UserName) + .WithPassword(Password) + .Build(); + + public string HostName => _container.Hostname; + public int Port => _container.GetMappedPublicPort(5672); + + public Dictionary GetConfiguration() => new() + { + ["RabbitMq:HostName"] = HostName, + ["RabbitMq:Port"] = Port.ToString(CultureInfo.InvariantCulture), + ["RabbitMq:UserName"] = UserName, + ["RabbitMq:Password"] = Password + }; + + public Task InitializeAsync(CancellationToken cancellationToken) => _container.StartAsync(cancellationToken); + + public ValueTask DisposeAsync() => _container.DisposeAsync(); +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs index be6a771..35ceaf0 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs @@ -8,12 +8,13 @@ namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; -public sealed class ApiFactory(SaveChangesInterceptor? interceptor = null, int? rabbitMqPort = null) +public sealed class ApiFactory(Dictionary settings, SaveChangesInterceptor? interceptor = null, int? rabbitMqPort = null) : WebApplicationFactory { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); + builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(settings)); if (rabbitMqPort is not null) { builder.ConfigureAppConfiguration((_, configuration) => diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/FunctionalTestFixture.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/FunctionalTestFixture.cs new file mode 100644 index 0000000..c25098c --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/FunctionalTestFixture.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using PANiXiDA.TaskApi.Testing; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; + +public sealed class FunctionalTestFixture : IAsyncLifetime +{ + private readonly PostgreSqlTestDatabase _database = new(); + public RabbitMqTestBroker Broker { get; } = new(); + + public Dictionary Configuration + { + get + { + var configuration = Broker.GetConfiguration(); + configuration["ConnectionStrings:Tasks"] = _database.ConnectionString; + return configuration; + } + } + + public async ValueTask InitializeAsync() + { + var token = TestContext.Current.CancellationToken; + await Task.WhenAll(_database.InitializeAsync(token), Broker.InitializeAsync(token)); + await using var context = CreateDbContext(); + await context.Database.MigrateAsync(token); + } + + public async Task ResetDatabaseAsync(CancellationToken cancellationToken) + { + await using var context = CreateDbContext(); + await context.Tasks.ExecuteDeleteAsync(cancellationToken); + } + + public async ValueTask DisposeAsync() + { + try + { + await Broker.DisposeAsync(); + } + finally + { + await _database.DisposeAsync(); + } + } + + private TasksDbContext CreateDbContext() => new(new DbContextOptionsBuilder() + .UseNpgsql(_database.ConnectionString).Options); +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/PANiXiDA.TaskApi.Tasks.FunctionalTests.csproj b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/PANiXiDA.TaskApi.Tasks.FunctionalTests.csproj index 21aeb76..4d8b71f 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/PANiXiDA.TaskApi.Tasks.FunctionalTests.csproj +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/PANiXiDA.TaskApi.Tasks.FunctionalTests.csproj @@ -1,21 +1,14 @@ - - true - false - + - - - - diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs index 119383d..f19eef4 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using PANiXiDA.TaskApi.Testing; using PANiXiDA.TaskApi.Tasks.Application.Tasks; using PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; using RabbitMQ.Client; @@ -13,20 +14,21 @@ internal sealed class RabbitMqProbe(IConnection connection, IChannel channel, st Converters = { new JsonStringEnumConverter() } }; - public static async Task CreateAsync() + public static async Task CreateAsync(RabbitMqTestBroker broker) { var factory = new ConnectionFactory { - HostName = Environment.GetEnvironmentVariable("RabbitMq__HostName")!, - UserName = Environment.GetEnvironmentVariable("RabbitMq__UserName")!, - Password = Environment.GetEnvironmentVariable("RabbitMq__Password")!, + HostName = broker.HostName, + Port = broker.Port, + UserName = RabbitMqTestBroker.UserName, + Password = RabbitMqTestBroker.Password, AutomaticRecoveryEnabled = false }; - var connection = await factory.CreateConnectionAsync(); - var channel = await connection.CreateChannelAsync(); - await channel.ExchangeDeclareAsync(RabbitMqOptions.Exchange, ExchangeType.Topic, durable: true); - var queue = await channel.QueueDeclareAsync(); - await channel.QueueBindAsync(queue.QueueName, RabbitMqOptions.Exchange, RabbitMqOptions.RoutingKey); + var connection = await factory.CreateConnectionAsync(TestContext.Current.CancellationToken); + var channel = await connection.CreateChannelAsync(cancellationToken: TestContext.Current.CancellationToken); + await channel.ExchangeDeclareAsync(RabbitMqOptions.Exchange, ExchangeType.Topic, durable: true, cancellationToken: TestContext.Current.CancellationToken); + var queue = await channel.QueueDeclareAsync(cancellationToken: TestContext.Current.CancellationToken); + await channel.QueueBindAsync(queue.QueueName, RabbitMqOptions.Exchange, RabbitMqOptions.RoutingKey, cancellationToken: TestContext.Current.CancellationToken); return new RabbitMqProbe(connection, channel, queue.QueueName); } @@ -61,9 +63,9 @@ public async Task AssertNoMessageAsync() public async ValueTask DisposeAsync() { - await channel.CloseAsync(); + await channel.CloseAsync(TestContext.Current.CancellationToken); await channel.DisposeAsync(); - await connection.CloseAsync(); + await connection.CloseAsync(TestContext.Current.CancellationToken); await connection.DisposeAsync(); } } diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs index 9a266d9..e1dfa84 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs @@ -12,29 +12,33 @@ namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; -public sealed class TasksEndpointsTests +public sealed class TasksEndpointsTests(FunctionalTestFixture fixture) : IClassFixture, IAsyncLifetime { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; - [Fact] + public async ValueTask InitializeAsync() => await fixture.ResetDatabaseAsync(TestContext.Current.CancellationToken); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + [Fact(DisplayName = "Завершение сохраняет задачу и публикует одно событие с данными из БД")] public async Task Complete_Should_PublishPersistedTask_When_TaskExists() { - await using var probe = await RabbitMqProbe.CreateAsync(); - await using var factory = new ApiFactory(); + await using var probe = await RabbitMqProbe.CreateAsync(fixture.Broker); + await using var factory = new ApiFactory(fixture.Configuration); using var client = factory.CreateClient(); var created = await CreateAsync(client, "Buy milk", "High"); Assert.False(created.IsCompleted); Assert.Null(created.CompletedAt); Assert.Equal(Priority.High, created.Priority); - using var completedResponse = await client.PutAsync($"/tasks/{created.Id}/complete", null); + using var completedResponse = await client.PutAsync($"/tasks/{created.Id}/complete", null, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, completedResponse.StatusCode); - var completed = (await completedResponse.Content.ReadFromJsonAsync(JsonOptions))!; + var completed = (await completedResponse.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken))!; var message = await probe.ReadAsync(created.Id); - var tasks = (await client.GetFromJsonAsync("/tasks", JsonOptions))!; + var tasks = (await client.GetFromJsonAsync("/tasks", JsonOptions, TestContext.Current.CancellationToken))!; var saved = Assert.Single(tasks, x => x.Id == created.Id); Assert.True(saved.IsCompleted); @@ -45,23 +49,23 @@ public async Task Complete_Should_PublishPersistedTask_When_TaskExists() Assert.Equal(saved.Title, message.Title); Assert.Equal(saved.Priority, message.Priority); - using var repeated = await client.PutAsync($"/tasks/{created.Id}/complete", null); + using var repeated = await client.PutAsync($"/tasks/{created.Id}/complete", null, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Conflict, repeated.StatusCode); - var unchanged = Assert.Single((await client.GetFromJsonAsync("/tasks", JsonOptions))!, + var unchanged = Assert.Single((await client.GetFromJsonAsync("/tasks", JsonOptions, TestContext.Current.CancellationToken))!, x => x.Id == created.Id); Assert.Equal(saved.CompletedAt, unchanged.CompletedAt); await factory.DisposeAsync(); await probe.AssertNoMessageAsync(); } - [Fact] + [Fact(DisplayName = "Создание только с названием задаёт приоритет Medium и начальное состояние")] public async Task Create_Should_UseDefaults_When_OnlyTitleIsProvided() { - await using var factory = new ApiFactory(); + await using var factory = new ApiFactory(fixture.Configuration); using var client = factory.CreateClient(); - using var response = await client.PostAsJsonAsync("/tasks", new { title = new string('a', 200) }); + using var response = await client.PostAsJsonAsync("/tasks", new { title = new string('a', 200) }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var task = (await response.Content.ReadFromJsonAsync(JsonOptions))!; + var task = (await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken))!; Assert.Equal(Priority.Medium, task.Priority); Assert.False(task.IsCompleted); Assert.Null(task.CompletedAt); @@ -69,29 +73,29 @@ public async Task Create_Should_UseDefaults_When_OnlyTitleIsProvided() Assert.NotEqual(default, task.CreatedAt); } - [Theory] + [Theory(DisplayName = "Пустое или пробельное название возвращает HTTP 400")] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("\t\r\n")] public async Task Create_Should_Return400_When_TitleIsBlank(string? title) { - await using var factory = new ApiFactory(); + await using var factory = new ApiFactory(fixture.Configuration); using var client = factory.CreateClient(); - using var response = await client.PostAsJsonAsync("/tasks", new { title }); + using var response = await client.PostAsJsonAsync("/tasks", new { title }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } - [Fact] + [Fact(DisplayName = "Название длиннее 200 символов возвращает HTTP 400")] public async Task Create_Should_Return400_When_TitleIsTooLong() { - await using var factory = new ApiFactory(); + await using var factory = new ApiFactory(fixture.Configuration); using var client = factory.CreateClient(); - using var response = await client.PostAsJsonAsync("/tasks", new { title = new string('a', 201) }); + using var response = await client.PostAsJsonAsync("/tasks", new { title = new string('a', 201) }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } - [Theory] + [Theory(DisplayName = "Неверный JSON или приоритет возвращает HTTP 400")] [InlineData("{\"title\":\"test\",\"priority\":99}")] [InlineData("{\"title\":\"test\",\"priority\":\"Urgent\"}")] [InlineData("{\"title\":\"test\",\"priority\":null}")] @@ -100,24 +104,24 @@ public async Task Create_Should_Return400_When_TitleIsTooLong() [InlineData("{")] public async Task Create_Should_Return400_When_BodyIsInvalid(string json) { - await using var factory = new ApiFactory(); + await using var factory = new ApiFactory(fixture.Configuration); using var client = factory.CreateClient(); using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var response = await client.PostAsync("/tasks", content); + using var response = await client.PostAsync("/tasks", content, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } - [Fact] + [Fact(DisplayName = "Конкурентное завершение одной версии строки возвращает один успех и один конфликт")] public async Task Complete_Should_ReturnOneConflict_When_BothRequestsReadTheSameRowVersion() { - await using var probe = await RabbitMqProbe.CreateAsync(); - await using var factory = new ApiFactory(new CompletionBarrier()); + await using var probe = await RabbitMqProbe.CreateAsync(fixture.Broker); + await using var factory = new ApiFactory(fixture.Configuration, new CompletionBarrier()); using var client = factory.CreateClient(); var task = await CreateAsync(client); var responses = await Task.WhenAll( - client.PutAsync($"/tasks/{task.Id}/complete", null), - client.PutAsync($"/tasks/{task.Id}/complete", null)); + client.PutAsync($"/tasks/{task.Id}/complete", null, TestContext.Current.CancellationToken), + client.PutAsync($"/tasks/{task.Id}/complete", null, TestContext.Current.CancellationToken)); Assert.Single(responses, x => x.StatusCode == HttpStatusCode.OK); Assert.Single(responses, x => x.StatusCode == HttpStatusCode.Conflict); @@ -130,80 +134,80 @@ public async Task Complete_Should_ReturnOneConflict_When_BothRequestsReadTheSame } } - [Fact] + [Fact(DisplayName = "Недоступность RabbitMQ не мешает сохранению завершённой задачи")] public async Task Complete_Should_PersistTask_When_RabbitMqIsUnavailable() { - await using var factory = new ApiFactory(rabbitMqPort: 1); + await using var factory = new ApiFactory(fixture.Configuration, rabbitMqPort: 1); using var client = factory.CreateClient(); var task = await CreateAsync(client); - using var response = await client.PutAsync($"/tasks/{task.Id}/complete", null); + using var response = await client.PutAsync($"/tasks/{task.Id}/complete", null, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); using var scope = factory.Services.CreateScope(); var saved = await scope.ServiceProvider.GetRequiredService().Tasks - .AsNoTracking().SingleAsync(x => x.Id == task.Id); + .AsNoTracking().SingleAsync(x => x.Id == task.Id, TestContext.Current.CancellationToken); Assert.True(saved.IsCompleted); Assert.NotNull(saved.CompletedAt); } - [Fact] + [Fact(DisplayName = "Ошибка сохранения возвращает HTTP 500 без изменения БД и без события")] public async Task Complete_Should_NotPublish_When_DatabaseSaveFails() { - await using var probe = await RabbitMqProbe.CreateAsync(); - await using var factory = new ApiFactory(new RejectCompletion()); + await using var probe = await RabbitMqProbe.CreateAsync(fixture.Broker); + await using var factory = new ApiFactory(fixture.Configuration, new RejectCompletion()); using var client = factory.CreateClient(); var task = await CreateAsync(client); - using var response = await client.PutAsync($"/tasks/{task.Id}/complete", null); + using var response = await client.PutAsync($"/tasks/{task.Id}/complete", null, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); using var scope = factory.Services.CreateScope(); var saved = await scope.ServiceProvider.GetRequiredService().Tasks - .AsNoTracking().SingleAsync(x => x.Id == task.Id); + .AsNoTracking().SingleAsync(x => x.Id == task.Id, TestContext.Current.CancellationToken); Assert.False(saved.IsCompleted); Assert.Null(saved.CompletedAt); await factory.DisposeAsync(); await probe.AssertNoMessageAsync(); } - [Fact] + [Fact(DisplayName = "Удаление убирает задачу из БД, повторное обращение возвращает HTTP 404")] public async Task Delete_Should_RemoveTaskPermanently_When_TaskExists() { - await using var factory = new ApiFactory(); + await using var factory = new ApiFactory(fixture.Configuration); using var client = factory.CreateClient(); var task = await CreateAsync(client); - using var response = await client.DeleteAsync($"/tasks/{task.Id}"); + using var response = await client.DeleteAsync($"/tasks/{task.Id}", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - var tasks = (await client.GetFromJsonAsync("/tasks", JsonOptions))!; + var tasks = (await client.GetFromJsonAsync("/tasks", JsonOptions, TestContext.Current.CancellationToken))!; Assert.DoesNotContain(tasks, x => x.Id == task.Id); - using var missing = await client.DeleteAsync($"/tasks/{task.Id}"); + using var missing = await client.DeleteAsync($"/tasks/{task.Id}", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); - using var complete = await client.PutAsync($"/tasks/{task.Id}/complete", null); + using var complete = await client.PutAsync($"/tasks/{task.Id}/complete", null, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, complete.StatusCode); } - [Theory] + [Theory(DisplayName = "Некорректный ID возвращает HTTP 400, отсутствующая задача — HTTP 404")] [InlineData("not-a-guid", HttpStatusCode.BadRequest)] [InlineData("00000000-0000-0000-0000-000000000000", HttpStatusCode.NotFound)] public async Task Endpoints_Should_ReturnExpectedStatus_When_IdIsInvalidOrMissing( string id, HttpStatusCode expected) { - await using var factory = new ApiFactory(); + await using var factory = new ApiFactory(fixture.Configuration); using var client = factory.CreateClient(); - using var complete = await client.PutAsync($"/tasks/{id}/complete", null); - using var delete = await client.DeleteAsync($"/tasks/{id}"); + using var complete = await client.PutAsync($"/tasks/{id}/complete", null, TestContext.Current.CancellationToken); + using var delete = await client.DeleteAsync($"/tasks/{id}", TestContext.Current.CancellationToken); Assert.Equal(expected, complete.StatusCode); Assert.Equal(expected, delete.StatusCode); } private static async Task CreateAsync(HttpClient client, string title = "Test task", string priority = "Medium") { - using var response = await client.PostAsJsonAsync("/tasks", new { title, priority }); + using var response = await client.PostAsJsonAsync("/tasks", new { title, priority }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, response.StatusCode); - return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + return (await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken))!; } private sealed class RejectCompletion : SaveChangesInterceptor diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Messaging/RabbitMqTransportTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Messaging/RabbitMqTransportTests.cs new file mode 100644 index 0000000..0eaf509 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Messaging/RabbitMqTransportTests.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using PANiXiDA.TaskApi.Testing; +using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; +using RabbitMQ.Client; + +namespace PANiXiDA.TaskApi.Tasks.IntegrationTests.Infrastructure.Messaging; + +public sealed class RabbitMqTransportTests(IntegrationTestFixture fixture) : IntegrationTestBase(fixture) +{ + [Fact(DisplayName = "Транспорт отправляет persistent JSON в настоящий RabbitMQ и восстанавливает закрытое соединение")] + public async Task Send_Should_DeliverContractAndReconnect_When_TransportWasClosed() + { + var token = TestContext.Current.CancellationToken; + var connectionFactory = new ConnectionFactory + { + HostName = Fixture.Broker.HostName, + Port = Fixture.Broker.Port, + UserName = RabbitMqTestBroker.UserName, + Password = RabbitMqTestBroker.Password + }; + await using var connection = await connectionFactory.CreateConnectionAsync(token); + await using var channel = await connection.CreateChannelAsync(cancellationToken: token); + await channel.ExchangeDeclareAsync(RabbitMqOptions.Exchange, ExchangeType.Topic, durable: true, cancellationToken: token); + var queue = await channel.QueueDeclareAsync(cancellationToken: token); + await channel.QueueBindAsync(queue.QueueName, RabbitMqOptions.Exchange, RabbitMqOptions.RoutingKey, cancellationToken: token); + await using var scope = Fixture.CreateScope(); + var transport = scope.ServiceProvider.GetRequiredService(); + + foreach (var priority in new[] { Priority.High, Priority.Low }) + { + var message = new TaskCompleted(Guid.NewGuid(), "Купить молоко", DateTimeOffset.UnixEpoch.AddDays(1), priority); + await transport.SendAsync(message, token); + var delivery = await ReadAsync(channel, queue.QueueName, token); + + Assert.Equal(RabbitMqOptions.Exchange, delivery.Exchange); + Assert.Equal(RabbitMqOptions.RoutingKey, delivery.RoutingKey); + Assert.Equal("application/json", delivery.BasicProperties.ContentType); + Assert.True(delivery.BasicProperties.Persistent); + Assert.Equal(message.TaskId.ToString(), delivery.BasicProperties.MessageId); + Assert.Equal(RabbitMqOptions.RoutingKey, delivery.BasicProperties.Type); + using var json = JsonDocument.Parse(delivery.Body); + Assert.Equal(4, json.RootElement.EnumerateObject().Count()); + Assert.Equal(message.TaskId, json.RootElement.GetProperty("taskId").GetGuid()); + Assert.Equal(message.Title, json.RootElement.GetProperty("title").GetString()); + Assert.Equal(message.CompletedAt, json.RootElement.GetProperty("completedAt").GetDateTimeOffset()); + Assert.Equal(priority.ToString(), json.RootElement.GetProperty("priority").GetString()); + await transport.CloseAsync(token); + } + } + + private static async Task ReadAsync(IChannel channel, string queue, CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + while (true) + { + var message = await channel.BasicGetAsync(queue, autoAck: true, timeout.Token); + if (message is not null) + { + return message; + } + + await Task.Delay(25, timeout.Token); + } + } +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs new file mode 100644 index 0000000..f99f003 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +namespace PANiXiDA.TaskApi.Tasks.IntegrationTests.Infrastructure.Persistence; + +public sealed class TasksMigrationTests(IntegrationTestFixture fixture) : IntegrationTestBase(fixture) +{ + [Fact(DisplayName = "Повторное применение миграции сохраняет существующие задачи")] + public async Task Migrate_Should_PreserveData_When_AppliedAgain() + { + var token = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var task = TaskItem.Create("Задача", Priority.Medium, DateTimeOffset.UnixEpoch); + await scope.ServiceProvider.GetRequiredService().AddAsync(task, token); + var context = scope.ServiceProvider.GetRequiredService(); + + await context.Database.MigrateAsync(token); + + Assert.Empty(await context.Database.GetPendingMigrationsAsync(token)); + Assert.Equal(task.Id, (await context.Tasks.AsNoTracking().SingleAsync(token)).Id); + } + + [Theory(DisplayName = "Ограничения PostgreSQL отклоняют некорректные данные при прямой записи")] + [InlineData("UPDATE tasks SET \"Title\" = ' '", "23514")] + [InlineData("UPDATE tasks SET \"Title\" = repeat('a', 201)", "22001")] + [InlineData("UPDATE tasks SET \"Priority\" = 'Other'", "23514")] + [InlineData("UPDATE tasks SET \"IsCompleted\" = true", "23514")] + public async Task Constraints_Should_RejectInvalidData_When_BypassingDomain(string sql, string sqlState) + { + var token = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var task = TaskItem.Create("Задача", Priority.Medium, DateTimeOffset.UnixEpoch); + await scope.ServiceProvider.GetRequiredService().AddAsync(task, token); + var context = scope.ServiceProvider.GetRequiredService(); + + var exception = await Assert.ThrowsAsync(() => context.Database.ExecuteSqlRawAsync(sql, token)); + + Assert.Equal(sqlState, exception.SqlState); + Assert.False((await context.Tasks.AsNoTracking().SingleAsync(token)).IsCompleted); + } +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksRepositoryTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksRepositoryTests.cs new file mode 100644 index 0000000..6e36824 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksRepositoryTests.cs @@ -0,0 +1,111 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +namespace PANiXiDA.TaskApi.Tasks.IntegrationTests.Infrastructure.Persistence; + +public sealed class TasksRepositoryTests(IntegrationTestFixture fixture) : IntegrationTestBase(fixture) +{ + [Fact(DisplayName = "Репозиторий сохраняет задачу в PostgreSQL с UTC и версией xmin")] + public async Task Add_Should_PersistAllFields_When_TaskIsValid() + { + var token = TestContext.Current.CancellationToken; + var task = TaskItem.Create("Задача", Priority.High, DateTimeOffset.UnixEpoch.AddHours(1)); + await using (var write = Fixture.CreateScope()) + { + await write.ServiceProvider.GetRequiredService().AddAsync(task, token); + } + + await using var read = Fixture.CreateScope(); + var saved = await read.ServiceProvider.GetRequiredService().FindAsync(task.Id, token); + + Assert.NotNull(saved); + Assert.Equal(task.Title, saved.Title); + Assert.Equal(task.Priority, saved.Priority); + Assert.Equal(task.CreatedAt, saved.CreatedAt); + Assert.Equal(TimeSpan.Zero, saved.CreatedAt.Offset); + Assert.False(saved.IsCompleted); + Assert.Null(saved.CompletedAt); + Assert.NotEqual(0u, saved.RowVersion); + } + + [Fact(DisplayName = "Список задач сортируется по времени и ID и не отслеживается контекстом")] + public async Task GetAll_Should_ReturnOrderedUntrackedTasks_When_TimestampsMatch() + { + var token = TestContext.Current.CancellationToken; + var first = TaskItem.Create("Первая", Priority.Low, DateTimeOffset.UnixEpoch); + var second = TaskItem.Create("Вторая", Priority.Medium, DateTimeOffset.UnixEpoch); + var last = TaskItem.Create("Последняя", Priority.High, DateTimeOffset.UnixEpoch.AddSeconds(1)); + await using (var write = Fixture.CreateScope()) + { + var repository = write.ServiceProvider.GetRequiredService(); + foreach (var task in new[] { last, second, first }) + { + await repository.AddAsync(task, token); + } + } + + await using var read = Fixture.CreateScope(); + var tasks = await read.ServiceProvider.GetRequiredService().GetAllAsync(token); + + Assert.Equal(new[] { first, second, last }.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id).Select(x => x.Id), tasks.Select(x => x.Id)); + Assert.Empty(read.ServiceProvider.GetRequiredService().ChangeTracker.Entries()); + } + + [Fact(DisplayName = "Конкурентное сохранение одной версии xmin допускает только одно завершение")] + public async Task TrySave_Should_RejectStaleVersion_When_TwoContextsCompleteSameTask() + { + var token = TestContext.Current.CancellationToken; + var task = TaskItem.Create("Задача", Priority.Medium, DateTimeOffset.UnixEpoch); + await using (var seed = Fixture.CreateScope()) + { + await seed.ServiceProvider.GetRequiredService().AddAsync(task, token); + } + + await using var firstScope = Fixture.CreateScope(); + await using var secondScope = Fixture.CreateScope(); + var firstRepository = firstScope.ServiceProvider.GetRequiredService(); + var secondRepository = secondScope.ServiceProvider.GetRequiredService(); + var first = await firstRepository.FindAsync(task.Id, token); + var second = await secondRepository.FindAsync(task.Id, token); + Assert.NotNull(first); + Assert.NotNull(second); + Assert.Equal(first.RowVersion, second.RowVersion); + first.TryComplete(DateTimeOffset.UnixEpoch.AddHours(1)); + second.TryComplete(DateTimeOffset.UnixEpoch.AddHours(2)); + + var savedFirst = await firstRepository.TrySaveAsync(token); + var savedSecond = await secondRepository.TrySaveAsync(token); + + Assert.True(savedFirst); + Assert.False(savedSecond); + await using var read = Fixture.CreateScope(); + var saved = await read.ServiceProvider.GetRequiredService().FindAsync(task.Id, token); + Assert.NotNull(saved); + Assert.Equal(first.CompletedAt, saved.CompletedAt); + Assert.NotEqual(second.RowVersion, saved.RowVersion); + } + + [Fact(DisplayName = "Удаление строки делает ранее прочитанную версию непригодной для сохранения")] + public async Task TrySave_Should_ReturnConflict_When_TaskWasDeletedAfterRead() + { + var token = TestContext.Current.CancellationToken; + var task = TaskItem.Create("Задача", Priority.Low, DateTimeOffset.UnixEpoch); + await using var write = Fixture.CreateScope(); + var repository = write.ServiceProvider.GetRequiredService(); + await repository.AddAsync(task, token); + await using var deletion = Fixture.CreateScope(); + var deletingRepository = deletion.ServiceProvider.GetRequiredService(); + + var deleted = await deletingRepository.DeleteAsync(task.Id, token); + task.TryComplete(DateTimeOffset.UnixEpoch.AddHours(1)); + var saved = await repository.TrySaveAsync(token); + + Assert.True(deleted); + Assert.False(saved); + Assert.Null(await deletingRepository.FindAsync(task.Id, token)); + Assert.False(await deletingRepository.DeleteAsync(task.Id, token)); + } +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/IntegrationTestBase.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/IntegrationTestBase.cs new file mode 100644 index 0000000..34d4059 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/IntegrationTestBase.cs @@ -0,0 +1,11 @@ +namespace PANiXiDA.TaskApi.Tasks.IntegrationTests; + +[Collection(IntegrationTestCollectionDefinition.Name)] +public abstract class IntegrationTestBase(IntegrationTestFixture fixture) : IAsyncLifetime +{ + protected IntegrationTestFixture Fixture { get; } = fixture; + + public async ValueTask InitializeAsync() => await Fixture.ResetDatabaseAsync(TestContext.Current.CancellationToken); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/IntegrationTestCollectionDefinition.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/IntegrationTestCollectionDefinition.cs new file mode 100644 index 0000000..09fb984 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/IntegrationTestCollectionDefinition.cs @@ -0,0 +1,7 @@ +namespace PANiXiDA.TaskApi.Tasks.IntegrationTests; + +[CollectionDefinition(Name)] +public sealed class IntegrationTestCollectionDefinition : ICollectionFixture +{ + public const string Name = "Интеграционные тесты задач"; +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/IntegrationTestFixture.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/IntegrationTestFixture.cs new file mode 100644 index 0000000..bf29f65 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/IntegrationTestFixture.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PANiXiDA.TaskApi.Testing; +using PANiXiDA.TaskApi.Tasks.Infrastructure.DependencyInjection; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +namespace PANiXiDA.TaskApi.Tasks.IntegrationTests; + +public sealed class IntegrationTestFixture : IAsyncLifetime +{ + private readonly PostgreSqlTestDatabase _database = new(); + private ServiceProvider? _services; + + public RabbitMqTestBroker Broker { get; } = new(); + public string ConnectionString => _database.ConnectionString; + + public AsyncServiceScope CreateScope() => (_services ?? throw new InvalidOperationException("Fixture is not initialized.")).CreateAsyncScope(); + + public async ValueTask InitializeAsync() + { + var token = TestContext.Current.CancellationToken; + await Task.WhenAll(_database.InitializeAsync(token), Broker.InitializeAsync(token)); + var settings = Broker.GetConfiguration(); + settings["ConnectionStrings:Tasks"] = ConnectionString; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddInfrastructure(configuration); + _services = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + await using var scope = CreateScope(); + await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync(token); + } + + public async Task ResetDatabaseAsync(CancellationToken cancellationToken) + { + await using var scope = CreateScope(); + await scope.ServiceProvider.GetRequiredService().Tasks.ExecuteDeleteAsync(cancellationToken); + } + + public async ValueTask DisposeAsync() + { + try + { + if (_services is not null) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + await _services.GetRequiredService().CloseAsync(timeout.Token); + } + finally + { + await _services.DisposeAsync(); + } + } + } + finally + { + try + { + await Broker.DisposeAsync(); + } + finally + { + await _database.DisposeAsync(); + } + } + } +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/PANiXiDA.TaskApi.Tasks.IntegrationTests.csproj b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/PANiXiDA.TaskApi.Tasks.IntegrationTests.csproj new file mode 100644 index 0000000..e497119 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/PANiXiDA.TaskApi.Tasks.IntegrationTests.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/TasksServiceTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/TasksServiceTests.cs new file mode 100644 index 0000000..54ebbf6 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/TasksServiceTests.cs @@ -0,0 +1,127 @@ +using NSubstitute; +using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.UnitTests.Application.Tasks; + +public sealed class TasksServiceTests +{ + private static readonly DateTimeOffset Now = DateTimeOffset.UnixEpoch.AddDays(1).AddTicks(1234567); + private readonly ITasksRepository _repository = Substitute.For(); + private readonly ITaskEventPublisher _publisher = Substitute.For(); + + [Fact(DisplayName = "Создание использует часы сервиса и сохраняет время с точностью до микросекунды")] + public async Task Create_Should_SaveMicrosecondTimestamp_When_TaskIsValid() + { + var token = TestContext.Current.CancellationToken; + var service = CreateService(); + + var task = await service.CreateAsync("Задача", Priority.Low, token); + + Assert.Equal(Now.AddTicks(-7), task.CreatedAt); + await _repository.Received(1).AddAsync(task, token); + _publisher.DidNotReceive().Publish(Arg.Any()); + } + + [Fact(DisplayName = "Завершение публикует сохранённые данные только после успешного сохранения")] + public async Task Complete_Should_PublishAfterSave_When_SaveSucceeds() + { + var token = TestContext.Current.CancellationToken; + var task = TaskItem.Create("Задача", Priority.High, DateTimeOffset.UnixEpoch); + var saved = false; + _repository.FindAsync(task.Id, token).Returns(task); + _repository.TrySaveAsync(token).Returns(_ => + { + saved = true; + return true; + }); + _publisher.When(x => x.Publish(Arg.Any())).Do(_ => Assert.True(saved)); + + var result = await CreateService().CompleteAsync(task.Id, token); + + Assert.Equal(CompletionStatus.Completed, result.Status); + Assert.Same(task, result.Task); + Assert.Equal(Now.AddTicks(-7), task.CompletedAt); + Assert.NotNull(task.CompletedAt); + _publisher.Received(1).Publish(new TaskCompleted(task.Id, task.Title, task.CompletedAt.Value, task.Priority)); + } + + [Fact(DisplayName = "Завершение отсутствующей задачи не сохраняет и не публикует событие")] + public async Task Complete_Should_ReturnNotFound_When_TaskDoesNotExist() + { + var token = TestContext.Current.CancellationToken; + + var result = await CreateService().CompleteAsync(Guid.NewGuid(), token); + + Assert.Equal(CompletionStatus.NotFound, result.Status); + Assert.Null(result.Task); + await _repository.DidNotReceive().TrySaveAsync(Arg.Any()); + _publisher.DidNotReceive().Publish(Arg.Any()); + } + + [Fact(DisplayName = "Повторное завершение возвращает конфликт без сохранения и публикации")] + public async Task Complete_Should_ReturnConflict_When_TaskIsAlreadyCompleted() + { + var token = TestContext.Current.CancellationToken; + var task = TaskItem.Create("Задача", Priority.Medium, DateTimeOffset.UnixEpoch); + task.TryComplete(DateTimeOffset.UnixEpoch.AddHours(1)); + _repository.FindAsync(task.Id, token).Returns(task); + + var result = await CreateService().CompleteAsync(task.Id, token); + + Assert.Equal(CompletionStatus.Conflict, result.Status); + Assert.Equal(DateTimeOffset.UnixEpoch.AddHours(1), task.CompletedAt); + await _repository.DidNotReceive().TrySaveAsync(Arg.Any()); + _publisher.DidNotReceive().Publish(Arg.Any()); + } + + [Fact(DisplayName = "Конфликт версии строки предотвращает публикацию события")] + public async Task Complete_Should_NotPublish_When_RepositoryReportsConcurrencyConflict() + { + var token = TestContext.Current.CancellationToken; + var task = TaskItem.Create("Задача", Priority.Medium, DateTimeOffset.UnixEpoch); + _repository.FindAsync(task.Id, token).Returns(task); + _repository.TrySaveAsync(token).Returns(false); + + var result = await CreateService().CompleteAsync(task.Id, token); + + Assert.Equal(CompletionStatus.Conflict, result.Status); + _publisher.DidNotReceive().Publish(Arg.Any()); + } + + [Fact(DisplayName = "Ошибка сохранения передаётся вызывающему коду без публикации события")] + public async Task Complete_Should_PropagateOriginalFailure_When_SaveThrows() + { + var token = TestContext.Current.CancellationToken; + var task = TaskItem.Create("Задача", Priority.Medium, DateTimeOffset.UnixEpoch); + var failure = new InvalidOperationException("Ошибка сохранения"); + _repository.FindAsync(task.Id, token).Returns(task); + _repository.TrySaveAsync(token).Returns(Task.FromException(failure)); + + var actual = await Assert.ThrowsAsync(() => CreateService().CompleteAsync(task.Id, token)); + + Assert.Same(failure, actual); + _publisher.DidNotReceive().Publish(Arg.Any()); + } + + [Fact(DisplayName = "Отмена сохранения не приводит к публикации события")] + public async Task Complete_Should_NotPublish_When_SaveIsCancelled() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var task = TaskItem.Create("Задача", Priority.Medium, DateTimeOffset.UnixEpoch); + _repository.FindAsync(task.Id, cancellation.Token).Returns(task); + _repository.TrySaveAsync(cancellation.Token).Returns(Task.FromCanceled(cancellation.Token)); + + await Assert.ThrowsAnyAsync(() => CreateService().CompleteAsync(task.Id, cancellation.Token)); + + _publisher.DidNotReceive().Publish(Arg.Any()); + } + + private TasksService CreateService() => new(_repository, _publisher, new FixedTimeProvider()); + + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => Now; + } +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Domain/Tasks/TaskItemTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Domain/Tasks/TaskItemTests.cs new file mode 100644 index 0000000..28d6acf --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Domain/Tasks/TaskItemTests.cs @@ -0,0 +1,68 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.UnitTests.Domain.Tasks; + +public sealed class TaskItemTests +{ + [Fact(DisplayName = "Создание задачи сохраняет название и приоритет и переводит время в UTC")] + public void Create_Should_PreserveDetailsAndNormalizeTime_When_ValuesAreValid() + { + var createdAt = new DateTimeOffset(2026, 10, 16, 12, 0, 0, TimeSpan.FromHours(4)); + + var task = TaskItem.Create(" Купить молоко ", Priority.High, createdAt); + + Assert.NotEqual(Guid.Empty, task.Id); + Assert.Equal(" Купить молоко ", task.Title); + Assert.Equal(Priority.High, task.Priority); + Assert.Equal(createdAt.ToUniversalTime(), task.CreatedAt); + Assert.Equal(TimeSpan.Zero, task.CreatedAt.Offset); + Assert.False(task.IsCompleted); + Assert.Null(task.CompletedAt); + } + + [Theory(DisplayName = "Создание задачи отклоняет пустое или пробельное название")] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\r\n")] + public void Create_Should_RejectTitle_When_TitleIsBlank(string? title) + { + Assert.ThrowsAny(() => TaskItem.Create(title!, Priority.Medium, DateTimeOffset.UnixEpoch)); + } + + [Fact(DisplayName = "Название длиной 200 символов допустимо, а 201 символ — нет")] + public void Create_Should_EnforceTitleLimit_When_TitleIsAtBoundary() + { + var task = TaskItem.Create(new string('я', 200), Priority.Low, DateTimeOffset.UnixEpoch); + + Assert.Equal(200, task.Title.Length); + Assert.Throws(() => + TaskItem.Create(new string('я', 201), Priority.Low, DateTimeOffset.UnixEpoch)); + } + + [Theory(DisplayName = "Создание задачи отклоняет неизвестный приоритет")] + [InlineData(-1)] + [InlineData(3)] + public void Create_Should_RejectPriority_When_ValueIsUndefined(int priority) + { + Assert.Throws(() => + TaskItem.Create("Задача", (Priority)priority, DateTimeOffset.UnixEpoch)); + } + + [Fact(DisplayName = "Первое завершение устанавливает время UTC, повторное сохраняет исходное время")] + public void TryComplete_Should_PreserveFirstCompletion_When_CalledTwice() + { + var task = TaskItem.Create("Задача", Priority.Medium, DateTimeOffset.UnixEpoch); + var completedAt = new DateTimeOffset(2026, 10, 16, 12, 0, 0, TimeSpan.FromHours(4)); + + var first = task.TryComplete(completedAt); + var repeated = task.TryComplete(completedAt.AddHours(1)); + + Assert.True(first); + Assert.False(repeated); + Assert.True(task.IsCompleted); + Assert.Equal(completedAt.ToUniversalTime(), task.CompletedAt); + Assert.NotNull(task.CompletedAt); + Assert.Equal(TimeSpan.Zero, task.CompletedAt.Value.Offset); + } +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TaskEventPublisherTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Infrastructure/Messaging/TaskEventPublisherTests.cs similarity index 76% rename from tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TaskEventPublisherTests.cs rename to tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Infrastructure/Messaging/TaskEventPublisherTests.cs index 93bcfb9..43f3276 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TaskEventPublisherTests.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Infrastructure/Messaging/TaskEventPublisherTests.cs @@ -6,11 +6,11 @@ using PANiXiDA.TaskApi.Tasks.Domain.Tasks; using PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; -namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; +namespace PANiXiDA.TaskApi.Tasks.UnitTests.Infrastructure.Messaging; public sealed class TaskEventPublisherTests { - [Fact] + [Fact(DisplayName = "Остановка отправляет начатое и ожидающие события до закрытия соединения")] public async Task Stop_Should_DrainStartedAndQueuedPublications_Before_ClosingTransport() { var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -21,24 +21,24 @@ public async Task Stop_Should_DrainStartedAndQueuedPublications_Before_ClosingTr await release.Task.WaitAsync(token); }); using var publisher = CreatePublisher(transport); - await publisher.StartAsync(CancellationToken.None); + await publisher.StartAsync(TestContext.Current.CancellationToken); var first = Message(); var second = Message(); publisher.Publish(first); - await entered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); publisher.Publish(second); - var stopping = publisher.StopAsync(CancellationToken.None); + var stopping = publisher.StopAsync(TestContext.Current.CancellationToken); Assert.False(stopping.IsCompleted); Assert.False(transport.Closed); release.SetResult(); - await stopping.WaitAsync(TimeSpan.FromSeconds(5)); + await stopping.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.Equal([first, second], transport.Messages.ToArray()); Assert.True(transport.Closed); } - [Fact] + [Fact(DisplayName = "Ошибка отправки записывает событие в лог и не мешает следующей публикации")] public async Task Publish_Should_LogPayloadAndContinue_When_TransportFails() { var first = Message(); @@ -47,11 +47,11 @@ public async Task Publish_Should_LogPayloadAndContinue_When_TransportFails() var transport = new RecordingTransport((message, _) => message == first ? Task.FromException(new IOException("Broker unavailable")) : Task.CompletedTask); using var publisher = CreatePublisher(transport, logger); - await publisher.StartAsync(CancellationToken.None); + await publisher.StartAsync(TestContext.Current.CancellationToken); publisher.Publish(first); publisher.Publish(second); - await publisher.StopAsync(CancellationToken.None); + await publisher.StopAsync(TestContext.Current.CancellationToken); Assert.Equal(second, Assert.Single(transport.Messages)); Assert.Contains(logger.Messages, x => x.Contains(first.TaskId.ToString(), StringComparison.Ordinal) @@ -59,7 +59,7 @@ public async Task Publish_Should_LogPayloadAndContinue_When_TransportFails() Assert.True(transport.Closed); } - [Fact] + [Fact(DisplayName = "Переполнение или остановка очереди записывает неотправленное событие в лог")] public async Task Publish_Should_LogDroppedEvent_When_QueueIsFullOrStopped() { var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -71,15 +71,15 @@ public async Task Publish_Should_LogDroppedEvent_When_QueueIsFullOrStopped() await release.Task.WaitAsync(token); }); using var publisher = CreatePublisher(transport, logger, capacity: 1); - await publisher.StartAsync(CancellationToken.None); + await publisher.StartAsync(TestContext.Current.CancellationToken); publisher.Publish(Message()); - await entered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); publisher.Publish(Message()); var overflow = Message(); publisher.Publish(overflow); release.SetResult(); - await publisher.StopAsync(CancellationToken.None); + await publisher.StopAsync(TestContext.Current.CancellationToken); var afterStop = Message(); publisher.Publish(afterStop); @@ -88,7 +88,7 @@ public async Task Publish_Should_LogDroppedEvent_When_QueueIsFullOrStopped() Assert.Contains(logger.Messages, x => x.Contains(afterStop.TaskId.ToString(), StringComparison.Ordinal)); } - [Fact] + [Fact(DisplayName = "Остановка отменяет зависшие публикации и закрывает соединение за пять секунд")] public async Task Stop_Should_CancelHungPublicationsAndClose_Within_ShutdownBudget() { var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -99,16 +99,16 @@ public async Task Stop_Should_CancelHungPublicationsAndClose_Within_ShutdownBudg }); var logger = new RecordingLogger(); using var publisher = CreatePublisher(transport, logger); - await publisher.StartAsync(CancellationToken.None); + await publisher.StartAsync(TestContext.Current.CancellationToken); var events = Enumerable.Range(0, 5).Select(_ => Message()).ToArray(); foreach (var message in events) { publisher.Publish(message); } - await entered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); var elapsed = Stopwatch.StartNew(); - await publisher.StopAsync(CancellationToken.None); + await publisher.StopAsync(TestContext.Current.CancellationToken); Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(5), $"Shutdown took {elapsed.Elapsed}."); Assert.True(transport.Closed); diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/PANiXiDA.TaskApi.Tasks.UnitTests.csproj b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/PANiXiDA.TaskApi.Tasks.UnitTests.csproj new file mode 100644 index 0000000..c5c584b --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/PANiXiDA.TaskApi.Tasks.UnitTests.csproj @@ -0,0 +1,9 @@ + + + + + + + + + From 2376fb3dcca81012264270fde8397351a0de9888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Fri, 16 Oct 2026 21:20:19 +0400 Subject: [PATCH 5/8] test: configure functional host before application startup --- .../PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs index 35ceaf0..fcc444f 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; @@ -11,10 +12,15 @@ namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; public sealed class ApiFactory(Dictionary settings, SaveChangesInterceptor? interceptor = null, int? rabbitMqPort = null) : WebApplicationFactory { + protected override IHost CreateHost(IHostBuilder builder) + { + builder.ConfigureHostConfiguration(configuration => configuration.AddInMemoryCollection(settings)); + return base.CreateHost(builder); + } + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); - builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(settings)); if (rabbitMqPort is not null) { builder.ConfigureAppConfiguration((_, configuration) => From 1e3aa85d5d28519b09fb5e6f57bb7df198c9a170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Fri, 16 Oct 2026 21:29:12 +0400 Subject: [PATCH 6/8] refactor: organize task application and endpoints by feature --- README.md | 23 ++++++- src/PANiXiDA.TaskApi.Host/Program.cs | 2 +- .../Tasks/Abstractions/ITaskEventPublisher.cs | 8 +++ .../{ => Abstractions}/ITasksRepository.cs | 2 +- .../Tasks/Common/TaskTimestamp.cs | 10 +++ .../Tasks/Complete/CompleteTaskCommand.cs | 3 + .../Tasks/Complete/CompleteTaskHandler.cs | 27 ++++++++ .../{ => Complete}/CompleteTaskResult.cs | 2 +- .../Tasks/{ => Complete}/TaskCompleted.cs | 2 +- .../Tasks/Create/CreateTaskCommand.cs | 5 ++ .../Tasks/Create/CreateTaskHandler.cs | 15 +++++ .../Tasks/Delete/DeleteTaskCommand.cs | 3 + .../Tasks/Delete/DeleteTaskHandler.cs | 9 +++ .../Tasks/GetList/GetTasksHandler.cs | 10 +++ .../Tasks/GetList/GetTasksQuery.cs | 3 + .../Tasks/ITaskEventPublisher.cs | 6 -- .../Tasks/TasksService.cs | 45 ------------- .../ServiceCollectionExtensions.cs | 11 ++- .../Messaging/ITaskEventTransport.cs | 2 +- .../Messaging/RabbitMqTransport.cs | 2 +- .../Messaging/TaskEventPublisher.cs | 3 +- .../Persistence/TasksRepository.cs | 2 +- .../Tasks/Complete/CompleteTaskEndpoint.cs | 26 +++++++ .../Tasks/Complete/CompleteTaskMapper.cs | 12 ++++ .../Tasks/Complete/CompleteTaskResponse.cs | 11 +++ .../Tasks/Create/CreateTaskEndpoint.cs | 37 ++++++++++ .../Features/Tasks/Create/CreateTaskMapper.cs | 12 ++++ .../Tasks/Create}/CreateTaskRequest.cs | 2 +- .../Tasks/Create/CreateTaskResponse.cs | 11 +++ .../Tasks/Delete/DeleteTaskEndpoint.cs | 19 ++++++ .../Features/Tasks/Delete/DeleteTaskMapper.cs | 8 +++ .../Tasks/GetList/GetTasksEndpoint.cs | 19 ++++++ .../Features/Tasks/GetList/GetTasksMapper.cs | 12 ++++ .../Tasks/GetList/TaskListItemResponse.cs | 11 +++ .../Features/Tasks/TasksEndpoints.cs | 21 ++++++ .../Tasks/TaskResponse.cs | 15 ----- .../Tasks/TasksEndpoints.cs | 67 ------------------- .../RabbitMqProbe.cs | 2 +- .../TasksEndpointsTests.cs | 18 ++--- .../Messaging/RabbitMqTransportTests.cs | 2 +- .../Persistence/TasksMigrationTests.cs | 2 +- .../Persistence/TasksRepositoryTests.cs | 2 +- .../CompleteTaskHandlerTests.cs} | 46 +++++-------- .../Tasks/Create/CreateTaskHandlerTests.cs | 29 ++++++++ .../Messaging/TaskEventPublisherTests.cs | 2 +- 45 files changed, 394 insertions(+), 187 deletions(-) create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Abstractions/ITaskEventPublisher.cs rename src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/{ => Abstractions}/ITasksRepository.cs (87%) create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Common/TaskTimestamp.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskCommand.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskHandler.cs rename src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/{ => Complete}/CompleteTaskResult.cs (77%) rename src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/{ => Complete}/TaskCompleted.cs (71%) create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Create/CreateTaskCommand.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Create/CreateTaskHandler.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Delete/DeleteTaskCommand.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Delete/DeleteTaskHandler.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/GetList/GetTasksHandler.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/GetList/GetTasksQuery.cs delete mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITaskEventPublisher.cs delete mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TasksService.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Complete/CompleteTaskEndpoint.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Complete/CompleteTaskMapper.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Complete/CompleteTaskResponse.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskEndpoint.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskMapper.cs rename src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/{Tasks => Features/Tasks/Create}/CreateTaskRequest.cs (66%) create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskResponse.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Delete/DeleteTaskEndpoint.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Delete/DeleteTaskMapper.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/GetList/GetTasksEndpoint.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/GetList/GetTasksMapper.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/GetList/TaskListItemResponse.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/TasksEndpoints.cs delete mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TaskResponse.cs delete mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TasksEndpoints.cs rename tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/{TasksServiceTests.cs => Complete/CompleteTaskHandlerTests.cs} (71%) create mode 100644 tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/Create/CreateTaskHandlerTests.cs diff --git a/README.md b/README.md index b8048b2..d33f998 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,27 @@ tests/ Host связывает слои через встроенный DI. Application зависит только от Domain; Presentation не обращается к EF или RabbitMQ. Версии NuGet централизованы в `Directory.Packages.props`. +Сценарии сгруппированы по фичам, как в Tactical Heroes API: + +```text +Application/Tasks/ + Abstractions/ # ITasksRepository, ITaskEventPublisher + Common/ # единая точность временных меток + Create/ # CreateTaskCommand, CreateTaskHandler + GetList/ # GetTasksQuery, GetTasksHandler + Complete/ # CompleteTaskCommand, CompleteTaskHandler, результат и событие + Delete/ # DeleteTaskCommand, DeleteTaskHandler + +Presentation/Features/Tasks/ + TasksEndpoints.cs # только регистрация группы /tasks + Create/ # endpoint, request, response, mapper + GetList/ # endpoint, response, mapper + Complete/ # endpoint, response, mapper + Delete/ # endpoint, mapper +``` + +Каждый endpoint вызывает свой обработчик Application через встроенный DI. Mapper переводит входные данные в команду/запрос, а результат — в DTO ответа своей фичи. Unit-тесты обработчиков размещены в соответствующих папках `Application/Tasks/Create` и `Application/Tasks/Complete`. + `TaskItem.RowVersion` имеет тип `uint` и настроен через `IsRowVersion()`: Npgsql использует системную колонку PostgreSQL `xmin`. EF добавляет исходную версию в условие UPDATE; `DbUpdateConcurrencyException` преобразуется в `409`. Для двух запросов, прочитавших одну версию строки, сохраняется только один результат. Подробнее: [Npgsql concurrency tokens](https://www.npgsql.org/efcore/modeling/concurrency.html). ## RabbitMQ и остановка @@ -215,7 +236,7 @@ dotnet test | Проект | Что проверяет | | --- | --- | -| `UnitTests` | Инварианты `TaskItem`, время и порядок сохранения/публикации в `TasksService`, конфликты, ошибки и отмена, очередь publisher и остановка. Внешние сервисы не нужны. | +| `UnitTests` | Инварианты `TaskItem`, время и порядок сохранения/публикации в обработчиках фич, конфликты, ошибки и отмена, очередь publisher и остановка. Внешние сервисы не нужны. | | `IntegrationTests` | Репозиторий и миграции на настоящем PostgreSQL: сортировка, ограничения, `xmin`, удаление. Контракт события и повторное соединение транспорта с настоящим RabbitMQ. | | `FunctionalTests` | HTTP API через `WebApplicationFactory`: статусы, валидация, значения по умолчанию, CRUD, реальное событие, недоступный брокер и конкурентное завершение. | diff --git a/src/PANiXiDA.TaskApi.Host/Program.cs b/src/PANiXiDA.TaskApi.Host/Program.cs index e682ae6..e1be891 100644 --- a/src/PANiXiDA.TaskApi.Host/Program.cs +++ b/src/PANiXiDA.TaskApi.Host/Program.cs @@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore; using PANiXiDA.TaskApi.Tasks.Infrastructure.DependencyInjection; using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; -using PANiXiDA.TaskApi.Tasks.Presentation.Tasks; +using PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks; var builder = WebApplication.CreateBuilder(args); builder.Services.AddProblemDetails(); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Abstractions/ITaskEventPublisher.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Abstractions/ITaskEventPublisher.cs new file mode 100644 index 0000000..3af26a9 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Abstractions/ITaskEventPublisher.cs @@ -0,0 +1,8 @@ +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; + +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; + +public interface ITaskEventPublisher +{ + void Publish(TaskCompleted message); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITasksRepository.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Abstractions/ITasksRepository.cs similarity index 87% rename from src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITasksRepository.cs rename to src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Abstractions/ITasksRepository.cs index c062567..2e1b151 100644 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITasksRepository.cs +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Abstractions/ITasksRepository.cs @@ -1,6 +1,6 @@ using PANiXiDA.TaskApi.Tasks.Domain.Tasks; -namespace PANiXiDA.TaskApi.Tasks.Application.Tasks; +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; public interface ITasksRepository { diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Common/TaskTimestamp.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Common/TaskTimestamp.cs new file mode 100644 index 0000000..59f926a --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Common/TaskTimestamp.cs @@ -0,0 +1,10 @@ +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Common; + +internal static class TaskTimestamp +{ + internal static DateTimeOffset GetUtcNow(TimeProvider timeProvider) + { + var now = timeProvider.GetUtcNow(); + return now.AddTicks(-(now.Ticks % TimeSpan.TicksPerMicrosecond)); + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskCommand.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskCommand.cs new file mode 100644 index 0000000..c043220 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskCommand.cs @@ -0,0 +1,3 @@ +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; + +public sealed record CompleteTaskCommand(Guid Id); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskHandler.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskHandler.cs new file mode 100644 index 0000000..fb356a2 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskHandler.cs @@ -0,0 +1,27 @@ +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Common; + +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; + +public sealed class CompleteTaskHandler( + ITasksRepository repository, + ITaskEventPublisher publisher, + TimeProvider timeProvider) +{ + public async Task HandleAsync(CompleteTaskCommand command, CancellationToken cancellationToken) + { + var task = await repository.FindAsync(command.Id, cancellationToken); + if (task is null) + { + return new CompleteTaskResult(CompletionStatus.NotFound); + } + + if (!task.TryComplete(TaskTimestamp.GetUtcNow(timeProvider)) || !await repository.TrySaveAsync(cancellationToken)) + { + return new CompleteTaskResult(CompletionStatus.Conflict); + } + + publisher.Publish(new TaskCompleted(task.Id, task.Title, task.CompletedAt!.Value, task.Priority)); + return new CompleteTaskResult(CompletionStatus.Completed, task); + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/CompleteTaskResult.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskResult.cs similarity index 77% rename from src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/CompleteTaskResult.cs rename to src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskResult.cs index f4aa583..71f81e6 100644 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/CompleteTaskResult.cs +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskResult.cs @@ -1,6 +1,6 @@ using PANiXiDA.TaskApi.Tasks.Domain.Tasks; -namespace PANiXiDA.TaskApi.Tasks.Application.Tasks; +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; public enum CompletionStatus { diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TaskCompleted.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/TaskCompleted.cs similarity index 71% rename from src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TaskCompleted.cs rename to src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/TaskCompleted.cs index 6576690..1f2afab 100644 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TaskCompleted.cs +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/TaskCompleted.cs @@ -1,5 +1,5 @@ using PANiXiDA.TaskApi.Tasks.Domain.Tasks; -namespace PANiXiDA.TaskApi.Tasks.Application.Tasks; +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; public sealed record TaskCompleted(Guid TaskId, string Title, DateTimeOffset CompletedAt, Priority Priority); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Create/CreateTaskCommand.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Create/CreateTaskCommand.cs new file mode 100644 index 0000000..64886d3 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Create/CreateTaskCommand.cs @@ -0,0 +1,5 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Create; + +public sealed record CreateTaskCommand(string Title, Priority Priority); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Create/CreateTaskHandler.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Create/CreateTaskHandler.cs new file mode 100644 index 0000000..a883a87 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Create/CreateTaskHandler.cs @@ -0,0 +1,15 @@ +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Common; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Create; + +public sealed class CreateTaskHandler(ITasksRepository repository, TimeProvider timeProvider) +{ + public async Task HandleAsync(CreateTaskCommand command, CancellationToken cancellationToken) + { + var task = TaskItem.Create(command.Title, command.Priority, TaskTimestamp.GetUtcNow(timeProvider)); + await repository.AddAsync(task, cancellationToken); + return task; + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Delete/DeleteTaskCommand.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Delete/DeleteTaskCommand.cs new file mode 100644 index 0000000..246b4c0 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Delete/DeleteTaskCommand.cs @@ -0,0 +1,3 @@ +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Delete; + +public sealed record DeleteTaskCommand(Guid Id); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Delete/DeleteTaskHandler.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Delete/DeleteTaskHandler.cs new file mode 100644 index 0000000..f7c6ab3 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Delete/DeleteTaskHandler.cs @@ -0,0 +1,9 @@ +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; + +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Delete; + +public sealed class DeleteTaskHandler(ITasksRepository repository) +{ + public Task HandleAsync(DeleteTaskCommand command, CancellationToken cancellationToken) => + repository.DeleteAsync(command.Id, cancellationToken); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/GetList/GetTasksHandler.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/GetList/GetTasksHandler.cs new file mode 100644 index 0000000..277e1cb --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/GetList/GetTasksHandler.cs @@ -0,0 +1,10 @@ +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.GetList; + +public sealed class GetTasksHandler(ITasksRepository repository) +{ + public Task> HandleAsync(GetTasksQuery query, CancellationToken cancellationToken) => + repository.GetAllAsync(cancellationToken); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/GetList/GetTasksQuery.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/GetList/GetTasksQuery.cs new file mode 100644 index 0000000..eb0991b --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/GetList/GetTasksQuery.cs @@ -0,0 +1,3 @@ +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.GetList; + +public sealed record GetTasksQuery; diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITaskEventPublisher.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITaskEventPublisher.cs deleted file mode 100644 index 078eda9..0000000 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/ITaskEventPublisher.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace PANiXiDA.TaskApi.Tasks.Application.Tasks; - -public interface ITaskEventPublisher -{ - void Publish(TaskCompleted message); -} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TasksService.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TasksService.cs deleted file mode 100644 index 8b4d3e8..0000000 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/TasksService.cs +++ /dev/null @@ -1,45 +0,0 @@ -using PANiXiDA.TaskApi.Tasks.Domain.Tasks; - -namespace PANiXiDA.TaskApi.Tasks.Application.Tasks; - -public sealed class TasksService( - ITasksRepository repository, - ITaskEventPublisher publisher, - TimeProvider timeProvider) -{ - public async Task CreateAsync(string title, Priority priority, CancellationToken cancellationToken) - { - var task = TaskItem.Create(title, priority, GetTimestamp()); - await repository.AddAsync(task, cancellationToken); - return task; - } - - public Task> GetAllAsync(CancellationToken cancellationToken) => - repository.GetAllAsync(cancellationToken); - - public async Task CompleteAsync(Guid id, CancellationToken cancellationToken) - { - var task = await repository.FindAsync(id, cancellationToken); - if (task is null) - { - return new CompleteTaskResult(CompletionStatus.NotFound); - } - - if (!task.TryComplete(GetTimestamp()) || !await repository.TrySaveAsync(cancellationToken)) - { - return new CompleteTaskResult(CompletionStatus.Conflict); - } - - publisher.Publish(new TaskCompleted(task.Id, task.Title, task.CompletedAt!.Value, task.Priority)); - return new CompleteTaskResult(CompletionStatus.Completed, task); - } - - public Task DeleteAsync(Guid id, CancellationToken cancellationToken) => - repository.DeleteAsync(id, cancellationToken); - - private DateTimeOffset GetTimestamp() - { - var now = timeProvider.GetUtcNow(); - return now.AddTicks(-(now.Ticks % TimeSpan.TicksPerMicrosecond)); - } -} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index 0d647ac..3d8af3f 100644 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,7 +1,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Create; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Delete; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.GetList; using PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; @@ -15,7 +19,10 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi ?? throw new InvalidOperationException("ConnectionStrings:Tasks is required."); services.AddDbContext(options => options.UseNpgsql(connectionString)); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddSingleton(TimeProvider.System); services.AddOptions() .Bind(configuration.GetSection(RabbitMqOptions.SectionName)) diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/ITaskEventTransport.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/ITaskEventTransport.cs index 2eba0eb..0404a19 100644 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/ITaskEventTransport.cs +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/ITaskEventTransport.cs @@ -1,4 +1,4 @@ -using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/RabbitMqTransport.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/RabbitMqTransport.cs index 6c3ef09..0bd4005 100644 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/RabbitMqTransport.cs +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/RabbitMqTransport.cs @@ -1,6 +1,6 @@ using System.Text.Json; using Microsoft.Extensions.Options; -using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; using RabbitMQ.Client; namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventPublisher.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventPublisher.cs index ce85646..fb95625 100644 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventPublisher.cs +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventPublisher.cs @@ -3,7 +3,8 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksRepository.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksRepository.cs index 7851c61..26a649a 100644 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksRepository.cs +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksRepository.cs @@ -1,5 +1,5 @@ using Microsoft.EntityFrameworkCore; -using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; using PANiXiDA.TaskApi.Tasks.Domain.Tasks; namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Complete/CompleteTaskEndpoint.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Complete/CompleteTaskEndpoint.cs new file mode 100644 index 0000000..003a641 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Complete/CompleteTaskEndpoint.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Complete; + +internal static class CompleteTaskEndpoint +{ + internal static void Map(RouteGroupBuilder endpoints) => + endpoints.MapPut("/{id}/complete", HandleAsync).WithName("CompleteTask"); + + private static async Task, NotFound, ProblemHttpResult>> HandleAsync( + Guid id, CompleteTaskHandler handler, CancellationToken cancellationToken) + { + var result = await handler.HandleAsync(CompleteTaskMapper.ToCommand(id), cancellationToken); + return result.Status switch + { + CompletionStatus.Completed => TypedResults.Ok(CompleteTaskMapper.ToResponse(result.Task!)), + CompletionStatus.NotFound => TypedResults.NotFound(), + _ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, + title: "The task is already completed or was modified concurrently.") + }; + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Complete/CompleteTaskMapper.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Complete/CompleteTaskMapper.cs new file mode 100644 index 0000000..9b60ba9 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Complete/CompleteTaskMapper.cs @@ -0,0 +1,12 @@ +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Complete; + +internal static class CompleteTaskMapper +{ + internal static CompleteTaskCommand ToCommand(Guid id) => new(id); + + internal static CompleteTaskResponse ToResponse(TaskItem task) => + new(task.Id, task.Title, task.IsCompleted, task.CreatedAt, task.CompletedAt, task.Priority); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Complete/CompleteTaskResponse.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Complete/CompleteTaskResponse.cs new file mode 100644 index 0000000..d28e119 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Complete/CompleteTaskResponse.cs @@ -0,0 +1,11 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Complete; + +public sealed record CompleteTaskResponse( + Guid Id, + string Title, + bool IsCompleted, + DateTimeOffset CreatedAt, + DateTimeOffset? CompletedAt, + Priority Priority); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskEndpoint.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskEndpoint.cs new file mode 100644 index 0000000..0f18e71 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskEndpoint.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Create; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Create; + +internal static class CreateTaskEndpoint +{ + internal static void Map(RouteGroupBuilder endpoints) => + endpoints.MapPost("/", HandleAsync).WithName("CreateTask"); + + private static async Task, ValidationProblem>> HandleAsync( + CreateTaskRequest request, CreateTaskHandler handler, CancellationToken cancellationToken) + { + var errors = new Dictionary(); + if (string.IsNullOrWhiteSpace(request.Title) || request.Title.Length > TaskItem.MaxTitleLength) + { + errors["title"] = ["Title must contain non-whitespace characters and be at most 200 characters long."]; + } + + if (!Enum.IsDefined(request.Priority)) + { + errors["priority"] = ["Priority must be Low, Medium or High."]; + } + + if (errors.Count > 0) + { + return TypedResults.ValidationProblem(errors); + } + + var task = await handler.HandleAsync(CreateTaskMapper.ToCommand(request), cancellationToken); + return TypedResults.Created("/tasks", CreateTaskMapper.ToResponse(task)); + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskMapper.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskMapper.cs new file mode 100644 index 0000000..ac2b695 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskMapper.cs @@ -0,0 +1,12 @@ +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Create; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Create; + +internal static class CreateTaskMapper +{ + internal static CreateTaskCommand ToCommand(CreateTaskRequest request) => new(request.Title!, request.Priority); + + internal static CreateTaskResponse ToResponse(TaskItem task) => + new(task.Id, task.Title, task.IsCompleted, task.CreatedAt, task.CompletedAt, task.Priority); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/CreateTaskRequest.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskRequest.cs similarity index 66% rename from src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/CreateTaskRequest.cs rename to src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskRequest.cs index efd0722..360f6f6 100644 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/CreateTaskRequest.cs +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskRequest.cs @@ -1,5 +1,5 @@ using PANiXiDA.TaskApi.Tasks.Domain.Tasks; -namespace PANiXiDA.TaskApi.Tasks.Presentation.Tasks; +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Create; public sealed record CreateTaskRequest(string? Title, Priority Priority = Priority.Medium); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskResponse.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskResponse.cs new file mode 100644 index 0000000..b0e49c6 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskResponse.cs @@ -0,0 +1,11 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Create; + +public sealed record CreateTaskResponse( + Guid Id, + string Title, + bool IsCompleted, + DateTimeOffset CreatedAt, + DateTimeOffset? CompletedAt, + Priority Priority); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Delete/DeleteTaskEndpoint.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Delete/DeleteTaskEndpoint.cs new file mode 100644 index 0000000..b016316 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Delete/DeleteTaskEndpoint.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Delete; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Delete; + +internal static class DeleteTaskEndpoint +{ + internal static void Map(RouteGroupBuilder endpoints) => + endpoints.MapDelete("/{id}", HandleAsync).WithName("DeleteTask"); + + private static async Task> HandleAsync( + Guid id, DeleteTaskHandler handler, CancellationToken cancellationToken) => + await handler.HandleAsync(DeleteTaskMapper.ToCommand(id), cancellationToken) + ? TypedResults.NoContent() + : TypedResults.NotFound(); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Delete/DeleteTaskMapper.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Delete/DeleteTaskMapper.cs new file mode 100644 index 0000000..b22f5ff --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Delete/DeleteTaskMapper.cs @@ -0,0 +1,8 @@ +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Delete; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Delete; + +internal static class DeleteTaskMapper +{ + internal static DeleteTaskCommand ToCommand(Guid id) => new(id); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/GetList/GetTasksEndpoint.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/GetList/GetTasksEndpoint.cs new file mode 100644 index 0000000..fd06f18 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/GetList/GetTasksEndpoint.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.GetList; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.GetList; + +internal static class GetTasksEndpoint +{ + internal static void Map(RouteGroupBuilder endpoints) => + endpoints.MapGet("/", HandleAsync).WithName("GetTasks"); + + private static async Task> HandleAsync(GetTasksHandler handler, CancellationToken cancellationToken) + { + var tasks = await handler.HandleAsync(GetTasksMapper.ToQuery(), cancellationToken); + return TypedResults.Ok(tasks.Select(GetTasksMapper.ToResponse).ToArray()); + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/GetList/GetTasksMapper.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/GetList/GetTasksMapper.cs new file mode 100644 index 0000000..6ee15b6 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/GetList/GetTasksMapper.cs @@ -0,0 +1,12 @@ +using PANiXiDA.TaskApi.Tasks.Application.Tasks.GetList; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.GetList; + +internal static class GetTasksMapper +{ + internal static GetTasksQuery ToQuery() => new(); + + internal static TaskListItemResponse ToResponse(TaskItem task) => + new(task.Id, task.Title, task.IsCompleted, task.CreatedAt, task.CompletedAt, task.Priority); +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/GetList/TaskListItemResponse.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/GetList/TaskListItemResponse.cs new file mode 100644 index 0000000..7bcbf9f --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/GetList/TaskListItemResponse.cs @@ -0,0 +1,11 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.GetList; + +public sealed record TaskListItemResponse( + Guid Id, + string Title, + bool IsCompleted, + DateTimeOffset CreatedAt, + DateTimeOffset? CompletedAt, + Priority Priority); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/TasksEndpoints.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/TasksEndpoints.cs new file mode 100644 index 0000000..d067e19 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/TasksEndpoints.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Complete; +using PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Create; +using PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Delete; +using PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.GetList; + +namespace PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks; + +public static class TasksEndpoints +{ + public static IEndpointRouteBuilder MapTasks(this IEndpointRouteBuilder endpoints) + { + var tasks = endpoints.MapGroup("/tasks"); + CreateTaskEndpoint.Map(tasks); + GetTasksEndpoint.Map(tasks); + CompleteTaskEndpoint.Map(tasks); + DeleteTaskEndpoint.Map(tasks); + return endpoints; + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TaskResponse.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TaskResponse.cs deleted file mode 100644 index 850a0c6..0000000 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TaskResponse.cs +++ /dev/null @@ -1,15 +0,0 @@ -using PANiXiDA.TaskApi.Tasks.Domain.Tasks; - -namespace PANiXiDA.TaskApi.Tasks.Presentation.Tasks; - -public sealed record TaskResponse( - Guid Id, - string Title, - bool IsCompleted, - DateTimeOffset CreatedAt, - DateTimeOffset? CompletedAt, - Priority Priority) -{ - internal static TaskResponse From(TaskItem task) => - new(task.Id, task.Title, task.IsCompleted, task.CreatedAt, task.CompletedAt, task.Priority); -} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TasksEndpoints.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TasksEndpoints.cs deleted file mode 100644 index 87a4e43..0000000 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Tasks/TasksEndpoints.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Routing; -using PANiXiDA.TaskApi.Tasks.Application.Tasks; -using PANiXiDA.TaskApi.Tasks.Domain.Tasks; - -namespace PANiXiDA.TaskApi.Tasks.Presentation.Tasks; - -public static class TasksEndpoints -{ - public static IEndpointRouteBuilder MapTasks(this IEndpointRouteBuilder endpoints) - { - var tasks = endpoints.MapGroup("/tasks"); - tasks.MapPost("/", CreateAsync).WithName("CreateTask"); - tasks.MapGet("/", GetAllAsync).WithName("GetTasks"); - tasks.MapPut("/{id}/complete", CompleteAsync).WithName("CompleteTask"); - tasks.MapDelete("/{id}", DeleteAsync).WithName("DeleteTask"); - return endpoints; - } - - private static async Task, ValidationProblem>> CreateAsync( - CreateTaskRequest request, TasksService service, CancellationToken cancellationToken) - { - var errors = new Dictionary(); - if (string.IsNullOrWhiteSpace(request.Title) || request.Title.Length > TaskItem.MaxTitleLength) - { - errors["title"] = ["Title must contain non-whitespace characters and be at most 200 characters long."]; - } - - if (!Enum.IsDefined(request.Priority)) - { - errors["priority"] = ["Priority must be Low, Medium or High."]; - } - - if (errors.Count > 0) - { - return TypedResults.ValidationProblem(errors); - } - - var task = await service.CreateAsync(request.Title!, request.Priority, cancellationToken); - return TypedResults.Created("/tasks", TaskResponse.From(task)); - } - - private static async Task> GetAllAsync(TasksService service, CancellationToken cancellationToken) - { - var tasks = await service.GetAllAsync(cancellationToken); - return TypedResults.Ok(tasks.Select(TaskResponse.From).ToArray()); - } - - private static async Task, NotFound, ProblemHttpResult>> CompleteAsync( - Guid id, TasksService service, CancellationToken cancellationToken) - { - var result = await service.CompleteAsync(id, cancellationToken); - return result.Status switch - { - CompletionStatus.Completed => TypedResults.Ok(TaskResponse.From(result.Task!)), - CompletionStatus.NotFound => TypedResults.NotFound(), - _ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, - title: "The task is already completed or was modified concurrently.") - }; - } - - private static async Task> DeleteAsync( - Guid id, TasksService service, CancellationToken cancellationToken) => - await service.DeleteAsync(id, cancellationToken) ? TypedResults.NoContent() : TypedResults.NotFound(); -} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs index f19eef4..d7c8189 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using PANiXiDA.TaskApi.Testing; -using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; using PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; using RabbitMQ.Client; diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs index e1dfa84..c297b63 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs @@ -8,7 +8,9 @@ using Microsoft.Extensions.DependencyInjection; using PANiXiDA.TaskApi.Tasks.Domain.Tasks; using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; -using PANiXiDA.TaskApi.Tasks.Presentation.Tasks; +using PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Complete; +using PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Create; +using PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.GetList; namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; @@ -36,9 +38,9 @@ public async Task Complete_Should_PublishPersistedTask_When_TaskExists() using var completedResponse = await client.PutAsync($"/tasks/{created.Id}/complete", null, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, completedResponse.StatusCode); - var completed = (await completedResponse.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken))!; + var completed = (await completedResponse.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken))!; var message = await probe.ReadAsync(created.Id); - var tasks = (await client.GetFromJsonAsync("/tasks", JsonOptions, TestContext.Current.CancellationToken))!; + var tasks = (await client.GetFromJsonAsync("/tasks", JsonOptions, TestContext.Current.CancellationToken))!; var saved = Assert.Single(tasks, x => x.Id == created.Id); Assert.True(saved.IsCompleted); @@ -51,7 +53,7 @@ public async Task Complete_Should_PublishPersistedTask_When_TaskExists() using var repeated = await client.PutAsync($"/tasks/{created.Id}/complete", null, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Conflict, repeated.StatusCode); - var unchanged = Assert.Single((await client.GetFromJsonAsync("/tasks", JsonOptions, TestContext.Current.CancellationToken))!, + var unchanged = Assert.Single((await client.GetFromJsonAsync("/tasks", JsonOptions, TestContext.Current.CancellationToken))!, x => x.Id == created.Id); Assert.Equal(saved.CompletedAt, unchanged.CompletedAt); await factory.DisposeAsync(); @@ -65,7 +67,7 @@ public async Task Create_Should_UseDefaults_When_OnlyTitleIsProvided() using var client = factory.CreateClient(); using var response = await client.PostAsJsonAsync("/tasks", new { title = new string('a', 200) }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var task = (await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken))!; + var task = (await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken))!; Assert.Equal(Priority.Medium, task.Priority); Assert.False(task.IsCompleted); Assert.Null(task.CompletedAt); @@ -181,7 +183,7 @@ public async Task Delete_Should_RemoveTaskPermanently_When_TaskExists() using var response = await client.DeleteAsync($"/tasks/{task.Id}", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - var tasks = (await client.GetFromJsonAsync("/tasks", JsonOptions, TestContext.Current.CancellationToken))!; + var tasks = (await client.GetFromJsonAsync("/tasks", JsonOptions, TestContext.Current.CancellationToken))!; Assert.DoesNotContain(tasks, x => x.Id == task.Id); using var missing = await client.DeleteAsync($"/tasks/{task.Id}", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); @@ -203,11 +205,11 @@ public async Task Endpoints_Should_ReturnExpectedStatus_When_IdIsInvalidOrMissin Assert.Equal(expected, delete.StatusCode); } - private static async Task CreateAsync(HttpClient client, string title = "Test task", string priority = "Medium") + private static async Task CreateAsync(HttpClient client, string title = "Test task", string priority = "Medium") { using var response = await client.PostAsJsonAsync("/tasks", new { title, priority }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, response.StatusCode); - return (await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken))!; + return (await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken))!; } private sealed class RejectCompletion : SaveChangesInterceptor diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Messaging/RabbitMqTransportTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Messaging/RabbitMqTransportTests.cs index 0eaf509..5c44ed2 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Messaging/RabbitMqTransportTests.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Messaging/RabbitMqTransportTests.cs @@ -1,7 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using PANiXiDA.TaskApi.Testing; -using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; using PANiXiDA.TaskApi.Tasks.Domain.Tasks; using PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; using RabbitMQ.Client; diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs index f99f003..acda0ef 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Npgsql; -using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; using PANiXiDA.TaskApi.Tasks.Domain.Tasks; using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksRepositoryTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksRepositoryTests.cs index 6e36824..863fda9 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksRepositoryTests.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksRepositoryTests.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; using PANiXiDA.TaskApi.Tasks.Domain.Tasks; using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/TasksServiceTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/Complete/CompleteTaskHandlerTests.cs similarity index 71% rename from tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/TasksServiceTests.cs rename to tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/Complete/CompleteTaskHandlerTests.cs index 54ebbf6..ae4029e 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/TasksServiceTests.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/Complete/CompleteTaskHandlerTests.cs @@ -1,30 +1,18 @@ using NSubstitute; -using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; using PANiXiDA.TaskApi.Tasks.Domain.Tasks; -namespace PANiXiDA.TaskApi.Tasks.UnitTests.Application.Tasks; +namespace PANiXiDA.TaskApi.Tasks.UnitTests.Application.Tasks.Complete; -public sealed class TasksServiceTests +public sealed class CompleteTaskHandlerTests { private static readonly DateTimeOffset Now = DateTimeOffset.UnixEpoch.AddDays(1).AddTicks(1234567); private readonly ITasksRepository _repository = Substitute.For(); private readonly ITaskEventPublisher _publisher = Substitute.For(); - [Fact(DisplayName = "Создание использует часы сервиса и сохраняет время с точностью до микросекунды")] - public async Task Create_Should_SaveMicrosecondTimestamp_When_TaskIsValid() - { - var token = TestContext.Current.CancellationToken; - var service = CreateService(); - - var task = await service.CreateAsync("Задача", Priority.Low, token); - - Assert.Equal(Now.AddTicks(-7), task.CreatedAt); - await _repository.Received(1).AddAsync(task, token); - _publisher.DidNotReceive().Publish(Arg.Any()); - } - [Fact(DisplayName = "Завершение публикует сохранённые данные только после успешного сохранения")] - public async Task Complete_Should_PublishAfterSave_When_SaveSucceeds() + public async Task HandleAsync_Should_PublishAfterSave_When_SaveSucceeds() { var token = TestContext.Current.CancellationToken; var task = TaskItem.Create("Задача", Priority.High, DateTimeOffset.UnixEpoch); @@ -37,7 +25,7 @@ public async Task Complete_Should_PublishAfterSave_When_SaveSucceeds() }); _publisher.When(x => x.Publish(Arg.Any())).Do(_ => Assert.True(saved)); - var result = await CreateService().CompleteAsync(task.Id, token); + var result = await CreateHandler().HandleAsync(new CompleteTaskCommand(task.Id), token); Assert.Equal(CompletionStatus.Completed, result.Status); Assert.Same(task, result.Task); @@ -47,11 +35,11 @@ public async Task Complete_Should_PublishAfterSave_When_SaveSucceeds() } [Fact(DisplayName = "Завершение отсутствующей задачи не сохраняет и не публикует событие")] - public async Task Complete_Should_ReturnNotFound_When_TaskDoesNotExist() + public async Task HandleAsync_Should_ReturnNotFound_When_TaskDoesNotExist() { var token = TestContext.Current.CancellationToken; - var result = await CreateService().CompleteAsync(Guid.NewGuid(), token); + var result = await CreateHandler().HandleAsync(new CompleteTaskCommand(Guid.NewGuid()), token); Assert.Equal(CompletionStatus.NotFound, result.Status); Assert.Null(result.Task); @@ -60,14 +48,14 @@ public async Task Complete_Should_ReturnNotFound_When_TaskDoesNotExist() } [Fact(DisplayName = "Повторное завершение возвращает конфликт без сохранения и публикации")] - public async Task Complete_Should_ReturnConflict_When_TaskIsAlreadyCompleted() + public async Task HandleAsync_Should_ReturnConflict_When_TaskIsAlreadyCompleted() { var token = TestContext.Current.CancellationToken; var task = TaskItem.Create("Задача", Priority.Medium, DateTimeOffset.UnixEpoch); task.TryComplete(DateTimeOffset.UnixEpoch.AddHours(1)); _repository.FindAsync(task.Id, token).Returns(task); - var result = await CreateService().CompleteAsync(task.Id, token); + var result = await CreateHandler().HandleAsync(new CompleteTaskCommand(task.Id), token); Assert.Equal(CompletionStatus.Conflict, result.Status); Assert.Equal(DateTimeOffset.UnixEpoch.AddHours(1), task.CompletedAt); @@ -76,21 +64,21 @@ public async Task Complete_Should_ReturnConflict_When_TaskIsAlreadyCompleted() } [Fact(DisplayName = "Конфликт версии строки предотвращает публикацию события")] - public async Task Complete_Should_NotPublish_When_RepositoryReportsConcurrencyConflict() + public async Task HandleAsync_Should_NotPublish_When_RepositoryReportsConcurrencyConflict() { var token = TestContext.Current.CancellationToken; var task = TaskItem.Create("Задача", Priority.Medium, DateTimeOffset.UnixEpoch); _repository.FindAsync(task.Id, token).Returns(task); _repository.TrySaveAsync(token).Returns(false); - var result = await CreateService().CompleteAsync(task.Id, token); + var result = await CreateHandler().HandleAsync(new CompleteTaskCommand(task.Id), token); Assert.Equal(CompletionStatus.Conflict, result.Status); _publisher.DidNotReceive().Publish(Arg.Any()); } [Fact(DisplayName = "Ошибка сохранения передаётся вызывающему коду без публикации события")] - public async Task Complete_Should_PropagateOriginalFailure_When_SaveThrows() + public async Task HandleAsync_Should_PropagateOriginalFailure_When_SaveThrows() { var token = TestContext.Current.CancellationToken; var task = TaskItem.Create("Задача", Priority.Medium, DateTimeOffset.UnixEpoch); @@ -98,14 +86,14 @@ public async Task Complete_Should_PropagateOriginalFailure_When_SaveThrows() _repository.FindAsync(task.Id, token).Returns(task); _repository.TrySaveAsync(token).Returns(Task.FromException(failure)); - var actual = await Assert.ThrowsAsync(() => CreateService().CompleteAsync(task.Id, token)); + var actual = await Assert.ThrowsAsync(() => CreateHandler().HandleAsync(new CompleteTaskCommand(task.Id), token)); Assert.Same(failure, actual); _publisher.DidNotReceive().Publish(Arg.Any()); } [Fact(DisplayName = "Отмена сохранения не приводит к публикации события")] - public async Task Complete_Should_NotPublish_When_SaveIsCancelled() + public async Task HandleAsync_Should_NotPublish_When_SaveIsCancelled() { using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); @@ -113,12 +101,12 @@ public async Task Complete_Should_NotPublish_When_SaveIsCancelled() _repository.FindAsync(task.Id, cancellation.Token).Returns(task); _repository.TrySaveAsync(cancellation.Token).Returns(Task.FromCanceled(cancellation.Token)); - await Assert.ThrowsAnyAsync(() => CreateService().CompleteAsync(task.Id, cancellation.Token)); + await Assert.ThrowsAnyAsync(() => CreateHandler().HandleAsync(new CompleteTaskCommand(task.Id), cancellation.Token)); _publisher.DidNotReceive().Publish(Arg.Any()); } - private TasksService CreateService() => new(_repository, _publisher, new FixedTimeProvider()); + private CompleteTaskHandler CreateHandler() => new(_repository, _publisher, new FixedTimeProvider()); private sealed class FixedTimeProvider : TimeProvider { diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/Create/CreateTaskHandlerTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/Create/CreateTaskHandlerTests.cs new file mode 100644 index 0000000..9cdbeb6 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/Create/CreateTaskHandlerTests.cs @@ -0,0 +1,29 @@ +using NSubstitute; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Create; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.UnitTests.Application.Tasks.Create; + +public sealed class CreateTaskHandlerTests +{ + private static readonly DateTimeOffset Now = DateTimeOffset.UnixEpoch.AddDays(1).AddTicks(1234567); + + [Fact(DisplayName = "Создание использует часы обработчика и сохраняет время с точностью до микросекунды")] + public async Task HandleAsync_Should_SaveMicrosecondTimestamp_When_TaskIsValid() + { + var token = TestContext.Current.CancellationToken; + var repository = Substitute.For(); + var handler = new CreateTaskHandler(repository, new FixedTimeProvider()); + + var task = await handler.HandleAsync(new CreateTaskCommand("Задача", Priority.Low), token); + + Assert.Equal(Now.AddTicks(-7), task.CreatedAt); + await repository.Received(1).AddAsync(task, token); + } + + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => Now; + } +} diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Infrastructure/Messaging/TaskEventPublisherTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Infrastructure/Messaging/TaskEventPublisherTests.cs index 43f3276..cfa2ef1 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Infrastructure/Messaging/TaskEventPublisherTests.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Infrastructure/Messaging/TaskEventPublisherTests.cs @@ -2,7 +2,7 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using PANiXiDA.TaskApi.Tasks.Application.Tasks; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; using PANiXiDA.TaskApi.Tasks.Domain.Tasks; using PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; From 9cf2d5411942d426f959d07520e178b332d0c597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Fri, 16 Oct 2026 21:36:00 +0400 Subject: [PATCH 7/8] docs: simplify customer startup instructions --- README.md | 47 +++++++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d33f998..d4ac3f4 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,21 @@ Структура основана на [dotnet-backend-template](https://github.com/panixida-templates/dotnet-backend-template/tree/development); зависимости от PANiXiDA.Core не используются. -## Проверка для заказчика: без установки .NET и знания кода +## Пошаговый запуск и проверка для заказчика -Это серверное API без отдельного сайта с кнопками. Готовность автотестов можно проверить в GitHub Actions, а сам сервис — запустить через Docker по инструкции ниже. Для ручной проверки .NET устанавливать не нужно. +Это серверное API без отдельного сайта с кнопками. Выполните шаги ниже по порядку: Docker запустит приложение, базу данных и RabbitMQ, а готовые команды помогут проверить работу с задачами. Инструкция рассчитана на Windows; устанавливать .NET и разбираться в коде не нужно. -### 1. Подготовить компьютер и скачать проект +### 1. Установить и запустить Docker 1. Установите [Docker Desktop](https://docs.docker.com/desktop/setup/install/windows-install/) и запустите его. На Windows используйте Linux containers; если установщик попросит настроить WSL или перезагрузиться, выполните его инструкции. Docker Compose входит в Docker Desktop. 2. Дождитесь, когда Docker Desktop сообщит, что Docker Engine запущен. Для первой сборки нужен интернет: Docker скачает необходимые компоненты. -3. Распакуйте полученный от разработчика ZIP-архив проекта. Если скачиваете с GitHub, откройте [ветку с реализацией](https://github.com/PANiXiDA/task-api/tree/feature/implement-task-api), затем **Code → Download ZIP**. Пока [PR №1](https://github.com/PANiXiDA/task-api/pull/1) не слит, скачивать нужно именно эту ветку, а не `development`. Репозиторий приватный: для скачивания через GitHub нужен выданный доступ; переданный ZIP такого доступа не требует. -4. Откройте распакованную папку, в которой лежат `README.md` и `compose.yaml`. В Проводнике Windows нажмите на адресную строку, введите `powershell` и нажмите Enter. Все команды ниже выполняйте в открывшемся окне. + +### 2. Скачать проект и открыть PowerShell в его папке + +1. Войдите в GitHub под учётной записью, которой выдан доступ к приватному репозиторию. +2. Откройте [ветку с реализацией](https://github.com/PANiXiDA/task-api/tree/feature/implement-task-api) и нажмите **Code → Download ZIP**. Пока [PR №1](https://github.com/PANiXiDA/task-api/pull/1) не слит, используйте именно эту ветку: в `development` приложения ещё нет. +3. Распакуйте скачанные исходники через **Извлечь всё**. Откройте внутри папку, в которой лежат `README.md` и `compose.yaml`. +4. В Проводнике Windows нажмите на адресную строку, введите `powershell` и нажмите Enter. Все команды ниже выполняйте в открывшемся окне. Проверьте, что Docker доступен: @@ -23,23 +28,23 @@ docker compose version Должна появиться строка с версией Docker Compose. Устанавливать .NET, PostgreSQL, RabbitMQ, Git или Postman отдельно не нужно. -### 2. Проверить результат автотестов в GitHub - -Откройте [PR №1 → Checks](https://github.com/PANiXiDA/task-api/pull/1/checks) или [GitHub Actions](https://github.com/PANiXiDA/task-api/actions/workflows/ci.yml) и выберите запуск для последнего коммита ветки с реализацией. Зелёные проверки означают, что тесты и сборка прошли; красные — что есть ошибка, жёлтые — что проверка ещё выполняется. +### 3. Запустить сервис и проверить, что он работает -В CI отдельно запускаются проекты `UnitTests`, `IntegrationTests` и `FunctionalTests`. В отчёте **Tests** названия проверок указаны на русском. Интеграционные и функциональные тесты сами запускают временные PostgreSQL и RabbitMQ, проверяют сохранение данных и получают настоящее сообщение из брокера. +В том же окне PowerShell выполните: -Для просмотра приватного репозитория нужен доступ GitHub. Если у вас только ZIP, переходите к ручной проверке ниже; результаты CI можно запросить у разработчика. Команда запуска тестов для разработчика описана в разделе [Тесты](#тесты). +```powershell +docker compose up --build -d +``` -### 3. Запустить сервис для ручной проверки +Дождитесь завершения команды: первая сборка может занять несколько минут. Она поднимает всё необходимое: **API + PostgreSQL + RabbitMQ с веб-панелью управления**. Подключения уже настроены, таблицы создаются автоматически миграциями. -В том же окне PowerShell выполните: +Проверьте состояние контейнеров: ```powershell -docker compose up --build -d +docker compose ps ``` -Команда поднимает всё необходимое: **API + PostgreSQL + RabbitMQ с веб-панелью управления**. Подключения уже настроены, таблицы создаются автоматически миграциями. Проверить контейнеры можно командой `docker compose ps`: у API ожидается состояние `Up`, у PostgreSQL и RabbitMQ — `Up (healthy)`. +У API ожидается состояние `Up`, у PostgreSQL и RabbitMQ — `Up (healthy)`. Откройте в браузере [http://localhost:8080/health](http://localhost:8080/health). Ожидаемый ответ: @@ -49,7 +54,7 @@ docker compose up --build -d Если страница не открылась сразу, подождите несколько секунд после запуска и обновите её. Список задач доступен по адресу [http://localhost:8080/tasks](http://localhost:8080/tasks); в новой БД он пустой: `[]`. -#### Веб-панель RabbitMQ +### 4. Открыть веб-панель RabbitMQ Панель запускается автоматически той же командой `docker compose up --build -d`. Отдельно включать плагины не нужно. @@ -66,7 +71,7 @@ docker compose up --build -d Порт `5672` используется приложением для AMQP внутри Docker и не открывается на компьютере. Порт `15672` предназначен для браузера и доступен только с этого компьютера. Автотесты используют отдельные временные контейнеры без веб-панели и подключаются к брокеру напрямую. -### 4. Проверить создание, завершение и удаление вручную +### 5. Проверить создание, завершение и удаление задачи Копируйте блоки по порядку в **одно и то же окно PowerShell**. ID задачи сохраняется автоматически: ничего подставлять вместо `` не нужно. @@ -103,15 +108,15 @@ Invoke-RestMethod -Uri "$baseUrl/tasks" | ConvertTo-Json Успешное удаление не выводит текст. Задача исчезнет из списка; если других задач нет, список пустой (PowerShell может не вывести ничего). -Получение события из RabbitMQ проверяют автотесты в CI из шага 2. В обычном запуске доступна веб-панель брокера, но очередь получателя автоматически не создаётся; тест создаёт свою очередь сам. Чтобы увидеть сообщение вручную в панели, до завершения задачи создайте очередь в virtual host `/` и привяжите её к exchange `task.events` с ключом `task.completed`. Если exchange ещё нет, сначала создайте его с типом `topic` и параметром `Durable = true`. Успешное ручное завершение задачи само по себе не подтверждает доставку события. +Получение события из RabbitMQ проверяют [автотесты в CI](#тесты). В обычном запуске доступна веб-панель брокера, но очередь получателя автоматически не создаётся; тест создаёт свою очередь сам. Чтобы увидеть сообщение вручную в панели, до завершения задачи создайте очередь в virtual host `/` и привяжите её к exchange `task.events` с ключом `task.completed`. Если exchange ещё нет, сначала создайте его с типом `topic` и параметром `Durable = true`. Успешное ручное завершение задачи само по себе не подтверждает доставку события. -### 5. Остановить сервис +### 6. Остановить сервис ```powershell docker compose down ``` -Созданные вручную задачи сохраняются в Docker volume и будут доступны после следующего запуска. +Созданные вручную и не удалённые задачи сохраняются в PostgreSQL и будут доступны после следующего запуска. Для повторного запуска откройте PowerShell в той же папке и выполните `docker compose up --build -d`. ### Если что-то не получилось @@ -124,7 +129,7 @@ docker compose down | `port is already allocated` / занят 8080 | Выполните `$env:TASK_API_PORT = "8081"`, затем повторите `docker compose up --build -d`. В адресах браузера и `$baseUrl` используйте порт 8081. | | Занят порт панели RabbitMQ 15672 | Выполните `$env:RABBITMQ_UI_PORT = "15673"`, затем повторите `docker compose up --build -d`. Панель будет доступна на `http://localhost:15673`. | | API не отвечает после запуска | Выполните `docker compose ps` и `docker compose logs --tail=100 api`, передайте вывод разработчику. | -| При скачивании с GitHub ошибка 404 | Проверьте доступ к приватному репозиторию или используйте ZIP, полученный от разработчика. | +| При скачивании с GitHub ошибка 404 | Войдите в учётную запись с доступом к репозиторию. Если доступа нет, попросите разработчика выдать его. | Команды Docker работают и на macOS/Linux с установленным Docker Compose; блоки `Invoke-RestMethod` выше предназначены для PowerShell. @@ -224,6 +229,8 @@ Presentation/Features/Tasks/ ## Тесты +Результаты автотестов можно посмотреть без запуска команд: откройте [PR №1 → Checks](https://github.com/PANiXiDA/task-api/pull/1/checks) или [GitHub Actions](https://github.com/PANiXiDA/task-api/actions/workflows/ci.yml) и выберите запуск для последнего коммита ветки с реализацией. Нужен доступ к приватному репозиторию. Зелёные проверки означают, что тесты и сборка прошли; красные — что есть ошибка, жёлтые — что проверка ещё выполняется. В отчёте **Tests** названия проверок указаны на русском. + Тесты запускаются обычной командой из корня репозитория: ```sh From 815c88464d650251023e63cfeb7df91905781039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Fri, 16 Oct 2026 21:52:52 +0400 Subject: [PATCH 8/8] fix: address task creation and constraint review feedback --- README.md | 5 +- ...000_RemoveTaskCheckConstraints.Designer.cs | 66 +++++++++++++++++++ ...261016180000_RemoveTaskCheckConstraints.cs | 45 +++++++++++++ .../Migrations/TasksDbContextModelSnapshot.cs | 9 +-- .../Persistence/TasksDbContext.cs | 7 +- .../Tasks/Create/CreateTaskEndpoint.cs | 2 +- .../TasksEndpointsTests.cs | 6 +- .../Persistence/TasksMigrationTests.cs | 31 +++++++-- .../Domain/Tasks/TaskItemTests.cs | 1 + 9 files changed, 151 insertions(+), 21 deletions(-) create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016180000_RemoveTaskCheckConstraints.Designer.cs create mode 100644 src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016180000_RemoveTaskCheckConstraints.cs diff --git a/README.md b/README.md index d4ac3f4..c51951a 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ docker compose down | Метод | Путь | Результат | | --- | --- | --- | -| POST | `/tasks` | `201`, созданная задача; неверный JSON, Title или Priority → `400` | +| POST | `/tasks` | `201`, созданная задача и `Location: /tasks/{id}`; неверный JSON, Title или Priority → `400` | | GET | `/tasks` | `200`, все задачи в порядке CreatedAt / Id | | PUT | `/tasks/{id}/complete` | `200`, завершённая задача; отсутствует → `404`; уже завершена или конкурентно изменена → `409` | | DELETE | `/tasks/{id}` | `204`, физическое удаление; отсутствует → `404` | @@ -158,8 +158,11 @@ curl -i http://localhost:8080/tasks \ `title` обязателен, содержит хотя бы один непробельный символ, максимум 200 символов; исходный текст сохраняется. `priority` по умолчанию `Medium`; принимаются `Low`, `Medium`, `High` или соответствующие числа `0`, `1`, `2`. Ответы и события содержат строковое значение приоритета. `IsCompleted=false`, `CompletedAt=null` при создании. +`Location` идентифицирует созданную задачу; отдельный `GET /tasks/{id}` не предусмотрен, для чтения используется список `GET /tasks`. Временные метки записываются в UTC с точностью PostgreSQL до микросекунды, одинаковой для API, БД и события. Повторное завершение не изменяет `CompletedAt` и не публикует событие. +Проверки пробельного названия, допустимого приоритета и состояния завершения выполняются приложением. CHECK-ограничений этих правил в БД нет; ограничения `NOT NULL` и длины полей сохранены. Миграция `RemoveTaskCheckConstraints` удаляет прежние CHECK-ограничения при следующем запуске обновлённого приложения, сохраняя существующие задачи. + ```sh curl -i -X PUT http://localhost:8080/tasks//complete curl http://localhost:8080/tasks diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016180000_RemoveTaskCheckConstraints.Designer.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016180000_RemoveTaskCheckConstraints.Designer.cs new file mode 100644 index 0000000..dc99057 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016180000_RemoveTaskCheckConstraints.Designer.cs @@ -0,0 +1,66 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +#nullable disable + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(TasksDbContext))] + [Migration("20261016180000_RemoveTaskCheckConstraints")] + partial class RemoveTaskCheckConstraints + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.21") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PANiXiDA.TaskApi.Tasks.Domain.Tasks.TaskItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("character varying(6)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("tasks", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016180000_RemoveTaskCheckConstraints.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016180000_RemoveTaskCheckConstraints.cs new file mode 100644 index 0000000..654156f --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/20261016180000_RemoveTaskCheckConstraints.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence.Migrations +{ + /// + public partial class RemoveTaskCheckConstraints : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "ck_tasks_completion", + table: "tasks"); + + migrationBuilder.DropCheckConstraint( + name: "ck_tasks_priority", + table: "tasks"); + + migrationBuilder.DropCheckConstraint( + name: "ck_tasks_title", + table: "tasks"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddCheckConstraint( + name: "ck_tasks_completion", + table: "tasks", + sql: "\"IsCompleted\" = (\"CompletedAt\" IS NOT NULL)"); + + migrationBuilder.AddCheckConstraint( + name: "ck_tasks_priority", + table: "tasks", + sql: "\"Priority\" IN ('Low', 'Medium', 'High')"); + + migrationBuilder.AddCheckConstraint( + name: "ck_tasks_title", + table: "tasks", + sql: "length(btrim(\"Title\")) > 0"); + } + } +} diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/TasksDbContextModelSnapshot.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/TasksDbContextModelSnapshot.cs index 9b515e4..1377c83 100644 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/TasksDbContextModelSnapshot.cs +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/TasksDbContextModelSnapshot.cs @@ -55,14 +55,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("tasks", null, t => - { - t.HasCheckConstraint("ck_tasks_completion", "\"IsCompleted\" = (\"CompletedAt\" IS NOT NULL)"); - - t.HasCheckConstraint("ck_tasks_priority", "\"Priority\" IN ('Low', 'Medium', 'High')"); - - t.HasCheckConstraint("ck_tasks_title", "length(btrim(\"Title\")) > 0"); - }); + b.ToTable("tasks", (string)null); }); #pragma warning restore 612, 618 } diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContext.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContext.cs index 0b6d1a2..20177fb 100644 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContext.cs +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContext.cs @@ -10,12 +10,7 @@ public sealed class TasksDbContext(DbContextOptions options) : D protected override void OnModelCreating(ModelBuilder modelBuilder) { var task = modelBuilder.Entity(); - task.ToTable("tasks", table => - { - table.HasCheckConstraint("ck_tasks_title", "length(btrim(\"Title\")) > 0"); - table.HasCheckConstraint("ck_tasks_priority", "\"Priority\" IN ('Low', 'Medium', 'High')"); - table.HasCheckConstraint("ck_tasks_completion", "\"IsCompleted\" = (\"CompletedAt\" IS NOT NULL)"); - }); + task.ToTable("tasks"); task.HasKey(x => x.Id); task.Property(x => x.Title).IsRequired().HasMaxLength(TaskItem.MaxTitleLength); task.Property(x => x.Priority).HasConversion().HasMaxLength(6); diff --git a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskEndpoint.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskEndpoint.cs index 0f18e71..091c75f 100644 --- a/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskEndpoint.cs +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskEndpoint.cs @@ -32,6 +32,6 @@ private static async Task, ValidationProblem } var task = await handler.HandleAsync(CreateTaskMapper.ToCommand(request), cancellationToken); - return TypedResults.Created("/tasks", CreateTaskMapper.ToResponse(task)); + return TypedResults.Created($"/tasks/{task.Id}", CreateTaskMapper.ToResponse(task)); } } diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs index c297b63..066eea4 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs @@ -68,6 +68,7 @@ public async Task Create_Should_UseDefaults_When_OnlyTitleIsProvided() using var response = await client.PostAsJsonAsync("/tasks", new { title = new string('a', 200) }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, response.StatusCode); var task = (await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken))!; + Assert.Equal($"/tasks/{task.Id}", response.Headers.Location?.OriginalString); Assert.Equal(Priority.Medium, task.Priority); Assert.False(task.IsCompleted); Assert.Null(task.CompletedAt); @@ -80,6 +81,7 @@ public async Task Create_Should_UseDefaults_When_OnlyTitleIsProvided() [InlineData("")] [InlineData(" ")] [InlineData("\t\r\n")] + [InlineData("\u00a0\u2003\u2028")] public async Task Create_Should_Return400_When_TitleIsBlank(string? title) { await using var factory = new ApiFactory(fixture.Configuration); @@ -209,7 +211,9 @@ private static async Task CreateAsync(HttpClient client, str { using var response = await client.PostAsJsonAsync("/tasks", new { title, priority }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, response.StatusCode); - return (await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken))!; + var task = (await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken))!; + Assert.Equal($"/tasks/{task.Id}", response.Headers.Location?.OriginalString); + return task; } private sealed class RejectCompletion : SaveChangesInterceptor diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs index acda0ef..42722f1 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs @@ -1,4 +1,6 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; using Npgsql; using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; @@ -24,11 +26,32 @@ public async Task Migrate_Should_PreserveData_When_AppliedAgain() Assert.Equal(task.Id, (await context.Tasks.AsNoTracking().SingleAsync(token)).Id); } - [Theory(DisplayName = "Ограничения PostgreSQL отклоняют некорректные данные при прямой записи")] - [InlineData("UPDATE tasks SET \"Title\" = ' '", "23514")] + [Fact(DisplayName = "Обновление существующей БД удаляет CHECK-ограничения и сохраняет задачи")] + public async Task Migrate_Should_RemoveCheckConstraintsAndPreserveData_When_UpgradingInitialSchema() + { + var token = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + await context.GetService().MigrateAsync("20261016162834_InitialCreate", token); + var task = TaskItem.Create("Существующая задача", Priority.High, DateTimeOffset.UnixEpoch); + await scope.ServiceProvider.GetRequiredService().AddAsync(task, token); + const string countConstraintsSql = "SELECT count(*)::integer AS \"Value\" FROM pg_constraint WHERE conrelid = 'tasks'::regclass AND contype = 'c'"; + Assert.Equal(3, await context.Database.SqlQueryRaw(countConstraintsSql).SingleAsync(token)); + + await context.Database.MigrateAsync(token); + + Assert.Equal(0, await context.Database.SqlQueryRaw(countConstraintsSql).SingleAsync(token)); + Assert.Empty(await context.Database.GetPendingMigrationsAsync(token)); + var saved = await context.Tasks.AsNoTracking().SingleAsync(token); + Assert.Equal(task.Id, saved.Id); + Assert.Equal(task.Title, saved.Title); + Assert.Equal(task.Priority, saved.Priority); + Assert.Equal(task.CreatedAt, saved.CreatedAt); + } + + [Theory(DisplayName = "PostgreSQL отклоняет NULL и превышение длины названия при прямой записи")] + [InlineData("UPDATE tasks SET \"Title\" = NULL", "23502")] [InlineData("UPDATE tasks SET \"Title\" = repeat('a', 201)", "22001")] - [InlineData("UPDATE tasks SET \"Priority\" = 'Other'", "23514")] - [InlineData("UPDATE tasks SET \"IsCompleted\" = true", "23514")] public async Task Constraints_Should_RejectInvalidData_When_BypassingDomain(string sql, string sqlState) { var token = TestContext.Current.CancellationToken; diff --git a/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Domain/Tasks/TaskItemTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Domain/Tasks/TaskItemTests.cs index 28d6acf..e51a4b4 100644 --- a/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Domain/Tasks/TaskItemTests.cs +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Domain/Tasks/TaskItemTests.cs @@ -25,6 +25,7 @@ public void Create_Should_PreserveDetailsAndNormalizeTime_When_ValuesAreValid() [InlineData("")] [InlineData(" ")] [InlineData("\t\r\n")] + [InlineData("\u00a0\u2003\u2028")] public void Create_Should_RejectTitle_When_TitleIsBlank(string? title) { Assert.ThrowsAny(() => TaskItem.Create(title!, Priority.Medium, DateTimeOffset.UnixEpoch));