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..bbf45a1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [development] + pull_request: + branches: [development] + workflow_dispatch: + +permissions: + contents: read + checks: write + +jobs: + 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 runtime image + run: docker build -f src/PANiXiDA.TaskApi.Host/Dockerfile -t task-api:ci . 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.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..5f72479 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,20 @@ + + + true + true + + + + + + + + + + + + + + + + diff --git a/PANiXiDA.TaskApi.sln b/PANiXiDA.TaskApi.sln new file mode 100644 index 0000000..5800c14 --- /dev/null +++ b/PANiXiDA.TaskApi.sln @@ -0,0 +1,161 @@ + +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 +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 + 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 + {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 + {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 + {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} = {75C100DC-8B11-45CF-9AB3-F62C88C951ED} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 716fa34..c51951a 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,272 @@ Тестовое задание: 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 не используются. + +## Пошаговый запуск и проверка для заказчика + +Это серверное API без отдельного сайта с кнопками. Выполните шаги ниже по порядку: Docker запустит приложение, базу данных и RabbitMQ, а готовые команды помогут проверить работу с задачами. Инструкция рассчитана на Windows; устанавливать .NET и разбираться в коде не нужно. + +### 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 скачает необходимые компоненты. + +### 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 доступен: + +```powershell +docker compose version +``` + +Должна появиться строка с версией Docker Compose. Устанавливать .NET, PostgreSQL, RabbitMQ, Git или Postman отдельно не нужно. + +### 3. Запустить сервис и проверить, что он работает + +В том же окне PowerShell выполните: + +```powershell +docker compose up --build -d +``` + +Дождитесь завершения команды: первая сборка может занять несколько минут. Она поднимает всё необходимое: **API + PostgreSQL + RabbitMQ с веб-панелью управления**. Подключения уже настроены, таблицы создаются автоматически миграциями. + +Проверьте состояние контейнеров: + +```powershell +docker compose ps +``` + +У API ожидается состояние `Up`, у PostgreSQL и RabbitMQ — `Up (healthy)`. + +Откройте в браузере [http://localhost:8080/health](http://localhost:8080/health). Ожидаемый ответ: + +```json +{"status":"ok"} +``` + +Если страница не открылась сразу, подождите несколько секунд после запуска и обновите её. Список задач доступен по адресу [http://localhost:8080/tasks](http://localhost:8080/tasks); в новой БД он пустой: `[]`. + +### 4. Открыть веб-панель 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` предназначен для браузера и доступен только с этого компьютера. Автотесты используют отдельные временные контейнеры без веб-панели и подключаются к брокеру напрямую. + +### 5. Проверить создание, завершение и удаление задачи + +Копируйте блоки по порядку в **одно и то же окно 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 проверяют [автотесты в CI](#тесты). В обычном запуске доступна веб-панель брокера, но очередь получателя автоматически не создаётся; тест создаёт свою очередь сам. Чтобы увидеть сообщение вручную в панели, до завершения задачи создайте очередь в virtual host `/` и привяжите её к exchange `task.events` с ключом `task.completed`. Если exchange ещё нет, сначала создайте его с типом `topic` и параметром `Durable = true`. Успешное ручное завершение задачи само по себе не подтверждает доставку события. + +### 6. Остановить сервис + +```powershell +docker compose down +``` + +Созданные вручную и не удалённые задачи сохраняются в PostgreSQL и будут доступны после следующего запуска. Для повторного запуска откройте PowerShell в той же папке и выполните `docker compose up --build -d`. + +### Если что-то не получилось + +| Что видно | Что сделать | +| --- | --- | +| `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. | +| Занят порт панели 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 | Войдите в учётную запись с доступом к репозиторию. Если доступа нет, попросите разработчика выдать его. | + +Команды Docker работают и на macOS/Linux с установленным Docker Compose; блоки `Invoke-RestMethod` выше предназначены для PowerShell. + +Пароли в Compose — демонстрационные значения только для локальных контейнеров. Порты PostgreSQL и AMQP RabbitMQ не публикуются. API и веб-панель RabbitMQ доступны только через loopback. + +## API + +| Метод | Путь | Результат | +| --- | --- | --- | +| 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` | + +Некорректный 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` при создании. +`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 +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/ + 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`. + +Сценарии сгруппированы по фичам, как в 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 и остановка + +После успешного `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 удалит сообщение. Завершение задачи при этом сохраняется. + +## Тесты + +Результаты автотестов можно посмотреть без запуска команд: откройте [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 +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`, время и порядок сохранения/публикации в обработчиках фич, конфликты, ошибки и отмена, очередь publisher и остановка. Внешние сервисы не нужны. | +| `IntegrationTests` | Репозиторий и миграции на настоящем PostgreSQL: сортировка, ограничения, `xmin`, удаление. Контракт события и повторное соединение транспорта с настоящим RabbitMQ. | +| `FunctionalTests` | HTTP API через `WebApplicationFactory`: статусы, валидация, значения по умолчанию, CRUD, реальное событие, недоступный брокер и конкурентное завершение. | + +Запуск только unit-тестов без Docker: + +```sh +dotnet test --project tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/PANiXiDA.TaskApi.Tasks.UnitTests.csproj +``` + +Все `[Fact]` и `[Theory]` имеют русские `DisplayName`. Для интеграционных тестов общий fixture создаёт окружение, а перед каждой проверкой очищаются тестовые задачи. Гонка HTTP-запросов воспроизводится барьером перед 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. + +## Конфигурация + +| Переменная | Назначение | +| --- | --- | +| `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.yaml b/compose.yaml new file mode 100644 index 0000000..975a992 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,48 @@ +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-management-alpine + ports: + - "127.0.0.1:${RABBITMQ_UI_PORT:-15672}:15672" + environment: + RABBITMQ_DEFAULT_USER: tasks + RABBITMQ_DEFAULT_PASS: local-development-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 + + 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..c412d6b --- /dev/null +++ b/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "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 new file mode 100644 index 0000000..9153aa3 --- /dev/null +++ b/src/PANiXiDA.TaskApi.Host/Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.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..e1be891 --- /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.Features.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/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/Abstractions/ITasksRepository.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Abstractions/ITasksRepository.cs new file mode 100644 index 0000000..2e1b151 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Abstractions/ITasksRepository.cs @@ -0,0 +1,12 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; + +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/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/Complete/CompleteTaskResult.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskResult.cs new file mode 100644 index 0000000..71f81e6 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/CompleteTaskResult.cs @@ -0,0 +1,12 @@ +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; + +namespace PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; + +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/Complete/TaskCompleted.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/TaskCompleted.cs new file mode 100644 index 0000000..1f2afab --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Application/Tasks/Complete/TaskCompleted.cs @@ -0,0 +1,5 @@ +using PANiXiDA.TaskApi.Tasks.Domain.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.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..3d8af3f --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +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; + +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.AddScoped(); + 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..0404a19 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/ITaskEventTransport.cs @@ -0,0 +1,9 @@ +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; + +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..0bd4005 --- /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.Complete; +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..fb95625 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Messaging/TaskEventPublisher.cs @@ -0,0 +1,97 @@ +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.Abstractions; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; + +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/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 new file mode 100644 index 0000000..1377c83 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/Migrations/TasksDbContextModelSnapshot.cs @@ -0,0 +1,63 @@ +// +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", (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 new file mode 100644 index 0000000..20177fb --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Infrastructure/Persistence/TasksDbContext.cs @@ -0,0 +1,19 @@ +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"); + 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..26a649a --- /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.Abstractions; +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/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..091c75f --- /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/{task.Id}", 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/Features/Tasks/Create/CreateTaskRequest.cs b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskRequest.cs new file mode 100644 index 0000000..360f6f6 --- /dev/null +++ b/src/Tasks/PANiXiDA.TaskApi.Tasks.Presentation/Features/Tasks/Create/CreateTaskRequest.cs @@ -0,0 +1,5 @@ +using PANiXiDA.TaskApi.Tasks.Domain.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/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/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/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 new file mode 100644 index 0000000..fcc444f --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/ApiFactory.cs @@ -0,0 +1,39 @@ +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 Microsoft.Extensions.Hosting; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Persistence; + +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"); + 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/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 new file mode 100644 index 0000000..4d8b71f --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/PANiXiDA.TaskApi.Tasks.FunctionalTests.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + 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..d7c8189 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/RabbitMqProbe.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using PANiXiDA.TaskApi.Testing; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Complete; +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(RabbitMqTestBroker broker) + { + var factory = new ConnectionFactory + { + HostName = broker.HostName, + Port = broker.Port, + UserName = RabbitMqTestBroker.UserName, + Password = RabbitMqTestBroker.Password, + AutomaticRecoveryEnabled = false + }; + 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); + } + + 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(TestContext.Current.CancellationToken); + await channel.DisposeAsync(); + 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 new file mode 100644 index 0000000..066eea4 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.FunctionalTests/TasksEndpointsTests.cs @@ -0,0 +1,232 @@ +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.Features.Tasks.Complete; +using PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.Create; +using PANiXiDA.TaskApi.Tasks.Presentation.Features.Tasks.GetList; + +namespace PANiXiDA.TaskApi.Tasks.FunctionalTests; + +public sealed class TasksEndpointsTests(FunctionalTestFixture fixture) : IClassFixture, IAsyncLifetime +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + 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(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, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, completedResponse.StatusCode); + 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 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, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Conflict, repeated.StatusCode); + 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(DisplayName = "Создание только с названием задаёт приоритет Medium и начальное состояние")] + public async Task Create_Should_UseDefaults_When_OnlyTitleIsProvided() + { + 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) }, 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); + Assert.NotEqual(Guid.Empty, task.Id); + Assert.NotEqual(default, task.CreatedAt); + } + + [Theory(DisplayName = "Пустое или пробельное название возвращает HTTP 400")] + [InlineData(null)] + [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); + using var client = factory.CreateClient(); + using var response = await client.PostAsJsonAsync("/tasks", new { title }, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact(DisplayName = "Название длиннее 200 символов возвращает HTTP 400")] + public async Task Create_Should_Return400_When_TitleIsTooLong() + { + 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) }, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory(DisplayName = "Неверный JSON или приоритет возвращает HTTP 400")] + [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(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, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact(DisplayName = "Конкурентное завершение одной версии строки возвращает один успех и один конфликт")] + public async Task Complete_Should_ReturnOneConflict_When_BothRequestsReadTheSameRowVersion() + { + 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, 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); + await probe.ReadAsync(task.Id); + await factory.DisposeAsync(); + await probe.AssertNoMessageAsync(); + foreach (var response in responses) + { + response.Dispose(); + } + } + + [Fact(DisplayName = "Недоступность RabbitMQ не мешает сохранению завершённой задачи")] + public async Task Complete_Should_PersistTask_When_RabbitMqIsUnavailable() + { + 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, 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, TestContext.Current.CancellationToken); + Assert.True(saved.IsCompleted); + Assert.NotNull(saved.CompletedAt); + } + + [Fact(DisplayName = "Ошибка сохранения возвращает HTTP 500 без изменения БД и без события")] + public async Task Complete_Should_NotPublish_When_DatabaseSaveFails() + { + 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, 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, TestContext.Current.CancellationToken); + Assert.False(saved.IsCompleted); + Assert.Null(saved.CompletedAt); + await factory.DisposeAsync(); + await probe.AssertNoMessageAsync(); + } + + [Fact(DisplayName = "Удаление убирает задачу из БД, повторное обращение возвращает HTTP 404")] + public async Task Delete_Should_RemoveTaskPermanently_When_TaskExists() + { + 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}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + 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); + using var complete = await client.PutAsync($"/tasks/{task.Id}/complete", null, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, complete.StatusCode); + } + + [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(fixture.Configuration); + using var client = factory.CreateClient(); + 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 }, 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); + return task; + } + + 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); + } + } +} 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..5c44ed2 --- /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.Complete; +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..42722f1 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.IntegrationTests/Infrastructure/Persistence/TasksMigrationTests.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using PANiXiDA.TaskApi.Tasks.Application.Tasks.Abstractions; +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); + } + + [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")] + 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..863fda9 --- /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.Abstractions; +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/Complete/CompleteTaskHandlerTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/Complete/CompleteTaskHandlerTests.cs new file mode 100644 index 0000000..ae4029e --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Application/Tasks/Complete/CompleteTaskHandlerTests.cs @@ -0,0 +1,115 @@ +using NSubstitute; +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.Complete; + +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 HandleAsync_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 CreateHandler().HandleAsync(new CompleteTaskCommand(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 HandleAsync_Should_ReturnNotFound_When_TaskDoesNotExist() + { + var token = TestContext.Current.CancellationToken; + + var result = await CreateHandler().HandleAsync(new CompleteTaskCommand(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 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 CreateHandler().HandleAsync(new CompleteTaskCommand(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 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 CreateHandler().HandleAsync(new CompleteTaskCommand(task.Id), token); + + Assert.Equal(CompletionStatus.Conflict, result.Status); + _publisher.DidNotReceive().Publish(Arg.Any()); + } + + [Fact(DisplayName = "Ошибка сохранения передаётся вызывающему коду без публикации события")] + public async Task HandleAsync_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(() => CreateHandler().HandleAsync(new CompleteTaskCommand(task.Id), token)); + + Assert.Same(failure, actual); + _publisher.DidNotReceive().Publish(Arg.Any()); + } + + [Fact(DisplayName = "Отмена сохранения не приводит к публикации события")] + public async Task HandleAsync_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(() => CreateHandler().HandleAsync(new CompleteTaskCommand(task.Id), cancellation.Token)); + + _publisher.DidNotReceive().Publish(Arg.Any()); + } + + private CompleteTaskHandler CreateHandler() => new(_repository, _publisher, new FixedTimeProvider()); + + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => Now; + } +} 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/Domain/Tasks/TaskItemTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Domain/Tasks/TaskItemTests.cs new file mode 100644 index 0000000..e51a4b4 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Domain/Tasks/TaskItemTests.cs @@ -0,0 +1,69 @@ +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")] + [InlineData("\u00a0\u2003\u2028")] + 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.UnitTests/Infrastructure/Messaging/TaskEventPublisherTests.cs b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Infrastructure/Messaging/TaskEventPublisherTests.cs new file mode 100644 index 0000000..cfa2ef1 --- /dev/null +++ b/tests/Tasks/PANiXiDA.TaskApi.Tasks.UnitTests/Infrastructure/Messaging/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.Complete; +using PANiXiDA.TaskApi.Tasks.Domain.Tasks; +using PANiXiDA.TaskApi.Tasks.Infrastructure.Messaging; + +namespace PANiXiDA.TaskApi.Tasks.UnitTests.Infrastructure.Messaging; + +public sealed class TaskEventPublisherTests +{ + [Fact(DisplayName = "Остановка отправляет начатое и ожидающие события до закрытия соединения")] + 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(TestContext.Current.CancellationToken); + var first = Message(); + var second = Message(); + publisher.Publish(first); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + publisher.Publish(second); + + var stopping = publisher.StopAsync(TestContext.Current.CancellationToken); + Assert.False(stopping.IsCompleted); + Assert.False(transport.Closed); + release.SetResult(); + await stopping.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal([first, second], transport.Messages.ToArray()); + Assert.True(transport.Closed); + } + + [Fact(DisplayName = "Ошибка отправки записывает событие в лог и не мешает следующей публикации")] + 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(TestContext.Current.CancellationToken); + + publisher.Publish(first); + publisher.Publish(second); + 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) + && x.Contains(first.Title, StringComparison.Ordinal)); + Assert.True(transport.Closed); + } + + [Fact(DisplayName = "Переполнение или остановка очереди записывает неотправленное событие в лог")] + 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(TestContext.Current.CancellationToken); + publisher.Publish(Message()); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + publisher.Publish(Message()); + var overflow = Message(); + + publisher.Publish(overflow); + release.SetResult(); + await publisher.StopAsync(TestContext.Current.CancellationToken); + 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(DisplayName = "Остановка отменяет зависшие публикации и закрывает соединение за пять секунд")] + 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(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), TestContext.Current.CancellationToken); + var elapsed = Stopwatch.StartNew(); + await publisher.StopAsync(TestContext.Current.CancellationToken); + + 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.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 @@ + + + + + + + + +