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 @@
+
+
+
+
+
+
+
+
+