diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..104b5441 --- /dev/null +++ b/.gitignore @@ -0,0 +1,484 @@ +## 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/master/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/master/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 diff --git a/.idea/.idea.InvestmentPerformanceWebApi/.idea/.gitignore b/.idea/.idea.InvestmentPerformanceWebApi/.idea/.gitignore new file mode 100644 index 00000000..7406e18f --- /dev/null +++ b/.idea/.idea.InvestmentPerformanceWebApi/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/.idea.InvestmentPerformanceWebApi.iml +/modules.xml +/projectSettingsUpdater.xml +/contentModel.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.InvestmentPerformanceWebApi/.idea/.name b/.idea/.idea.InvestmentPerformanceWebApi/.idea/.name new file mode 100644 index 00000000..dc9a991c --- /dev/null +++ b/.idea/.idea.InvestmentPerformanceWebApi/.idea/.name @@ -0,0 +1 @@ +InvestmentPerformanceWebApi \ No newline at end of file diff --git a/.idea/.idea.InvestmentPerformanceWebApi/.idea/encodings.xml b/.idea/.idea.InvestmentPerformanceWebApi/.idea/encodings.xml new file mode 100644 index 00000000..df87cf95 --- /dev/null +++ b/.idea/.idea.InvestmentPerformanceWebApi/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.InvestmentPerformanceWebApi/.idea/indexLayout.xml b/.idea/.idea.InvestmentPerformanceWebApi/.idea/indexLayout.xml new file mode 100644 index 00000000..7b08163c --- /dev/null +++ b/.idea/.idea.InvestmentPerformanceWebApi/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.InvestmentPerformanceWebApi/.idea/vcs.xml b/.idea/.idea.InvestmentPerformanceWebApi/.idea/vcs.xml new file mode 100644 index 00000000..83067447 --- /dev/null +++ b/.idea/.idea.InvestmentPerformanceWebApi/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.InvestmentPerformanceWebApi/Docker/compose.generated.override.yml b/.idea/.idea.InvestmentPerformanceWebApi/Docker/compose.generated.override.yml new file mode 100644 index 00000000..aab51cd5 --- /dev/null +++ b/.idea/.idea.InvestmentPerformanceWebApi/Docker/compose.generated.override.yml @@ -0,0 +1,20 @@ +# This is a generated file. Not intended for manual editing. +services: + investmentperformancewebapi: + build: + context: "/Users/solomon/Documents/GitHub/CodingExercise/InvestmentPerformanceWebApi" + dockerfile: "Dockerfile" + target: "base" + command: [] + entrypoint: + - "dotnet" + - "/app/bin/Debug/net8.0/InvestmentPerformanceWebApi.dll" + environment: + DOTNET_USE_POLLING_FILE_WATCHER: "true" + image: "investmentperformancewebapi:dev" + ports: [] + volumes: + - "/Users/solomon/Documents/GitHub/CodingExercise/InvestmentPerformanceWebApi:/app:rw" + - "/Users/solomon/Documents/GitHub/CodingExercise:/src:rw" + - "/Users/solomon/.nuget/packages:/root/.nuget/packages" + working_dir: "/app" diff --git a/InvestmentPerformanceWebAPI.md b/InvestmentPerformanceWebAPI.md deleted file mode 100644 index 2e96afac..00000000 --- a/InvestmentPerformanceWebAPI.md +++ /dev/null @@ -1,35 +0,0 @@ -# Investment Performance Web API - -## General instructions: -Please fork this project and submit a pull request when completed. You should submit a working piece of software that is tested and can be run. We will review your pull request and execute your code, please provide instructions on how to do so. - -Please keep in mind that this exercise is intended to be achievable in a couple of hours. We expect a production ready submission that demonstrates not only the code you write but quality controls such as exception handling, logging, unit tests, etc. Assume that this api will be part of a larger system. If there are larger considerations, that would have affected decisions of what is in/out of scope, please make note of your assumptions. The majority of the tech stack in use at Nuix Discover is SQLServer, C#.NET, ExtJs, Vue.js. We prefer the use of that stack, but we want you to showcase your abilities. If you don't know those specific technologies, you can substitute. - - - -## Problem statement -The company you are working for is building an investment trading platform for stock, bond and mutual funds. The platform will have various functionality to buy, sell, deposit, withdrawal, and report on investments. For this part of the product, they need you to create a web api to return data for investment performance. The type of api to create is your decision, but it must be hosted on a webserver and accessed via web request. - -## Problem details/user stories: -- ### Get a list of current investments for the user - - As an API user, I want to be able to query the list of investments for a user. The query should return the investment id and name. - -- ### Get details for a user's investment: - - As an API user, I want to be able to query the details of a specific investment for a user. The query should return number of shares, cost basis per share, current value, current price, term, and total gain/loss. - -#### Definitions: - -- Cost basis per share: this is the price of 1 share of stock at the time it was purchased - -- Current value: this is the number of shares multiplied by the current price per share - -- Current price: this is the current price of 1 share of the stock - -- Term: this is how long the stock has been owned. <=1 year is short term, >1 year is long term - -- Total gain or loss: this is the difference between the current value, and the amount paid for all shares when they were purchased - - - diff --git a/InvestmentPerformanceWebApi.Tests/Controllers/InvestmentControllerTests.cs b/InvestmentPerformanceWebApi.Tests/Controllers/InvestmentControllerTests.cs new file mode 100644 index 00000000..a98ac2e4 --- /dev/null +++ b/InvestmentPerformanceWebApi.Tests/Controllers/InvestmentControllerTests.cs @@ -0,0 +1,181 @@ +using System.Threading; +using System.Threading.Tasks; +using InvestmentPerformanceWebApi.Controllers; +using InvestmentPerformanceWebApi.Models; +using InvestmentPerformanceWebApi.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace InvestmentPerformanceWebApi.Tests.Controllers; + +public class InvestmentControllerTests +{ + private readonly InvestmentController _controller; + private readonly Mock _serviceMock; + + public InvestmentControllerTests() + { + _serviceMock = new Mock(); + _controller = new InvestmentController(Mock.Of>(), _serviceMock.Object); + } + + [Fact] + public async Task Get_WithNonPositiveUserId_ReturnsBadRequest() + { + var result = await _controller.Get(0, CancellationToken.None); + + var badRequest = Assert.IsType(result.Result); + Assert.Equal("UserId must be a positive integer.", badRequest.Value); + _serviceMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Get_WithValidUserId_ReturnsOkWithInvestments() + { + var userId = 42; + var expected = new List + { + new(1, "ETF A"), + new(2, "Stock B") + }; + + _serviceMock + .Setup(s => s.GetInvestmentsByUserIdAsync(userId, It.IsAny())) + .ReturnsAsync(expected); + + var result = await _controller.Get(userId, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var actual = Assert.IsAssignableFrom>(ok.Value); + Assert.Equal(expected.Count, actual.Count); + _serviceMock.VerifyAll(); + } + + [Fact] + public async Task Get_WhenCancelled_Returns499() + { + var userId = 7; + + _serviceMock + .Setup(s => s.GetInvestmentsByUserIdAsync(userId, It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + var result = await _controller.Get(userId, CancellationToken.None); + + var objectResult = Assert.IsType(result.Result); + Assert.Equal(499, objectResult.StatusCode); + Assert.Equal("Client Closed Request", objectResult.Value); + _serviceMock.VerifyAll(); + } + + [Fact] + public async Task Get_WhenUnhandledException_Returns500() + { + var userId = 9; + + _serviceMock + .Setup(s => s.GetInvestmentsByUserIdAsync(userId, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + + var result = await _controller.Get(userId, CancellationToken.None); + + var objectResult = Assert.IsType(result.Result); + Assert.Equal(500, objectResult.StatusCode); + Assert.Equal("An error occurred while retrieving investments.", objectResult.Value); + _serviceMock.VerifyAll(); + } + + [Fact] + public async Task GetDetails_WithInvalidIds_ReturnsBadRequest() + { + var result = await _controller.GetDetails(0, -1, CancellationToken.None); + + var badRequest = Assert.IsType(result.Result); + Assert.Equal("UserId and InvestmentId must be positive integers.", badRequest.Value); + _serviceMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task GetDetails_NotFound_ReturnsNotFound() + { + var userId = 5; + var investmentId = 123; + + _serviceMock + .Setup(s => s.GetInvestmentDetailsAsync(userId, investmentId, It.IsAny())) + .ReturnsAsync((InvestmentDetailDto?)null); + + var result = await _controller.GetDetails(userId, investmentId, CancellationToken.None); + + Assert.IsType(result.Result); + _serviceMock.VerifyAll(); + } + + [Fact] + public async Task GetDetails_Found_ReturnsOkWithDetails() + { + var userId = 5; + var investmentId = 123; + + var expected = + new InvestmentDetailDto(investmentId, userId, "Bond C", 1000m, 100m, 1500m, 1000m, "2022-01-01", 10m); + + _serviceMock + .Setup(s => s.GetInvestmentDetailsAsync(userId, investmentId, It.IsAny())) + .ReturnsAsync(expected); + + var result = await _controller.GetDetails(userId, investmentId, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var actual = Assert.IsType(ok.Value); + Assert.Equal(expected.Id, actual.Id); + _serviceMock.VerifyAll(); + } + + [Fact] + public async Task GetDetails_WhenCancelled_Returns499() + { + var userId = 2; + var investmentId = 3; + + _serviceMock + .Setup(s => s.GetInvestmentDetailsAsync(userId, investmentId, It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + var result = await _controller.GetDetails(userId, investmentId, CancellationToken.None); + + var objectResult = Assert.IsType(result.Result); + Assert.Equal(499, objectResult.StatusCode); + Assert.Equal("Client Closed Request", objectResult.Value); + _serviceMock.VerifyAll(); + } + + [Fact] + public async Task GetDetails_WhenUnhandledException_Returns500() + { + var userId = 2; + var investmentId = 3; + + _serviceMock + .Setup(s => s.GetInvestmentDetailsAsync(userId, investmentId, It.IsAny())) + .ThrowsAsync(new Exception("unexpected")); + + var result = await _controller.GetDetails(userId, investmentId, CancellationToken.None); + + var objectResult = Assert.IsType(result.Result); + Assert.Equal(500, objectResult.StatusCode); + Assert.Equal("An error occurred while retrieving investment details.", objectResult.Value); + _serviceMock.VerifyAll(); + } + + [Fact] + public void GetHealth_ReturnsOk() + { + var result = _controller.Get(); + + var ok = Assert.IsType(result); + Assert.Equal("Investment API is running.", ok.Value); + } +} diff --git a/InvestmentPerformanceWebApi.Tests/InvestmentPerformanceWebApi.Tests.csproj b/InvestmentPerformanceWebApi.Tests/InvestmentPerformanceWebApi.Tests.csproj new file mode 100644 index 00000000..0690f481 --- /dev/null +++ b/InvestmentPerformanceWebApi.Tests/InvestmentPerformanceWebApi.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/InvestmentPerformanceWebApi.Tests/UnitTest1.cs b/InvestmentPerformanceWebApi.Tests/UnitTest1.cs new file mode 100644 index 00000000..720b70f5 --- /dev/null +++ b/InvestmentPerformanceWebApi.Tests/UnitTest1.cs @@ -0,0 +1,9 @@ +namespace InvestmentPerformanceWebApi.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + } +} \ No newline at end of file diff --git a/InvestmentPerformanceWebApi.sln b/InvestmentPerformanceWebApi.sln new file mode 100644 index 00000000..78937754 --- /dev/null +++ b/InvestmentPerformanceWebApi.sln @@ -0,0 +1,38 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvestmentPerformanceWebApi", "InvestmentPerformanceWebApi\InvestmentPerformanceWebApi.csproj", "{F47C3C24-2A75-4F75-901B-4678D686AC7F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{CB7FBD03-01E6-4FB1-90FE-30E8D957AD4C}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "InvestmentPerformanceWebApi", "InvestmentPerformanceWebApi\InvestmentPerformanceWebApi.csproj", "{7ABA96F3-82E3-4FE4-848A-66C21CF821F1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Src", "Src", "{14289EB3-560B-4809-862B-D409D59D4175}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{270CFF4E-0879-4A11-8853-2B5A0D6A003D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvestmentPerformanceWebApi.Tests", "InvestmentPerformanceWebApi.Tests\InvestmentPerformanceWebApi.Tests.csproj", "{67657AED-A457-4EAC-BF4F-C221B0E03A7B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F47C3C24-2A75-4F75-901B-4678D686AC7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F47C3C24-2A75-4F75-901B-4678D686AC7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F47C3C24-2A75-4F75-901B-4678D686AC7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F47C3C24-2A75-4F75-901B-4678D686AC7F}.Release|Any CPU.Build.0 = Release|Any CPU + {67657AED-A457-4EAC-BF4F-C221B0E03A7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {67657AED-A457-4EAC-BF4F-C221B0E03A7B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {67657AED-A457-4EAC-BF4F-C221B0E03A7B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {67657AED-A457-4EAC-BF4F-C221B0E03A7B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F47C3C24-2A75-4F75-901B-4678D686AC7F} = {7ABA96F3-82E3-4FE4-848A-66C21CF821F1} + {7ABA96F3-82E3-4FE4-848A-66C21CF821F1} = {14289EB3-560B-4809-862B-D409D59D4175} + {67657AED-A457-4EAC-BF4F-C221B0E03A7B} = {270CFF4E-0879-4A11-8853-2B5A0D6A003D} + EndGlobalSection +EndGlobal diff --git a/InvestmentPerformanceWebApi/.dockerignore b/InvestmentPerformanceWebApi/.dockerignore new file mode 100644 index 00000000..cd967fc3 --- /dev/null +++ b/InvestmentPerformanceWebApi/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/.gitignore b/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/.gitignore new file mode 100644 index 00000000..7406e18f --- /dev/null +++ b/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/.idea.InvestmentPerformanceWebApi.iml +/modules.xml +/projectSettingsUpdater.xml +/contentModel.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/codeStyles/codeStyleConfig.xml b/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..a55e7a17 --- /dev/null +++ b/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/encodings.xml b/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/encodings.xml new file mode 100644 index 00000000..df87cf95 --- /dev/null +++ b/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/indexLayout.xml b/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/indexLayout.xml new file mode 100644 index 00000000..7b08163c --- /dev/null +++ b/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/vcs.xml b/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/vcs.xml new file mode 100644 index 00000000..6c0b8635 --- /dev/null +++ b/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/Docker/compose.generated.override.yml b/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/Docker/compose.generated.override.yml new file mode 100644 index 00000000..d40e7afe --- /dev/null +++ b/InvestmentPerformanceWebApi/.idea/.idea.InvestmentPerformanceWebApi/Docker/compose.generated.override.yml @@ -0,0 +1,20 @@ +# This is a generated file. Not intended for manual editing. +services: + investmentperformancewebapi: + build: + context: "/Users/solomon/Documents/GitHub/CodingExercise/InvestmentPerformanceWebApi" + dockerfile: "Dockerfile" + target: "base" + command: [] + entrypoint: + - "dotnet" + - "/app/bin/Debug/net8.0/InvestmentPerformanceWebApi.dll" + environment: + DOTNET_USE_POLLING_FILE_WATCHER: "true" + image: "investmentperformancewebapi:dev" + ports: [] + volumes: + - "/Users/solomon/Documents/GitHub/CodingExercise/InvestmentPerformanceWebApi:/app:rw" + - "/Users/solomon/Documents/GitHub/CodingExercise/InvestmentPerformanceWebApi:/src:rw" + - "/Users/solomon/.nuget/packages:/root/.nuget/packages" + working_dir: "/app" diff --git a/InvestmentPerformanceWebApi/Controllers/InvestmentController.cs b/InvestmentPerformanceWebApi/Controllers/InvestmentController.cs new file mode 100644 index 00000000..e74804fc --- /dev/null +++ b/InvestmentPerformanceWebApi/Controllers/InvestmentController.cs @@ -0,0 +1,74 @@ +using InvestmentPerformanceWebApi.Models; +using Microsoft.AspNetCore.Mvc; +using InvestmentPerformanceWebApi.Services; + +namespace InvestmentPerformanceWebApi.Controllers; + +[ApiController] +[Route("[controller]")] +public class InvestmentController(ILogger logger, IInvestmentService investmentService) : ControllerBase +{ + private readonly ILogger _logger = logger; + private readonly IInvestmentService _investmentService = investmentService; + + [HttpGet("{userId:int}")] + public async Task>> Get(int userId, CancellationToken cancellationToken) + { + try + { + if (userId <= 0) + { + return BadRequest("UserId must be a positive integer."); + } + + var investments = await _investmentService.GetInvestmentsByUserIdAsync(userId, cancellationToken); + return Ok(investments); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Request cancelled while retrieving investments for user {UserId}", userId); + return StatusCode(499, "Client Closed Request"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving investments for user {UserId}", userId); + return StatusCode(500, "An error occurred while retrieving investments."); + } + } + + [HttpGet("{userId:int}/{investmentId:int}")] + public async Task> GetDetails(int userId, int investmentId, CancellationToken cancellationToken) + { + try + { + if (userId <= 0 || investmentId <= 0) + { + return BadRequest("UserId and InvestmentId must be positive integers."); + } + + var details = await _investmentService.GetInvestmentDetailsAsync(userId, investmentId, cancellationToken); + if (details is null) + { + return NotFound(); + } + + return Ok(details); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Request cancelled while retrieving investment {InvestmentId} for user {UserId}", investmentId, userId); + return StatusCode(499, "Client Closed Request"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving investment {InvestmentId} for user {UserId}", investmentId, userId); + return StatusCode(500, "An error occurred while retrieving investment details."); + } + } + + [HttpGet("health")] + public IActionResult Get() + { + return Ok("Investment API is running."); + } +} \ No newline at end of file diff --git a/InvestmentPerformanceWebApi/Data/InvestmentContext.cs b/InvestmentPerformanceWebApi/Data/InvestmentContext.cs new file mode 100644 index 00000000..aadcea4a --- /dev/null +++ b/InvestmentPerformanceWebApi/Data/InvestmentContext.cs @@ -0,0 +1,8 @@ +using InvestmentPerformanceWebApi.Models; + +namespace InvestmentPerformanceWebApi.Data; + +public class InvestmentContext +{ + public List Investments { get; } = []; +} diff --git a/InvestmentPerformanceWebApi/Dockerfile b/InvestmentPerformanceWebApi/Dockerfile new file mode 100644 index 00000000..6cb94029 --- /dev/null +++ b/InvestmentPerformanceWebApi/Dockerfile @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +EXPOSE 5001 +ENV ASPNETCORE_URLS=http://+:5001 \ + ASPNETCORE_ENVIRONMENT=Development \ + DOTNET_EnableDiagnostics=0 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["InvestmentPerformanceWebApi.csproj", "./"] +RUN dotnet restore "InvestmentPerformanceWebApi.csproj" +COPY . . +WORKDIR "/src/" +RUN dotnet build "InvestmentPerformanceWebApi.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "InvestmentPerformanceWebApi.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false /p:PublishReadyToRun=true + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +RUN useradd -m appuser && chown -R appuser /app +USER appuser +ENTRYPOINT ["dotnet", "InvestmentPerformanceWebApi.dll"] diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi.csproj b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi.csproj new file mode 100644 index 00000000..ee03a5e9 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + enable + enable + Linux + default + + + + + + + + diff --git a/InvestmentPerformanceWebApi/Models/Investment.cs b/InvestmentPerformanceWebApi/Models/Investment.cs new file mode 100644 index 00000000..a0155d35 --- /dev/null +++ b/InvestmentPerformanceWebApi/Models/Investment.cs @@ -0,0 +1,16 @@ +namespace InvestmentPerformanceWebApi.Models; + +public class Investment +{ + public int Id { get; init; } + public int UserId { get; init; } + public string Name { get; init; } = string.Empty; + public decimal NumberOfShares { get; init; } + public decimal CostBasisPerShare { get; init; } + public decimal CurrentPrice { get; init; } + public DateTime PurchaseDate { get; init; } + + public decimal CurrentValue => NumberOfShares * CurrentPrice; + public string Term => (DateTime.Now - PurchaseDate).TotalDays > 365 ? "Long Term" : "Short Term"; + public decimal TotalGainLoss => CurrentValue - NumberOfShares * CostBasisPerShare; +} \ No newline at end of file diff --git a/InvestmentPerformanceWebApi/Models/InvestmentDetailDto.cs b/InvestmentPerformanceWebApi/Models/InvestmentDetailDto.cs new file mode 100644 index 00000000..443576a9 --- /dev/null +++ b/InvestmentPerformanceWebApi/Models/InvestmentDetailDto.cs @@ -0,0 +1,12 @@ +namespace InvestmentPerformanceWebApi.Models; +public record InvestmentDetailDto( + int Id, + int UserId, + string Name, + decimal NumberOfShares, + decimal CostBasisPerShare, + decimal CurrentPrice, + decimal CurrentValue, + string Term, + decimal TotalGainLoss +); diff --git a/InvestmentPerformanceWebApi/Models/InvestmentDto.cs b/InvestmentPerformanceWebApi/Models/InvestmentDto.cs new file mode 100644 index 00000000..9345ee37 --- /dev/null +++ b/InvestmentPerformanceWebApi/Models/InvestmentDto.cs @@ -0,0 +1,3 @@ +namespace InvestmentPerformanceWebApi.Models; + +public record InvestmentDto(int Id, string Name); diff --git a/InvestmentPerformanceWebApi/Program.cs b/InvestmentPerformanceWebApi/Program.cs new file mode 100644 index 00000000..8d4f596c --- /dev/null +++ b/InvestmentPerformanceWebApi/Program.cs @@ -0,0 +1,78 @@ +using InvestmentPerformanceWebApi.Data; +using InvestmentPerformanceWebApi.Models; +using InvestmentPerformanceWebApi.Repositories; +using InvestmentPerformanceWebApi.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); + app.UseDeveloperExceptionPage(); +} + +app.UseRouting(); +app.UseCors("_allowLocalOrigins"); + +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} +app.MapControllers(); + +// Seed demo data +SeedData(app.Services); + +app.Run(); +return; + +static void SeedData(IServiceProvider services) +{ + var ctx = services.GetRequiredService(); + + if (ctx.Investments.Count != 0) return; + + ctx.Investments.AddRange([ + new Investment + { + Id = 1, + UserId = 1001, + Name = "Nuix", + NumberOfShares = 50m, + CostBasisPerShare = 100m, + CurrentPrice = 120m, + PurchaseDate = DateTime.UtcNow.AddMonths(-6) + }, + new Investment + { + Id = 2, + UserId = 1001, + Name = "Google", + NumberOfShares = 200m, + CostBasisPerShare = 10m, + CurrentPrice = 9.5m, + PurchaseDate = DateTime.UtcNow.AddYears(-2) + }, + new Investment + { + Id = 3, + UserId = 1002, + Name = "Amazon", + NumberOfShares = 10m, + CostBasisPerShare = 300m, + CurrentPrice = 350m, + PurchaseDate = DateTime.UtcNow.AddMonths(-3) + } + ]); +} \ No newline at end of file diff --git a/InvestmentPerformanceWebApi/Properties/launchSettings.json b/InvestmentPerformanceWebApi/Properties/launchSettings.json new file mode 100644 index 00000000..15bc0031 --- /dev/null +++ b/InvestmentPerformanceWebApi/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "InvestmentPerformanceWebApi": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5113", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/InvestmentPerformanceWebApi/Repositories/IInvestmentRepository.cs b/InvestmentPerformanceWebApi/Repositories/IInvestmentRepository.cs new file mode 100644 index 00000000..8a5948c8 --- /dev/null +++ b/InvestmentPerformanceWebApi/Repositories/IInvestmentRepository.cs @@ -0,0 +1,9 @@ +using InvestmentPerformanceWebApi.Models; + +namespace InvestmentPerformanceWebApi.Repositories; + +public interface IInvestmentRepository +{ + Task> GetByUserAsync(int userId, CancellationToken cancellationToken); + Task GetByUserAndIdAsync(int userId, int investmentId, CancellationToken cancellationToken); +} diff --git a/InvestmentPerformanceWebApi/Repositories/InMemoryInvestmentRepository.cs b/InvestmentPerformanceWebApi/Repositories/InMemoryInvestmentRepository.cs new file mode 100644 index 00000000..7712610b --- /dev/null +++ b/InvestmentPerformanceWebApi/Repositories/InMemoryInvestmentRepository.cs @@ -0,0 +1,27 @@ +using InvestmentPerformanceWebApi.Data; +using InvestmentPerformanceWebApi.Models; + +namespace InvestmentPerformanceWebApi.Repositories; + +public class InMemoryInvestmentRepository(InvestmentContext context, ILogger logger) : IInvestmentRepository +{ + private readonly InvestmentContext _context = context; + private readonly ILogger _logger = logger; + + public Task> GetByUserAsync(int userId, CancellationToken cancellationToken) + { + // AsQueryable to allow future extensions (filtering/sorting) + var list = _context.Investments + .Where(i => i.UserId == userId) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(list); + } + + public Task GetByUserAndIdAsync(int userId, int investmentId, CancellationToken cancellationToken) + { + var investment = _context.Investments.FirstOrDefault(i => i.UserId == userId && i.Id == investmentId); + return Task.FromResult(investment); + } +} \ No newline at end of file diff --git a/InvestmentPerformanceWebApi/Services/IInvestmentService.cs b/InvestmentPerformanceWebApi/Services/IInvestmentService.cs new file mode 100644 index 00000000..1fbfdb6f --- /dev/null +++ b/InvestmentPerformanceWebApi/Services/IInvestmentService.cs @@ -0,0 +1,9 @@ +using InvestmentPerformanceWebApi.Models; + +namespace InvestmentPerformanceWebApi.Services; + +public interface IInvestmentService +{ + Task> GetInvestmentsByUserIdAsync(int userId, CancellationToken cancellationToken); + Task GetInvestmentDetailsAsync(int userId, int investmentId, CancellationToken cancellationToken); +} diff --git a/InvestmentPerformanceWebApi/Services/InvestmentService.cs b/InvestmentPerformanceWebApi/Services/InvestmentService.cs new file mode 100644 index 00000000..053870a1 --- /dev/null +++ b/InvestmentPerformanceWebApi/Services/InvestmentService.cs @@ -0,0 +1,40 @@ +using InvestmentPerformanceWebApi.Models; +using InvestmentPerformanceWebApi.Repositories; + +namespace InvestmentPerformanceWebApi.Services; + +public class InvestmentService(IInvestmentRepository repository, ILogger logger) : IInvestmentService +{ + private readonly IInvestmentRepository _repository = repository; + private readonly ILogger _logger = logger; + + public async Task> GetInvestmentsByUserIdAsync(int userId, CancellationToken cancellationToken) + { + _logger.LogInformation("Fetching investments for user {UserId}", userId); + var investments = await _repository.GetByUserAsync(userId, cancellationToken); + + return investments + .OrderBy(i => i.Name) + .Select(i => new InvestmentDto(i.Id, i.Name)) + .ToList(); + } + + public async Task GetInvestmentDetailsAsync(int userId, int investmentId, CancellationToken cancellationToken) + { + _logger.LogInformation("Fetching investment {InvestmentId} for user {UserId}", investmentId, userId); + var i = await _repository.GetByUserAndIdAsync(userId, investmentId, cancellationToken); + if (i is null) return null; + + return new InvestmentDetailDto( + i.Id, + i.UserId, + i.Name, + i.NumberOfShares, + i.CostBasisPerShare, + i.CurrentPrice, + i.CurrentValue, + i.Term, + i.TotalGainLoss + ); + } +} \ No newline at end of file diff --git a/InvestmentPerformanceWebApi/compose.yaml b/InvestmentPerformanceWebApi/compose.yaml new file mode 100644 index 00000000..ec876354 --- /dev/null +++ b/InvestmentPerformanceWebApi/compose.yaml @@ -0,0 +1,8 @@ +services: + investmentperformancewebapi: + image: investmentperformancewebapi + build: + context: . + dockerfile: Dockerfile + ports: + - "5001:5001" diff --git a/InvestmentPerformanceWebApi/global.json b/InvestmentPerformanceWebApi/global.json new file mode 100644 index 00000000..2ddda36c --- /dev/null +++ b/InvestmentPerformanceWebApi/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/OnlineOrderingSQL.md b/OnlineOrderingSQL.md deleted file mode 100644 index f17262bd..00000000 --- a/OnlineOrderingSQL.md +++ /dev/null @@ -1,59 +0,0 @@ -# Online Ordering - -## General instructions: -Please keep in mind that this exercise is intended to be achievable in a couple of hours. Assume that this will be part of a larger system. If there are larger considerations, that would have affected decisions of what is in/out of scope, please make note of your assumptions. We use SQL server for our database for Nuix Discover and would like this to be written in T-SQL. Any middle tier/UI code is out of scope. Assume any data sent from middle tier/UI will be sent the way you require. Likewise returned data can be done any way you like. Security and error handling are out of scope. Assume the front end is sending you good data. - -## Problem statement: -The company you are working for is building an online ordering system. They are expecting millions of orders in this system so scale and performance is a concern. Your tasks will be limited to a small subset of the database code. - -## User stories: - -1. Products: - - As an owner I would like to store a list of products I have to sell in the data base along with their current cost - - For brevity just create the table(s) with bare minimum information. Assume another developer is handling the CRUD operations on the table(s). For the sake of this exercise manually enter a couple of rows. - - Required fields: Product name, and cost. Add others as needed. - -2. Customers: - - As an owner I would like to store a list of customers who buy my products and their current discount percentage. - - For brevity just create the table(s) with bare minimum information. Assume another developer is handling the CRUD operations on the table(s). For the sake of this exercise manually enter a couple of rows. - - Required fields: Customer name, and discount percentage. Add others as needed. - -3. Orders - - As an owner I would like to store my customers orders. - - For brevity just create the table(s) with bare minimum information. You will be tasked will a portion of the CRUD operations for this table(s) below. - - Required fields: Order number, customer, order date/time, item(s), cost(s), quantity, total cost (after discount). Add others as needed. - - For brevity ignore taxes and shipping. - -4. Add item to order - - As a buyer I would like items added to an existing order or create a new order if it’s the first item. - - Assume the middle tier/ UI will send one product at a time as opposed to the whole order at once. - - Add item/Create new order based on input - - You are responsible for creating unique order number for new orders and returning it to UI - - Required results/return: order number - -5. Delete item from order - - As a buyer I would like to delete an item from an existing order. - - Delete item and update order accordingly. - - Required results/return: none - -6. Update quantity on order - - As a buyer I would like to change the quantity of a product I ordered. - - Update quantity and order accordingly. - - Required results/return: none - -7. Read order - - As a buyer I would like to see an existing/previous order - - Take order number and return order in the same order it was entered. - - Required results/return: Order number, customer, order date/time, item(s), cost(s), quantity, total cost, discount percentage. - -8. Calculate discount - - As a owner, once a quarter I want to update the customers discount percentage based on the prior quarters purchases. - - Using the following formula update customers discount percentage. - - Discount is based on the previous quarter's purchases before discount. - - $0 to $1,000.00 = 0% - - $1,000.01 to $4,999.99 = 2% - - $5,000.00 to $9,999.99 = 4% - - $10,000.00 to $24,999.99 = 5% - - $25,000.00 to $99,999.99 = 6% - - $100000.00 and up = 7% - - This discount formula can change quarter to quarter. diff --git a/README.md b/README.md new file mode 100644 index 00000000..d2e3c700 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Investment Performance Web API + +## Prerequisites +- .NET SDK 8.0+ +- Docker + Docker Compose (optional, for containerized run) + +## Run locally (using launchSettings profile) +1. Restore and build: + ```bash + dotnet restore + dotnet build + ``` +2. Run with the "InvestmentPerformanceWebApi" launch profile: + ```bash + dotnet run --launch-profile "InvestmentPerformanceWebApi" + ``` + - In IDEs, select the desired launch profile from the run configuration and start debugging/run. +3. Browse: + - Swagger UI: http://localhost:5113/swagger/index.html + - Health: http://localhost:5113/Investment/health + +Note: The app seeds demo data on startup. + +## Run with Docker Compose +1. Build and start: + ```bash + docker compose up --build + ``` +2. Browse (use the port published in your compose file/output): + - Swagger UI: http://localhost:5001/swagger/index.html + - Health: http://localhost:5001/Investment/health + +## Run tests +From the solution root (or tests project directory): + ```bash + dotnet test + ``` +## Quick request examples +- Health: + ```bash + curl http://localhost:PORT/Investment/health + ``` +- Investments for a user: + ```bash + curl http://localhost:PORT/Investment/1001 + ``` +- Investment details: + ```bash + curl http://localhost:PORT/Investment/1001/1 + ``` + +Replace PORT with the port configured by your selected launch profile or Docker Compose. diff --git a/ReadMe.md b/ReadMe.md deleted file mode 100644 index ad2afefb..00000000 --- a/ReadMe.md +++ /dev/null @@ -1,28 +0,0 @@ -# Coding Exercise -> This repository holds coding exercises for candidates going through the hiring process. - -You should have been assigned one of the coding exercises in this list. More details can be found in the specific md file for that assignment. - -Instructions: Fork this repository, do the assigned work, and submit a pull request for review. - -[Investment Performance Web API](InvestmentPerformanceWebAPI.md#investment-performance-web-api) - -[Online Ordering SQL](OnlineOrderingSQL.md#online-ordering) - -# License - -``` -Copyright 2021 Nuix - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` \ No newline at end of file