From 3785b58ecb07001ceab800a2013cdd13c046f0ca Mon Sep 17 00:00:00 2001 From: Ben Sgroi Date: Wed, 10 Sep 2025 12:36:27 +0300 Subject: [PATCH 1/7] gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0a343817 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vs \ No newline at end of file From 258b0a33474c54b37f79ac82d2e38a45c6019e4b Mon Sep 17 00:00:00 2001 From: Ben Sgroi Date: Fri, 12 Sep 2025 09:05:02 +0300 Subject: [PATCH 2/7] api & tests --- .gitignore | 2 +- InvestmentPerformance.Tests/.gitignore | 2 + .../Controllers/InvestmentsControllerTests.cs | 201 ++++++++++++++++++ .../InvestmentPerformance.Tests.csproj | 35 +++ InvestmentPerformance.Tests/MSTestSettings.cs | 1 + InvestmentPerformance/.gitignore | 2 + .../Controllers/InvestmentsController.cs | 77 +++++++ .../DTOs/InvestmentDetailsDto.cs | 13 ++ InvestmentPerformance/DTOs/InvestmentDto.cs | 8 + InvestmentPerformance/Data/DbInitializer.cs | 44 ++++ .../InvestmentPerformance.csproj | 21 ++ .../InvestmentPerformance.csproj.user | 15 ++ .../InvestmentPerformance.http | 6 + .../InvestmentPerformance.sln | 31 +++ .../InvestmentMappingProfile.cs | 21 ++ InvestmentPerformance/Models/Investment.cs | 14 ++ .../Models/InvestmentContext.cs | 14 ++ InvestmentPerformance/Models/Stock.cs | 13 ++ InvestmentPerformance/Program.cs | 60 ++++++ .../Properties/launchSettings.json | 41 ++++ .../Repositories/IInvestmentRepository.cs | 11 + .../Repositories/InvestmentRepository.cs | 32 +++ .../appsettings.Development.json | 10 + .../appsettings.Production.json | 10 + InvestmentPerformance/appsettings.json | 12 ++ 25 files changed, 695 insertions(+), 1 deletion(-) create mode 100644 InvestmentPerformance.Tests/.gitignore create mode 100644 InvestmentPerformance.Tests/Controllers/InvestmentsControllerTests.cs create mode 100644 InvestmentPerformance.Tests/InvestmentPerformance.Tests.csproj create mode 100644 InvestmentPerformance.Tests/MSTestSettings.cs create mode 100644 InvestmentPerformance/.gitignore create mode 100644 InvestmentPerformance/Controllers/InvestmentsController.cs create mode 100644 InvestmentPerformance/DTOs/InvestmentDetailsDto.cs create mode 100644 InvestmentPerformance/DTOs/InvestmentDto.cs create mode 100644 InvestmentPerformance/Data/DbInitializer.cs create mode 100644 InvestmentPerformance/InvestmentPerformance.csproj create mode 100644 InvestmentPerformance/InvestmentPerformance.csproj.user create mode 100644 InvestmentPerformance/InvestmentPerformance.http create mode 100644 InvestmentPerformance/InvestmentPerformance.sln create mode 100644 InvestmentPerformance/MappingProfiles/InvestmentMappingProfile.cs create mode 100644 InvestmentPerformance/Models/Investment.cs create mode 100644 InvestmentPerformance/Models/InvestmentContext.cs create mode 100644 InvestmentPerformance/Models/Stock.cs create mode 100644 InvestmentPerformance/Program.cs create mode 100644 InvestmentPerformance/Properties/launchSettings.json create mode 100644 InvestmentPerformance/Repositories/IInvestmentRepository.cs create mode 100644 InvestmentPerformance/Repositories/InvestmentRepository.cs create mode 100644 InvestmentPerformance/appsettings.Development.json create mode 100644 InvestmentPerformance/appsettings.Production.json create mode 100644 InvestmentPerformance/appsettings.json diff --git a/.gitignore b/.gitignore index 0a343817..360ea127 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -.vs \ No newline at end of file +.vs diff --git a/InvestmentPerformance.Tests/.gitignore b/InvestmentPerformance.Tests/.gitignore new file mode 100644 index 00000000..4c7473de --- /dev/null +++ b/InvestmentPerformance.Tests/.gitignore @@ -0,0 +1,2 @@ +/bin +/obj diff --git a/InvestmentPerformance.Tests/Controllers/InvestmentsControllerTests.cs b/InvestmentPerformance.Tests/Controllers/InvestmentsControllerTests.cs new file mode 100644 index 00000000..ba760bcf --- /dev/null +++ b/InvestmentPerformance.Tests/Controllers/InvestmentsControllerTests.cs @@ -0,0 +1,201 @@ +using AutoMapper; +using InvestmentPerformance.Controllers; +using InvestmentPerformance.DTOs; +using InvestmentPerformance.Models; +using InvestmentPerformance.Repositories; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace InvestmentPerformance.Tests.Controllers +{ + [TestClass] + public class InvestmentsControllerTests + { + private readonly Mock _mapper; + + private readonly Mock _investmentRepository; + + private readonly InvestmentsController _controller; + + private readonly HttpContext _httpContext; + + public InvestmentsControllerTests() + { + _mapper = new Mock(); + _investmentRepository = new Mock(); + _controller = new InvestmentsController(_mapper.Object, _investmentRepository.Object); + + _httpContext = new DefaultHttpContext(); + _controller.ControllerContext = new ControllerContext + { + HttpContext = _httpContext + }; + _httpContext.Request.Headers["UserID"] = "1"; // Set a default UserID header for tests + } + + [TestMethod] + public void GetInvestments_ReturnsInvestments() + { + // Arrange + var userId = 1; + var investments = new List + { + new() { ID = 1, UserID = userId, StockID = 1, SharesOwned = 10, CostBasisPerShare = 100.00m, PurchaseDate = DateTime.Now.AddMonths(-5) }, + new() { ID = 2, UserID = userId, StockID = 2, SharesOwned = 20, CostBasisPerShare = 200.00m, PurchaseDate = DateTime.Now.AddYears(-2) } + }; + _investmentRepository.Setup(repo => repo.GetInvestmentsByUserId(userId)).ReturnsAsync(investments); + _mapper.Setup(m => m.Map>(It.IsAny>())) + .Returns(new List + { + new() { ID = 1, StockName = "Stock1" }, + new() { ID = 2, StockName = "Stock2" } + }); + + // Act + var result = _controller.GetInvestments().Result; + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(2, result?.Value?.Count()); + } + + [TestMethod] + public void GetInvestments_ReturnsBadRequest_WhenUserIdHeaderIsMissing() + { + // Arrange + _httpContext.Request.Headers.Clear(); // Ensure no UserID header is present + + // Act + var result = _controller.GetInvestments().Result; + + // Assert + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result.Result, typeof(BadRequestObjectResult)); + var badRequestResult = result.Result as BadRequestObjectResult; + Assert.AreEqual("UserID header is required.", badRequestResult.Value); + } + + [TestMethod] + public void GetInvestment_ReturnsInvestmentDetails() + { + // Arrange + var userId = 1; + var investmentId = 1; + var investment = new Investment + { + ID = investmentId, + UserID = userId, + StockID = 1, + SharesOwned = 10, + CostBasisPerShare = 100.00m, + PurchaseDate = DateTime.Now.AddMonths(-5) + }; + _investmentRepository.Setup(repo => repo.GetInvestmentById(investmentId)).ReturnsAsync(investment); + _mapper.Setup(m => m.Map(It.IsAny())) + .Returns(new InvestmentDetailsDto + { + ID = investmentId, + CostBasisPerShare = 100.00m, + CurrentValue = 1500.00m, + CurrentPrice = 150.00m, + Term = "Short-Term", + TotalGainOrLoss = 500.00m, + }); + + // Act + var result = _controller.GetInvestment(investmentId).Result; + + // Assert + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result.Value, typeof(InvestmentDetailsDto)); + Assert.AreEqual(investmentId, result.Value.ID); + } + + [TestMethod] + public void GetInvestment_ReturnsBadRequest_WhenHeaderIsMissing() + { + // Arrange + _httpContext.Request.Headers.Clear(); // Ensure no UserID header is present + + // Act + var result = _controller.GetInvestment(5).Result; + + // Assert + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result.Result, typeof(BadRequestObjectResult)); + var badRequestResult = result.Result as BadRequestObjectResult; + Assert.AreEqual("UserID header is required.", badRequestResult.Value); + } + + [TestMethod] + public void GetInvestment_ReturnsNotFound_WhenInvestmentDoesNotExist() + { + // Arrange + var userId = 1; + var investmentId = 1; + var investment = new Investment + { + ID = investmentId, + UserID = userId, + StockID = 1, + SharesOwned = 10, + CostBasisPerShare = 100.00m, + PurchaseDate = DateTime.Now.AddMonths(-5) + }; + _investmentRepository.Setup(repo => repo.GetInvestmentById(investmentId)).ReturnsAsync(investment); + _mapper.Setup(m => m.Map(It.IsAny())) + .Returns(new InvestmentDetailsDto + { + ID = investmentId, + CostBasisPerShare = 100.00m, + CurrentValue = 1500.00m, + CurrentPrice = 150.00m, + Term = "Short-Term", + TotalGainOrLoss = 500.00m, + }); + + // Act + var result = _controller.GetInvestment(investmentId + 1).Result; + + // Assert + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result.Result, typeof(NotFoundResult)); + } + + [TestMethod] + public void GetInvestment_ReturnsUnauthorized_WhenAccessingOthersInvestment() + { + // Arrange + var userId = 1; + var investmentId = 1; + var investment = new Investment + { + ID = investmentId, + UserID = userId + 1, // Different user + StockID = 1, + SharesOwned = 10, + CostBasisPerShare = 100.00m, + PurchaseDate = DateTime.Now.AddMonths(-5) + }; + _investmentRepository.Setup(repo => repo.GetInvestmentById(investmentId)).ReturnsAsync(investment); + _mapper.Setup(m => m.Map(It.IsAny())) + .Returns(new InvestmentDetailsDto + { + ID = investmentId, + CostBasisPerShare = 100.00m, + CurrentValue = 1500.00m, + CurrentPrice = 150.00m, + Term = "Short-Term", + TotalGainOrLoss = 500.00m, + }); + + // Act + var result = _controller.GetInvestment(investmentId).Result; + + // Assert + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result.Result, typeof(UnauthorizedResult)); + } + } +} diff --git a/InvestmentPerformance.Tests/InvestmentPerformance.Tests.csproj b/InvestmentPerformance.Tests/InvestmentPerformance.Tests.csproj new file mode 100644 index 00000000..fb93931e --- /dev/null +++ b/InvestmentPerformance.Tests/InvestmentPerformance.Tests.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + latest + enable + enable + true + Exe + true + + true + + + + + + + + + + + + + + + + + + + + diff --git a/InvestmentPerformance.Tests/MSTestSettings.cs b/InvestmentPerformance.Tests/MSTestSettings.cs new file mode 100644 index 00000000..aaf278c8 --- /dev/null +++ b/InvestmentPerformance.Tests/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/InvestmentPerformance/.gitignore b/InvestmentPerformance/.gitignore new file mode 100644 index 00000000..0a079b63 --- /dev/null +++ b/InvestmentPerformance/.gitignore @@ -0,0 +1,2 @@ +/bin +/obj \ No newline at end of file diff --git a/InvestmentPerformance/Controllers/InvestmentsController.cs b/InvestmentPerformance/Controllers/InvestmentsController.cs new file mode 100644 index 00000000..0bbdfd42 --- /dev/null +++ b/InvestmentPerformance/Controllers/InvestmentsController.cs @@ -0,0 +1,77 @@ +using AutoMapper; +using InvestmentPerformance.DTOs; +using InvestmentPerformance.Repositories; +using Microsoft.AspNetCore.Mvc; + +namespace InvestmentPerformance.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class InvestmentsController : ControllerBase + { + private readonly IMapper _mapper; + private readonly IInvestmentRepository _investmentRepository; + + public InvestmentsController(IMapper mapper, IInvestmentRepository investmentRepository) + { + _mapper = mapper; + _investmentRepository = investmentRepository; + } + + // GET: api/Investments + [HttpGet] + public async Task>> GetInvestments() + { + var userId = GetUserId(); + + if (!userId.HasValue) + { + return BadRequest("UserID header is required."); + } + + var investments = await _investmentRepository.GetInvestmentsByUserId(userId.Value); + + return _mapper.Map>(investments); + } + + // GET: api/Investments/5 + [HttpGet("{id}")] + public async Task> GetInvestment(int id) + { + var userId = GetUserId(); + + if (userId == null) + { + return BadRequest("UserID header is required."); + } + + var investment = await _investmentRepository.GetInvestmentById(id); + + if (investment == null) + { + return NotFound(); + } + + if (investment.UserID != userId) + { + return Unauthorized(); // 403 would be better here + } + + var investmentDto = _mapper.Map(investment); + + return investmentDto; + } + + private int? GetUserId() + { + HttpContext.Request.Headers.TryGetValue("UserID", out var userIdHeader); + + if (string.IsNullOrEmpty(userIdHeader) || !int.TryParse(userIdHeader, out var userId)) + { + return null; + } + + return userId; + } + } +} diff --git a/InvestmentPerformance/DTOs/InvestmentDetailsDto.cs b/InvestmentPerformance/DTOs/InvestmentDetailsDto.cs new file mode 100644 index 00000000..edf9812d --- /dev/null +++ b/InvestmentPerformance/DTOs/InvestmentDetailsDto.cs @@ -0,0 +1,13 @@ +namespace InvestmentPerformance.DTOs +{ + public class InvestmentDetailsDto + { + public int ID { get; set; } + public decimal CostBasisPerShare { get; set; } + public decimal CurrentValue { get; set; } + public decimal CurrentPrice { get; set; } + public required string Term { get; set; } // "Short-Term" or "Long-Term" + public decimal TotalGainOrLoss { get; set; } + + } +} diff --git a/InvestmentPerformance/DTOs/InvestmentDto.cs b/InvestmentPerformance/DTOs/InvestmentDto.cs new file mode 100644 index 00000000..5390f1ac --- /dev/null +++ b/InvestmentPerformance/DTOs/InvestmentDto.cs @@ -0,0 +1,8 @@ +namespace InvestmentPerformance.DTOs +{ + public class InvestmentDto + { + public int ID { get; set; } + public required string StockName { get; set; } + } +} diff --git a/InvestmentPerformance/Data/DbInitializer.cs b/InvestmentPerformance/Data/DbInitializer.cs new file mode 100644 index 00000000..1355b582 --- /dev/null +++ b/InvestmentPerformance/Data/DbInitializer.cs @@ -0,0 +1,44 @@ +using InvestmentPerformance.Models; + +namespace InvestmentPerformance.Data +{ + public static class DbInitializer + { + public static void Initialize(Models.InvestmentContext context) + { + context.Database.EnsureCreated(); + + // Look for any stocks. + if (context.Stocks.Any()) + { + return; // DB has been seeded + } + var stocks = new Stock[] + { + new Stock { Name = "Apple", TickerSymbol = "AAPL", CurrentPricePerShare = 150.00m, TotalNumberOfShares = 1000000 }, + new Stock { Name = "Microsoft", TickerSymbol = "MSFT", CurrentPricePerShare = 250.00m, TotalNumberOfShares = 800000 }, + new Stock { Name = "Google", TickerSymbol = "GOOGL", CurrentPricePerShare = 2800.00m, TotalNumberOfShares = 500000 }, + new Stock { Name = "Amazon", TickerSymbol = "AMZN", CurrentPricePerShare = 3400.00m, TotalNumberOfShares = 300000 }, + new Stock { Name = "Tesla", TickerSymbol = "TSLA", CurrentPricePerShare = 700.00m, TotalNumberOfShares = 600000 } + }; + foreach (Models.Stock s in stocks) + { + context.Stocks.Add(s); + } + context.SaveChanges(); + var investments = new Investment[] + { + new Investment { UserID = 1, StockID = stocks[0].ID, SharesOwned = 50, CostBasisPerShare = 120.00m, PurchaseDate = DateTime.Parse("2021-01-15") }, + new Investment { UserID = 1, StockID = stocks[1].ID, SharesOwned = 30, CostBasisPerShare = 200.00m, PurchaseDate = DateTime.Parse("2021-03-22") }, + new Investment { UserID = 2, StockID = stocks[2].ID, SharesOwned = 10, CostBasisPerShare = 2500.00m, PurchaseDate = DateTime.Parse("2021-06-10") }, + new Investment { UserID = 2, StockID = stocks[3].ID, SharesOwned = 5, CostBasisPerShare = 3200.00m, PurchaseDate = DateTime.Parse("2021-09-05") }, + new Investment { UserID = 3, StockID = stocks[4].ID, SharesOwned = 20, CostBasisPerShare = 600.00m, PurchaseDate = DateTime.Parse("2021-11-11") } + }; + foreach (Models.Investment i in investments) + { + context.Investments.Add(i); + } + context.SaveChanges(); + } + } +} diff --git a/InvestmentPerformance/InvestmentPerformance.csproj b/InvestmentPerformance/InvestmentPerformance.csproj new file mode 100644 index 00000000..38c0ecde --- /dev/null +++ b/InvestmentPerformance/InvestmentPerformance.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/InvestmentPerformance/InvestmentPerformance.csproj.user b/InvestmentPerformance/InvestmentPerformance.csproj.user new file mode 100644 index 00000000..3d61a38d --- /dev/null +++ b/InvestmentPerformance/InvestmentPerformance.csproj.user @@ -0,0 +1,15 @@ + + + + https + ApiControllerWithContextScaffolder + root/Common/Api + 650 + True + False + True + + InvestmentPerformance.Models.InvestmentContext + False + + \ No newline at end of file diff --git a/InvestmentPerformance/InvestmentPerformance.http b/InvestmentPerformance/InvestmentPerformance.http new file mode 100644 index 00000000..faaa0af6 --- /dev/null +++ b/InvestmentPerformance/InvestmentPerformance.http @@ -0,0 +1,6 @@ +@InvestmentPerformance_HostAddress = http://localhost:5199 + +GET {{InvestmentPerformance_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/InvestmentPerformance/InvestmentPerformance.sln b/InvestmentPerformance/InvestmentPerformance.sln new file mode 100644 index 00000000..64ed780f --- /dev/null +++ b/InvestmentPerformance/InvestmentPerformance.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36429.23 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvestmentPerformance", "InvestmentPerformance.csproj", "{C1091CA6-56D7-45C1-AB97-22C053D2A879}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvestmentPerformance.Tests", "..\InvestmentPerformance.Tests\InvestmentPerformance.Tests.csproj", "{9D6EE1D1-AFE1-4072-8D70-E650B5988620}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C1091CA6-56D7-45C1-AB97-22C053D2A879}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1091CA6-56D7-45C1-AB97-22C053D2A879}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1091CA6-56D7-45C1-AB97-22C053D2A879}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1091CA6-56D7-45C1-AB97-22C053D2A879}.Release|Any CPU.Build.0 = Release|Any CPU + {9D6EE1D1-AFE1-4072-8D70-E650B5988620}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D6EE1D1-AFE1-4072-8D70-E650B5988620}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D6EE1D1-AFE1-4072-8D70-E650B5988620}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D6EE1D1-AFE1-4072-8D70-E650B5988620}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E2AA415B-FA63-4FB1-9189-C5BC5E86F609} + EndGlobalSection +EndGlobal diff --git a/InvestmentPerformance/MappingProfiles/InvestmentMappingProfile.cs b/InvestmentPerformance/MappingProfiles/InvestmentMappingProfile.cs new file mode 100644 index 00000000..11ce1db0 --- /dev/null +++ b/InvestmentPerformance/MappingProfiles/InvestmentMappingProfile.cs @@ -0,0 +1,21 @@ +using AutoMapper; +using InvestmentPerformance.Models; +using InvestmentPerformance.DTOs; + +namespace InvestmentPerformance.MappingProfiles +{ + public class InvestmentMappingProfile : Profile + { + public InvestmentMappingProfile() + { + CreateMap() + .ForMember(dest => dest.StockName, opt => opt.MapFrom(src => src.Stock.Name)); + CreateMap() + .ForMember(dest => dest.CurrentValue, opt => opt.MapFrom(src => src.SharesOwned * src.Stock.CurrentPricePerShare)) + .ForMember(dest => dest.CurrentPrice, opt => opt.MapFrom(src => src.Stock.CurrentPricePerShare)) + .ForMember(dest => dest.TotalGainOrLoss, opt => opt.MapFrom(src => (src.SharesOwned * src.Stock.CurrentPricePerShare) - (src.SharesOwned * src.CostBasisPerShare) )) + .ForMember(dest => dest.Term, opt => opt.MapFrom(src => (DateTime.Now - src.PurchaseDate).TotalDays > 365 ? "Long-Term" : "Short-Term")); + + } + } +} diff --git a/InvestmentPerformance/Models/Investment.cs b/InvestmentPerformance/Models/Investment.cs new file mode 100644 index 00000000..22afeaae --- /dev/null +++ b/InvestmentPerformance/Models/Investment.cs @@ -0,0 +1,14 @@ +namespace InvestmentPerformance.Models +{ + public class Investment + { + public int ID { get; set; } + public int UserID { get; set; } + public int StockID { get; set; } + public int SharesOwned { get; set; } + public decimal CostBasisPerShare { get; set; } + public DateTime PurchaseDate { get; set; } + + public Stock Stock { get; set; } + } +} diff --git a/InvestmentPerformance/Models/InvestmentContext.cs b/InvestmentPerformance/Models/InvestmentContext.cs new file mode 100644 index 00000000..7c2142b0 --- /dev/null +++ b/InvestmentPerformance/Models/InvestmentContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace InvestmentPerformance.Models +{ + public class InvestmentContext : DbContext + { + public InvestmentContext(DbContextOptions options) : base(options) + { + } + + public DbSet Stocks { get; set; } + public DbSet Investments { get; set; } + } +} diff --git a/InvestmentPerformance/Models/Stock.cs b/InvestmentPerformance/Models/Stock.cs new file mode 100644 index 00000000..1433e033 --- /dev/null +++ b/InvestmentPerformance/Models/Stock.cs @@ -0,0 +1,13 @@ +namespace InvestmentPerformance.Models +{ + public class Stock + { + public int ID { get; set; } + public required string Name { get; set; } + public required string TickerSymbol { get; set; } + public decimal CurrentPricePerShare { get; set; } + public int TotalNumberOfShares { get; set; } + + public ICollection Investments { get; set; } + } +} diff --git a/InvestmentPerformance/Program.cs b/InvestmentPerformance/Program.cs new file mode 100644 index 00000000..f37f4713 --- /dev/null +++ b/InvestmentPerformance/Program.cs @@ -0,0 +1,60 @@ +using InvestmentPerformance.Data; +using InvestmentPerformance.Models; +using InvestmentPerformance.Repositories; +using Microsoft.EntityFrameworkCore; + +internal class Program +{ + private static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + // Add services to the container. + + builder.Services.AddControllers(); + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + builder.Services.AddAutoMapper(cfg => cfg.LicenseKey = builder.Configuration["AutoMapper:LicenseKey"], typeof(Program)); + builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); + builder.Services.AddScoped(); + + var app = builder.Build(); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.UseHttpsRedirection(); + + app.UseAuthorization(); + + app.MapControllers(); + + CreateDbIfNotExists(app); + + app.Run(); + + void CreateDbIfNotExists(IHost host) + { + using (var scope = host.Services.CreateScope()) + { + var services = scope.ServiceProvider; + try + { + var context = services.GetRequiredService(); + DbInitializer.Initialize(context); + } + catch (Exception ex) + { + var logger = services.GetRequiredService>(); + logger.LogError(ex, "An error occurred creating the DB."); + } + } + } + } +} \ No newline at end of file diff --git a/InvestmentPerformance/Properties/launchSettings.json b/InvestmentPerformance/Properties/launchSettings.json new file mode 100644 index 00000000..902b67a1 --- /dev/null +++ b/InvestmentPerformance/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:54118", + "sslPort": 44375 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5199", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7028;http://localhost:5199", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/InvestmentPerformance/Repositories/IInvestmentRepository.cs b/InvestmentPerformance/Repositories/IInvestmentRepository.cs new file mode 100644 index 00000000..b774659e --- /dev/null +++ b/InvestmentPerformance/Repositories/IInvestmentRepository.cs @@ -0,0 +1,11 @@ +using InvestmentPerformance.Models; + +namespace InvestmentPerformance.Repositories +{ + public interface IInvestmentRepository + { + Task> GetInvestmentsByUserId(int userId); + + Task GetInvestmentById(int investmentId); + } +} diff --git a/InvestmentPerformance/Repositories/InvestmentRepository.cs b/InvestmentPerformance/Repositories/InvestmentRepository.cs new file mode 100644 index 00000000..45ca2268 --- /dev/null +++ b/InvestmentPerformance/Repositories/InvestmentRepository.cs @@ -0,0 +1,32 @@ +using InvestmentPerformance.Models; +using Microsoft.EntityFrameworkCore; + +namespace InvestmentPerformance.Repositories +{ + public class InvestmentRepository : IInvestmentRepository + { + private readonly InvestmentContext _context; + + public InvestmentRepository(InvestmentContext context) + { + _context = context; + } + + public async Task GetInvestmentById(int investmentId) + { + return await _context.Investments + .Include(i => i.Stock) + .AsNoTracking() + .SingleOrDefaultAsync(i => i.ID == investmentId); + } + + public async Task> GetInvestmentsByUserId(int userId) + { + return await _context.Investments + .Where(i => i.UserID == userId) + .Include(i => i.Stock) + .AsNoTracking() + .ToListAsync(); + } + } +} diff --git a/InvestmentPerformance/appsettings.Development.json b/InvestmentPerformance/appsettings.Development.json new file mode 100644 index 00000000..4f9ca622 --- /dev/null +++ b/InvestmentPerformance/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "ConnectionStrings": { + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/InvestmentPerformance/appsettings.Production.json b/InvestmentPerformance/appsettings.Production.json new file mode 100644 index 00000000..4f9ca622 --- /dev/null +++ b/InvestmentPerformance/appsettings.Production.json @@ -0,0 +1,10 @@ +{ + "ConnectionStrings": { + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/InvestmentPerformance/appsettings.json b/InvestmentPerformance/appsettings.json new file mode 100644 index 00000000..ceb5ed6d --- /dev/null +++ b/InvestmentPerformance/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=InvestmentPerformance;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From 873cc5e7853aa61a86967c429334688a4a612b93 Mon Sep 17 00:00:00 2001 From: Ben Sgroi Date: Fri, 12 Sep 2025 11:00:32 +0300 Subject: [PATCH 3/7] various updates --- InvestmentPerformance/Data/DbInitializer.cs | 20 ++++++++++---------- InvestmentPerformance/appsettings.json | 5 ++++- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/InvestmentPerformance/Data/DbInitializer.cs b/InvestmentPerformance/Data/DbInitializer.cs index 1355b582..e8fb9e33 100644 --- a/InvestmentPerformance/Data/DbInitializer.cs +++ b/InvestmentPerformance/Data/DbInitializer.cs @@ -15,11 +15,11 @@ public static void Initialize(Models.InvestmentContext context) } var stocks = new Stock[] { - new Stock { Name = "Apple", TickerSymbol = "AAPL", CurrentPricePerShare = 150.00m, TotalNumberOfShares = 1000000 }, - new Stock { Name = "Microsoft", TickerSymbol = "MSFT", CurrentPricePerShare = 250.00m, TotalNumberOfShares = 800000 }, - new Stock { Name = "Google", TickerSymbol = "GOOGL", CurrentPricePerShare = 2800.00m, TotalNumberOfShares = 500000 }, - new Stock { Name = "Amazon", TickerSymbol = "AMZN", CurrentPricePerShare = 3400.00m, TotalNumberOfShares = 300000 }, - new Stock { Name = "Tesla", TickerSymbol = "TSLA", CurrentPricePerShare = 700.00m, TotalNumberOfShares = 600000 } + new() { Name = "Apple", TickerSymbol = "AAPL", CurrentPricePerShare = 150.00m, TotalNumberOfShares = 1000000 }, + new() { Name = "Microsoft", TickerSymbol = "MSFT", CurrentPricePerShare = 250.00m, TotalNumberOfShares = 800000 }, + new() { Name = "Google", TickerSymbol = "GOOGL", CurrentPricePerShare = 2800.00m, TotalNumberOfShares = 500000 }, + new() { Name = "Amazon", TickerSymbol = "AMZN", CurrentPricePerShare = 3400.00m, TotalNumberOfShares = 300000 }, + new() { Name = "Tesla", TickerSymbol = "TSLA", CurrentPricePerShare = 700.00m, TotalNumberOfShares = 600000 } }; foreach (Models.Stock s in stocks) { @@ -28,11 +28,11 @@ public static void Initialize(Models.InvestmentContext context) context.SaveChanges(); var investments = new Investment[] { - new Investment { UserID = 1, StockID = stocks[0].ID, SharesOwned = 50, CostBasisPerShare = 120.00m, PurchaseDate = DateTime.Parse("2021-01-15") }, - new Investment { UserID = 1, StockID = stocks[1].ID, SharesOwned = 30, CostBasisPerShare = 200.00m, PurchaseDate = DateTime.Parse("2021-03-22") }, - new Investment { UserID = 2, StockID = stocks[2].ID, SharesOwned = 10, CostBasisPerShare = 2500.00m, PurchaseDate = DateTime.Parse("2021-06-10") }, - new Investment { UserID = 2, StockID = stocks[3].ID, SharesOwned = 5, CostBasisPerShare = 3200.00m, PurchaseDate = DateTime.Parse("2021-09-05") }, - new Investment { UserID = 3, StockID = stocks[4].ID, SharesOwned = 20, CostBasisPerShare = 600.00m, PurchaseDate = DateTime.Parse("2021-11-11") } + new() { UserID = 1, StockID = stocks[0].ID, SharesOwned = 50, CostBasisPerShare = 120.00m, PurchaseDate = DateTime.Parse("2021-01-15") }, + new() { UserID = 1, StockID = stocks[1].ID, SharesOwned = 30, CostBasisPerShare = 200.00m, PurchaseDate = DateTime.Parse("2025-06-22") }, + new() { UserID = 2, StockID = stocks[2].ID, SharesOwned = 10, CostBasisPerShare = 2500.00m, PurchaseDate = DateTime.Parse("2024-09-10") }, + new() { UserID = 2, StockID = stocks[3].ID, SharesOwned = 5, CostBasisPerShare = 3200.00m, PurchaseDate = DateTime.Parse("2021-09-05") }, + new() { UserID = 3, StockID = stocks[4].ID, SharesOwned = 20, CostBasisPerShare = 600.00m, PurchaseDate = DateTime.Parse("2021-11-11") } }; foreach (Models.Investment i in investments) { diff --git a/InvestmentPerformance/appsettings.json b/InvestmentPerformance/appsettings.json index ceb5ed6d..c3984001 100644 --- a/InvestmentPerformance/appsettings.json +++ b/InvestmentPerformance/appsettings.json @@ -8,5 +8,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "AutoMapper": { + "LicenceKey": "" + } } From 93dfe536c5d0ecb52f7edc9e9deb94ec6fbebbb1 Mon Sep 17 00:00:00 2001 From: Ben Sgroi Date: Fri, 12 Sep 2025 11:25:00 +0300 Subject: [PATCH 4/7] cleanup --- InvestmentPerformance/Data/DbInitializer.cs | 2 +- .../MappingProfiles/InvestmentMappingProfile.cs | 8 ++++---- InvestmentPerformance/Models/Stock.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/InvestmentPerformance/Data/DbInitializer.cs b/InvestmentPerformance/Data/DbInitializer.cs index e8fb9e33..5ffe4e89 100644 --- a/InvestmentPerformance/Data/DbInitializer.cs +++ b/InvestmentPerformance/Data/DbInitializer.cs @@ -4,7 +4,7 @@ namespace InvestmentPerformance.Data { public static class DbInitializer { - public static void Initialize(Models.InvestmentContext context) + public static void Initialize(InvestmentContext context) { context.Database.EnsureCreated(); diff --git a/InvestmentPerformance/MappingProfiles/InvestmentMappingProfile.cs b/InvestmentPerformance/MappingProfiles/InvestmentMappingProfile.cs index 11ce1db0..9a4bfef1 100644 --- a/InvestmentPerformance/MappingProfiles/InvestmentMappingProfile.cs +++ b/InvestmentPerformance/MappingProfiles/InvestmentMappingProfile.cs @@ -11,10 +11,10 @@ public InvestmentMappingProfile() CreateMap() .ForMember(dest => dest.StockName, opt => opt.MapFrom(src => src.Stock.Name)); CreateMap() - .ForMember(dest => dest.CurrentValue, opt => opt.MapFrom(src => src.SharesOwned * src.Stock.CurrentPricePerShare)) - .ForMember(dest => dest.CurrentPrice, opt => opt.MapFrom(src => src.Stock.CurrentPricePerShare)) - .ForMember(dest => dest.TotalGainOrLoss, opt => opt.MapFrom(src => (src.SharesOwned * src.Stock.CurrentPricePerShare) - (src.SharesOwned * src.CostBasisPerShare) )) - .ForMember(dest => dest.Term, opt => opt.MapFrom(src => (DateTime.Now - src.PurchaseDate).TotalDays > 365 ? "Long-Term" : "Short-Term")); + .ForMember(dest => dest.CurrentValue, opt => opt.MapFrom(investment => investment.SharesOwned * investment.Stock.CurrentPricePerShare)) + .ForMember(dest => dest.CurrentPrice, opt => opt.MapFrom(investment => investment.Stock.CurrentPricePerShare)) + .ForMember(dest => dest.TotalGainOrLoss, opt => opt.MapFrom(investment => (investment.SharesOwned * investment.Stock.CurrentPricePerShare) - (investment.SharesOwned * investment.CostBasisPerShare) )) + .ForMember(dest => dest.Term, opt => opt.MapFrom(investment => (DateTime.Now - investment.PurchaseDate).TotalDays > 365 ? "Long-Term" : "Short-Term")); } } diff --git a/InvestmentPerformance/Models/Stock.cs b/InvestmentPerformance/Models/Stock.cs index 1433e033..eb64b5a1 100644 --- a/InvestmentPerformance/Models/Stock.cs +++ b/InvestmentPerformance/Models/Stock.cs @@ -8,6 +8,6 @@ public class Stock public decimal CurrentPricePerShare { get; set; } public int TotalNumberOfShares { get; set; } - public ICollection Investments { get; set; } + public ICollection Investments { get; set; } = new List(); } } From 02ac91b9723f353c61ef277dc853a4c1088ba67d Mon Sep 17 00:00:00 2001 From: Ben Sgroi Date: Fri, 12 Sep 2025 11:35:48 +0300 Subject: [PATCH 5/7] Update ReadMe.md --- ReadMe.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/ReadMe.md b/ReadMe.md index ad2afefb..e80000b3 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,3 +1,25 @@ +# InvestmentPerformance Web API +Submitted by Ben Sgroi + +## Running the Code +The API is written in .NET Core 8. From the InvestmentPerformance folder, run `dotnet run` to start the web server. When run locally, it will use a SQL Server Express LocalDB with seeded data (see `DbInitializer.cs`). Use Postman or similar tools to run the APIs. The current user ID should be set in a request header "UserID". + +The latest version of AutoMapper requires a license key. [Create one on their site](https://automapper.io/) and set it in `appsettings.json` or in an environment variable named `AutoSettings__LicenceKey`. + +## Assumptions & Notes +- I didn't implement authentication, as this seemed out of scope for a simple Web API that's part of a larger system. I decided to use a request header "UserID" to specify the user, but in a real system, the user would likely be derived from the authorization header. +- I assumed that an investment record is immutable, that additional stocks bought would result in a new investment record. Otherwise, fields like "term" and "cost basis per share" don't make sense. +- A RDBMS like SQL Server is the right choice for this application, since consistency is critical in fast-moving domains like stock transactions. +- AutoMapper may have been overkill for a small application like this, but it can be useful for larger projects/models. + +## Future Enhancements +- Add more unit test coverage. +- Use a centralized logging solution. +- A StockPrices table could be added to track the performance of a stock over time. +- Depending on the anticipated load, we may need to scale resources. For web servers, we can rely on a load balancer or API manager for horizontal scaling. Distributed caching via Redis or similar may be required to improve performance. Auto-scaling can help if the user load is not predictable. + +*** + # Coding Exercise > This repository holds coding exercises for candidates going through the hiring process. @@ -25,4 +47,4 @@ 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 +``` From a681653379513f55de8d79652cc2bf5512d14445 Mon Sep 17 00:00:00 2001 From: Ben Sgroi Date: Fri, 12 Sep 2025 11:40:56 +0300 Subject: [PATCH 6/7] Update ReadMe.md --- ReadMe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ReadMe.md b/ReadMe.md index e80000b3..90ca5e72 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -2,7 +2,7 @@ Submitted by Ben Sgroi ## Running the Code -The API is written in .NET Core 8. From the InvestmentPerformance folder, run `dotnet run` to start the web server. When run locally, it will use a SQL Server Express LocalDB with seeded data (see `DbInitializer.cs`). Use Postman or similar tools to run the APIs. The current user ID should be set in a request header "UserID". +The API is written in .NET Core 8. From the InvestmentPerformance folder, run `dotnet run` to start the web server. When run locally, it will create a SQL Server Express LocalDB with seeded data (see `DbInitializer.cs`). Use Postman or similar tools to run the APIs. The current user ID should be set in a request header "UserID". The latest version of AutoMapper requires a license key. [Create one on their site](https://automapper.io/) and set it in `appsettings.json` or in an environment variable named `AutoSettings__LicenceKey`. From 500e09e3cbe4ae1852b6954e768a931891571307 Mon Sep 17 00:00:00 2001 From: Ben Sgroi Date: Fri, 12 Sep 2025 12:24:47 +0300 Subject: [PATCH 7/7] removed http file --- InvestmentPerformance/InvestmentPerformance.http | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 InvestmentPerformance/InvestmentPerformance.http diff --git a/InvestmentPerformance/InvestmentPerformance.http b/InvestmentPerformance/InvestmentPerformance.http deleted file mode 100644 index faaa0af6..00000000 --- a/InvestmentPerformance/InvestmentPerformance.http +++ /dev/null @@ -1,6 +0,0 @@ -@InvestmentPerformance_HostAddress = http://localhost:5199 - -GET {{InvestmentPerformance_HostAddress}}/weatherforecast/ -Accept: application/json - -###