Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
428 changes: 428 additions & 0 deletions .gitignore

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\InvestmentPerformanceWebAPI\InvestmentPerformanceWebAPI.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
116 changes: 116 additions & 0 deletions InvestmentPerformanceWebAPI.Tests/UserInvestmentServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using InvestmentPerformanceWebAPI.Database.Contexts;
using InvestmentPerformanceWebAPI.Database.Models;
using InvestmentPerformanceWebAPI.Services;
using Microsoft.EntityFrameworkCore;

namespace InvestmentPerformanceWebAPI.Tests
{
public class UserInvestmentServiceTests
{
private static InvestmentDbContext CreateInMemoryContext()
{
var options = new DbContextOptionsBuilder<InvestmentDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;

var context = new InvestmentDbContext(options);

context.Investments.Add(new Investment
{
InvestmentId = 1,
UserId = 1,
Name = "Test",
Ticker = "TST",
NumberOfShares = 10m,
CostBasisPerShare = 100m,
CurrentPrice = 150m,
TimeOfPurchase = DateTime.UtcNow.AddDays(-100)
});

context.SaveChanges();
return context;
}

[Fact]
public async Task GetInvestment_Computes_Performance_Correctly()
{
// Arrange
using var context = CreateInMemoryContext();
var service = new UserInvestmentService(context);

// Act
var result = await service.GetInvestmentAsync(1, 1);

// Assert
Assert.NotNull(result);
Assert.Equal(1500m, result!.CurrentValue); // 10 * 150
Assert.Equal(500m, result.TotalGainLoss); // 1500 - 1000
Assert.Equal("ShortTerm", result.Term); // 100 days < 365
}

[Fact]
public async Task GetInvestment_ReturnsNull_For_WrongUser()
{
using var context = CreateInMemoryContext();

// Add same investment for different user
context.Investments.Add(new Investment
{
InvestmentId = 2,
UserId = 2,
Name = "ForeignInvestment",
Ticker = "FI",
NumberOfShares = 10m,
CostBasisPerShare = 20m,
CurrentPrice = 22m,
TimeOfPurchase = DateTime.UtcNow.AddDays(-10)
});

context.SaveChanges();

var service = new UserInvestmentService(context);

// Act - user 1 tries to access investment 2 (owned by user 2)
var result = await service.GetInvestmentAsync(1, 2);

// Assert
Assert.Null(result);
}


[Fact]
public async Task GetInvestment_ReturnsNull_If_NotFound()
{
using var context = CreateInMemoryContext();
var service = new UserInvestmentService(context);

// Act
var result = await service.GetInvestmentAsync(1, 999);

// Assert
Assert.Null(result);
}

[Fact]
public async Task GetInvestment_Computes_LongTerm_Correctly()
{
using var context = CreateInMemoryContext();

// Modify existing seed to be long-term (> 365 days)
var investment = context.Investments.First();
investment.TimeOfPurchase = DateTime.UtcNow.AddDays(-400);
context.SaveChanges();

var service = new UserInvestmentService(context);

// Act
var result = await service.GetInvestmentAsync(1, 1);

// Assert
Assert.NotNull(result);
Assert.Equal("LongTerm", result!.Term);
}


}
}
66 changes: 66 additions & 0 deletions InvestmentPerformanceWebAPI/Controllers/UserInvestments.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using InvestmentPerformanceWebAPI.DTOs;
using InvestmentPerformanceWebAPI.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace InvestmentPerformanceWebAPI.Controllers
{
/// <summary>
/// API controller that exposes endpoints for querying user investments and investment details.
/// </summary>
[ApiController]
[Route("api/users/{userId:int}/investments")]
public class UserInvestmentsController(ILogger<UserInvestmentsController> logger, IUserInvestmentService userInvestments) : ControllerBase
{
private readonly ILogger<UserInvestmentsController> _logger = logger;
private readonly IUserInvestmentService _userInvestments = userInvestments;

/// <summary>
/// Gets a list of investments for a given user (id + name only).
/// </summary>
/// <param name="userId">Identifier of the user whose investments to retrieve.</param>
[HttpGet]
public async Task<ActionResult<UserInvestmentsDTO>> GetUserInvestments(int userId)
{
try
{
var dto = await _userInvestments.GetUserInvestmentsAsync(userId);
return Ok(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while fetching investments for userId {userId}", userId);
return Problem("Internal server error", statusCode: 500);
}
}

/// <summary>
/// Gets detailed information about a single investment for a given user.
/// </summary>
/// <param name="userId">Identifier of the owning user.</param>
/// <param name="investmentId">Identifier of the investment to retrieve.</param>
[HttpGet("{investmentId:int}")]
public async Task<ActionResult<InvestmentDTO>> GetInvestment(int userId, int investmentId)
{
try
{
var dto = await _userInvestments.GetInvestmentAsync(userId, investmentId);

if (dto is null)
{
if (_logger.IsEnabled(LogLevel.Warning))
{
_logger.LogWarning("Investment {investmentId} for user {userId} not found", investmentId, userId);
}
return NotFound();
}

return Ok(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while fetching investment {investmentId} for userId {userId}", investmentId, userId);
return Problem("Internal server error", statusCode: 500);
}
}
}
}
21 changes: 21 additions & 0 deletions InvestmentPerformanceWebAPI/DTOs/InvestmentDTO.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace InvestmentPerformanceWebAPI.DTOs
{
public class InvestmentDTO
{
public int InvestmentId { get; set; }
public string Name { get; set; } = string.Empty;
public string Ticker { get; set; } = string.Empty;
public decimal NumberOfShares { get; set; }
public decimal CostBasisPerShare { get; set; }
public decimal CurrentPrice { get; set; }

// Current total value of the position (NumberOfShares * CurrentPrice).
public decimal CurrentValue { get; set; }

// Investment term classification: "ShortTerm" or "LongTerm".
public string Term { get; set; } = string.Empty;

// CurrentValue - (NumberOfShares * CostBasisPerShare).
public decimal TotalGainLoss { get; set; }
}
}
19 changes: 19 additions & 0 deletions InvestmentPerformanceWebAPI/DTOs/UserInvestmentsDTO.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace InvestmentPerformanceWebAPI.DTOs
{
/// <summary>
/// Response DTO representing a user's list of investments.
/// </summary>
public class UserInvestmentsDTO
{
public List<UserInvestmentSummaryDTO> Investments { get; set; } = [];
}

/// <summary>
/// Summary view for a single investment, used in list endpoints.
/// </summary>
public class UserInvestmentSummaryDTO
{
public int InvestmentId { get; set; }
public string Name { get; set; } = string.Empty;
}
}
143 changes: 143 additions & 0 deletions InvestmentPerformanceWebAPI/Database/Contexts/InvestmentDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using InvestmentPerformanceWebAPI.Database.Models;
using Microsoft.EntityFrameworkCore;

namespace InvestmentPerformanceWebAPI.Database.Contexts
{
public class InvestmentDbContext(DbContextOptions<InvestmentDbContext> options) : DbContext(options)
{
public DbSet<User> Users { get; set; } = null!;
public DbSet<Investment> Investments { get; set; } = null!;

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>()
.HasMany(u => u.Investments)
.WithOne(i => i.User!)
.HasForeignKey(i => i.UserId);


// manually create some data entities
modelBuilder.Entity<User>().HasData(
new User { UserId = 1, Name = "Daniel Rivers" },
new User { UserId = 2, Name = "Sophia Bennett" },
new User { UserId = 3, Name = "Marcus Holt" },
new User { UserId = 4, Name = "Emma Caldwell" },
new User { UserId = 5, Name = "Jonathan Pierce" }
);


modelBuilder.Entity<Investment>().HasData(
new Investment
{
InvestmentId = 1,
UserId = 1,
Name = "Berkshire Hathaway Inc.",
Ticker = "BRK.B",
NumberOfShares = 4,
CostBasisPerShare = 290.75m,
CurrentPrice = 365.40m,
TimeOfPurchase = new DateTime(2022, 4, 12)
},
new Investment
{
InvestmentId = 2,
UserId = 1,
Name = "Procter & Gamble Co.",
Ticker = "PG",
NumberOfShares = 18,
CostBasisPerShare = 130.10m,
CurrentPrice = 153.25m,
TimeOfPurchase = new DateTime(2023, 11, 3)
},
new Investment
{
InvestmentId = 3,
UserId = 2,
Name = "Costco Wholesale Corporation",
Ticker = "COST",
NumberOfShares = 6,
CostBasisPerShare = 470.20m,
CurrentPrice = 680.50m,
TimeOfPurchase = new DateTime(2021, 8, 19)
},
new Investment
{
InvestmentId = 4,
UserId = 2,
Name = "Walt Disney Company",
Ticker = "DIS",
NumberOfShares = 22,
CostBasisPerShare = 92.15m,
CurrentPrice = 101.85m,
TimeOfPurchase = new DateTime(2024, 1, 7)
},
new Investment
{
InvestmentId = 5,
UserId = 3,
Name = "Intel Corporation",
Ticker = "INTC",
NumberOfShares = 30,
CostBasisPerShare = 28.00m,
CurrentPrice = 36.75m,
TimeOfPurchase = new DateTime(2023, 5, 16)
},
new Investment
{
InvestmentId = 6,
UserId = 3,
Name = "Chevron Corporation",
Ticker = "CVX",
NumberOfShares = 12,
CostBasisPerShare = 148.50m,
CurrentPrice = 159.95m,
TimeOfPurchase = new DateTime(2022, 2, 27)
},
new Investment
{
InvestmentId = 7,
UserId = 4,
Name = "UnitedHealth Group Incorporated",
Ticker = "UNH",
NumberOfShares = 3,
CostBasisPerShare = 460.00m,
CurrentPrice = 529.40m,
TimeOfPurchase = new DateTime(2023, 8, 24)
},
new Investment
{
InvestmentId = 8,
UserId = 4,
Name = "PepsiCo, Inc.",
Ticker = "PEP",
NumberOfShares = 14,
CostBasisPerShare = 165.25m,
CurrentPrice = 172.45m,
TimeOfPurchase = new DateTime(2021, 12, 10)
},
new Investment
{
InvestmentId = 9,
UserId = 5,
Name = "AbbVie Inc.",
Ticker = "ABBV",
NumberOfShares = 9,
CostBasisPerShare = 112.80m,
CurrentPrice = 157.60m,
TimeOfPurchase = new DateTime(2024, 3, 5)
},
new Investment
{
InvestmentId = 10,
UserId = 5,
Name = "Ford Motor Company",
Ticker = "F",
NumberOfShares = 40,
CostBasisPerShare = 11.45m,
CurrentPrice = 13.90m,
TimeOfPurchase = new DateTime(2022, 9, 22)
}
);
}
}
}
Loading