diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..7347a7f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,25 @@
+**/.classpath
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/bin
+**/charts
+**/docker-compose*
+**/compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+LICENSE
+README.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1ae5370
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,487 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from `dotnet new gitignore`
+
+# dotenv files
+.env
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+.idea/
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# Mac bundle stuff
+*.dmg
+*.app
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# Vim temporary swap files
+*.swp
+.vscode
+src/ContactBook.Contacts.Infrastructure/obj
+src/ContactBook.Contacts.Infrastructure/obj
diff --git a/ContactBook.sln b/ContactBook.sln
new file mode 100644
index 0000000..4d60026
--- /dev/null
+++ b/ContactBook.sln
@@ -0,0 +1,236 @@
+
+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}") = "ContactBook.Contacts.API", "src\ContactBook.Contacts.API\ContactBook.Contacts.API.csproj", "{3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Contacts.Application", "src\ContactBook.Contacts.Application\ContactBook.Contacts.Application.csproj", "{8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Contacts.Domain", "src\ContactBook.Contacts.Domain\ContactBook.Contacts.Domain.csproj", "{F549A8F4-19BB-477D-9862-9E93AFAB960C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Contacts.Infrastructure", "src\ContactBook.Contacts.Infrastructure\ContactBook.Contacts.Infrastructure.csproj", "{8650BEF1-D11A-4671-B4BC-230E01C37781}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Reports.API", "src\ContactBook.Reports.API\ContactBook.Reports.API.csproj", "{88B82573-996C-4CA5-9D62-BB12F906F610}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Reports.Application", "src\ContactBook.Reports.Application\ContactBook.Reports.Application.csproj", "{9462E5CC-5E5C-4FBA-B139-16173F936358}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Reports.Consumer", "src\ContactBook.Reports.Consumer\ContactBook.Reports.Consumer.csproj", "{9A75A252-10E6-4464-908B-008D50D381AE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Reports.Domain", "src\ContactBook.Reports.Domain\ContactBook.Reports.Domain.csproj", "{4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Reports.Infrastructure", "src\ContactBook.Reports.Infrastructure\ContactBook.Reports.Infrastructure.csproj", "{F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Contacts.API.Tests", "tests\ContactBook.Contacts.API.Tests\ContactBook.Contacts.API.Tests.csproj", "{96B09810-75EC-4F4C-8326-5BD7573C0707}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Domain.Tests", "tests\ContactBook.Domain.Tests\ContactBook.Domain.Tests.csproj", "{315D3B05-2654-41FE-8E8B-A7418D7EE3FE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Infrastructure.Tests", "tests\ContactBook.Infrastructure.Tests\ContactBook.Infrastructure.Tests.csproj", "{12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Reports.API.Tests", "tests\ContactBook.Reports.API.Tests\ContactBook.Reports.API.Tests.csproj", "{07C8DDD7-4115-4D8F-9075-81E9B674541B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactBook.Reports.Consumer.Tests", "tests\ContactBook.Reports.Consumer.Tests\ContactBook.Reports.Consumer.Tests.csproj", "{EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}"
+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
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}.Debug|x64.Build.0 = Debug|Any CPU
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}.Debug|x86.Build.0 = Debug|Any CPU
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}.Release|x64.ActiveCfg = Release|Any CPU
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}.Release|x64.Build.0 = Release|Any CPU
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}.Release|x86.ActiveCfg = Release|Any CPU
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49}.Release|x86.Build.0 = Release|Any CPU
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}.Debug|x64.Build.0 = Debug|Any CPU
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}.Debug|x86.Build.0 = Debug|Any CPU
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}.Release|x64.ActiveCfg = Release|Any CPU
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}.Release|x64.Build.0 = Release|Any CPU
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}.Release|x86.ActiveCfg = Release|Any CPU
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29}.Release|x86.Build.0 = Release|Any CPU
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C}.Debug|x64.Build.0 = Debug|Any CPU
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C}.Debug|x86.Build.0 = Debug|Any CPU
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C}.Release|x64.ActiveCfg = Release|Any CPU
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C}.Release|x64.Build.0 = Release|Any CPU
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C}.Release|x86.ActiveCfg = Release|Any CPU
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C}.Release|x86.Build.0 = Release|Any CPU
+ {8650BEF1-D11A-4671-B4BC-230E01C37781}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8650BEF1-D11A-4671-B4BC-230E01C37781}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8650BEF1-D11A-4671-B4BC-230E01C37781}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8650BEF1-D11A-4671-B4BC-230E01C37781}.Debug|x64.Build.0 = Debug|Any CPU
+ {8650BEF1-D11A-4671-B4BC-230E01C37781}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8650BEF1-D11A-4671-B4BC-230E01C37781}.Debug|x86.Build.0 = Debug|Any CPU
+ {8650BEF1-D11A-4671-B4BC-230E01C37781}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8650BEF1-D11A-4671-B4BC-230E01C37781}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8650BEF1-D11A-4671-B4BC-230E01C37781}.Release|x64.ActiveCfg = Release|Any CPU
+ {8650BEF1-D11A-4671-B4BC-230E01C37781}.Release|x64.Build.0 = Release|Any CPU
+ {8650BEF1-D11A-4671-B4BC-230E01C37781}.Release|x86.ActiveCfg = Release|Any CPU
+ {8650BEF1-D11A-4671-B4BC-230E01C37781}.Release|x86.Build.0 = Release|Any CPU
+ {88B82573-996C-4CA5-9D62-BB12F906F610}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {88B82573-996C-4CA5-9D62-BB12F906F610}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {88B82573-996C-4CA5-9D62-BB12F906F610}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {88B82573-996C-4CA5-9D62-BB12F906F610}.Debug|x64.Build.0 = Debug|Any CPU
+ {88B82573-996C-4CA5-9D62-BB12F906F610}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {88B82573-996C-4CA5-9D62-BB12F906F610}.Debug|x86.Build.0 = Debug|Any CPU
+ {88B82573-996C-4CA5-9D62-BB12F906F610}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {88B82573-996C-4CA5-9D62-BB12F906F610}.Release|Any CPU.Build.0 = Release|Any CPU
+ {88B82573-996C-4CA5-9D62-BB12F906F610}.Release|x64.ActiveCfg = Release|Any CPU
+ {88B82573-996C-4CA5-9D62-BB12F906F610}.Release|x64.Build.0 = Release|Any CPU
+ {88B82573-996C-4CA5-9D62-BB12F906F610}.Release|x86.ActiveCfg = Release|Any CPU
+ {88B82573-996C-4CA5-9D62-BB12F906F610}.Release|x86.Build.0 = Release|Any CPU
+ {9462E5CC-5E5C-4FBA-B139-16173F936358}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9462E5CC-5E5C-4FBA-B139-16173F936358}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9462E5CC-5E5C-4FBA-B139-16173F936358}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9462E5CC-5E5C-4FBA-B139-16173F936358}.Debug|x64.Build.0 = Debug|Any CPU
+ {9462E5CC-5E5C-4FBA-B139-16173F936358}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9462E5CC-5E5C-4FBA-B139-16173F936358}.Debug|x86.Build.0 = Debug|Any CPU
+ {9462E5CC-5E5C-4FBA-B139-16173F936358}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9462E5CC-5E5C-4FBA-B139-16173F936358}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9462E5CC-5E5C-4FBA-B139-16173F936358}.Release|x64.ActiveCfg = Release|Any CPU
+ {9462E5CC-5E5C-4FBA-B139-16173F936358}.Release|x64.Build.0 = Release|Any CPU
+ {9462E5CC-5E5C-4FBA-B139-16173F936358}.Release|x86.ActiveCfg = Release|Any CPU
+ {9462E5CC-5E5C-4FBA-B139-16173F936358}.Release|x86.Build.0 = Release|Any CPU
+ {9A75A252-10E6-4464-908B-008D50D381AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9A75A252-10E6-4464-908B-008D50D381AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9A75A252-10E6-4464-908B-008D50D381AE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9A75A252-10E6-4464-908B-008D50D381AE}.Debug|x64.Build.0 = Debug|Any CPU
+ {9A75A252-10E6-4464-908B-008D50D381AE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9A75A252-10E6-4464-908B-008D50D381AE}.Debug|x86.Build.0 = Debug|Any CPU
+ {9A75A252-10E6-4464-908B-008D50D381AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9A75A252-10E6-4464-908B-008D50D381AE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9A75A252-10E6-4464-908B-008D50D381AE}.Release|x64.ActiveCfg = Release|Any CPU
+ {9A75A252-10E6-4464-908B-008D50D381AE}.Release|x64.Build.0 = Release|Any CPU
+ {9A75A252-10E6-4464-908B-008D50D381AE}.Release|x86.ActiveCfg = Release|Any CPU
+ {9A75A252-10E6-4464-908B-008D50D381AE}.Release|x86.Build.0 = Release|Any CPU
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}.Debug|x64.Build.0 = Debug|Any CPU
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}.Debug|x86.Build.0 = Debug|Any CPU
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}.Release|x64.ActiveCfg = Release|Any CPU
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}.Release|x64.Build.0 = Release|Any CPU
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}.Release|x86.ActiveCfg = Release|Any CPU
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F}.Release|x86.Build.0 = Release|Any CPU
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}.Debug|x64.Build.0 = Debug|Any CPU
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}.Debug|x86.Build.0 = Debug|Any CPU
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}.Release|x64.ActiveCfg = Release|Any CPU
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}.Release|x64.Build.0 = Release|Any CPU
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}.Release|x86.ActiveCfg = Release|Any CPU
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7}.Release|x86.Build.0 = Release|Any CPU
+ {96B09810-75EC-4F4C-8326-5BD7573C0707}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {96B09810-75EC-4F4C-8326-5BD7573C0707}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {96B09810-75EC-4F4C-8326-5BD7573C0707}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {96B09810-75EC-4F4C-8326-5BD7573C0707}.Debug|x64.Build.0 = Debug|Any CPU
+ {96B09810-75EC-4F4C-8326-5BD7573C0707}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {96B09810-75EC-4F4C-8326-5BD7573C0707}.Debug|x86.Build.0 = Debug|Any CPU
+ {96B09810-75EC-4F4C-8326-5BD7573C0707}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {96B09810-75EC-4F4C-8326-5BD7573C0707}.Release|Any CPU.Build.0 = Release|Any CPU
+ {96B09810-75EC-4F4C-8326-5BD7573C0707}.Release|x64.ActiveCfg = Release|Any CPU
+ {96B09810-75EC-4F4C-8326-5BD7573C0707}.Release|x64.Build.0 = Release|Any CPU
+ {96B09810-75EC-4F4C-8326-5BD7573C0707}.Release|x86.ActiveCfg = Release|Any CPU
+ {96B09810-75EC-4F4C-8326-5BD7573C0707}.Release|x86.Build.0 = Release|Any CPU
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE}.Debug|x64.Build.0 = Debug|Any CPU
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE}.Debug|x86.Build.0 = Debug|Any CPU
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE}.Release|x64.ActiveCfg = Release|Any CPU
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE}.Release|x64.Build.0 = Release|Any CPU
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE}.Release|x86.ActiveCfg = Release|Any CPU
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE}.Release|x86.Build.0 = Release|Any CPU
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}.Debug|x64.Build.0 = Debug|Any CPU
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}.Debug|x86.Build.0 = Debug|Any CPU
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}.Release|x64.ActiveCfg = Release|Any CPU
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}.Release|x64.Build.0 = Release|Any CPU
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}.Release|x86.ActiveCfg = Release|Any CPU
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E}.Release|x86.Build.0 = Release|Any CPU
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B}.Debug|x64.Build.0 = Debug|Any CPU
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B}.Debug|x86.Build.0 = Debug|Any CPU
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B}.Release|x64.ActiveCfg = Release|Any CPU
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B}.Release|x64.Build.0 = Release|Any CPU
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B}.Release|x86.ActiveCfg = Release|Any CPU
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B}.Release|x86.Build.0 = Release|Any CPU
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}.Debug|x64.Build.0 = Debug|Any CPU
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}.Debug|x86.Build.0 = Debug|Any CPU
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}.Release|x64.ActiveCfg = Release|Any CPU
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}.Release|x64.Build.0 = Release|Any CPU
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}.Release|x86.ActiveCfg = Release|Any CPU
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {3B63BC8F-3F49-4E27-B1A0-4B59C10D4C49} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {8910805D-9A6E-4A73-8A4B-CE0DC7CB1C29} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {F549A8F4-19BB-477D-9862-9E93AFAB960C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {8650BEF1-D11A-4671-B4BC-230E01C37781} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {88B82573-996C-4CA5-9D62-BB12F906F610} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {9462E5CC-5E5C-4FBA-B139-16173F936358} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {9A75A252-10E6-4464-908B-008D50D381AE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {4C2C8CEB-B0E9-4B7D-844E-72091F13E03F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {F04C9DAD-E1FA-4CD2-BCFC-048C257E75F7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {96B09810-75EC-4F4C-8326-5BD7573C0707} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {315D3B05-2654-41FE-8E8B-A7418D7EE3FE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {12584AA4-EFAB-47A6-83C7-A77A8A5EEE9E} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {07C8DDD7-4115-4D8F-9075-81E9B674541B} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {EFAA7EC3-1580-4DCB-8FF2-98B2B2D41D2F} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ EndGlobalSection
+EndGlobal
diff --git a/README.md b/README.md
index a3a8f7e..f261207 100644
--- a/README.md
+++ b/README.md
@@ -1,90 +1,119 @@
-# Backend (.NET) Assessment
+# ContactBook Çözümü
-## Merhaba
+.NET 9 ile geliştirilmiş, mikroservis mimarisi, olay odaklı iletişim ve kapsamlı raporlama özellikleri içeren kapsamlı bir iletişim yönetim sistemi.
+
+## 🏗️ Mimari Genel Bakış
-Bu değerlendirme işe başvuru sürecindeki adaylar için hazırlanmış olup, katılacak kimselerin yaklaşım ve yetkinliklerini değerlendirmede bizlere yardımcı olmak için tasarlanmıştır.
+Bu çözüm, Clean Architecture (Temiz Mimari) prensiplerini takip eder ve aşağıdaki bileşenlerle bir mikroservis yapısını uygular:
-Değerlendirme dahilinde; belirtilen süre içerisinde aşağıda kapsamı ve detayları belirlenmiş projeyi tamamlamanızı beklemekteyiz. Dikkat edebileceğiniz bir diğer husus ise, bizlerin doğru bir değerlendirme yapmamıza yardımcı olacak şekilde iletebileceğiniz en iyi çalışmayı bizlere teslim ediyor olmanız.
+### Temel Servisler
+- **ContactBook.Contacts.API** - Kişi yönetimi için RESTful API
+- **ContactBook.Reports.API** - Rapor oluşturma ve alma için RESTful API
+- **ContactBook.Reports.Consumer** - Rapor isteklerini işleyen arka plan servisi
+### Mimari Katmanlar
+- **Domain Katmanı** - İş varlıkları ve iş kuralları
+- **Application Katmanı** - Arayüzler, DTO’lar ve uygulama servisleri
+- **Infrastructure Katmanı** - Veri erişimi, dış servisler ve kalıcılık
+- **API Katmanı** - Controller’lar, middleware ve API yapılandırması
-### Senaryo
+## 📁 Proje Yapısı
-Birbirleri ile haberleşen minimum iki microservice'in olduğu bir yapı tasarlayarak, basit bir telefon rehberi uygulaması oluşturulması sağlanacaktır.
+(proje klasör yapısı görseli korunmuştur)
-Beklenen işlevler:
-- Rehberde kişi oluşturma
-- Rehberde kişi kaldırma
-- Rehberdeki kişiye iletişim bilgisi ekleme
-- Rehberdeki kişiden iletişim bilgisi kaldırma
-- Rehberdeki kişilerin listelenmesi
-- Rehberdeki bir kişiyle ilgili iletişim bilgilerinin de yer aldığı detay bilgilerin getirilmesi
-- Rehberdeki kişilerin bulundukları konuma göre istatistiklerini çıkartan bir rapor talebi
-- Sistemin oluşturduğu raporların listelenmesi
-- Sistemin oluşturduğu bir raporun detay bilgilerinin getirilmesi
+## 🚀 Özellikler
+### Kişi Yönetimi
+- ✅ Kişi oluşturma, okuma, güncelleme ve silme
+- ✅ İletişim bilgileri (e-posta, telefon, konum) yönetimi
+- ✅ Varsayılan iletişim bilgisi belirleme
+- ✅ Kapsamlı doğrulama ve hata yönetimi
+- ✅ Tüm işlemler için iptal destekli (CancellationToken)
-### Teknik Tasarım
+### Raporlama Sistemi
+- ✅ Asenkron rapor oluşturma
+- ✅ Konuma göre iletişim istatistikleri
+- ✅ Konuma göre telefon numarası dağılımı
+- ✅ Apache Kafka ile olay tabanlı mimari
+- ✅ Rapor durumu takibi (İstendi → İşleniyor → Tamamlandı)
-**Kişiler:**
-Sistemde teorik anlamda sınırsız sayıda kişi kaydı yapılabilecektir. Her kişiye bağlı iletişim bilgileri de yine sınırsız bir biçimde eklenebilmelidir.
+### Teknik Özellikler
+- ✅ Clean Architecture ve DDD prensipleri
+- ✅ Entity Framework Core & PostgreSQL
+- ✅ Apache Kafka mesajlaşma sistemi
+- ✅ Kapsamlı hata yönetimi ve loglama
+- ✅ İstek iptal desteği
+- ✅ Sağlık kontrolleri
+- ✅ OpenAPI/Swagger belgeleri
+- ✅ Docker ile konteynerleştirme
-Karşılanması beklenen veri yapısındaki gerekli alanlar aşağıdaki gibidir:
+## 🛠️ Teknoloji Yığını
-- UUID
-- Ad
-- Soyad
-- Firma
-- İletişim Bilgisi
- - Bilgi Tipi: Telefon Numarası, E-mail Adresi, Konum
- - Bilgi İçeriği
+- **Framework**: .NET 9
+- **Veritabanı**: PostgreSQL
+- **Mesaj Aracısı**: Apache Kafka
+- **ORM**: Entity Framework Core
+- **API Belgeleme**: OpenAPI/Swagger
+- **Test**: xUnit, Moq, FluentAssertions
+- **Konteyner**: Docker & Docker Compose
-**Rapor:**
-Rapor talepleri asenkron çalışacaktır. Kullanıcı bir rapor talep ettiğinde, sistem arkaplanda bu çalışmayı darboğaz yaratmadan sıralı bir biçimde ele alacak; rapor tamamlandığında ise kullanıcının "raporların listelendiği" endpoint üzerinden raporun durumunu "tamamlandı" olarak gözlemleyebilmesi gerekmektedir.
+## 📋 Gereksinimler
-Rapor basitçe aşağıdaki bilgileri içerecektir:
+- .NET 9 SDK
+- Docker ve Docker Compose
+- PostgreSQL (ya da Docker ile)
+- Apache Kafka (ya da Docker ile)
-- Konum Bilgisi
-- O konumda yer alan rehbere kayıtlı kişi sayısı
-- O konumda yer alan rehbere kayıtlı telefon numarası sayısı
+## 🏃♂️ Başlangıç Adımları
-Veri yapısı olarak da:
+1. Repoyu klonlayın
+2. Altyapı servislerini Docker Compose ile başlatın
+3. `appsettings.Development.json` dosyalarında bağlantı bilgilerini güncelleyin
+4. Veritabanı göçlerini (migrations) uygulayın
+5. Servisleri başlatın (Docker veya manuel)
+6. API’lara şu adreslerden erişin:
+ - **Contacts API**: http://localhost:5115
+ - **Swagger**: http://localhost:5115/swagger
+ - **Reports API**: http://localhost:5263
+ - **Swagger**: http://localhost:5263/swagger
-- UUID
-- Raporun Talep Edildiği Tarih
-- Rapor Durumu (Hazırlanıyor, Tamamlandı)
+## 📖 API Kullanım Örnekleri
+- Kişi oluştur, oku, güncelle, sil
+- Rapor isteği gönder
+- Tüm raporları veya belirli bir raporu getir
-**NOT:** Değerlendirme ile ilgili beklentiler için *Teknik Beklentiler* bölümünü dikkatli okuyunuz.
+## 🧪 Testleri Çalıştırma
+- Tüm testleri çalıştır: `dotnet test`
+- Kapsama raporu üret: `dotnet test --collect:"XPlat Code Coverage"`
+- Belirli bir test projesini çalıştır
+- Kapsama raporu görselleştirme: `reportgenerator`
-### Teknik Beklentiler
+## 🏗️ Geliştirme Süreci
-- Kullanılacak Teknolojiler:
- - .NET Core
- - Git
- - Postgres veya MongoDB
- - Kafka v.b. Message Queue sistemi
+Yeni iletişim türü veya rapor tipi eklemek için:
+- Domain ve Application katmanlarını güncelle
+- EF Migration oluştur ve uygula
+- Gerekirse Controller’ı düzenle
-- Kısıtlamalar ve Gereksinimler:
- - Projenin sık commitlerle Git üzerinde geliştirilmesi
- - Git üzerinde master, development branchleri ve sürüm taglemelerinin kullanımı
- - Minimum %60 unit testing code coverage
- - Projenin veritabanını oluşturacak migration yapısının oluşturulmuş olması
- - Projenin nasıl çalıştırılacağına dair README.md dokümantasyonu
- - Servislerin HTTP üzerinden REST veya GraphQL protokolleri üzerinden iletişimi sağlanmalı
- - Rapor kısmındaki asenkron yapının sağlanması için message queue gibi sistemler kullanılmalıdır
+## 🔧 Yapılandırma
-
-### Çalışmanın Tamamlanması
-
-Çalışma tamamlandığında bu codebase'i kişisel git repository'sine aktarmanız, çalışma bitiminde de bu repository adresiyle paylaşmanız beklenecektir.
-
-
-## Sorularınız
-
-Değerlendirmelerle ilgili sorularınızı [github@setur.com.tr](mailto:github@setur.com.tr) adresine iletebilirsiniz.
-
-
-### Lisans
-
-[Apache 2.0](LICENSE) ile lisanslanmıştır.
+Servisler çevresel değişkenlerle yapılandırılabilir:
+- PostgreSQL bağlantı bilgileri
+- Kafka sunucu adresi
+- Ortam seçimi (Development/Production)
+
+## 🔍 İzleme & Sağlık Kontrolleri
+
+- `/health` endpoint’leri
+- Yapılandırılmış loglama
+- Performans metrikleri
+- Kafka mesaj işleme günlükleri
+
+## 🚨 Hata Yönetimi
+
+- Global hata middleware’i (`ErrorHandlingMiddleware`)
+- İstek iptali middleware’i
+- Doğrulama & iş kuralları kontrolü
+- Uygun HTTP durum kodları
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..fb4f04b
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,151 @@
+services:
+ kafka-ui:
+ image: provectuslabs/kafka-ui:latest
+ container_name: kafka-ui
+ ports:
+ - "8080:8080"
+ depends_on:
+ kafka:
+ condition: service_healthy
+ environment:
+ KAFKA_CLUSTERS_0_NAME: local
+ KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
+
+ postgres:
+ image: postgres:17-alpine
+ environment:
+ - POSTGRES_USER=postgres
+ - POSTGRES_PASSWORD=postgres
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: [ "CMD-SHELL", "pg_isready -U postgres" ]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ contacts-api:
+ build:
+ context: .
+ dockerfile: src/ContactBook.Contacts.API/Dockerfile
+ ports:
+ - "5001:80"
+ - "5002:443"
+ environment:
+ - ConnectionStrings__contactsDb=Host=postgres;Database=ContactBook;Username=postgres;Password=postgres
+ - ASPNETCORE_ENVIRONMENT=Development
+ - ASPNETCORE_URLS=http://+:80;https://+:443
+ - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/localhost.pfx
+ - ASPNETCORE_Kestrel__Certificates__Default__Password=1
+ volumes:
+ - ~/.aspnet/https:/https:ro
+ depends_on:
+ postgres:
+ condition: service_healthy
+
+ reports-api:
+ build:
+ context: .
+ dockerfile: src/ContactBook.Reports.API/Dockerfile
+ ports:
+ - "5003:80"
+ - "5004:443"
+ environment:
+ - ConnectionStrings__reportsDb=Host=postgres;Database=ContactBookReports;Username=postgres;Password=postgres
+ - ASPNETCORE_ENVIRONMENT=Development
+ - ASPNETCORE_URLS=http://+:80;https://+:443
+ - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/localhost.pfx
+ - ASPNETCORE_Kestrel__Certificates__Default__Password=1
+ - Kafka__BootstrapServers=kafka:29092
+ volumes:
+ - ~/.aspnet/https:/https:ro
+ depends_on:
+ postgres:
+ condition: service_healthy
+ kafka:
+ condition: service_healthy
+
+ reports-consumer:
+ build:
+ context: .
+ dockerfile: src/ContactBook.Reports.Consumer/Dockerfile
+ environment:
+ - DOTNET_ENVIRONMENT=Development
+ - ConnectionStrings__contactsDb=Host=postgres;Database=ContactBook;Username=postgres;Password=postgres
+ - ConnectionStrings__reportsDb=Host=postgres;Database=ContactBookReports;Username=postgres;Password=postgres
+ - Kafka__BootstrapServers=kafka:29092
+ - Kafka__ConsumerGroupId=contactbook-report-worker
+ - Kafka__ReportRequestTopic=report-requests
+ depends_on:
+ postgres:
+ condition: service_healthy
+ kafka:
+ condition: service_healthy
+ restart: on-failure
+
+ kafka:
+ image: confluentinc/cp-kafka:latest
+ container_name: kafka
+ ports:
+ - "9092:9092"
+ - "9101:9101" # for JMX or monitoring (optional)
+ environment:
+ KAFKA_PROCESS_ROLES: broker,controller
+ KAFKA_NODE_ID: 1
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:29093
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
+ KAFKA_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092,CONTROLLER://kafka:29093
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT
+ KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
+ KAFKA_LOG_DIRS: /tmp/kraft-combined-logs
+ CLUSTER_ID: "local-kafka-cluster-id"
+ KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
+ KAFKA_OFFSET_REPLICATION_FACTOR: 1
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
+ # Corrected coordinator settings
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
+ KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS: 50
+ KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
+ # Force creation of consumer offsets topic
+ KAFKA_OFFSETS_TOPIC_SEGMENT_BYTES: 104857600
+ KAFKA_OFFSETS_RETENTION_MINUTES: 10080
+ healthcheck:
+ test: [ "CMD-SHELL", "kafka-topics --bootstrap-server kafka:29092 --list && kafka-consumer-groups --bootstrap-server kafka:29092 --list || exit 1" ]
+ interval: 10s
+ timeout: 10s
+ retries: 5
+ start_period: 30s
+
+ kafka-init:
+ image: bitnami/kafka:latest
+ depends_on:
+ kafka:
+ condition: service_healthy
+ entrypoint: sh
+ command:
+ - -c
+ - >
+ echo 'Waiting for Kafka to be fully ready...';
+ sleep 10;
+ echo 'Checking coordinator availability...';
+ until kafka-consumer-groups.sh --bootstrap-server kafka:29092 --list > /dev/null 2>&1; do
+ echo 'Coordinator not ready, waiting...';
+ sleep 3;
+ done;
+ echo 'Coordinator is ready!';
+ if ! kafka-topics.sh --bootstrap-server kafka:29092 --list | grep -q '^report-requests$'; then
+ echo 'Creating topic: report-requests';
+ kafka-topics.sh --create --if-not-exists --bootstrap-server kafka:29092 --replication-factor 1 --partitions 1 --topic report-requests;
+ else
+ echo 'Topic report-requests already exists.';
+ fi;
+ echo 'Ensuring __consumer_offsets topic exists...';
+ kafka-topics.sh --create --if-not-exists --bootstrap-server kafka:29092 --replication-factor 1 --partitions 50 --topic __consumer_offsets || echo 'Offsets topic already exists or will be auto-created';
+
+ restart: "no"
+
+volumes:
+ postgres_data:
\ No newline at end of file
diff --git a/src/ContactBook.Contacts.API/ContactBook.Contacts.API.csproj b/src/ContactBook.Contacts.API/ContactBook.Contacts.API.csproj
new file mode 100644
index 0000000..467a017
--- /dev/null
+++ b/src/ContactBook.Contacts.API/ContactBook.Contacts.API.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net9.0
+ enable
+ enable
+ true
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ContactBook.Contacts.API/ContactBook.Contacts.API.http b/src/ContactBook.Contacts.API/ContactBook.Contacts.API.http
new file mode 100644
index 0000000..1f18ac4
--- /dev/null
+++ b/src/ContactBook.Contacts.API/ContactBook.Contacts.API.http
@@ -0,0 +1,6 @@
+@ContactBook.Contacts.API_HostAddress = http://localhost:5115
+
+GET {{ContactBook.Contacts.API_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/src/ContactBook.Contacts.API/Controllers/ContactsController.cs b/src/ContactBook.Contacts.API/Controllers/ContactsController.cs
new file mode 100644
index 0000000..f69bf5f
--- /dev/null
+++ b/src/ContactBook.Contacts.API/Controllers/ContactsController.cs
@@ -0,0 +1,246 @@
+using ContactBook.Contacts.Application.Interfaces;
+using ContactBook.Contacts.Application.DTOs;
+using Microsoft.AspNetCore.Mvc;
+using ContactBook.Contects.Application.DTOs;
+using ContactBook.Contacts.Domain.Entities;
+
+namespace ContactBook.Contacts.API.Controllers;
+
+[ApiController]
+[Route("[controller]")]
+public class ContactsController : ControllerBase
+{
+ private readonly IContactsUnitOfWork _unitOfWork;
+ private readonly ILogger _logger;
+
+ public ContactsController(IContactsUnitOfWork unitOfWork,
+ ILogger logger)
+ {
+
+ _unitOfWork = unitOfWork;
+ _logger = logger;
+ }
+
+ [HttpGet]
+ public async Task GetContacts(CancellationToken cancellationToken)
+ {
+ try
+ {
+ var contactList = await _unitOfWork.ContactRepository.GetContactsAsync(cancellationToken);
+ var contactsResult = contactList.Select(c => new ContactListDto(
+ c.Id,
+ $"{c.FirstName} {c.LastName}"
+ ));
+
+ return Results.Ok(contactsResult);
+ }
+ catch (OperationCanceledException cancelledEx)
+ {
+ _logger.LogWarning("Operation was cancelled: {Message}", cancelledEx.Message);
+ return Results.StatusCode(499);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex.Message);
+ return Results.InternalServerError("An error occurred while retrieving contacts.");
+ }
+
+ }
+
+ [HttpGet("{id}")]
+ public async Task GetContactById(Guid id, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var contact = await _unitOfWork.ContactRepository.GetContactByIdAsync(id);
+ if (contact == null)
+ {
+ return Results.NotFound($"Contact with ID {id} not found.");
+ }
+ var contactInfoList = await _unitOfWork.ContactInfoRepository.GetContactInfoByContactIdAsync(id, cancellationToken);
+ var contactDto = new ContactDto(contact.Id,
+ $"{contact.FirstName} {contact.LastName}",
+ contactInfoList.Select(ci => new ContactInfoDto(ci.Id, Enum.GetName(ci.InfoType)!, ci.Value, ci.IsDefault)).ToList()
+ );
+
+ return Results.Ok(contactDto);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex.Message);
+ return Results.InternalServerError("An error occurred while retrieving the contact.");
+ }
+ }
+
+ [HttpPost]
+ public async Task CreateContact(CreateContactDto createContactDto, CancellationToken cancellationToken)
+ {
+ try
+ {
+
+ var contact = new Contact()
+ {
+ Id = Guid.NewGuid(),
+ FirstName = createContactDto.FirstName,
+ LastName = createContactDto.LastName
+ };
+
+ await _unitOfWork.ContactRepository.CreateContactAsync(contact, cancellationToken);
+
+ if (createContactDto.ContactInfo != null && createContactDto.ContactInfo.Any())
+ {
+ foreach (var contactInfo in createContactDto.ContactInfo)
+ {
+ await _unitOfWork.ContactInfoRepository.AddContactInfoAsync(new ContactInfo
+ {
+ Id = Guid.NewGuid(),
+ InfoType = contactInfo.ContactInfoType,
+ Value = contactInfo.Value,
+ IsDefault = contactInfo.IsDefault,
+ ContactId = contact.Id
+ }, cancellationToken);
+ }
+ }
+
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+ return Results.Created();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex.Message);
+ return Results.InternalServerError("An error occurred while creating the contact.");
+ }
+ }
+
+ [HttpPut("{id}")]
+ public async Task UpdateContact(Guid id, UpdateContactDto updateContactDto, CancellationToken cancellationToken)
+ {
+
+ var existingContact = await _unitOfWork.ContactRepository.GetContactByIdAsync(id, cancellationToken);
+
+ if (existingContact == null)
+ {
+ return Results.NotFound($"Contact with ID {id} not found.");
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+ existingContact.FirstName = updateContactDto.FirstName;
+ existingContact.LastName = updateContactDto.LastName;
+
+ _unitOfWork.ContactRepository.UpdateContact(existingContact);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+ return Results.Ok();
+
+ }
+
+ [HttpDelete("{id}")]
+ public async Task DeleteContact(Guid id, CancellationToken cancellationToken)
+ {
+
+ var contact = await _unitOfWork.ContactRepository.GetContactByIdAsync(id);
+ if (contact == null)
+ {
+ return Results.NotFound($"Contact with ID {id} not found.");
+ }
+
+ await _unitOfWork.ContactRepository.DeleteContactAsync(id);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Results.NoContent();
+ }
+
+ [HttpGet("{id}/info")]
+ public async Task GetContactInfo(Guid id, CancellationToken cancellationToken)
+ {
+ var contact = await _unitOfWork.ContactRepository.GetContactByIdAsync(id, cancellationToken);
+
+ if (contact == null)
+ {
+ return Results.NotFound($"Contact with ID {id} not found.");
+ }
+
+ var contactInfoList = await _unitOfWork.ContactInfoRepository.GetContactInfoByContactIdAsync(id, cancellationToken);
+
+ var contactInfoDtoList = contactInfoList.Select(ci => new ContactInfoDto(
+ ci.Id,
+ Enum.GetName(ci.InfoType)!,
+ ci.Value,
+ ci.IsDefault
+ )).ToList();
+
+ return Results.Ok(contactInfoDtoList);
+ }
+
+ [HttpPost("{id}/info")]
+ public async Task AddContactInfo(Guid id, CreateContactInfoDto createContactInfoDto, CancellationToken cancellationToken)
+ {
+
+ var contact = await _unitOfWork.ContactRepository.GetContactByIdAsync(id, cancellationToken);
+
+ if (contact == null)
+ {
+ return Results.NotFound($"Contact with ID {id} not found.");
+ }
+
+ await _unitOfWork.ContactInfoRepository.AddContactInfoAsync(new ContactInfo
+ {
+ Id = Guid.NewGuid(),
+ InfoType = createContactInfoDto.ContactInfoType,
+ Value = createContactInfoDto.Value,
+ IsDefault = createContactInfoDto.IsDefault,
+ ContactId = id
+ }, cancellationToken);
+
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Results.NoContent();
+
+ }
+
+ [HttpPut("{id}/info")]
+ public async Task UpdateContactInfo(Guid id, ContactInfoDto updateContactInfoDto, CancellationToken cancellationToken)
+ {
+
+ var contact = await _unitOfWork.ContactRepository.GetContactByIdAsync(id, cancellationToken);
+
+ if (contact == null)
+ {
+ return Results.NotFound($"Contact not found.");
+ }
+
+ var contactInfo = await _unitOfWork.ContactInfoRepository.GetContactInfoAsync(updateContactInfoDto.Id, cancellationToken);
+
+ if (contactInfo == null)
+ {
+ return Results.NotFound($"Contact information not found.");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ contactInfo.InfoType = updateContactInfoDto.Type switch
+ {
+ "Location" => ContactInfoType.Location,
+ "Phone" => ContactInfoType.Phone,
+ "Email" => ContactInfoType.Email,
+ _ => throw new ArgumentException("Invalid contact info type", nameof(updateContactInfoDto.Type))
+ };
+ contactInfo.Value = updateContactInfoDto.Value;
+ contactInfo.IsDefault = updateContactInfoDto.IsDefault;
+
+ _unitOfWork.ContactInfoRepository.UpdateContactInfo(contactInfo);
+
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Results.NoContent();
+
+ }
+
+ [HttpDelete("contactInfo/{infoId}")]
+ public async Task DeleteContactInfo(Guid infoId, CancellationToken cancellationToken)
+ {
+ await _unitOfWork.ContactInfoRepository.DeleteContactInfoAsync(infoId, cancellationToken);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Results.NoContent();
+ }
+}
+
diff --git a/src/ContactBook.Contacts.API/Dockerfile b/src/ContactBook.Contacts.API/Dockerfile
new file mode 100644
index 0000000..f87d33d
--- /dev/null
+++ b/src/ContactBook.Contacts.API/Dockerfile
@@ -0,0 +1,23 @@
+FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
+WORKDIR /app
+EXPOSE 80
+EXPOSE 443
+
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+WORKDIR /src
+COPY ["src/ContactBook.Contacts.API/ContactBook.Contacts.API.csproj", "ContactBook.Contacts.API/"]
+COPY ["src/ContactBook.Contacts.Application/ContactBook.Contacts.Application.csproj", "ContactBook.Contacts.Application/"]
+COPY ["src/ContactBook.Contacts.Domain/ContactBook.Contacts.Domain.csproj", "ContactBook.Contacts.Domain/"]
+COPY ["src/ContactBook.Contacts.Infrastructure/ContactBook.Contacts.Infrastructure.csproj", "ContactBook.Contacts.Infrastructure/"]
+RUN dotnet restore "ContactBook.Contacts.API/ContactBook.Contacts.API.csproj"
+COPY src/ .
+WORKDIR "/src/ContactBook.Contacts.API"
+RUN dotnet build "ContactBook.Contacts.API.csproj" -c Release -o /app/build
+
+FROM build AS publish
+RUN dotnet publish "ContactBook.Contacts.API.csproj" -c Release -o /app/publish
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "ContactBook.Contacts.API.dll"]
diff --git a/src/ContactBook.Contacts.API/Middleware/ErrorHandlerMiddleware.cs b/src/ContactBook.Contacts.API/Middleware/ErrorHandlerMiddleware.cs
new file mode 100644
index 0000000..14405aa
--- /dev/null
+++ b/src/ContactBook.Contacts.API/Middleware/ErrorHandlerMiddleware.cs
@@ -0,0 +1,49 @@
+using System.Text.Json;
+
+namespace ContactBook.Contacts.API.Middleware;
+
+public class ErrorHandlingMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ public ErrorHandlingMiddleware(RequestDelegate next, ILogger logger)
+ {
+ _next = next;
+ _logger = logger;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await _next(context);
+ }
+ catch (OperationCanceledException)
+ {
+ // Let RequestCancellationMiddleware handle this
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "An unhandled exception occurred while processing the request. Path: {Path}", context.Request.Path);
+
+ if (!context.Response.HasStarted)
+ {
+ context.Response.Clear();
+ context.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ context.Response.ContentType = "application/json";
+
+ var errorResponse = new
+ {
+ status = 500,
+ message = "An internal server error occurred. Please try again later.",
+ traceId = context.TraceIdentifier
+ };
+
+ var json = JsonSerializer.Serialize(errorResponse);
+ await context.Response.WriteAsync(json);
+ }
+ }
+ }
+}
diff --git a/src/ContactBook.Contacts.API/Middleware/RequestCancellationMiddleware.cs b/src/ContactBook.Contacts.API/Middleware/RequestCancellationMiddleware.cs
new file mode 100644
index 0000000..93cfa43
--- /dev/null
+++ b/src/ContactBook.Contacts.API/Middleware/RequestCancellationMiddleware.cs
@@ -0,0 +1,32 @@
+namespace ContactBook.Contacts.API.Middleware;
+
+public class RequestCancellationMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ public RequestCancellationMiddleware(RequestDelegate next, ILogger logger)
+ {
+ _next = next;
+ _logger = logger;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await _next(context);
+ }
+ catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
+ {
+ _logger.LogWarning("Request was cancelled by the client. Path: {Path}", context.Request.Path);
+
+ if (!context.Response.HasStarted)
+ {
+ context.Response.Clear();
+ context.Response.StatusCode = 499; // Client Closed Request (unofficial)
+ await context.Response.WriteAsync("Request cancelled by client");
+ }
+ }
+ }
+}
diff --git a/src/ContactBook.Contacts.API/Program.cs b/src/ContactBook.Contacts.API/Program.cs
new file mode 100644
index 0000000..b95cb39
--- /dev/null
+++ b/src/ContactBook.Contacts.API/Program.cs
@@ -0,0 +1,58 @@
+using ContactBook.Contacts.API.Middleware;
+using ContactBook.Contacts.Application.Interfaces;
+using ContactBook.Contacts.Infrastructure.Persistence;
+using ContactBook.Contacts.Infrastructure.Repository;
+using Microsoft.EntityFrameworkCore;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddOpenApi();
+
+builder.Services.AddDbContext(options =>
+ options.UseNpgsql(builder.Configuration.GetConnectionString("contactsDb")));
+builder.Services.AddScoped();
+builder.Services.AddHealthChecks();
+builder.Services.AddControllers();
+
+var app = builder.Build();
+
+app.Use(async (context, next) =>
+{
+ var cancellationToken = context.RequestAborted;
+
+ try
+ {
+ // Simulate artificial latency (e.g., 2 seconds)
+ await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // If request was cancelled during the delay, let RequestCancellationMiddleware handle it
+ return;
+ }
+
+ await next(); // Continue to next middleware
+});
+app.UseMiddleware();
+app.UseMiddleware();
+app.UseHttpsRedirection();
+app.MapHealthChecks("/health");
+app.MapControllers();
+
+if (app.Environment.IsDevelopment())
+{
+ app.MapOpenApi();
+ app.UseSwaggerUI(options =>
+ {
+ options.SwaggerEndpoint("/openapi/v1.json", "ContactBook Contacts API V1");
+ });
+
+ using (var scope = app.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Database.Migrate(); // Ensures DB and schema exist
+ }
+}
+
+app.Run();
+
+public partial class Program { } // This is required for the integration tests to work correctly
\ No newline at end of file
diff --git a/src/ContactBook.Contacts.API/Properties/launchSettings.json b/src/ContactBook.Contacts.API/Properties/launchSettings.json
new file mode 100644
index 0000000..aaaa5fa
--- /dev/null
+++ b/src/ContactBook.Contacts.API/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5115",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7179;http://localhost:5115",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/ContactBook.Contacts.API/appsettings.Development.json b/src/ContactBook.Contacts.API/appsettings.Development.json
new file mode 100644
index 0000000..f6c9c95
--- /dev/null
+++ b/src/ContactBook.Contacts.API/appsettings.Development.json
@@ -0,0 +1,11 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "ConnectionStrings": {
+ "contactsDb": "Host=postgres;Port=5435;Database=ContactsBook;Username=postgres;Password=postgres"
+ }
+}
\ No newline at end of file
diff --git a/src/ContactBook.Contacts.API/appsettings.json b/src/ContactBook.Contacts.API/appsettings.json
new file mode 100644
index 0000000..149cb09
--- /dev/null
+++ b/src/ContactBook.Contacts.API/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
\ No newline at end of file
diff --git a/src/ContactBook.Contacts.Application/ContactBook.Contacts.Application.csproj b/src/ContactBook.Contacts.Application/ContactBook.Contacts.Application.csproj
new file mode 100644
index 0000000..9fd91ab
--- /dev/null
+++ b/src/ContactBook.Contacts.Application/ContactBook.Contacts.Application.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/src/ContactBook.Contacts.Application/DTOs/ContactDto.cs b/src/ContactBook.Contacts.Application/DTOs/ContactDto.cs
new file mode 100644
index 0000000..93c3136
--- /dev/null
+++ b/src/ContactBook.Contacts.Application/DTOs/ContactDto.cs
@@ -0,0 +1,3 @@
+namespace ContactBook.Contacts.Application.DTOs;
+
+public record ContactDto(Guid Id, string FullName, IList? ContactInfo);
diff --git a/src/ContactBook.Contacts.Application/DTOs/ContactInfoDto.cs b/src/ContactBook.Contacts.Application/DTOs/ContactInfoDto.cs
new file mode 100644
index 0000000..7231786
--- /dev/null
+++ b/src/ContactBook.Contacts.Application/DTOs/ContactInfoDto.cs
@@ -0,0 +1,3 @@
+namespace ContactBook.Contacts.Application.DTOs;
+
+public record ContactInfoDto(Guid Id,string Type, string Value, bool IsDefault);
diff --git a/src/ContactBook.Contacts.Application/DTOs/ContactListDto.cs b/src/ContactBook.Contacts.Application/DTOs/ContactListDto.cs
new file mode 100644
index 0000000..a1ea66e
--- /dev/null
+++ b/src/ContactBook.Contacts.Application/DTOs/ContactListDto.cs
@@ -0,0 +1,6 @@
+namespace ContactBook.Contects.Application.DTOs;
+
+public record ContactListDto(
+ Guid Id,
+ string FullName
+);
diff --git a/src/ContactBook.Contacts.Application/DTOs/CreateContactDto.cs b/src/ContactBook.Contacts.Application/DTOs/CreateContactDto.cs
new file mode 100644
index 0000000..1fbc9d4
--- /dev/null
+++ b/src/ContactBook.Contacts.Application/DTOs/CreateContactDto.cs
@@ -0,0 +1,7 @@
+namespace ContactBook.Contacts.Application.DTOs;
+
+public record CreateContactDto(
+ string FirstName,
+ string LastName,
+ IList? ContactInfo
+);
\ No newline at end of file
diff --git a/src/ContactBook.Contacts.Application/DTOs/CreateContactInfoDto.cs b/src/ContactBook.Contacts.Application/DTOs/CreateContactInfoDto.cs
new file mode 100644
index 0000000..8b2a7c7
--- /dev/null
+++ b/src/ContactBook.Contacts.Application/DTOs/CreateContactInfoDto.cs
@@ -0,0 +1,9 @@
+using ContactBook.Contacts.Domain.Entities;
+
+namespace ContactBook.Contacts.Application.DTOs;
+
+public record CreateContactInfoDto(
+ ContactInfoType ContactInfoType,
+ string Value,
+ bool IsDefault
+);
diff --git a/src/ContactBook.Contacts.Application/DTOs/UpdateContactDto.cs b/src/ContactBook.Contacts.Application/DTOs/UpdateContactDto.cs
new file mode 100644
index 0000000..535193c
--- /dev/null
+++ b/src/ContactBook.Contacts.Application/DTOs/UpdateContactDto.cs
@@ -0,0 +1,3 @@
+namespace ContactBook.Contacts.Application.DTOs;
+
+public record UpdateContactDto(string FirstName, string LastName);
diff --git a/src/ContactBook.Contacts.Application/DTOs/UpdateContactInfoDto.cs b/src/ContactBook.Contacts.Application/DTOs/UpdateContactInfoDto.cs
new file mode 100644
index 0000000..6ab208c
--- /dev/null
+++ b/src/ContactBook.Contacts.Application/DTOs/UpdateContactInfoDto.cs
@@ -0,0 +1,7 @@
+using ContactBook.Contacts.Domain.Entities;
+
+public record UpdateContactInfoDto(
+ ContactInfoType ContactInfoType,
+ string Value,
+ bool IsDefault
+);
\ No newline at end of file
diff --git a/src/ContactBook.Contacts.Application/Interfaces/IContactInfoRepository.cs b/src/ContactBook.Contacts.Application/Interfaces/IContactInfoRepository.cs
new file mode 100644
index 0000000..b6065a5
--- /dev/null
+++ b/src/ContactBook.Contacts.Application/Interfaces/IContactInfoRepository.cs
@@ -0,0 +1,12 @@
+using ContactBook.Contacts.Domain.Entities;
+
+namespace ContactBook.Contacts.Application.Interfaces;
+public interface IContactInfoRepository
+{
+ Task> GetContactInfosAsync(CancellationToken cancellationToken = default);
+ Task GetContactInfoAsync(Guid id,CancellationToken cancellationToken = default);
+ Task> GetContactInfoByContactIdAsync(Guid contactId,CancellationToken cancellationToken = default);
+ Task AddContactInfoAsync(ContactInfo contactInfo,CancellationToken cancellationToken = default);
+ void UpdateContactInfo(ContactInfo contactInfo);
+ Task DeleteContactInfoAsync(Guid id,CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/src/ContactBook.Contacts.Application/Interfaces/IContactRepository.cs b/src/ContactBook.Contacts.Application/Interfaces/IContactRepository.cs
new file mode 100644
index 0000000..2db9db0
--- /dev/null
+++ b/src/ContactBook.Contacts.Application/Interfaces/IContactRepository.cs
@@ -0,0 +1,12 @@
+using ContactBook.Contacts.Domain.Entities;
+
+namespace ContactBook.Contacts.Application.Interfaces;
+
+public interface IContactRepository
+{
+ Task> GetContactsAsync(CancellationToken cancellationToken = default);
+ Task GetContactByIdAsync(Guid id,CancellationToken cancellationToken = default);
+ Task CreateContactAsync(Contact contact,CancellationToken cancellationToken = default);
+ void UpdateContact(Contact contact);
+ Task DeleteContactAsync(Guid id,CancellationToken cancellationToken = default);
+}
diff --git a/src/ContactBook.Contacts.Application/Interfaces/IContactsUnitOfWork.cs b/src/ContactBook.Contacts.Application/Interfaces/IContactsUnitOfWork.cs
new file mode 100644
index 0000000..30ec286
--- /dev/null
+++ b/src/ContactBook.Contacts.Application/Interfaces/IContactsUnitOfWork.cs
@@ -0,0 +1,10 @@
+namespace ContactBook.Contacts.Application.Interfaces;
+
+public interface IContactsUnitOfWork
+{
+ IContactRepository ContactRepository { get; }
+ IContactInfoRepository ContactInfoRepository { get; }
+
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+
+}
diff --git a/src/ContactBook.Contacts.Domain/ContactBook.Contacts.Domain.csproj b/src/ContactBook.Contacts.Domain/ContactBook.Contacts.Domain.csproj
new file mode 100644
index 0000000..125f4c9
--- /dev/null
+++ b/src/ContactBook.Contacts.Domain/ContactBook.Contacts.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/src/ContactBook.Contacts.Domain/Entities/Contact.cs b/src/ContactBook.Contacts.Domain/Entities/Contact.cs
new file mode 100644
index 0000000..3bb357a
--- /dev/null
+++ b/src/ContactBook.Contacts.Domain/Entities/Contact.cs
@@ -0,0 +1,9 @@
+namespace ContactBook.Contacts.Domain.Entities;
+
+public class Contact
+{
+ public Guid Id { get; set; }
+ public string FirstName { get; set; } = default!;
+ public string LastName { get; set; } = default!;
+ public IList? ContactInfos { get; set; }
+}
diff --git a/src/ContactBook.Contacts.Domain/Entities/ContactInfo.cs b/src/ContactBook.Contacts.Domain/Entities/ContactInfo.cs
new file mode 100644
index 0000000..e8b4848
--- /dev/null
+++ b/src/ContactBook.Contacts.Domain/Entities/ContactInfo.cs
@@ -0,0 +1,11 @@
+namespace ContactBook.Contacts.Domain.Entities;
+
+public class ContactInfo
+{
+ public Guid Id { get; set; }
+ public Guid ContactId { get; set; }
+ public ContactInfoType InfoType { get; set; }
+ public string Value { get; set; } = default!;
+ public bool IsDefault { get; set; } = default!;
+ public Contact Contact { get; set; } = default!;
+}
diff --git a/src/ContactBook.Contacts.Domain/Entities/ContactInfoType.cs b/src/ContactBook.Contacts.Domain/Entities/ContactInfoType.cs
new file mode 100644
index 0000000..0aaf689
--- /dev/null
+++ b/src/ContactBook.Contacts.Domain/Entities/ContactInfoType.cs
@@ -0,0 +1,8 @@
+namespace ContactBook.Contacts.Domain.Entities;
+
+public enum ContactInfoType
+{
+ Phone=1,
+ Email=2,
+ Location=3
+}
diff --git a/src/ContactBook.Contacts.Infrastructure/ContactBook.Contacts.Infrastructure.csproj b/src/ContactBook.Contacts.Infrastructure/ContactBook.Contacts.Infrastructure.csproj
new file mode 100644
index 0000000..3ea60b0
--- /dev/null
+++ b/src/ContactBook.Contacts.Infrastructure/ContactBook.Contacts.Infrastructure.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/src/ContactBook.Contacts.Infrastructure/Migrations/20250801153037_InitialCreate.Designer.cs b/src/ContactBook.Contacts.Infrastructure/Migrations/20250801153037_InitialCreate.Designer.cs
new file mode 100644
index 0000000..c05b701
--- /dev/null
+++ b/src/ContactBook.Contacts.Infrastructure/Migrations/20250801153037_InitialCreate.Designer.cs
@@ -0,0 +1,92 @@
+//
+using System;
+using ContactBook.Contacts.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ContactBook.Contacts.Infrastructure.Migrations
+{
+ [DbContext(typeof(ContactsDbContext))]
+ [Migration("20250801153037_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ContactBook.Contacts.Domain.Entities.Contact", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Contacts");
+ });
+
+ modelBuilder.Entity("ContactBook.Contacts.Domain.Entities.ContactInfo", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ContactId")
+ .HasColumnType("uuid");
+
+ b.Property("InfoType")
+ .HasColumnType("integer");
+
+ b.Property("IsDefault")
+ .HasColumnType("boolean");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContactId")
+ .HasDatabaseName("IX_ContactInfo_ContactId");
+
+ b.ToTable("ContactInfos");
+ });
+
+ modelBuilder.Entity("ContactBook.Contacts.Domain.Entities.ContactInfo", b =>
+ {
+ b.HasOne("ContactBook.Contacts.Domain.Entities.Contact", "Contact")
+ .WithMany("ContactInfos")
+ .HasForeignKey("ContactId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Contact");
+ });
+
+ modelBuilder.Entity("ContactBook.Contacts.Domain.Entities.Contact", b =>
+ {
+ b.Navigation("ContactInfos");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ContactBook.Contacts.Infrastructure/Migrations/20250801153037_InitialCreate.cs b/src/ContactBook.Contacts.Infrastructure/Migrations/20250801153037_InitialCreate.cs
new file mode 100644
index 0000000..ebbd6b4
--- /dev/null
+++ b/src/ContactBook.Contacts.Infrastructure/Migrations/20250801153037_InitialCreate.cs
@@ -0,0 +1,63 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ContactBook.Contacts.Infrastructure.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Contacts",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ FirstName = table.Column(type: "text", nullable: false),
+ LastName = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Contacts", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "ContactInfos",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ ContactId = table.Column(type: "uuid", nullable: false),
+ InfoType = table.Column(type: "integer", nullable: false),
+ Value = table.Column(type: "text", nullable: false),
+ IsDefault = table.Column(type: "boolean", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ContactInfos", x => x.Id);
+ table.ForeignKey(
+ name: "FK_ContactInfos_Contacts_ContactId",
+ column: x => x.ContactId,
+ principalTable: "Contacts",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ContactInfo_ContactId",
+ table: "ContactInfos",
+ column: "ContactId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "ContactInfos");
+
+ migrationBuilder.DropTable(
+ name: "Contacts");
+ }
+ }
+}
diff --git a/src/ContactBook.Contacts.Infrastructure/Migrations/ContactsDbContextModelSnapshot.cs b/src/ContactBook.Contacts.Infrastructure/Migrations/ContactsDbContextModelSnapshot.cs
new file mode 100644
index 0000000..5cdfd6e
--- /dev/null
+++ b/src/ContactBook.Contacts.Infrastructure/Migrations/ContactsDbContextModelSnapshot.cs
@@ -0,0 +1,89 @@
+//
+using System;
+using ContactBook.Contacts.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ContactBook.Contacts.Infrastructure.Migrations
+{
+ [DbContext(typeof(ContactsDbContext))]
+ partial class ContactsDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ContactBook.Contacts.Domain.Entities.Contact", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Contacts");
+ });
+
+ modelBuilder.Entity("ContactBook.Contacts.Domain.Entities.ContactInfo", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ContactId")
+ .HasColumnType("uuid");
+
+ b.Property("InfoType")
+ .HasColumnType("integer");
+
+ b.Property("IsDefault")
+ .HasColumnType("boolean");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContactId")
+ .HasDatabaseName("IX_ContactInfo_ContactId");
+
+ b.ToTable("ContactInfos");
+ });
+
+ modelBuilder.Entity("ContactBook.Contacts.Domain.Entities.ContactInfo", b =>
+ {
+ b.HasOne("ContactBook.Contacts.Domain.Entities.Contact", "Contact")
+ .WithMany("ContactInfos")
+ .HasForeignKey("ContactId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Contact");
+ });
+
+ modelBuilder.Entity("ContactBook.Contacts.Domain.Entities.Contact", b =>
+ {
+ b.Navigation("ContactInfos");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ContactBook.Contacts.Infrastructure/Persistence/ContactDbContext.cs b/src/ContactBook.Contacts.Infrastructure/Persistence/ContactDbContext.cs
new file mode 100644
index 0000000..e91e66e
--- /dev/null
+++ b/src/ContactBook.Contacts.Infrastructure/Persistence/ContactDbContext.cs
@@ -0,0 +1,38 @@
+using ContactBook.Contacts.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace ContactBook.Contacts.Infrastructure.Persistence;
+
+public class ContactsDbContext : DbContext
+{
+ public ContactsDbContext(DbContextOptions options)
+ : base(options)
+ {
+
+ }
+ public DbSet Contacts => Set();
+ public DbSet ContactInfos => Set();
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(c =>
+ {
+ c.HasKey(c => c.Id);
+ c.HasMany(c => c.ContactInfos)
+ .WithOne(ci => ci.Contact)
+ .HasForeignKey(ci => ci.ContactId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity(ci =>
+ {
+ ci.HasKey(ci => ci.Id);
+ ci.Property(ci => ci.InfoType)
+ .HasConversion();
+ ci.HasIndex(ci =>ci.ContactId)
+ .HasDatabaseName("IX_ContactInfo_ContactId");
+ });
+
+
+ base.OnModelCreating(modelBuilder);
+ }
+}
diff --git a/src/ContactBook.Contacts.Infrastructure/Repository/ContactInfoRepository.cs b/src/ContactBook.Contacts.Infrastructure/Repository/ContactInfoRepository.cs
new file mode 100644
index 0000000..972ca01
--- /dev/null
+++ b/src/ContactBook.Contacts.Infrastructure/Repository/ContactInfoRepository.cs
@@ -0,0 +1,53 @@
+using ContactBook.Contacts.Application.Interfaces;
+using ContactBook.Contacts.Domain.Entities;
+using ContactBook.Contacts.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace ContactBook.Contacts.Infrastructure.Repository;
+
+public class ContactInfoRepository : IContactInfoRepository
+{
+ private readonly ContactsDbContext _context;
+
+ public ContactInfoRepository(ContactsDbContext context)
+ {
+ _context = context;
+ }
+
+ public async Task AddContactInfoAsync(ContactInfo contactInfo,CancellationToken cancellationToken = default)
+ {
+ await _context.ContactInfos.AddAsync(contactInfo,cancellationToken);
+ }
+
+ public async Task DeleteContactInfoAsync(Guid id,CancellationToken cancellationToken = default)
+ {
+ var contactInfo = await _context.ContactInfos.FindAsync(id,cancellationToken) ?? throw new KeyNotFoundException($"ContactInfo with ID {id} not found.");
+
+ _context.ContactInfos.Remove(contactInfo);
+ }
+
+ public async Task GetContactInfoAsync(Guid id,CancellationToken cancellationToken = default)
+ {
+ return await _context.ContactInfos
+ .FindAsync(id,cancellationToken)
+ ?? throw new KeyNotFoundException($"ContactInfo with ID {id} not found.");
+ }
+
+ public async Task> GetContactInfoByContactIdAsync(Guid contactId,CancellationToken cancellationToken = default)
+ {
+ return await _context.ContactInfos
+ .Where(ci => ci.ContactId == contactId)
+ .ToListAsync(cancellationToken) ?? throw new KeyNotFoundException($"No ContactInfo found for Contact ID {contactId}.");
+ }
+
+ public async Task> GetContactInfosAsync(CancellationToken cancellationToken = default)
+ {
+ return await _context.ContactInfos
+ .ToListAsync(cancellationToken);
+ }
+
+ public void UpdateContactInfo(ContactInfo contactInfo)
+ {
+ _context.ContactInfos.Update(contactInfo);
+ }
+}
\ No newline at end of file
diff --git a/src/ContactBook.Contacts.Infrastructure/Repository/ContactRepository.cs b/src/ContactBook.Contacts.Infrastructure/Repository/ContactRepository.cs
new file mode 100644
index 0000000..f2750b2
--- /dev/null
+++ b/src/ContactBook.Contacts.Infrastructure/Repository/ContactRepository.cs
@@ -0,0 +1,46 @@
+using ContactBook.Contacts.Application.Interfaces;
+using ContactBook.Contacts.Domain.Entities;
+using ContactBook.Contacts.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace ContactBook.Contacts.Infrastructure.Repository;
+
+public class ContactRepository : IContactRepository
+{
+ private readonly ContactsDbContext _context;
+
+ public ContactRepository(ContactsDbContext context)
+ {
+ _context = context;
+ }
+
+
+ public async Task CreateContactAsync(Contact contact,CancellationToken cancellationToken = default)
+ {
+ await _context.Contacts.AddAsync(contact, cancellationToken);
+ }
+
+ public async Task DeleteContactAsync(Guid id,CancellationToken cancellationToken = default)
+ {
+ var contact = await _context.Contacts.FindAsync(id,cancellationToken) ?? throw new NullReferenceException($"Contact with {id} not found.");
+
+ _context.Contacts.Remove(contact);
+ }
+
+ public async Task GetContactByIdAsync(Guid id,CancellationToken cancellationToken = default)
+ {
+ var contact = await _context.Contacts.FindAsync(id,cancellationToken) ?? throw new NullReferenceException($"Contact with {id} not found.");
+
+ return contact;
+ }
+
+ public async Task> GetContactsAsync(CancellationToken cancellationToken = default)
+ {
+ return await _context.Contacts.ToListAsync(cancellationToken);
+ }
+
+ public void UpdateContact(Contact contact)
+ {
+ _context.Contacts.Update(contact);
+ }
+}
diff --git a/src/ContactBook.Contacts.Infrastructure/Repository/ContactsUnitOfWork.cs b/src/ContactBook.Contacts.Infrastructure/Repository/ContactsUnitOfWork.cs
new file mode 100644
index 0000000..be25677
--- /dev/null
+++ b/src/ContactBook.Contacts.Infrastructure/Repository/ContactsUnitOfWork.cs
@@ -0,0 +1,27 @@
+using ContactBook.Contacts.Application.Interfaces;
+using ContactBook.Contacts.Infrastructure.Persistence;
+
+namespace ContactBook.Contacts.Infrastructure.Repository;
+
+public class ContactsUnitOfWork : IContactsUnitOfWork
+{
+
+ private readonly ContactsDbContext _context;
+
+ public ContactsUnitOfWork(ContactsDbContext context)
+ {
+ _context = context;
+ ContactRepository = new ContactRepository(_context);
+ ContactInfoRepository = new ContactInfoRepository(_context);
+ }
+
+ public IContactRepository ContactRepository { get; }
+
+ public IContactInfoRepository ContactInfoRepository { get; }
+
+ public Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ _context.ChangeTracker.DetectChanges();
+ return _context.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/ContactBook.Reports.API/ContactBook.Reports.API.csproj b/src/ContactBook.Reports.API/ContactBook.Reports.API.csproj
new file mode 100644
index 0000000..c8b391e
--- /dev/null
+++ b/src/ContactBook.Reports.API/ContactBook.Reports.API.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net9.0
+ enable
+ enable
+ true
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ContactBook.Reports.API/ContactBook.Reports.API.http b/src/ContactBook.Reports.API/ContactBook.Reports.API.http
new file mode 100644
index 0000000..41f696d
--- /dev/null
+++ b/src/ContactBook.Reports.API/ContactBook.Reports.API.http
@@ -0,0 +1,6 @@
+@ContactBook.Reports.API_HostAddress = http://localhost:5263
+
+GET {{ContactBook.Reports.API_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/src/ContactBook.Reports.API/Controllers/ReportsController.cs b/src/ContactBook.Reports.API/Controllers/ReportsController.cs
new file mode 100644
index 0000000..7106098
--- /dev/null
+++ b/src/ContactBook.Reports.API/Controllers/ReportsController.cs
@@ -0,0 +1,76 @@
+using ContactBook.Reports.Application.DTOs;
+using ContactBook.Reports.Application.Interfaces;
+using ContactBook.Reports.Domain.Entities;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ContactBook.Reports.API.Controllers;
+
+[ApiController]
+[Route("[controller]")]
+public class ReportsController : ControllerBase
+{
+ private readonly IReportRepository _reportRepository;
+ private readonly IKafkaProducer _kafkaProducer;
+ public ReportsController(IReportRepository reportRepository, IKafkaProducer kafkaProducer)
+ {
+ _reportRepository = reportRepository;
+ _kafkaProducer = kafkaProducer;
+ }
+
+ [HttpGet]
+ public async Task GetReports(CancellationToken cancellationToken)
+ {
+ var reports = await _reportRepository.GetReportsAsync(cancellationToken);
+ var result = reports.Select(r => new ReportListDto
+ (
+ r.Id,
+ r.RequestedAt,
+ r.Status.ToString()
+ ));
+
+ return Ok(result);
+ }
+
+ [HttpGet("{id}")]
+ public async Task GetReport(Guid id, CancellationToken cancellationToken)
+ {
+ var report = await _reportRepository.GetReportAsync(id, cancellationToken);
+ if (report == null)
+ {
+ return NotFound();
+ }
+ if (report.Status != ReportStatus.Completed)
+ {
+ return BadRequest("Report is not ready yet");
+ }
+
+ var result = new ReportDto
+ (
+ report.Id,
+ report.Data?.Select(rd => new LocationStatisticsDto
+ (
+ rd.Location,
+ rd.ContactCount,
+ rd.PhoneCount
+ )).ToList()
+ );
+
+ return Ok(result);
+ }
+
+ [HttpPost]
+ public async Task RequestReport(CancellationToken cancellationToken)
+ {
+ var report = new Report
+ {
+ Id = Guid.NewGuid(),
+ RequestedAt = DateTime.UtcNow,
+ Status = ReportStatus.Requested,
+ };
+
+ await _reportRepository.CreateReportAsync(report, cancellationToken);
+ await _kafkaProducer.PublishAsync("report-requests", report.Id, cancellationToken);
+
+ return Ok("Report reequested sucessfully");
+ }
+}
diff --git a/src/ContactBook.Reports.API/Dockerfile b/src/ContactBook.Reports.API/Dockerfile
new file mode 100644
index 0000000..c942864
--- /dev/null
+++ b/src/ContactBook.Reports.API/Dockerfile
@@ -0,0 +1,23 @@
+FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
+WORKDIR /app
+EXPOSE 80
+EXPOSE 443
+
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+WORKDIR /src
+COPY ["src/ContactBook.Reports.API/ContactBook.Reports.API.csproj", "ContactBook.Reports.API/"]
+COPY ["src/ContactBook.Reports.Application/ContactBook.Reports.Application.csproj", "ContactBook.Reports.Application/"]
+COPY ["src/ContactBook.Reports.Domain/ContactBook.Reports.Domain.csproj", "ContactBook.Reports.Domain/"]
+COPY ["src/ContactBook.Reports.Infrastructure/ContactBook.Reports.Infrastructure.csproj", "ContactBook.Reports.Infrastructure/"]
+RUN dotnet restore "ContactBook.Reports.API/ContactBook.Reports.API.csproj"
+COPY src/ .
+WORKDIR "/src/ContactBook.Reports.API"
+RUN dotnet build "ContactBook.Reports.API.csproj" -c Release -o /app/build
+
+FROM build AS publish
+RUN dotnet publish "ContactBook.Reports.API.csproj" -c Release -o /app/publish
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "ContactBook.Reports.API.dll"]
diff --git a/src/ContactBook.Reports.API/Middleware/ErrorHandlerMiddleware.cs b/src/ContactBook.Reports.API/Middleware/ErrorHandlerMiddleware.cs
new file mode 100644
index 0000000..891cdad
--- /dev/null
+++ b/src/ContactBook.Reports.API/Middleware/ErrorHandlerMiddleware.cs
@@ -0,0 +1,49 @@
+using System.Text.Json;
+
+namespace ContactBook.Reports.API.Middleware;
+
+public class ErrorHandlingMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ public ErrorHandlingMiddleware(RequestDelegate next, ILogger logger)
+ {
+ _next = next;
+ _logger = logger;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await _next(context);
+ }
+ catch (OperationCanceledException)
+ {
+ // Let RequestCancellationMiddleware handle this
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "An unhandled exception occurred while processing the request. Path: {Path}", context.Request.Path);
+
+ if (!context.Response.HasStarted)
+ {
+ context.Response.Clear();
+ context.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ context.Response.ContentType = "application/json";
+
+ var errorResponse = new
+ {
+ status = 500,
+ message = "An internal server error occurred. Please try again later.",
+ traceId = context.TraceIdentifier
+ };
+
+ var json = JsonSerializer.Serialize(errorResponse);
+ await context.Response.WriteAsync(json);
+ }
+ }
+ }
+}
diff --git a/src/ContactBook.Reports.API/Middleware/RequestCancellationMiddleware.cs b/src/ContactBook.Reports.API/Middleware/RequestCancellationMiddleware.cs
new file mode 100644
index 0000000..e18d8ce
--- /dev/null
+++ b/src/ContactBook.Reports.API/Middleware/RequestCancellationMiddleware.cs
@@ -0,0 +1,32 @@
+namespace ContactBook.Reports.API.Middleware;
+
+public class RequestCancellationMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ public RequestCancellationMiddleware(RequestDelegate next, ILogger logger)
+ {
+ _next = next;
+ _logger = logger;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await _next(context);
+ }
+ catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
+ {
+ _logger.LogWarning("Request was cancelled by the client. Path: {Path}", context.Request.Path);
+
+ if (!context.Response.HasStarted)
+ {
+ context.Response.Clear();
+ context.Response.StatusCode = 499; // Client Closed Request (unofficial)
+ await context.Response.WriteAsync("Request cancelled by client");
+ }
+ }
+ }
+}
diff --git a/src/ContactBook.Reports.API/Program.cs b/src/ContactBook.Reports.API/Program.cs
new file mode 100644
index 0000000..a4c5456
--- /dev/null
+++ b/src/ContactBook.Reports.API/Program.cs
@@ -0,0 +1,62 @@
+using ContactBook.Reports.API.Middleware;
+using ContactBook.Reports.Application.Interfaces;
+using ContactBook.Reports.Infrastructure.MesssageBroker;
+using ContactBook.Reports.Infrastructure.Persistence;
+using ContactBook.Reports.Infrastructure.Repository;
+using Microsoft.EntityFrameworkCore;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
+builder.Services.AddOpenApi();
+builder.Services.AddControllers();
+builder.Services.AddHealthChecks();
+// Configure database connection
+builder.Services.AddDbContext(options =>{
+ options.UseNpgsql(builder.Configuration.GetConnectionString("reportsDb"));
+ });
+ builder.Services.AddScoped();
+ builder.Services.AddSingleton();
+
+var app = builder.Build();
+// Configure the HTTP request pipeline.
+app.Use(async (context, next) =>
+{
+ var cancellationToken = context.RequestAborted;
+
+ try
+ {
+ // Simulate artificial latency (e.g., 2 seconds)
+ await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // If request was cancelled during the delay, let RequestCancellationMiddleware handle it
+ return;
+ }
+
+ await next(); // Continue to next middleware
+});
+
+app.UseMiddleware();
+app.UseMiddleware();
+app.UseHttpsRedirection();
+app.MapHealthChecks("/health");
+app.MapControllers();
+
+if (app.Environment.IsDevelopment())
+{
+ app.MapOpenApi();
+ app.UseSwaggerUI(options =>
+ {
+ options.SwaggerEndpoint("/openapi/v1.json", "ContactBook Reports API V1");
+ });
+ using (var scope = app.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Database.Migrate(); // Ensures DB and schema exist
+ }
+}
+app.Run();
+
diff --git a/src/ContactBook.Reports.API/Properties/launchSettings.json b/src/ContactBook.Reports.API/Properties/launchSettings.json
new file mode 100644
index 0000000..9d01802
--- /dev/null
+++ b/src/ContactBook.Reports.API/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5263",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7164;http://localhost:5263",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/ContactBook.Reports.API/appsettings.Development.json b/src/ContactBook.Reports.API/appsettings.Development.json
new file mode 100644
index 0000000..5e9fd7f
--- /dev/null
+++ b/src/ContactBook.Reports.API/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ContactBook.Reports.API/appsettings.json b/src/ContactBook.Reports.API/appsettings.json
new file mode 100644
index 0000000..4d56694
--- /dev/null
+++ b/src/ContactBook.Reports.API/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/ContactBook.Reports.Application/ContactBook.Reports.Application.csproj b/src/ContactBook.Reports.Application/ContactBook.Reports.Application.csproj
new file mode 100644
index 0000000..f3eb4ba
--- /dev/null
+++ b/src/ContactBook.Reports.Application/ContactBook.Reports.Application.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/src/ContactBook.Reports.Application/DTOs/LocationStatisticsDto.cs b/src/ContactBook.Reports.Application/DTOs/LocationStatisticsDto.cs
new file mode 100644
index 0000000..75bd53c
--- /dev/null
+++ b/src/ContactBook.Reports.Application/DTOs/LocationStatisticsDto.cs
@@ -0,0 +1,3 @@
+namespace ContactBook.Reports.Application.DTOs;
+
+public record LocationStatisticsDto(string Location, int ContactCount, int PhoneCount);
diff --git a/src/ContactBook.Reports.Application/DTOs/ReportDto.cs b/src/ContactBook.Reports.Application/DTOs/ReportDto.cs
new file mode 100644
index 0000000..5913a86
--- /dev/null
+++ b/src/ContactBook.Reports.Application/DTOs/ReportDto.cs
@@ -0,0 +1,3 @@
+namespace ContactBook.Reports.Application.DTOs;
+
+public record ReportDto(Guid Id,IList? Data);
diff --git a/src/ContactBook.Reports.Application/DTOs/ReportListDto.cs b/src/ContactBook.Reports.Application/DTOs/ReportListDto.cs
new file mode 100644
index 0000000..c6729fb
--- /dev/null
+++ b/src/ContactBook.Reports.Application/DTOs/ReportListDto.cs
@@ -0,0 +1,2 @@
+namespace ContactBook.Reports.Application.DTOs;
+public record ReportListDto(Guid Id, DateTime RequestedAt, string Status);
diff --git a/src/ContactBook.Reports.Application/Interfaces/IKafkaProducer.cs b/src/ContactBook.Reports.Application/Interfaces/IKafkaProducer.cs
new file mode 100644
index 0000000..350084b
--- /dev/null
+++ b/src/ContactBook.Reports.Application/Interfaces/IKafkaProducer.cs
@@ -0,0 +1,4 @@
+namespace ContactBook.Reports.Application.Interfaces;
+public interface IKafkaProducer {
+ Task PublishAsync(string topic, T message, CancellationToken cancellationToken);
+}
diff --git a/src/ContactBook.Reports.Application/Interfaces/IReportRepository.cs b/src/ContactBook.Reports.Application/Interfaces/IReportRepository.cs
new file mode 100644
index 0000000..33de62a
--- /dev/null
+++ b/src/ContactBook.Reports.Application/Interfaces/IReportRepository.cs
@@ -0,0 +1,12 @@
+using ContactBook.Reports.Domain.Entities;
+
+namespace ContactBook.Reports.Application.Interfaces;
+
+public interface IReportRepository
+{
+ Task> GetReportsAsync(CancellationToken cancellationToken = default);
+ Task GetReportAsync(Guid id, CancellationToken cancellationToken = default);
+ Task UpdateReportAsync(Report report,CancellationToken cancellationToken=default); //this will be used from consumer to update report status and data
+ Task DeleteReportAsync(Guid reportId, CancellationToken cancellationToken = default);
+ Task CreateReportAsync(Report report, CancellationToken cancellationToken = default);
+}
diff --git a/src/ContactBook.Reports.Consumer/ContactBook.Reports.Consumer.csproj b/src/ContactBook.Reports.Consumer/ContactBook.Reports.Consumer.csproj
new file mode 100644
index 0000000..45fa5d0
--- /dev/null
+++ b/src/ContactBook.Reports.Consumer/ContactBook.Reports.Consumer.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net9.0
+ enable
+ enable
+ dotnet-ContactBook.Reports.Consumer-63705fbf-6d94-4f91-a45e-74b9e9c40971
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ContactBook.Reports.Consumer/Dockerfile b/src/ContactBook.Reports.Consumer/Dockerfile
new file mode 100644
index 0000000..93edbb2
--- /dev/null
+++ b/src/ContactBook.Reports.Consumer/Dockerfile
@@ -0,0 +1,21 @@
+FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base
+WORKDIR /app
+
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+WORKDIR /src
+COPY ["src/ContactBook.Reports.Consumer/ContactBook.Reports.Consumer.csproj", "ContactBook.Reports.Consumer/"]
+COPY ["src/ContactBook.Reports.Application/ContactBook.Reports.Application.csproj", "ContactBook.Reports.Application/"]
+COPY ["src/ContactBook.Reports.Domain/ContactBook.Reports.Domain.csproj", "ContactBook.Reports.Domain/"]
+COPY ["src/ContactBook.Reports.Infrastructure/ContactBook.Reports.Infrastructure.csproj", "ContactBook.Reports.Infrastructure/"]
+RUN dotnet restore "ContactBook.Reports.Consumer/ContactBook.Reports.Consumer.csproj"
+COPY src/ .
+WORKDIR "/src/ContactBook.Reports.Consumer"
+RUN dotnet build "ContactBook.Reports.Consumer.csproj" -c Release -o /app/build
+
+FROM build AS publish
+RUN dotnet publish "ContactBook.Reports.Consumer.csproj" -c Release -o /app/publish
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "ContactBook.Reports.Consumer.dll"]
diff --git a/src/ContactBook.Reports.Consumer/Program.cs b/src/ContactBook.Reports.Consumer/Program.cs
new file mode 100644
index 0000000..96db964
--- /dev/null
+++ b/src/ContactBook.Reports.Consumer/Program.cs
@@ -0,0 +1,14 @@
+using ContactBook.Contacts.Infrastructure.Persistence;
+using ContactBook.Reports.Consumer;
+using ContactBook.Reports.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+var builder = Host.CreateApplicationBuilder(args);
+builder.Services.AddDbContext(options =>
+ options.UseNpgsql(builder.Configuration.GetConnectionString("reportsDb")));
+builder.Services.AddDbContext(options =>
+ options.UseNpgsql(builder.Configuration.GetConnectionString("contactsDb")));
+builder.Services.AddHostedService();
+
+var host = builder.Build();
+host.Run();
diff --git a/src/ContactBook.Reports.Consumer/Properties/launchSettings.json b/src/ContactBook.Reports.Consumer/Properties/launchSettings.json
new file mode 100644
index 0000000..19bc85a
--- /dev/null
+++ b/src/ContactBook.Reports.Consumer/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "ContactBook.Reports.Consumer": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/ContactBook.Reports.Consumer/ReportWorker.cs b/src/ContactBook.Reports.Consumer/ReportWorker.cs
new file mode 100644
index 0000000..61006e6
--- /dev/null
+++ b/src/ContactBook.Reports.Consumer/ReportWorker.cs
@@ -0,0 +1,156 @@
+using System.Text.Json;
+using Confluent.Kafka;
+using ContactBook.Contacts.Domain.Entities;
+using ContactBook.Contacts.Infrastructure.Persistence;
+using ContactBook.Reports.Domain.Entities;
+using ContactBook.Reports.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace ContactBook.Reports.Consumer;
+
+public class ReportWorker : BackgroundService
+{
+ private readonly ILogger _logger;
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IConfiguration _configuration;
+
+ public ReportWorker(ILogger logger, IServiceProvider serviceProvider, IConfiguration configuration)
+ {
+ _logger = logger;
+ _serviceProvider = serviceProvider;
+ _configuration = configuration;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var bootstrapServers = _configuration["Kafka:BootstrapServers"];
+ var topic = _configuration["Kafka:ReportRequestTopic"] ?? "report-requests";
+
+ if (string.IsNullOrWhiteSpace(bootstrapServers))
+ {
+ _logger.LogCritical("Kafka bootstrap server is not configured.");
+ return;
+ }
+
+ var config = new ConsumerConfig
+ {
+ BootstrapServers = bootstrapServers,
+ GroupId = _configuration["Kafka:ConsumerGroupId"] ?? "contactbook-report-worker",
+ AutoOffsetReset = AutoOffsetReset.Earliest,
+ EnableAutoCommit = false, // Manual commit after processing,
+ // Debug = "all",
+
+ };
+
+ using var consumer = new ConsumerBuilder(config).Build();
+ consumer.Subscribe(topic);
+
+ _logger.LogInformation("Kafka consumer subscribed to topic: {Topic}", topic);
+
+ try
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ var result = consumer.Consume(TimeSpan.FromSeconds(5));
+ if (result == null)
+ {
+ _logger.LogInformation("No message received, waiting for next iteration.");
+ continue; // No message received, continue to next iteration
+ }
+
+ _logger.LogInformation("Received message at offset {Offset}: {Message}", result.Offset, result.Message.Value);
+
+ if (!TryDeserialize(result.Message.Value, out Guid reportId))
+ {
+ _logger.LogWarning("Skipping invalid message: {RawMessage}", result.Message.Value);
+ continue;
+ }
+
+ await HandleReportGeneration(reportId, stoppingToken);
+
+ consumer.Commit(result); // ✅ Manual commit after successful handling
+ }
+ catch (ConsumeException ex)
+ {
+ _logger.LogError(ex, "Kafka consume error: {Reason}", ex.Error.Reason);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unexpected error while processing Kafka message.");
+ }
+ }
+ }
+ finally
+ {
+ consumer.Close();
+ _logger.LogInformation("Kafka consumer closed.");
+ }
+ }
+
+ private bool TryDeserialize(string message, out Guid reportId)
+ {
+ try
+ {
+ reportId = JsonSerializer.Deserialize(message);
+ _logger.LogInformation("Deserialized report ID: {ReportId}", reportId);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to deserialize message: {Message}", message);
+ reportId = Guid.Empty;
+ return false;
+ }
+ }
+
+ private async Task HandleReportGeneration(Guid reportId, CancellationToken cancellationToken)
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var contactDb = scope.ServiceProvider.GetRequiredService();
+ var reportDb = scope.ServiceProvider.GetRequiredService();
+
+ try
+ {
+ var locationInfos = await contactDb.ContactInfos
+ .Where(ci => ci.InfoType == ContactInfoType.Location)
+ .ToListAsync(cancellationToken);
+
+ var phoneInfos = await contactDb.ContactInfos
+ .Where(ci => ci.InfoType == ContactInfoType.Phone)
+ .ToListAsync(cancellationToken);
+
+ var reportData = locationInfos
+ .GroupBy(ci => ci.Value)
+ .Select(g =>
+ {
+ var location = g.Key;
+ var contactIds = g.Select(ci => ci.ContactId).Distinct().ToList();
+ var phoneCount = phoneInfos.Count(ci => contactIds.Contains(ci.ContactId));
+
+ return new LocationStatistics(location, contactIds.Count, phoneCount);
+ })
+ .ToList();
+
+ var report = await reportDb.Reports.FindAsync([reportId], cancellationToken);
+
+ if (report == null)
+ {
+ _logger.LogWarning("Report with ID {ReportId} not found.", reportId);
+ return;
+ }
+
+ report.Status = ReportStatus.Completed;
+ report.Data = reportData;
+
+ await reportDb.SaveChangesAsync(cancellationToken);
+
+ _logger.LogInformation("Report {ReportId} generated and saved successfully.", report.Id);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error generating report for ID {ReportId}", reportId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ContactBook.Reports.Consumer/appsettings.Development.json b/src/ContactBook.Reports.Consumer/appsettings.Development.json
new file mode 100644
index 0000000..45f48c2
--- /dev/null
+++ b/src/ContactBook.Reports.Consumer/appsettings.Development.json
@@ -0,0 +1,15 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "Kafka": {
+ "BootstrapServers": "localhost:9092"
+ },
+ "ConnectionStrings": {
+ "ContactDb": "Host=localhost;Database=ContactBook;Username=postgres;Password=postgres",
+ "ReportDb": "Host=localhost;Database=ContactBookReports;Username=postgres;Password=postgres"
+ }
+}
diff --git a/src/ContactBook.Reports.Consumer/appsettings.json b/src/ContactBook.Reports.Consumer/appsettings.json
new file mode 100644
index 0000000..6901764
--- /dev/null
+++ b/src/ContactBook.Reports.Consumer/appsettings.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ }
+}
diff --git a/src/ContactBook.Reports.Domain/ContactBook.Reports.Domain.csproj b/src/ContactBook.Reports.Domain/ContactBook.Reports.Domain.csproj
new file mode 100644
index 0000000..125f4c9
--- /dev/null
+++ b/src/ContactBook.Reports.Domain/ContactBook.Reports.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/src/ContactBook.Reports.Domain/Entities/LocationStatistics.cs b/src/ContactBook.Reports.Domain/Entities/LocationStatistics.cs
new file mode 100644
index 0000000..541a7c7
--- /dev/null
+++ b/src/ContactBook.Reports.Domain/Entities/LocationStatistics.cs
@@ -0,0 +1,8 @@
+namespace ContactBook.Reports.Domain.Entities;
+
+public record LocationStatistics
+(
+ string Location,
+ int ContactCount,
+ int PhoneCount
+);
\ No newline at end of file
diff --git a/src/ContactBook.Reports.Domain/Entities/Report.cs b/src/ContactBook.Reports.Domain/Entities/Report.cs
new file mode 100644
index 0000000..dd6ee6b
--- /dev/null
+++ b/src/ContactBook.Reports.Domain/Entities/Report.cs
@@ -0,0 +1,9 @@
+namespace ContactBook.Reports.Domain.Entities;
+
+public class Report
+{
+ public Guid Id { get; set; }
+ public DateTime RequestedAt { get; set; }
+ public ReportStatus Status { get; set; }
+ public IList? Data { get; set; }
+}
diff --git a/src/ContactBook.Reports.Domain/Entities/ReportStatus.cs b/src/ContactBook.Reports.Domain/Entities/ReportStatus.cs
new file mode 100644
index 0000000..ca76597
--- /dev/null
+++ b/src/ContactBook.Reports.Domain/Entities/ReportStatus.cs
@@ -0,0 +1,8 @@
+namespace ContactBook.Reports.Domain.Entities;
+
+public enum ReportStatus
+{
+ Requested, //Initial state of the report
+ InProgress, //Consumer taken the report request and working on generation
+ Completed //report generated and ready to serve
+}
diff --git a/src/ContactBook.Reports.Infrastructure/ContactBook.Reports.Infrastructure.csproj b/src/ContactBook.Reports.Infrastructure/ContactBook.Reports.Infrastructure.csproj
new file mode 100644
index 0000000..4d492b6
--- /dev/null
+++ b/src/ContactBook.Reports.Infrastructure/ContactBook.Reports.Infrastructure.csproj
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/src/ContactBook.Reports.Infrastructure/MesssageBroker/KafkaProducer.cs b/src/ContactBook.Reports.Infrastructure/MesssageBroker/KafkaProducer.cs
new file mode 100644
index 0000000..adb64fa
--- /dev/null
+++ b/src/ContactBook.Reports.Infrastructure/MesssageBroker/KafkaProducer.cs
@@ -0,0 +1,27 @@
+using System.Text.Json;
+using Confluent.Kafka;
+using ContactBook.Reports.Application.Interfaces;
+using Microsoft.Extensions.Configuration;
+
+namespace ContactBook.Reports.Infrastructure.MesssageBroker;
+
+public class KafkaProducer : IKafkaProducer
+{
+ private readonly IProducer _producer;
+
+ public KafkaProducer(IConfiguration configuration)
+ {
+ var config = new ProducerConfig
+ {
+ BootstrapServers = configuration["Kafka:BootstrapServers"]
+ };
+
+ _producer = new ProducerBuilder(config).Build();
+ }
+
+ public async Task PublishAsync(string topic, T message, CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(message);
+ await _producer.ProduceAsync(topic, new Message { Key= "report requested",Value = json }, cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/src/ContactBook.Reports.Infrastructure/Migrations/20250801153226_InitialCreate.Designer.cs b/src/ContactBook.Reports.Infrastructure/Migrations/20250801153226_InitialCreate.Designer.cs
new file mode 100644
index 0000000..b0898c4
--- /dev/null
+++ b/src/ContactBook.Reports.Infrastructure/Migrations/20250801153226_InitialCreate.Designer.cs
@@ -0,0 +1,81 @@
+//
+using System;
+using ContactBook.Reports.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ContactBook.Reports.Infrastructure.Migrations
+{
+ [DbContext(typeof(ReportDbContext))]
+ [Migration("20250801153226_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ContactBook.Reports.Domain.Entities.Report", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("RequestedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Reports");
+ });
+
+ modelBuilder.Entity("ContactBook.Reports.Domain.Entities.Report", b =>
+ {
+ b.OwnsMany("ContactBook.Reports.Domain.Entities.LocationStatistics", "Data", b1 =>
+ {
+ b1.Property("ReportId")
+ .HasColumnType("uuid");
+
+ b1.Property("__synthesizedOrdinal")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ b1.Property("ContactCount")
+ .HasColumnType("integer");
+
+ b1.Property("Location")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b1.Property("PhoneCount")
+ .HasColumnType("integer");
+
+ b1.HasKey("ReportId", "__synthesizedOrdinal");
+
+ b1.ToTable("Reports");
+
+ b1.ToJson("Data");
+
+ b1.WithOwner()
+ .HasForeignKey("ReportId");
+ });
+
+ b.Navigation("Data");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ContactBook.Reports.Infrastructure/Migrations/20250801153226_InitialCreate.cs b/src/ContactBook.Reports.Infrastructure/Migrations/20250801153226_InitialCreate.cs
new file mode 100644
index 0000000..e853df0
--- /dev/null
+++ b/src/ContactBook.Reports.Infrastructure/Migrations/20250801153226_InitialCreate.cs
@@ -0,0 +1,35 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ContactBook.Reports.Infrastructure.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Reports",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ RequestedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ Status = table.Column(type: "integer", nullable: false),
+ Data = table.Column(type: "jsonb", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Reports", x => x.Id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Reports");
+ }
+ }
+}
diff --git a/src/ContactBook.Reports.Infrastructure/Migrations/ReportDbContextModelSnapshot.cs b/src/ContactBook.Reports.Infrastructure/Migrations/ReportDbContextModelSnapshot.cs
new file mode 100644
index 0000000..01b6557
--- /dev/null
+++ b/src/ContactBook.Reports.Infrastructure/Migrations/ReportDbContextModelSnapshot.cs
@@ -0,0 +1,78 @@
+//
+using System;
+using ContactBook.Reports.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ContactBook.Reports.Infrastructure.Migrations
+{
+ [DbContext(typeof(ReportDbContext))]
+ partial class ReportDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ContactBook.Reports.Domain.Entities.Report", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("RequestedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Reports");
+ });
+
+ modelBuilder.Entity("ContactBook.Reports.Domain.Entities.Report", b =>
+ {
+ b.OwnsMany("ContactBook.Reports.Domain.Entities.LocationStatistics", "Data", b1 =>
+ {
+ b1.Property("ReportId")
+ .HasColumnType("uuid");
+
+ b1.Property("__synthesizedOrdinal")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ b1.Property("ContactCount")
+ .HasColumnType("integer");
+
+ b1.Property("Location")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b1.Property("PhoneCount")
+ .HasColumnType("integer");
+
+ b1.HasKey("ReportId", "__synthesizedOrdinal");
+
+ b1.ToTable("Reports");
+
+ b1.ToJson("Data");
+
+ b1.WithOwner()
+ .HasForeignKey("ReportId");
+ });
+
+ b.Navigation("Data");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ContactBook.Reports.Infrastructure/Persistence/ReportDbContext.cs b/src/ContactBook.Reports.Infrastructure/Persistence/ReportDbContext.cs
new file mode 100644
index 0000000..bf284a6
--- /dev/null
+++ b/src/ContactBook.Reports.Infrastructure/Persistence/ReportDbContext.cs
@@ -0,0 +1,25 @@
+using ContactBook.Reports.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace ContactBook.Reports.Infrastructure.Persistence;
+
+public class ReportDbContext : DbContext
+{
+ public ReportDbContext(DbContextOptions options)
+ : base(options)
+ {
+
+ }
+ public DbSet Reports => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(r =>
+ {
+ r.HasKey(r => r.Id);
+ r.OwnsMany(r => r.Data).ToJson();
+ });
+
+ base.OnModelCreating(modelBuilder);
+ }
+}
diff --git a/src/ContactBook.Reports.Infrastructure/Repository/ReportRepository.cs b/src/ContactBook.Reports.Infrastructure/Repository/ReportRepository.cs
new file mode 100644
index 0000000..2585397
--- /dev/null
+++ b/src/ContactBook.Reports.Infrastructure/Repository/ReportRepository.cs
@@ -0,0 +1,57 @@
+using ContactBook.Reports.Application.Interfaces;
+using ContactBook.Reports.Domain.Entities;
+using ContactBook.Reports.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace ContactBook.Reports.Infrastructure.Repository;
+
+public class ReportRepository : IReportRepository
+{
+ private readonly ReportDbContext _context;
+
+ public ReportRepository(ReportDbContext context)
+ {
+ _context = context;
+ }
+
+ public async Task DeleteReportAsync(Guid reportId,CancellationToken cancellationToken = default)
+ {
+ var report = await _context.Reports.FindAsync(reportId,cancellationToken) ?? throw new NullReferenceException($"Report with {reportId} not found.");
+
+ _context.Reports.Remove(report);
+
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task GetReportAsync(Guid id,CancellationToken cancellationToken = default)
+ {
+ var report = await _context.Reports.FindAsync(id,cancellationToken) ?? throw new NullReferenceException($"Report with {id} not found.");
+
+ return report;
+ }
+
+ public async Task> GetReportsAsync(CancellationToken cancellationToken = default)
+ {
+
+ return await _context.Reports.ToListAsync(cancellationToken);
+ }
+
+ public async Task UpdateReportAsync(Report report,CancellationToken cancellationToken = default)
+ {
+ _context.Reports.Update(report);
+
+ await _context.SaveChangesAsync(cancellationToken);
+
+ return report;
+ }
+
+ public async Task CreateReportAsync(Report report, CancellationToken cancellationToken = default)
+ {
+ _context.Reports.Add(report);
+
+ await _context.SaveChangesAsync(cancellationToken);
+
+ return report;
+ }
+
+}
diff --git a/tests/ContactBook.Contacts.API.Tests/ContactBook.Contacts.API.Tests.csproj b/tests/ContactBook.Contacts.API.Tests/ContactBook.Contacts.API.Tests.csproj
new file mode 100644
index 0000000..a5abce9
--- /dev/null
+++ b/tests/ContactBook.Contacts.API.Tests/ContactBook.Contacts.API.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ContactBook.Contacts.API.Tests/ContactsControllerTests.cs b/tests/ContactBook.Contacts.API.Tests/ContactsControllerTests.cs
new file mode 100644
index 0000000..101f928
--- /dev/null
+++ b/tests/ContactBook.Contacts.API.Tests/ContactsControllerTests.cs
@@ -0,0 +1,481 @@
+using ContactBook.Contacts.API.Controllers;
+using ContactBook.Contacts.Application.DTOs;
+using ContactBook.Contacts.Application.Interfaces;
+using ContactBook.Contacts.Domain.Entities;
+using ContactBook.Contects.Application.DTOs;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace ContactBook.Contacts.API.Tests.Controllers;
+
+public class ContactsControllerTests
+{
+ private readonly Mock _mockUnitOfWork;
+ private readonly Mock> _mockLogger;
+ private readonly Mock _mockContactRepository;
+ private readonly Mock _mockContactInfoRepository;
+ private readonly ContactsController _controller;
+
+ public ContactsControllerTests()
+ {
+ _mockUnitOfWork = new Mock();
+ _mockLogger = new Mock>();
+ _mockContactRepository = new Mock();
+ _mockContactInfoRepository = new Mock();
+
+ _mockUnitOfWork.Setup(x => x.ContactRepository).Returns(_mockContactRepository.Object);
+ _mockUnitOfWork.Setup(x => x.ContactInfoRepository).Returns(_mockContactInfoRepository.Object);
+
+ _controller = new ContactsController(_mockUnitOfWork.Object, _mockLogger.Object);
+ }
+
+ [Fact]
+ public async Task GetContacts_ReturnsOkResult_WhenContactsExist()
+ {
+ // Arrange
+ var contacts = new List
+ {
+ new() { Id = Guid.NewGuid(), FirstName = "John", LastName = "Doe" },
+ new() { Id = Guid.NewGuid(), FirstName = "Jane", LastName = "Smith" }
+ };
+
+ _mockContactRepository.Setup(x => x.GetContactsAsync(It.IsAny()))
+ .ReturnsAsync(contacts);
+
+ // Act
+ var result = await _controller.GetContacts(CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType>>();
+ }
+
+ [Fact]
+ public async Task GetContacts_ReturnsStatusCode499_WhenOperationCancelled()
+ {
+ // Arrange
+ _mockContactRepository.Setup(x => x.GetContactsAsync(It.IsAny()))
+ .ThrowsAsync(new OperationCanceledException());
+
+ // Act
+ var result = await _controller.GetContacts(CancellationToken.None);
+
+ // Assert
+ var statusCodeResult = result.Should().BeOfType().Subject;
+ statusCodeResult.StatusCode.Should().Be(499);
+ }
+
+ [Fact]
+ public async Task GetContacts_ReturnsInternalServerError_WhenExceptionThrown()
+ {
+ // Arrange
+ _mockContactRepository.Setup(x => x.GetContactsAsync(It.IsAny()))
+ .ThrowsAsync(new Exception("Database error"));
+
+ // Act
+ var result = await _controller.GetContacts(CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType>();
+ }
+
+ [Fact]
+ public async Task GetContactById_ReturnsOkResult_WhenContactExists()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var contact = new Contact { Id = contactId, FirstName = "John", LastName = "Doe" };
+ var contactInfos = new List
+ {
+ new() { Id = Guid.NewGuid(), InfoType = ContactInfoType.Email, Value = "john@example.com", IsDefault = true }
+ };
+
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync(contact);
+ _mockContactInfoRepository.Setup(x => x.GetContactInfoByContactIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync(contactInfos);
+
+ // Act
+ var result = await _controller.GetContactById(contactId, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType>();
+ }
+
+ [Fact]
+ public async Task GetContactById_ReturnsNotFound_WhenContactDoesNotExist()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync((Contact?)null);
+
+ // Act
+ var result = await _controller.GetContactById(contactId, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType>();
+ }
+
+ [Fact]
+ public async Task CreateContact_ReturnsCreated_WhenValidContactProvided()
+ {
+ // Arrange
+ var createContactDto = new CreateContactDto(
+ "John",
+ "Doe",
+ new List
+ {
+ new(ContactInfoType.Email, "john@example.com", true)
+ });
+
+ _mockUnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny()))
+ .ReturnsAsync(1);
+
+ // Act
+ var result = await _controller.CreateContact(createContactDto, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType();
+ _mockContactRepository.Verify(x => x.CreateContactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _mockContactInfoRepository.Verify(x => x.AddContactInfoAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _mockUnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task CreateContact_ReturnsInternalServerError_WhenExceptionThrown()
+ {
+ // Arrange
+ var createContactDto = new CreateContactDto("John", "Doe", null);
+ _mockContactRepository.Setup(x => x.CreateContactAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new Exception("Database error"));
+
+ // Act
+ var result = await _controller.CreateContact(createContactDto, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType>();
+ }
+
+ [Fact]
+ public async Task UpdateContact_ReturnsOk_WhenContactExists()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var contact = new Contact { Id = contactId, FirstName = "John", LastName = "Doe" };
+ var updateContactDto = new UpdateContactDto("Jane", "Smith");
+
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync(contact);
+ _mockUnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny()))
+ .ReturnsAsync(1);
+
+ // Act
+ var result = await _controller.UpdateContact(contactId, updateContactDto, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType();
+ contact.FirstName.Should().Be("Jane");
+ contact.LastName.Should().Be("Smith");
+ _mockContactRepository.Verify(x => x.UpdateContact(contact), Times.Once);
+ _mockUnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task UpdateContact_ReturnsNotFound_WhenContactDoesNotExist()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var updateContactDto = new UpdateContactDto("Jane", "Smith");
+
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync((Contact?)null);
+
+ // Act
+ var result = await _controller.UpdateContact(contactId, updateContactDto, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType>();
+ }
+
+ [Fact]
+ public async Task DeleteContact_ReturnsNoContent_WhenContactExists()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var contact = new Contact { Id = contactId, FirstName = "John", LastName = "Doe" };
+
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync(contact);
+ _mockUnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny()))
+ .ReturnsAsync(1);
+
+ // Act
+ var result = await _controller.DeleteContact(contactId, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType();
+ _mockContactRepository.Verify(x => x.DeleteContactAsync(contactId, CancellationToken.None), Times.Once);
+ _mockUnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task DeleteContact_ReturnsNotFound_WhenContactDoesNotExist()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync((Contact?)null);
+
+ // Act
+ var result = await _controller.DeleteContact(contactId, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType>();
+ }
+
+ [Fact]
+ public async Task AddContactInfo_ReturnsNoContent_WhenContactExists()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var contact = new Contact { Id = contactId, FirstName = "John", LastName = "Doe" };
+ var createContactInfoDto = new CreateContactInfoDto(ContactInfoType.Phone, "123-456-7890", false);
+
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync(contact);
+ _mockUnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny()))
+ .ReturnsAsync(1);
+
+ // Act
+ var result = await _controller.AddContactInfo(contactId, createContactInfoDto, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType();
+ _mockContactInfoRepository.Verify(x => x.AddContactInfoAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _mockUnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task UpdateContactInfo_ReturnsNoContent_WhenContactInfoExists()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var contactInfoId = Guid.NewGuid();
+ var contact = new Contact { Id = contactId, FirstName = "John", LastName = "Doe" };
+ var contactInfo = new ContactInfo
+ {
+ Id = contactInfoId,
+ ContactId = contactId,
+ InfoType = ContactInfoType.Email,
+ Value = "old@example.com",
+ IsDefault = false
+ };
+ var updateContactInfoDto = new ContactInfoDto(contactInfoId, "Email", "new@example.com", true);
+
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync(contact);
+ _mockContactInfoRepository.Setup(x => x.GetContactInfoAsync(contactInfoId, It.IsAny()))
+ .ReturnsAsync(contactInfo);
+ _mockUnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny()))
+ .ReturnsAsync(1);
+
+ // Act
+ var result = await _controller.UpdateContactInfo(contactId, updateContactInfoDto, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType();
+ contactInfo.Value.Should().Be("new@example.com");
+ contactInfo.IsDefault.Should().BeTrue();
+ _mockContactInfoRepository.Verify(x => x.UpdateContactInfo(contactInfo), Times.Once);
+ _mockUnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task DeleteContactInfo_ReturnsNoContent_WhenSuccessful()
+ {
+ // Arrange
+ var infoId = Guid.NewGuid();
+ _mockUnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny()))
+ .ReturnsAsync(1);
+
+ // Act
+ var result = await _controller.DeleteContactInfo(infoId, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType();
+ _mockContactInfoRepository.Verify(x => x.DeleteContactInfoAsync(infoId, It.IsAny()), Times.Once);
+ _mockUnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task GetContactInfo_ReturnsOkResult_WhenContactExists()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var contact = new Contact { Id = contactId, FirstName = "John", LastName = "Doe" };
+ var contactInfos = new List
+ {
+ new() { Id = Guid.NewGuid(), InfoType = ContactInfoType.Email, Value = "john@example.com", IsDefault = true }
+ };
+
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync(contact);
+ _mockContactInfoRepository.Setup(x => x.GetContactInfoByContactIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync(contactInfos);
+
+ // Act
+ var result = await _controller.GetContactInfo(contactId, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType>>();
+ }
+
+ [Fact]
+ public async Task GetContactInfo_ReturnsNotFound_WhenContactDoesNotExist()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync((Contact?)null);
+
+ // Act
+ var result = await _controller.GetContactInfo(contactId, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType>();
+ }
+
+ [Fact]
+ public async Task AddContactInfo_ReturnsNotFound_WhenContactDoesNotExist()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var createContactInfoDto = new CreateContactInfoDto(ContactInfoType.Phone, "123-456-7890", false);
+
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync((Contact?)null);
+
+ // Act
+ var result = await _controller.AddContactInfo(contactId, createContactInfoDto, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType>();
+ }
+
+ [Fact]
+ public async Task UpdateContactInfo_ReturnsNotFound_WhenContactDoesNotExist()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var updateContactInfoDto = new ContactInfoDto(Guid.NewGuid(), "Email", "test@example.com", true);
+
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync((Contact?)null);
+
+ // Act
+ var result = await _controller.UpdateContactInfo(contactId, updateContactInfoDto, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType>();
+ }
+
+ [Fact]
+ public async Task UpdateContactInfo_ReturnsNotFound_WhenContactInfoDoesNotExist()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var contactInfoId = Guid.NewGuid();
+ var contact = new Contact { Id = contactId, FirstName = "John", LastName = "Doe" };
+ var updateContactInfoDto = new ContactInfoDto(contactInfoId, "Email", "test@example.com", true);
+
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync(contact);
+ _mockContactInfoRepository.Setup(x => x.GetContactInfoAsync(contactInfoId, It.IsAny()))
+ .ReturnsAsync((ContactInfo?)null);
+
+ // Act
+ var result = await _controller.UpdateContactInfo(contactId, updateContactInfoDto, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType>();
+ }
+
+ [Theory]
+ [InlineData("Phone")]
+ [InlineData("Location")]
+ public async Task UpdateContactInfo_UpdatesDifferentInfoTypes(string infoType)
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var contactInfoId = Guid.NewGuid();
+ var contact = new Contact { Id = contactId, FirstName = "John", LastName = "Doe" };
+ var contactInfo = new ContactInfo
+ {
+ Id = contactInfoId,
+ ContactId = contactId,
+ InfoType = ContactInfoType.Email,
+ Value = "old@example.com",
+ IsDefault = false
+ };
+ var updateContactInfoDto = new ContactInfoDto(contactInfoId, infoType, "new value", true);
+
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync(contact);
+ _mockContactInfoRepository.Setup(x => x.GetContactInfoAsync(contactInfoId, It.IsAny()))
+ .ReturnsAsync(contactInfo);
+ _mockUnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny()))
+ .ReturnsAsync(1);
+
+ // Act
+ var result = await _controller.UpdateContactInfo(contactId, updateContactInfoDto, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task UpdateContactInfo_ThrowsArgumentException_ForInvalidInfoType()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var contactInfoId = Guid.NewGuid();
+ var contact = new Contact { Id = contactId, FirstName = "John", LastName = "Doe" };
+ var contactInfo = new ContactInfo
+ {
+ Id = contactInfoId,
+ ContactId = contactId,
+ InfoType = ContactInfoType.Email,
+ Value = "old@example.com",
+ IsDefault = false
+ };
+ var updateContactInfoDto = new ContactInfoDto(contactInfoId, "InvalidType", "new value", true);
+
+ _mockContactRepository.Setup(x => x.GetContactByIdAsync(contactId, It.IsAny()))
+ .ReturnsAsync(contact);
+ _mockContactInfoRepository.Setup(x => x.GetContactInfoAsync(contactInfoId, It.IsAny()))
+ .ReturnsAsync(contactInfo);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ _controller.UpdateContactInfo(contactId, updateContactInfoDto, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task CreateContact_WithoutContactInfo_ReturnsCreated()
+ {
+ // Arrange
+ var createContactDto = new CreateContactDto("John", "Doe", null);
+
+ _mockUnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny()))
+ .ReturnsAsync(1);
+
+ // Act
+ var result = await _controller.CreateContact(createContactDto, CancellationToken.None);
+
+ // Assert
+ result.Should().BeOfType();
+ _mockContactRepository.Verify(x => x.CreateContactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _mockContactInfoRepository.Verify(x => x.AddContactInfoAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+}
\ No newline at end of file
diff --git a/tests/ContactBook.Contacts.API.Tests/MiddlewareTests.cs b/tests/ContactBook.Contacts.API.Tests/MiddlewareTests.cs
new file mode 100644
index 0000000..e28571f
--- /dev/null
+++ b/tests/ContactBook.Contacts.API.Tests/MiddlewareTests.cs
@@ -0,0 +1,182 @@
+using ContactBook.Contacts.API.Middleware;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Moq;
+using System.Text.Json;
+
+namespace ContactBook.Contacts.API.Tests.Middleware;
+
+public class ErrorHandlingMiddlewareTests
+{
+ private readonly Mock> _mockLogger;
+ private readonly ErrorHandlingMiddleware _middleware;
+
+ public ErrorHandlingMiddlewareTests()
+ {
+ _mockLogger = new Mock>();
+ _middleware = new ErrorHandlingMiddleware(
+ context => throw new Exception("Test exception"),
+ _mockLogger.Object);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_HandlesException_AndReturns500()
+ {
+ // Arrange
+ var context = new DefaultHttpContext();
+ context.Response.Body = new MemoryStream();
+
+ // Act
+ await _middleware.InvokeAsync(context);
+
+ // Assert
+ context.Response.StatusCode.Should().Be(500);
+ context.Response.ContentType.Should().Be("application/json");
+
+ context.Response.Body.Seek(0, SeekOrigin.Begin);
+ var reader = new StreamReader(context.Response.Body);
+ var responseBody = await reader.ReadToEndAsync();
+
+ var errorResponse = JsonSerializer.Deserialize(responseBody);
+ errorResponse.GetProperty("status").GetInt32().Should().Be(500);
+ errorResponse.GetProperty("message").GetString().Should().Be("An internal server error occurred. Please try again later.");
+ }
+
+ [Fact]
+ public async Task InvokeAsync_LetsCancellationExceptionPropagate()
+ {
+ // Arrange
+ var middleware = new ErrorHandlingMiddleware(
+ context => throw new OperationCanceledException(),
+ _mockLogger.Object);
+
+ var context = new DefaultHttpContext();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => middleware.InvokeAsync(context));
+ }
+
+ [Fact]
+ public async Task InvokeAsync_DoesNotHandleException_WhenResponseAlreadyStarted()
+ {
+ // Arrange
+ var middleware = new ErrorHandlingMiddleware(
+ async context =>
+ {
+ await Task.Delay(100); // Simulate some processing
+ throw new Exception("Test exception");
+ },
+ _mockLogger.Object);
+
+ var mockResponse = new Mock();
+ mockResponse.Setup(r => r.HasStarted).Returns(true);
+ mockResponse.SetupProperty(r => r.StatusCode, StatusCodes.Status200OK);
+ mockResponse.SetupGet(r => r.Body).Returns(new MemoryStream());
+
+ var mockContext = new Mock();
+ mockContext.SetupGet(c => c.Response).Returns(mockResponse.Object);
+ mockContext.SetupGet(c => c.Request.Path).Returns("/failtest");
+
+ // Act
+ Func act = () => middleware.InvokeAsync(mockContext.Object);
+
+ act.Should().ThrowAsync()
+ .WithMessage("Test exception");
+
+
+ // Assert
+ mockContext.Object.Response.StatusCode.Should().Be(StatusCodes.Status200OK); // Default status, not changed to 500
+ }
+}
+
+public class RequestCancellationMiddlewareTests
+{
+ private readonly Mock> _mockLogger;
+
+ public RequestCancellationMiddlewareTests()
+ {
+ _mockLogger = new Mock>();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_HandlesCancellation_AndReturns499()
+ {
+ // Arrange
+ var cancellationTokenSource = new CancellationTokenSource();
+ cancellationTokenSource.Cancel();
+
+ var middleware = new RequestCancellationMiddleware(
+ context => throw new OperationCanceledException(cancellationTokenSource.Token),
+ _mockLogger.Object);
+
+ var context = new DefaultHttpContext();
+ context.RequestAborted = cancellationTokenSource.Token;
+ context.Response.Body = new MemoryStream();
+
+ // Act
+ await middleware.InvokeAsync(context);
+
+ // Assert
+ context.Response.StatusCode.Should().Be(499);
+
+ context.Response.Body.Seek(0, SeekOrigin.Begin);
+ var reader = new StreamReader(context.Response.Body);
+ var responseBody = await reader.ReadToEndAsync();
+ responseBody.Should().Be("Request cancelled by client");
+ }
+
+ [Fact]
+ public async Task InvokeAsync_DoesNotHandleCancellation_WhenResponseAlreadyStarted()
+ {
+ // Arrange
+ var cancellationTokenSource = new CancellationTokenSource();
+ cancellationTokenSource.Cancel();
+
+
+ var middleware = new RequestCancellationMiddleware(
+ async ctx =>
+ {
+ throw new OperationCanceledException(cancellationTokenSource.Token);
+ },
+ _mockLogger.Object);
+
+ var mockResponse = new Mock();
+ mockResponse.Setup(r => r.HasStarted).Returns(true);
+ mockResponse.SetupProperty(r => r.StatusCode, StatusCodes.Status200OK);
+ mockResponse.SetupGet(r => r.Body).Returns(new MemoryStream());
+
+ var mockContext = new Mock();
+ mockContext.SetupGet(c => c.RequestAborted).Returns(cancellationTokenSource.Token);
+ mockContext.SetupGet(c => c.Response).Returns(mockResponse.Object);
+
+ // Act
+ Func act = () => middleware.InvokeAsync(mockContext.Object);
+
+ act.Should().ThrowAsync();
+ // Assert
+ mockContext.Object.Response.StatusCode.Should().Be(StatusCodes.Status200OK); // Default status, not changed to 499
+ }
+
+ [Fact]
+ public async Task InvokeAsync_CallsNext_WhenNoException()
+ {
+ // Arrange
+ var nextCalled = false;
+ var middleware = new RequestCancellationMiddleware(
+ context =>
+ {
+ nextCalled = true;
+ return Task.CompletedTask;
+ },
+ _mockLogger.Object);
+
+ var context = new DefaultHttpContext();
+
+ // Act
+ await middleware.InvokeAsync(context);
+
+ // Assert
+ nextCalled.Should().BeTrue();
+ }
+}
\ No newline at end of file
diff --git a/tests/ContactBook.Domain.Tests/ContactBook.Domain.Tests.csproj b/tests/ContactBook.Domain.Tests/ContactBook.Domain.Tests.csproj
new file mode 100644
index 0000000..bc21374
--- /dev/null
+++ b/tests/ContactBook.Domain.Tests/ContactBook.Domain.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ContactBook.Domain.Tests/DomainEntityTests.cs b/tests/ContactBook.Domain.Tests/DomainEntityTests.cs
new file mode 100644
index 0000000..4638c7d
--- /dev/null
+++ b/tests/ContactBook.Domain.Tests/DomainEntityTests.cs
@@ -0,0 +1,222 @@
+using ContactBook.Contacts.Domain.Entities;
+using ContactBook.Reports.Domain.Entities;
+using FluentAssertions;
+
+namespace ContactBook.Domain.Tests.Entities;
+
+public class ContactTests
+{
+ [Fact]
+ public void Contact_CanBeCreated_WithValidProperties()
+ {
+ // Arrange & Act
+ var contact = new Contact
+ {
+ Id = Guid.NewGuid(),
+ FirstName = "John",
+ LastName = "Doe"
+ };
+
+ // Assert
+ contact.Id.Should().NotBe(Guid.Empty);
+ contact.FirstName.Should().Be("John");
+ contact.LastName.Should().Be("Doe");
+ contact.ContactInfos.Should().BeNull();
+ }
+
+ [Fact]
+ public void Contact_CanHaveContactInfos()
+ {
+ // Arrange
+ var contact = new Contact
+ {
+ Id = Guid.NewGuid(),
+ FirstName = "Jane",
+ LastName = "Smith"
+ };
+
+ var contactInfos = new List
+ {
+ new() { Id = Guid.NewGuid(), InfoType = ContactInfoType.Email, Value = "jane@example.com", IsDefault = true },
+ new() { Id = Guid.NewGuid(), InfoType = ContactInfoType.Phone, Value = "123-456-7890", IsDefault = false }
+ };
+
+ // Act
+ contact.ContactInfos = contactInfos;
+
+ // Assert
+ contact.ContactInfos.Should().HaveCount(2);
+ contact.ContactInfos.Should().Contain(ci => ci.InfoType == ContactInfoType.Email);
+ contact.ContactInfos.Should().Contain(ci => ci.InfoType == ContactInfoType.Phone);
+ }
+}
+
+public class ContactInfoTests
+{
+ [Fact]
+ public void ContactInfo_CanBeCreated_WithValidProperties()
+ {
+ // Arrange & Act
+ var contactInfo = new ContactInfo
+ {
+ Id = Guid.NewGuid(),
+ ContactId = Guid.NewGuid(),
+ InfoType = ContactInfoType.Email,
+ Value = "test@example.com",
+ IsDefault = true
+ };
+
+ // Assert
+ contactInfo.Id.Should().NotBe(Guid.Empty);
+ contactInfo.ContactId.Should().NotBe(Guid.Empty);
+ contactInfo.InfoType.Should().Be(ContactInfoType.Email);
+ contactInfo.Value.Should().Be("test@example.com");
+ contactInfo.IsDefault.Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData(ContactInfoType.Phone)]
+ [InlineData(ContactInfoType.Email)]
+ [InlineData(ContactInfoType.Location)]
+ public void ContactInfo_CanHaveDifferentInfoTypes(ContactInfoType infoType)
+ {
+ // Arrange & Act
+ var contactInfo = new ContactInfo
+ {
+ Id = Guid.NewGuid(),
+ ContactId = Guid.NewGuid(),
+ InfoType = infoType,
+ Value = "test value",
+ IsDefault = false
+ };
+
+ // Assert
+ contactInfo.InfoType.Should().Be(infoType);
+ }
+}
+
+public class ContactInfoTypeTests
+{
+ [Fact]
+ public void ContactInfoType_HasCorrectValues()
+ {
+ // Assert
+ ContactInfoType.Phone.Should().Be((ContactInfoType)1);
+ ContactInfoType.Email.Should().Be((ContactInfoType)2);
+ ContactInfoType.Location.Should().Be((ContactInfoType)3);
+ }
+
+ [Fact]
+ public void ContactInfoType_CanBeConvertedToInt()
+ {
+ // Act & Assert
+ ((int)ContactInfoType.Phone).Should().Be(1);
+ ((int)ContactInfoType.Email).Should().Be(2);
+ ((int)ContactInfoType.Location).Should().Be(3);
+ }
+}
+
+public class ReportTests
+{
+ [Fact]
+ public void Report_CanBeCreated_WithValidProperties()
+ {
+ // Arrange & Act
+ var report = new Report
+ {
+ Id = Guid.NewGuid(),
+ RequestedAt = DateTime.UtcNow,
+ Status = ReportStatus.Requested
+ };
+
+ // Assert
+ report.Id.Should().NotBe(Guid.Empty);
+ report.RequestedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
+ report.Status.Should().Be(ReportStatus.Requested);
+ report.Data.Should().BeNull();
+ }
+
+ [Fact]
+ public void Report_CanHaveLocationStatistics()
+ {
+ // Arrange
+ var report = new Report
+ {
+ Id = Guid.NewGuid(),
+ RequestedAt = DateTime.UtcNow,
+ Status = ReportStatus.Completed
+ };
+
+ var locationStats = new List
+ {
+ new("Istanbul", 10, 8),
+ new("Ankara", 5, 3)
+ };
+
+ // Act
+ report.Data = locationStats;
+
+ // Assert
+ report.Data.Should().HaveCount(2);
+ report.Data.Should().Contain(ls => ls.Location == "Istanbul" && ls.ContactCount == 10 && ls.PhoneCount == 8);
+ report.Data.Should().Contain(ls => ls.Location == "Ankara" && ls.ContactCount == 5 && ls.PhoneCount == 3);
+ }
+}
+
+public class LocationStatisticsTests
+{
+ [Fact]
+ public void LocationStatistics_CanBeCreated_WithValidProperties()
+ {
+ // Arrange & Act
+ var locationStats = new LocationStatistics("Istanbul", 15, 12);
+
+ // Assert
+ locationStats.Location.Should().Be("Istanbul");
+ locationStats.ContactCount.Should().Be(15);
+ locationStats.PhoneCount.Should().Be(12);
+ }
+
+ [Fact]
+ public void LocationStatistics_IsRecord_SupportsValueEquality()
+ {
+ // Arrange
+ var stats1 = new LocationStatistics("Istanbul", 10, 8);
+ var stats2 = new LocationStatistics("Istanbul", 10, 8);
+ var stats3 = new LocationStatistics("Ankara", 10, 8);
+
+ // Assert
+ stats1.Should().Be(stats2);
+ stats1.Should().NotBe(stats3);
+ }
+}
+
+public class ReportStatusTests
+{
+ [Fact]
+ public void ReportStatus_HasCorrectValues()
+ {
+ // Assert
+ ReportStatus.Requested.Should().Be((ReportStatus)0);
+ ReportStatus.InProgress.Should().Be((ReportStatus)1);
+ ReportStatus.Completed.Should().Be((ReportStatus)2);
+ }
+
+ [Theory]
+ [InlineData(ReportStatus.Requested)]
+ [InlineData(ReportStatus.InProgress)]
+ [InlineData(ReportStatus.Completed)]
+ public void ReportStatus_CanBeUsedInReport(ReportStatus status)
+ {
+ // Arrange & Act
+ var report = new Report
+ {
+ Id = Guid.NewGuid(),
+ RequestedAt = DateTime.UtcNow,
+ Status = status
+ };
+
+ // Assert
+ report.Status.Should().Be(status);
+ }
+}
\ No newline at end of file
diff --git a/tests/ContactBook.Infrastructure.Tests/ContactBook.Infrastructure.Tests.csproj b/tests/ContactBook.Infrastructure.Tests/ContactBook.Infrastructure.Tests.csproj
new file mode 100644
index 0000000..16f2e5d
--- /dev/null
+++ b/tests/ContactBook.Infrastructure.Tests/ContactBook.Infrastructure.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/ContactBook.Infrastructure.Tests/ContactInfoRepositoryTests.cs b/tests/ContactBook.Infrastructure.Tests/ContactInfoRepositoryTests.cs
new file mode 100644
index 0000000..466481a
--- /dev/null
+++ b/tests/ContactBook.Infrastructure.Tests/ContactInfoRepositoryTests.cs
@@ -0,0 +1,203 @@
+using ContactBook.Contacts.Domain.Entities;
+using ContactBook.Contacts.Infrastructure.Persistence;
+using ContactBook.Contacts.Infrastructure.Repository;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+
+namespace ContactBook.Infrastructure.Tests.Repository;
+
+public class ContactInfoRepositoryTests : IDisposable
+{
+ private readonly ContactsDbContext _context;
+ private readonly ContactInfoRepository _repository;
+
+ public ContactInfoRepositoryTests()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
+ .Options;
+
+ _context = new ContactsDbContext(options);
+ _repository = new ContactInfoRepository(_context);
+ }
+
+ [Fact]
+ public async Task GetContactInfosAsync_ReturnsAllContactInfos()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var contactInfos = new List
+ {
+ new() { Id = Guid.NewGuid(), ContactId = contactId, InfoType = ContactInfoType.Email, Value = "test1@example.com", IsDefault = true },
+ new() { Id = Guid.NewGuid(), ContactId = contactId, InfoType = ContactInfoType.Phone, Value = "123-456-7890", IsDefault = false }
+ };
+
+ await _context.ContactInfos.AddRangeAsync(contactInfos);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _repository.GetContactInfosAsync();
+
+ // Assert
+ result.Should().HaveCount(2);
+ result.Should().Contain(ci => ci.InfoType == ContactInfoType.Email);
+ result.Should().Contain(ci => ci.InfoType == ContactInfoType.Phone);
+ }
+
+ [Fact]
+ public async Task GetContactInfoAsync_ReturnsContactInfo_WhenExists()
+ {
+ // Arrange
+ var contactInfoId = Guid.NewGuid();
+ var contactInfo = new ContactInfo
+ {
+ Id = contactInfoId,
+ ContactId = Guid.NewGuid(),
+ InfoType = ContactInfoType.Email,
+ Value = "test@example.com",
+ IsDefault = true
+ };
+
+ await _context.ContactInfos.AddAsync(contactInfo);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _repository.GetContactInfoAsync(contactInfoId);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Id.Should().Be(contactInfoId);
+ result.Value.Should().Be("test@example.com");
+ result.InfoType.Should().Be(ContactInfoType.Email);
+ }
+
+ [Fact]
+ public async Task GetContactInfoAsync_ThrowsException_WhenNotExists()
+ {
+ // Arrange
+ var nonExistentId = Guid.NewGuid();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _repository.GetContactInfoAsync(nonExistentId));
+ }
+
+ [Fact]
+ public async Task GetContactInfoByContactIdAsync_ReturnsContactInfosForContact()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var otherContactId = Guid.NewGuid();
+
+ var contactInfos = new List
+ {
+ new() { Id = Guid.NewGuid(), ContactId = contactId, InfoType = ContactInfoType.Email, Value = "contact1@example.com", IsDefault = true },
+ new() { Id = Guid.NewGuid(), ContactId = contactId, InfoType = ContactInfoType.Phone, Value = "123-456-7890", IsDefault = false },
+ new() { Id = Guid.NewGuid(), ContactId = otherContactId, InfoType = ContactInfoType.Email, Value = "other@example.com", IsDefault = true }
+ };
+
+ await _context.ContactInfos.AddRangeAsync(contactInfos);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _repository.GetContactInfoByContactIdAsync(contactId);
+
+ // Assert
+ result.Should().HaveCount(2);
+ result.Should().OnlyContain(ci => ci.ContactId == contactId);
+ }
+
+ [Fact]
+ public async Task AddContactInfoAsync_AddsContactInfoToDatabase()
+ {
+ // Arrange
+ var contactInfo = new ContactInfo
+ {
+ Id = Guid.NewGuid(),
+ ContactId = Guid.NewGuid(),
+ InfoType = ContactInfoType.Location,
+ Value = "Istanbul, Turkey",
+ IsDefault = true
+ };
+
+ // Act
+ await _repository.AddContactInfoAsync(contactInfo);
+ await _context.SaveChangesAsync();
+
+ // Assert
+ var savedContactInfo = await _context.ContactInfos.FindAsync(contactInfo.Id);
+ savedContactInfo.Should().NotBeNull();
+ savedContactInfo!.Value.Should().Be("Istanbul, Turkey");
+ savedContactInfo.InfoType.Should().Be(ContactInfoType.Location);
+ }
+
+ [Fact]
+ public async Task UpdateContactInfo_ModifiesExistingContactInfo()
+ {
+ // Arrange
+ var contactInfo = new ContactInfo
+ {
+ Id = Guid.NewGuid(),
+ ContactId = Guid.NewGuid(),
+ InfoType = ContactInfoType.Email,
+ Value = "old@example.com",
+ IsDefault = false
+ };
+
+ await _context.ContactInfos.AddAsync(contactInfo);
+ await _context.SaveChangesAsync();
+
+ // Modify the contact info
+ contactInfo.Value = "new@example.com";
+ contactInfo.IsDefault = true;
+
+ // Act
+ _repository.UpdateContactInfo(contactInfo);
+ await _context.SaveChangesAsync();
+
+ // Assert
+ var updatedContactInfo = await _context.ContactInfos.FindAsync(contactInfo.Id);
+ updatedContactInfo.Should().NotBeNull();
+ updatedContactInfo!.Value.Should().Be("new@example.com");
+ updatedContactInfo.IsDefault.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task DeleteContactInfoAsync_RemovesContactInfoFromDatabase()
+ {
+ // Arrange
+ var contactInfo = new ContactInfo
+ {
+ Id = Guid.NewGuid(),
+ ContactId = Guid.NewGuid(),
+ InfoType = ContactInfoType.Phone,
+ Value = "555-1234",
+ IsDefault = false
+ };
+
+ await _context.ContactInfos.AddAsync(contactInfo);
+ await _context.SaveChangesAsync();
+
+ // Act
+ await _repository.DeleteContactInfoAsync(contactInfo.Id);
+ await _context.SaveChangesAsync();
+
+ // Assert
+ var deletedContactInfo = await _context.ContactInfos.FindAsync(contactInfo.Id);
+ deletedContactInfo.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task DeleteContactInfoAsync_ThrowsException_WhenContactInfoDoesNotExist()
+ {
+ // Arrange
+ var nonExistentId = Guid.NewGuid();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _repository.DeleteContactInfoAsync(nonExistentId));
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/tests/ContactBook.Infrastructure.Tests/ContactRepositoryTests.cs b/tests/ContactBook.Infrastructure.Tests/ContactRepositoryTests.cs
new file mode 100644
index 0000000..84dc813
--- /dev/null
+++ b/tests/ContactBook.Infrastructure.Tests/ContactRepositoryTests.cs
@@ -0,0 +1,146 @@
+using ContactBook.Contacts.Domain.Entities;
+using ContactBook.Contacts.Infrastructure.Persistence;
+using ContactBook.Contacts.Infrastructure.Repository;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+
+namespace ContactBook.Infrastructure.Tests.Repository;
+
+public class ContactRepositoryTests : IDisposable
+{
+ private readonly ContactsDbContext _context;
+ private readonly ContactRepository _repository;
+
+ public ContactRepositoryTests()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
+ .Options;
+
+ _context = new ContactsDbContext(options);
+ _repository = new ContactRepository(_context);
+ }
+
+ [Fact]
+ public async Task GetContactsAsync_ReturnsAllContacts()
+ {
+ // Arrange
+ var contacts = new List
+ {
+ new() { Id = Guid.NewGuid(), FirstName = "John", LastName = "Doe" },
+ new() { Id = Guid.NewGuid(), FirstName = "Jane", LastName = "Smith" }
+ };
+
+ await _context.Contacts.AddRangeAsync(contacts);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _repository.GetContactsAsync();
+
+ // Assert
+ result.Should().HaveCount(2);
+ result.Should().Contain(c => c.FirstName == "John" && c.LastName == "Doe");
+ result.Should().Contain(c => c.FirstName == "Jane" && c.LastName == "Smith");
+ }
+
+ [Fact]
+ public async Task GetContactByIdAsync_ReturnsContact_WhenContactExists()
+ {
+ // Arrange
+ var contactId = Guid.NewGuid();
+ var contact = new Contact { Id = contactId, FirstName = "John", LastName = "Doe" };
+
+ await _context.Contacts.AddAsync(contact);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _repository.GetContactByIdAsync(contactId);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Id.Should().Be(contactId);
+ result.FirstName.Should().Be("John");
+ result.LastName.Should().Be("Doe");
+ }
+
+ [Fact]
+ public async Task GetContactByIdAsync_ThrowsException_WhenContactDoesNotExist()
+ {
+ // Arrange
+ var nonExistentId = Guid.NewGuid();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _repository.GetContactByIdAsync(nonExistentId));
+ }
+
+ [Fact]
+ public async Task CreateContactAsync_AddsContactToDatabase()
+ {
+ // Arrange
+ var contact = new Contact { Id = Guid.NewGuid(), FirstName = "New", LastName = "Contact" };
+
+ // Act
+ await _repository.CreateContactAsync(contact);
+ await _context.SaveChangesAsync();
+
+ // Assert
+ var savedContact = await _context.Contacts.FindAsync(contact.Id);
+ savedContact.Should().NotBeNull();
+ savedContact!.FirstName.Should().Be("New");
+ savedContact.LastName.Should().Be("Contact");
+ }
+
+ [Fact]
+ public async Task UpdateContact_ModifiesExistingContact()
+ {
+ // Arrange
+ var contact = new Contact { Id = Guid.NewGuid(), FirstName = "Original", LastName = "Name" };
+ await _context.Contacts.AddAsync(contact);
+ await _context.SaveChangesAsync();
+
+ // Modify the contact
+ contact.FirstName = "Updated";
+ contact.LastName = "Name";
+
+ // Act
+ _repository.UpdateContact(contact);
+ await _context.SaveChangesAsync();
+
+ // Assert
+ var updatedContact = await _context.Contacts.FindAsync(contact.Id);
+ updatedContact.Should().NotBeNull();
+ updatedContact!.FirstName.Should().Be("Updated");
+ }
+
+ [Fact]
+ public async Task DeleteContactAsync_RemovesContactFromDatabase()
+ {
+ // Arrange
+ var contact = new Contact { Id = Guid.NewGuid(), FirstName = "To", LastName = "Delete" };
+ await _context.Contacts.AddAsync(contact);
+ await _context.SaveChangesAsync();
+
+ // Act
+ await _repository.DeleteContactAsync(contact.Id);
+ await _context.SaveChangesAsync();
+
+ // Assert
+ var deletedContact = await _context.Contacts.FindAsync(contact.Id);
+ deletedContact.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task DeleteContactAsync_ThrowsException_WhenContactDoesNotExist()
+ {
+ // Arrange
+ var nonExistentId = Guid.NewGuid();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _repository.DeleteContactAsync(nonExistentId));
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/tests/ContactBook.Infrastructure.Tests/ContactsUnitOfWorkTests.cs b/tests/ContactBook.Infrastructure.Tests/ContactsUnitOfWorkTests.cs
new file mode 100644
index 0000000..73ee690
--- /dev/null
+++ b/tests/ContactBook.Infrastructure.Tests/ContactsUnitOfWorkTests.cs
@@ -0,0 +1,107 @@
+using ContactBook.Contacts.Infrastructure.Persistence;
+using ContactBook.Contacts.Infrastructure.Repository;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+
+namespace ContactBook.Infrastructure.Tests.Repository;
+
+public class ContactsUnitOfWorkTests : IDisposable
+{
+ private readonly ContactsDbContext _context;
+ private readonly ContactsUnitOfWork _unitOfWork;
+
+ public ContactsUnitOfWorkTests()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
+ .Options;
+
+ _context = new ContactsDbContext(options);
+ _unitOfWork = new ContactsUnitOfWork(_context);
+ }
+
+ [Fact]
+ public void Constructor_InitializesRepositories()
+ {
+ // Assert
+ _unitOfWork.ContactRepository.Should().NotBeNull();
+ _unitOfWork.ContactInfoRepository.Should().NotBeNull();
+ _unitOfWork.ContactRepository.Should().BeOfType();
+ _unitOfWork.ContactInfoRepository.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task SaveChangesAsync_ReturnsNumberOfChanges()
+ {
+ // Arrange
+ var contact = new ContactBook.Contacts.Domain.Entities.Contact
+ {
+ Id = Guid.NewGuid(),
+ FirstName = "Test",
+ LastName = "User"
+ };
+
+ await _unitOfWork.ContactRepository.CreateContactAsync(contact);
+
+ // Act
+ var result = await _unitOfWork.SaveChangesAsync();
+
+ // Assert
+ result.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task SaveChangesAsync_WithCancellationToken_WorksCorrectly()
+ {
+ // Arrange
+ var contact = new ContactBook.Contacts.Domain.Entities.Contact
+ {
+ Id = Guid.NewGuid(),
+ FirstName = "Test",
+ LastName = "User"
+ };
+
+ await _unitOfWork.ContactRepository.CreateContactAsync(contact);
+ var cancellationToken = new CancellationToken();
+
+ // Act
+ var result = await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ // Assert
+ result.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task SaveChangesAsync_DetectsChangesBeforeSaving()
+ {
+ // Arrange
+ var contact = new ContactBook.Contacts.Domain.Entities.Contact
+ {
+ Id = Guid.NewGuid(),
+ FirstName = "Original",
+ LastName = "Name"
+ };
+
+ await _unitOfWork.ContactRepository.CreateContactAsync(contact);
+ await _unitOfWork.SaveChangesAsync();
+
+ // Modify the contact
+ contact.FirstName = "Modified";
+ _unitOfWork.ContactRepository.UpdateContact(contact);
+
+ // Act
+ var result = await _unitOfWork.SaveChangesAsync();
+
+ // Assert
+ result.Should().Be(1);
+
+ // Verify the change was saved
+ var savedContact = await _context.Contacts.FindAsync(contact.Id);
+ savedContact!.FirstName.Should().Be("Modified");
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/tests/ContactBook.Infrastructure.Tests/KafkaProducerTests.cs b/tests/ContactBook.Infrastructure.Tests/KafkaProducerTests.cs
new file mode 100644
index 0000000..0c62004
--- /dev/null
+++ b/tests/ContactBook.Infrastructure.Tests/KafkaProducerTests.cs
@@ -0,0 +1,53 @@
+using ContactBook.Reports.Infrastructure.MesssageBroker;
+using FluentAssertions;
+using Microsoft.Extensions.Configuration;
+using Moq;
+
+namespace ContactBook.Infrastructure.Tests.MessageBroker;
+
+public class KafkaProducerTests
+{
+ private readonly Mock _mockConfiguration;
+
+ public KafkaProducerTests()
+ {
+ _mockConfiguration = new Mock();
+ _mockConfiguration.Setup(x => x["Kafka:BootstrapServers"]).Returns("localhost:9092");
+ }
+
+ [Fact]
+ public void Constructor_InitializesWithConfiguration()
+ {
+ // Act & Assert
+ var action = () => new KafkaProducer(_mockConfiguration.Object);
+ action.Should().NotThrow();
+ }
+
+ [Fact]
+ public async Task PublishAsync_SerializesMessageCorrectly()
+ {
+ // Note: This test is limited because we can't easily mock the Kafka producer
+ // In a real scenario, you would use dependency injection to inject an IProducer interface
+ // and mock that interface for testing
+
+ // Arrange
+ var producer = new KafkaProducer(_mockConfiguration.Object);
+ var testMessage = new { Id = Guid.NewGuid(), Name = "Test" };
+
+ // Act & Assert
+ // This would throw if Kafka is not available, but we're testing the serialization logic
+ var action = async () => await producer.PublishAsync("test-topic", testMessage, CancellationToken.None);
+
+ // We can't easily test the actual publishing without a real Kafka instance
+ // But we can test that the method doesn't throw due to serialization issues
+ // In a production environment, you would use testcontainers or similar for integration tests
+
+ // For now, we just ensure the method signature is correct
+ action.Should().NotBeNull();
+ }
+}
+
+// Note: For comprehensive Kafka testing, you would typically use:
+// 1. Testcontainers to spin up a real Kafka instance
+// 2. Dependency injection with IProducer interface for better testability
+// 3. Integration tests with actual Kafka brokers
\ No newline at end of file
diff --git a/tests/ContactBook.Infrastructure.Tests/ReportRepositoryTests.cs b/tests/ContactBook.Infrastructure.Tests/ReportRepositoryTests.cs
new file mode 100644
index 0000000..e8540c3
--- /dev/null
+++ b/tests/ContactBook.Infrastructure.Tests/ReportRepositoryTests.cs
@@ -0,0 +1,209 @@
+using ContactBook.Reports.Domain.Entities;
+using ContactBook.Reports.Infrastructure.Persistence;
+using ContactBook.Reports.Infrastructure.Repository;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+
+namespace ContactBook.Infrastructure.Tests.Repository;
+
+public class ReportRepositoryTests : IDisposable
+{
+ private readonly ReportDbContext _context;
+ private readonly ReportRepository _repository;
+
+ public ReportRepositoryTests()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
+ .Options;
+
+ _context = new ReportDbContext(options);
+ _repository = new ReportRepository(_context);
+ }
+
+ [Fact]
+ public async Task GetReportsAsync_ReturnsAllReports()
+ {
+ // Arrange
+ var reports = new List
+ {
+ new() { Id = Guid.NewGuid(), RequestedAt = DateTime.UtcNow, Status = ReportStatus.Completed },
+ new() { Id = Guid.NewGuid(), RequestedAt = DateTime.UtcNow, Status = ReportStatus.Requested }
+ };
+
+ await _context.Reports.AddRangeAsync(reports);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _repository.GetReportsAsync();
+
+ // Assert
+ result.Should().HaveCount(2);
+ result.Should().Contain(r => r.Status == ReportStatus.Completed);
+ result.Should().Contain(r => r.Status == ReportStatus.Requested);
+ }
+
+ [Fact]
+ public async Task GetReportAsync_ReturnsReport_WhenExists()
+ {
+ // Arrange
+ var reportId = Guid.NewGuid();
+ var report = new Report
+ {
+ Id = reportId,
+ RequestedAt = DateTime.UtcNow,
+ Status = ReportStatus.Completed,
+ Data = new List
+ {
+ new("Istanbul", 5, 3),
+ new("Ankara", 2, 1)
+ }
+ };
+
+ await _context.Reports.AddAsync(report);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _repository.GetReportAsync(reportId);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Id.Should().Be(reportId);
+ result.Status.Should().Be(ReportStatus.Completed);
+ result.Data.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public async Task GetReportAsync_ThrowsException_WhenNotExists()
+ {
+ // Arrange
+ var nonExistentId = Guid.NewGuid();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _repository.GetReportAsync(nonExistentId));
+ }
+
+ [Fact]
+ public async Task CreateReportAsync_AddsReportToDatabase()
+ {
+ // Arrange
+ var report = new Report
+ {
+ Id = Guid.NewGuid(),
+ RequestedAt = DateTime.UtcNow,
+ Status = ReportStatus.Requested
+ };
+
+ // Act
+ var result = await _repository.CreateReportAsync(report);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Id.Should().Be(report.Id);
+
+ var savedReport = await _context.Reports.FindAsync(report.Id);
+ savedReport.Should().NotBeNull();
+ savedReport!.Status.Should().Be(ReportStatus.Requested);
+ }
+
+ [Fact]
+ public async Task UpdateReportAsync_ModifiesExistingReport()
+ {
+ // Arrange
+ var report = new Report
+ {
+ Id = Guid.NewGuid(),
+ RequestedAt = DateTime.UtcNow,
+ Status = ReportStatus.Requested
+ };
+
+ await _context.Reports.AddAsync(report);
+ await _context.SaveChangesAsync();
+
+ // Modify the report
+ report.Status = ReportStatus.Completed;
+ report.Data = new List
+ {
+ new("Test Location", 1, 1)
+ };
+
+ // Act
+ var result = await _repository.UpdateReportAsync(report);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Status.Should().Be(ReportStatus.Completed);
+ result.Data.Should().HaveCount(1);
+
+ var updatedReport = await _context.Reports.FindAsync(report.Id);
+ updatedReport.Should().NotBeNull();
+ updatedReport!.Status.Should().Be(ReportStatus.Completed);
+ }
+
+ [Fact]
+ public async Task DeleteReportAsync_RemovesReportFromDatabase()
+ {
+ // Arrange
+ var report = new Report
+ {
+ Id = Guid.NewGuid(),
+ RequestedAt = DateTime.UtcNow,
+ Status = ReportStatus.Completed
+ };
+
+ await _context.Reports.AddAsync(report);
+ await _context.SaveChangesAsync();
+
+ // Act
+ await _repository.DeleteReportAsync(report.Id);
+
+ // Assert
+ var deletedReport = await _context.Reports.FindAsync(report.Id);
+ deletedReport.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task DeleteReportAsync_ThrowsException_WhenReportDoesNotExist()
+ {
+ // Arrange
+ var nonExistentId = Guid.NewGuid();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _repository.DeleteReportAsync(nonExistentId));
+ }
+
+ [Fact]
+ public async Task CreateReportAsync_WithComplexData_SavesCorrectly()
+ {
+ // Arrange
+ var report = new Report
+ {
+ Id = Guid.NewGuid(),
+ RequestedAt = DateTime.UtcNow,
+ Status = ReportStatus.Completed,
+ Data = new List
+ {
+ new("Istanbul", 10, 8),
+ new("Ankara", 5, 3),
+ new("Izmir", 7, 5)
+ }
+ };
+
+ // Act
+ var result = await _repository.CreateReportAsync(report);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Data.Should().HaveCount(3);
+ result.Data.Should().Contain(d => d.Location == "Istanbul" && d.ContactCount == 10 && d.PhoneCount == 8);
+
+ var savedReport = await _context.Reports.FindAsync(report.Id);
+ savedReport.Should().NotBeNull();
+ savedReport!.Data.Should().HaveCount(3);
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/tests/ContactBook.Reports.API.Tests/ContactBook.Reports.API.Tests.csproj b/tests/ContactBook.Reports.API.Tests/ContactBook.Reports.API.Tests.csproj
new file mode 100644
index 0000000..5bfb6d5
--- /dev/null
+++ b/tests/ContactBook.Reports.API.Tests/ContactBook.Reports.API.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ContactBook.Reports.API.Tests/ReportsControllerTests.cs b/tests/ContactBook.Reports.API.Tests/ReportsControllerTests.cs
new file mode 100644
index 0000000..346e2d3
--- /dev/null
+++ b/tests/ContactBook.Reports.API.Tests/ReportsControllerTests.cs
@@ -0,0 +1,193 @@
+using ContactBook.Reports.API.Controllers;
+using ContactBook.Reports.Application.DTOs;
+using ContactBook.Reports.Application.Interfaces;
+using ContactBook.Reports.Domain.Entities;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+
+namespace ContactBook.Reports.API.Tests.Controllers;
+
+public class ReportsControllerTests
+{
+ private readonly Mock _mockReportRepository;
+ private readonly Mock _mockKafkaProducer;
+ private readonly ReportsController _controller;
+
+ public ReportsControllerTests()
+ {
+ _mockReportRepository = new Mock();
+ _mockKafkaProducer = new Mock();
+ _controller = new ReportsController(_mockReportRepository.Object, _mockKafkaProducer.Object);
+ }
+
+ [Fact]
+ public async Task GetReports_ReturnsOkResult_WhenReportsExist()
+ {
+ // Arrange
+ var reports = new List