diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..84adbdbb --- /dev/null +++ b/.gitignore @@ -0,0 +1,135 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results + +[Dd]ebug/ +[Rr]elease/ +x64/ +[Bb]in/ +[Oo]bj/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +.vs/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.log +*.svclog +*.scc + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# Click-Once directory +publish/ + +# Publish Web Output +*.Publish.xml +*.pubxml +*.azurePubxml + +# NuGet Packages Directory +## TODO: If you have NuGet Package Restore enabled, uncomment the next line +packages/ +## TODO: If the tool you use requires repositories.config, also uncomment the next line +!packages/repositories.config + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +sql/ +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +![Ss]tyle[Cc]op.targets +~$* +*~ +*.dbmdl +*.[Pp]ublish.xml + +*.publishsettings + +# 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 + +# SQL Server files +App_Data/*.mdf +App_Data/*.ldf + +# ========================= +# Windows detritus +# ========================= + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Mac desktop service store files +.DS_Store + +_NCrunch* \ No newline at end of file diff --git a/InvestmentPerformanceWebAPI/Controllers/TransactionsController.cs b/InvestmentPerformanceWebAPI/Controllers/TransactionsController.cs new file mode 100644 index 00000000..81e7d0cd --- /dev/null +++ b/InvestmentPerformanceWebAPI/Controllers/TransactionsController.cs @@ -0,0 +1,93 @@ +using InvestmentPerformanceWebAPI.Database; +using InvestmentPerformanceWebAPI.DataTransferObjects; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace InvestmentPerformanceWebAPI.Controllers +{ + /// + /// Controller responsible for handling transaction-related API endpoints. + /// Provides access to transaction data including basic transaction information and detailed financial calculations. + /// + [ApiController] + [Route("[controller]")] + public class TransactionsController : ControllerBase + { + private readonly ApplicationDbContext _context; + + /// + /// Initializes a new instance of the TransactionsController. + /// + /// The Entity Framework database context used to access transaction data. + public TransactionsController(ApplicationDbContext context) + { + _context = context; + } + + /// + /// Retrieves all transactions associated with a specific user. + /// Returns basic transaction information (ID and Name) for all transactions belonging to the specified user. + /// + /// The unique identifier of the user whose transactions should be retrieved. + [HttpGet] + [Route("user/{id}")] + public IActionResult GetTransactionsForUser(int id) + { + // Query the database for all transactions belonging to the specified user + // Project the results to TransactionDTO to return only basic information + var transactions = _context.Transactions.Where(t => t.UserId == id).Select( t => new TransactionDTO() + { + Id = t.Id, + Name = t.Name + }); + + return Ok(transactions); + } + + /// + /// Retrieves basic information for a specific transaction by its ID. + /// Returns only the transaction ID and company name. + /// + /// The unique identifier of the transaction to retrieve. + [HttpGet] + [Route("{id}")] + public IActionResult GetTransaction(int id) + { + // Query for a specific transaction by ID and project to basic DTO + // Note: Returns IQueryable which may contain 0 or 1 results + var transactions = _context.Transactions.Where(t => t.Id == id).Select(t => new TransactionDTO() + { + Id = t.Id, + Name = t.Name, + }); + + return Ok(transactions); + } + + /// + /// Retrieves comprehensive transaction details including financial calculations for a specific transaction. + /// Returns detailed information including current market value, total gain/loss, and investment performance metrics. + /// + /// The unique identifier of the transaction to retrieve detailed information for. + [HttpGet] + [Route("transaction-details/{id}")] + public IActionResult GetTransactionDetails(int id) + { + // Query for specific transaction and project to detailed DTO with financial calculations + var transactions = _context.Transactions.Where(t => t.Id == id).Select(t => new TransactionDetailsDTO() + { + Id = t.Id, + Name = t.Name, + Shares = t.Quantity, // Map Quantity to Shares for DTO + TransactionTime = t.TransactionTime, + CostBasisPerShare = t.SharePriceAtPurchase, // Original purchase price per share + CurrentValue = t.CurrentSharePrice * t.Quantity, // Total current market value + CurrentPrice = t.CurrentSharePrice, // Current market price per share + // Calculate total gain/loss: (Current Value) - (Original Investment) + TotalGain = (t.CurrentSharePrice * t.Quantity) - (t.SharePriceAtPurchase * t.Quantity) + }); + + return Ok(transactions); + } + } +} diff --git a/InvestmentPerformanceWebAPI/Controllers/UsersController.cs b/InvestmentPerformanceWebAPI/Controllers/UsersController.cs new file mode 100644 index 00000000..d73cada3 --- /dev/null +++ b/InvestmentPerformanceWebAPI/Controllers/UsersController.cs @@ -0,0 +1,88 @@ +using InvestmentPerformanceWebAPI.Database; +using InvestmentPerformanceWebAPI.DataTransferObjects; +using InvestmentPerformanceWebAPI.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace InvestmentPerformanceWebAPI.Controllers +{ + + [ApiController] + [Route("[controller]")] + public class UserController : ControllerBase + { + private readonly ApplicationDbContext _context; + + /// + /// Initializes a new instance of the UserController. + /// + /// Database context for accessing user and transaction data. + public UserController(ApplicationDbContext context) + { + _context = context; + } + + /// + /// Retrieves all users with basic information (ID and username only). + /// + [HttpGet] + public IActionResult GetUsers() + { + // Query all users and project to basic UserDTO + var users = _context.Users.Select(u => new UserDTO() + { + Id = u.Id, + Username = u.Username + }); + + // Note: This null check is unnecessary as LINQ Select never returns null + if (users == null) + { + return BadRequest(); + } + else + { + return Ok(users); + } + } + + /// + /// Retrieves detailed user information including complete investment portfolio with financial calculations. + /// + /// The unique identifier of the user to retrieve. + [HttpGet] + [Route("{id}")] + public IActionResult GetUser(int id) + { + // Query specific user with transactions, calculate financial metrics in projection + var user = _context.Users.Include("Transactions").Where(u => u.Id == id).Select(u => new UserDTO() + { + Id = u.Id, + Username = u.Username, + Transactions = u.Transactions.Select(t => new TransactionDetailsDTO() + { + Id = t.Id, + Name = t.Name, + Shares = t.Quantity, + TransactionTime = t.TransactionTime, + CostBasisPerShare = t.SharePriceAtPurchase, + CurrentValue = t.CurrentSharePrice * t.Quantity, + CurrentPrice = t.CurrentSharePrice, + TotalGain = (t.CurrentSharePrice * t.Quantity) - (t.SharePriceAtPurchase * t.Quantity) + }).ToList() + }).FirstOrDefault(); + + // return bad request if user not found + if (user == null) + { + return BadRequest(); + } + else + { + return Ok(user); + } + } + } +} diff --git a/InvestmentPerformanceWebAPI/DataTransferObjects/TransactionDTO.cs b/InvestmentPerformanceWebAPI/DataTransferObjects/TransactionDTO.cs new file mode 100644 index 00000000..6f298bb3 --- /dev/null +++ b/InvestmentPerformanceWebAPI/DataTransferObjects/TransactionDTO.cs @@ -0,0 +1,8 @@ +namespace InvestmentPerformanceWebAPI.DataTransferObjects +{ + public class TransactionDTO + { + public int Id { get; set; } + public string Name { get; set; } // e.g., "Microsoft" + } +} diff --git a/InvestmentPerformanceWebAPI/DataTransferObjects/TransactionDetailsDTO.cs b/InvestmentPerformanceWebAPI/DataTransferObjects/TransactionDetailsDTO.cs new file mode 100644 index 00000000..9b9c1ec2 --- /dev/null +++ b/InvestmentPerformanceWebAPI/DataTransferObjects/TransactionDetailsDTO.cs @@ -0,0 +1,22 @@ +namespace InvestmentPerformanceWebAPI.DataTransferObjects +{ + public class TransactionDetailsDTO : TransactionDTO + { + public DateTime TransactionTime { get; set; } + public int Shares { get; set; } + public string Term + { + get + { + return TransactionTime >= DateTime.UtcNow.AddYears(-1) ? "Short-Term" : "Long-Term"; + } + } + public double CostBasisPerShare { get; set; } + + public double CurrentValue {get; set;} + + public double CurrentPrice { get; set; } + + public double TotalGain { get; set; } + } +} diff --git a/InvestmentPerformanceWebAPI/DataTransferObjects/UserDTO.cs b/InvestmentPerformanceWebAPI/DataTransferObjects/UserDTO.cs new file mode 100644 index 00000000..2ca56a10 --- /dev/null +++ b/InvestmentPerformanceWebAPI/DataTransferObjects/UserDTO.cs @@ -0,0 +1,10 @@ +namespace InvestmentPerformanceWebAPI.DataTransferObjects +{ + public class UserDTO + { + public int Id { get; set; } + public string Username { get; set; } + + public ICollection Transactions { get; set; } = new List(); + } +} diff --git a/InvestmentPerformanceWebAPI/Database Models/Transaction.cs b/InvestmentPerformanceWebAPI/Database Models/Transaction.cs new file mode 100644 index 00000000..b1a0a438 --- /dev/null +++ b/InvestmentPerformanceWebAPI/Database Models/Transaction.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace InvestmentPerformanceWebAPI.Models +{ + public class Transaction + { + public int Id { get; set; } + public TransactionType Type { get; set; } + public string Name { get; set; } // e.g., "Microsoft" + public string Symbol { get; set; } // e.g., "IBM" + public int Quantity { get; set; } + public double SharePriceAtPurchase { get; set; } + public double CurrentSharePrice { get; set; } + public DateTime TransactionTime { get; set; } + [ForeignKey("User")] + public int UserId { get; set; } // Foreign key to User + } + + public enum TransactionType + { + Buy, + Sell + } +} diff --git a/InvestmentPerformanceWebAPI/Database Models/User.cs b/InvestmentPerformanceWebAPI/Database Models/User.cs new file mode 100644 index 00000000..43c4cba5 --- /dev/null +++ b/InvestmentPerformanceWebAPI/Database Models/User.cs @@ -0,0 +1,12 @@ +using System.Reflection.Metadata; + +namespace InvestmentPerformanceWebAPI.Models +{ + public class User + { + public int Id { get; set; } + public string Username { get; set; } + public string Email { get; set; } + public ICollection Transactions { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/InvestmentPerformanceWebAPI/Database/ApplicationDbContext.cs b/InvestmentPerformanceWebAPI/Database/ApplicationDbContext.cs new file mode 100644 index 00000000..1ce60311 --- /dev/null +++ b/InvestmentPerformanceWebAPI/Database/ApplicationDbContext.cs @@ -0,0 +1,103 @@ +using InvestmentPerformanceWebAPI.Models; +using Microsoft.EntityFrameworkCore; +using System.Collections.Generic; +using System.ComponentModel; + +namespace InvestmentPerformanceWebAPI.Database +{ + public class ApplicationDbContext : DbContext + { + public DbSet Users { get; set; } + + public DbSet Transactions { get; set; } + + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } + + /// + /// Overriding OnModelCreating to seed initial data into the database. + /// + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Add users + modelBuilder.Entity().HasData( + new User + { + Id = 1, // primary key + Username = "testaccount", + Email = "testaccount@test.com", + } + ); + + // Add transactions for user with UserId = 1 + modelBuilder.Entity().HasData( + + new Transaction + { + Id = 1, // primary key + Type = TransactionType.Buy, + Name = "International Business Machines", + Symbol = "IBM", + Quantity = 10, + SharePriceAtPurchase = 282.16, + CurrentSharePrice = 283.50, + TransactionTime = DateTime.UtcNow.AddYears(-1), + UserId = 1 // foreign key to User + }, + new Transaction + { + Id = 2, // primary key + Type = TransactionType.Buy, + Name = "Fidelity Total Market Index Fund", + Symbol = "FSKAX", + Quantity = 150, + SharePriceAtPurchase = 184.29, + CurrentSharePrice = 200.10, + TransactionTime = DateTime.UtcNow, + UserId = 1 // foreign key to User + }, + new Transaction + { + Id = 3, // primary key + Type = TransactionType.Buy, + Name = "Microsoft", + Symbol = "MSFT", + SharePriceAtPurchase = 282.16, + CurrentSharePrice = 300.04, + Quantity = 1, + TransactionTime = DateTime.UtcNow.AddDays(-4), + UserId = 1 // foreign key to User + }, + new Transaction + { + Id = 4, // primary key + Type = TransactionType.Buy, + Name = "Vitreous Glass Inc", + Symbol = "VCI", + Quantity = 5, + SharePriceAtPurchase = 6.16, + CurrentSharePrice = 8.02, + TransactionTime = DateTime.UtcNow.AddYears(-2), + UserId = 1 // foreign key to User + }, + new Transaction + { + Id = 5, // primary key + Type = TransactionType.Buy, + Name = "Corsair", + Symbol = "CRSR", + Quantity = 2, + SharePriceAtPurchase = 8.72, + CurrentSharePrice = 8.02, + TransactionTime = DateTime.UtcNow.AddDays(-2), + UserId = 1 // foreign key to User + } + ); + } + } +} diff --git a/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.csproj b/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.csproj new file mode 100644 index 00000000..69a415b6 --- /dev/null +++ b/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.db b/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.db new file mode 100644 index 00000000..9b6bc217 Binary files /dev/null and b/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.db differ diff --git a/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.db-shm b/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.db-shm new file mode 100644 index 00000000..fe9ac284 Binary files /dev/null and b/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.db-shm differ diff --git a/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.db-wal b/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.db-wal new file mode 100644 index 00000000..e69de29b diff --git a/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.sln b/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.sln new file mode 100644 index 00000000..37c9df3f --- /dev/null +++ b/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36310.24 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvestmentPerformanceWebAPI", "InvestmentPerformanceWebAPI.csproj", "{92148044-E466-4A89-A432-C209C392EDBF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvestmentPerformanceWebAPIUnitTests", "..\InvestmentPerformanceWebAPIUnitTests\InvestmentPerformanceWebAPIUnitTests.csproj", "{32836670-B007-45F2-8872-F6C8FB9E3253}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {92148044-E466-4A89-A432-C209C392EDBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92148044-E466-4A89-A432-C209C392EDBF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92148044-E466-4A89-A432-C209C392EDBF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92148044-E466-4A89-A432-C209C392EDBF}.Release|Any CPU.Build.0 = Release|Any CPU + {32836670-B007-45F2-8872-F6C8FB9E3253}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {32836670-B007-45F2-8872-F6C8FB9E3253}.Debug|Any CPU.Build.0 = Debug|Any CPU + {32836670-B007-45F2-8872-F6C8FB9E3253}.Release|Any CPU.ActiveCfg = Release|Any CPU + {32836670-B007-45F2-8872-F6C8FB9E3253}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CF7C718A-700A-4E94-AAA5-D59FDFABC2F9} + EndGlobalSection +EndGlobal diff --git a/InvestmentPerformanceWebAPI/Migrations/20251001171841_InitialCreate.Designer.cs b/InvestmentPerformanceWebAPI/Migrations/20251001171841_InitialCreate.Designer.cs new file mode 100644 index 00000000..334272b2 --- /dev/null +++ b/InvestmentPerformanceWebAPI/Migrations/20251001171841_InitialCreate.Designer.cs @@ -0,0 +1,136 @@ +// +using System; +using InvestmentPerformanceWebAPI.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InvestmentPerformanceWebAPI.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20251001171841_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TransactionTime") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Transactions"); + + b.HasData( + new + { + Id = 1, + Name = "International Business Machines", + Quantity = 10, + Symbol = "IBM", + TransactionTime = new DateTime(2025, 10, 1, 17, 18, 40, 881, DateTimeKind.Utc).AddTicks(175), + Type = 0, + UserId = 1 + }, + new + { + Id = 2, + Name = "Fidelity Total Market Index Fund", + Quantity = 150, + Symbol = "FSKAX", + TransactionTime = new DateTime(2025, 10, 1, 17, 18, 40, 881, DateTimeKind.Utc).AddTicks(758), + Type = 0, + UserId = 1 + }, + new + { + Id = 3, + Name = "Microsoft", + Quantity = 1, + Symbol = "MSFT", + TransactionTime = new DateTime(2025, 10, 1, 17, 18, 40, 881, DateTimeKind.Utc).AddTicks(761), + Type = 0, + UserId = 1 + }); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = 1, + Email = "testaccount@test.com", + PasswordHash = "AQAAAAIAAYagAAAAEL063horo+HRrTdEbV+L5+8GocegjOlb6XzR0aAzFkKOYs3XFHKtVv2VP9cqng4cwQ==", + Username = "testaccount" + }); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.Transaction", b => + { + b.HasOne("InvestmentPerformanceWebAPI.Models.User", null) + .WithMany("Transactions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.User", b => + { + b.Navigation("Transactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/InvestmentPerformanceWebAPI/Migrations/20251001171841_InitialCreate.cs b/InvestmentPerformanceWebAPI/Migrations/20251001171841_InitialCreate.cs new file mode 100644 index 00000000..90689aa2 --- /dev/null +++ b/InvestmentPerformanceWebAPI/Migrations/20251001171841_InitialCreate.cs @@ -0,0 +1,86 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace InvestmentPerformanceWebAPI.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Username = table.Column(type: "TEXT", nullable: false), + Email = table.Column(type: "TEXT", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Transactions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Type = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Symbol = table.Column(type: "TEXT", nullable: false), + Quantity = table.Column(type: "INTEGER", nullable: false), + TransactionTime = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Transactions", x => x.Id); + table.ForeignKey( + name: "FK_Transactions_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "Users", + columns: new[] { "Id", "Email", "PasswordHash", "Username" }, + values: new object[] { 1, "testaccount@test.com", "AQAAAAIAAYagAAAAEL063horo+HRrTdEbV+L5+8GocegjOlb6XzR0aAzFkKOYs3XFHKtVv2VP9cqng4cwQ==", "testaccount" }); + + migrationBuilder.InsertData( + table: "Transactions", + columns: new[] { "Id", "Name", "Quantity", "Symbol", "TransactionTime", "Type", "UserId" }, + values: new object[,] + { + { 1, "International Business Machines", 10, "IBM", new DateTime(2025, 10, 1, 17, 18, 40, 881, DateTimeKind.Utc).AddTicks(175), 0, 1 }, + { 2, "Fidelity Total Market Index Fund", 150, "FSKAX", new DateTime(2025, 10, 1, 17, 18, 40, 881, DateTimeKind.Utc).AddTicks(758), 0, 1 }, + { 3, "Microsoft", 1, "MSFT", new DateTime(2025, 10, 1, 17, 18, 40, 881, DateTimeKind.Utc).AddTicks(761), 0, 1 } + }); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_UserId", + table: "Transactions", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Transactions"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/InvestmentPerformanceWebAPI/Migrations/ApplicationDbContextModelSnapshot.cs b/InvestmentPerformanceWebAPI/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 00000000..38ad3a0b --- /dev/null +++ b/InvestmentPerformanceWebAPI/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,133 @@ +// +using System; +using InvestmentPerformanceWebAPI.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InvestmentPerformanceWebAPI.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TransactionTime") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Transactions"); + + b.HasData( + new + { + Id = 1, + Name = "International Business Machines", + Quantity = 10, + Symbol = "IBM", + TransactionTime = new DateTime(2025, 10, 1, 17, 18, 40, 881, DateTimeKind.Utc).AddTicks(175), + Type = 0, + UserId = 1 + }, + new + { + Id = 2, + Name = "Fidelity Total Market Index Fund", + Quantity = 150, + Symbol = "FSKAX", + TransactionTime = new DateTime(2025, 10, 1, 17, 18, 40, 881, DateTimeKind.Utc).AddTicks(758), + Type = 0, + UserId = 1 + }, + new + { + Id = 3, + Name = "Microsoft", + Quantity = 1, + Symbol = "MSFT", + TransactionTime = new DateTime(2025, 10, 1, 17, 18, 40, 881, DateTimeKind.Utc).AddTicks(761), + Type = 0, + UserId = 1 + }); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = 1, + Email = "testaccount@test.com", + PasswordHash = "AQAAAAIAAYagAAAAEL063horo+HRrTdEbV+L5+8GocegjOlb6XzR0aAzFkKOYs3XFHKtVv2VP9cqng4cwQ==", + Username = "testaccount" + }); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.Transaction", b => + { + b.HasOne("InvestmentPerformanceWebAPI.Models.User", null) + .WithMany("Transactions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.User", b => + { + b.Navigation("Transactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/InvestmentPerformanceWebAPI/Program.cs b/InvestmentPerformanceWebAPI/Program.cs new file mode 100644 index 00000000..9e216093 --- /dev/null +++ b/InvestmentPerformanceWebAPI/Program.cs @@ -0,0 +1,50 @@ +using InvestmentPerformanceWebAPI.Database; +using Microsoft.EntityFrameworkCore; + +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(); + +// using sqllite +builder.Services.AddDbContext(options => + options.UseSqlite(builder.Configuration.GetConnectionString("SqliteConnection") ?? + throw new InvalidOperationException("Connection string 'SqliteConnection' not found.")) +); + +var app = builder.Build(); + +// Ensure database is created and up to date +using (var scope = app.Services.CreateScope()) +{ + var context = scope.ServiceProvider.GetRequiredService(); + context.Database.EnsureCreated(); + // Alternative: Use migrations instead of EnsureCreated() + // context.Database.Migrate(); +} + +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} + +app.UseSwagger(); +app.UseSwaggerUI(c => +{ + c.SwaggerEndpoint("/swagger/v1/swagger.json", "InvestmentPerformanceWebAPI v1"); + c.RoutePrefix = ""; +}); + +app.UseHttpsRedirection(); + +app.UseRouting(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/InvestmentPerformanceWebAPI/Properties/launchSettings.json b/InvestmentPerformanceWebAPI/Properties/launchSettings.json new file mode 100644 index 00000000..40ef0b2b --- /dev/null +++ b/InvestmentPerformanceWebAPI/Properties/launchSettings.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Debug" + }, + "applicationUrl": "http://localhost:5053" + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "https://localhost:7005;http://localhost:5053", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "ProductionEnvironment": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Production" + }, + "applicationUrl": "http://localhost" + } + }, + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:63776", + "sslPort": 44343 + } + } +} diff --git a/InvestmentPerformanceWebAPI/appsettings.Development.json b/InvestmentPerformanceWebAPI/appsettings.Development.json new file mode 100644 index 00000000..7963f01b --- /dev/null +++ b/InvestmentPerformanceWebAPI/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "SqliteConnection": "Data Source=InvestmentPerformanceWebAPI.db" + } +} diff --git a/InvestmentPerformanceWebAPI/appsettings.json b/InvestmentPerformanceWebAPI/appsettings.json new file mode 100644 index 00000000..a796390b --- /dev/null +++ b/InvestmentPerformanceWebAPI/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "SqliteConnection": "Data Source=InvestmentPerformanceWebAPI.db" + }, + "AllowedHosts": "*" +} diff --git a/InvestmentPerformanceWebAPIUnitTests/InvestmentPerformanceWebAPIUnitTests.csproj b/InvestmentPerformanceWebAPIUnitTests/InvestmentPerformanceWebAPIUnitTests.csproj new file mode 100644 index 00000000..9d1c0d46 --- /dev/null +++ b/InvestmentPerformanceWebAPIUnitTests/InvestmentPerformanceWebAPIUnitTests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + latest + enable + enable + false + true + + + + + + + + + + + + + + + + + + diff --git a/InvestmentPerformanceWebAPIUnitTests/MSTestSettings.cs b/InvestmentPerformanceWebAPIUnitTests/MSTestSettings.cs new file mode 100644 index 00000000..aaf278c8 --- /dev/null +++ b/InvestmentPerformanceWebAPIUnitTests/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/InvestmentPerformanceWebAPIUnitTests/UserControllerTests.cs b/InvestmentPerformanceWebAPIUnitTests/UserControllerTests.cs new file mode 100644 index 00000000..ba7fb647 --- /dev/null +++ b/InvestmentPerformanceWebAPIUnitTests/UserControllerTests.cs @@ -0,0 +1,229 @@ +using InvestmentPerformanceWebAPI.Controllers; +using InvestmentPerformanceWebAPI.Database; +using InvestmentPerformanceWebAPI.DataTransferObjects; +using InvestmentPerformanceWebAPI.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace InvestmentPerformanceWebAPIUnitTests +{ + [TestClass] + public class UserControllerTests + { + private ApplicationDbContext _context = null!; + private UserController _controller = null!; + + [TestInitialize] + public void Setup() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _context = new ApplicationDbContext(options); + _controller = new UserController(_context); + SeedTestData(); + } + + [TestCleanup] + public void Cleanup() + { + _context?.Dispose(); + } + + private void SeedTestData() + { + var user = new User + { + Id = 1, + Username = "testuser", + Email = "test@example.com", + Transactions = new List + { + new Transaction + { + Id = 1, + Name = "Apple Inc.", + Symbol = "AAPL", + Quantity = 10, + SharePriceAtPurchase = 150.00, + CurrentSharePrice = 175.00, + TransactionTime = DateTime.UtcNow.AddDays(-30), + UserId = 1, + Type = TransactionType.Buy + }, + new Transaction + { + Id = 2, + Name = "Microsoft Corp.", + Symbol = "MSFT", + Quantity = 5, + SharePriceAtPurchase = 300.00, + CurrentSharePrice = 320.00, + TransactionTime = DateTime.UtcNow.AddDays(-15), + UserId = 1, + Type = TransactionType.Buy + } + } + }; + + _context.Users.Add(user); + _context.SaveChanges(); + } + + [TestMethod] + public void GetUser_WithValidId_ReturnsOkResultWithUserDTO() + { + int userId = 1; + var result = _controller.GetUser(userId); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + var okResult = (OkObjectResult)result; + + Assert.IsInstanceOfType(okResult.Value, typeof(IQueryable)); + var userQuery = (IQueryable)okResult.Value!; + var userDto = userQuery.FirstOrDefault(); + + Assert.IsNotNull(userDto); + Assert.AreEqual(userId, userDto.Id); + Assert.AreEqual("testuser", userDto.Username); + Assert.AreEqual(2, userDto.Transactions.Count); + } + + [TestMethod] + public void GetUser_WithValidId_ReturnsCorrectTransactionDetails() + { + int userId = 1; + var result = _controller.GetUser(userId); + + var okResult = (OkObjectResult)result; + var userQuery = (IQueryable)okResult.Value!; + var userDto = userQuery.FirstOrDefault(); + + Assert.IsNotNull(userDto); + var appleTransaction = userDto.Transactions.FirstOrDefault(t => t.Name == "Apple Inc."); + Assert.IsNotNull(appleTransaction); + Assert.AreEqual(1, appleTransaction.Id); + Assert.AreEqual(10, appleTransaction.Shares); + Assert.AreEqual(150.00, appleTransaction.CostBasisPerShare); + Assert.AreEqual(175.00, appleTransaction.CurrentPrice); + Assert.AreEqual(1750.00, appleTransaction.CurrentValue); + Assert.AreEqual(250.00, appleTransaction.TotalGain); + + var microsoftTransaction = userDto.Transactions.FirstOrDefault(t => t.Name == "Microsoft Corp."); + Assert.IsNotNull(microsoftTransaction); + Assert.AreEqual(2, microsoftTransaction.Id); + Assert.AreEqual(5, microsoftTransaction.Shares); + Assert.AreEqual(300.00, microsoftTransaction.CostBasisPerShare); + Assert.AreEqual(320.00, microsoftTransaction.CurrentPrice); + Assert.AreEqual(1600.00, microsoftTransaction.CurrentValue); + Assert.AreEqual(100.00, microsoftTransaction.TotalGain); + } + + [TestMethod] + public void GetUser_WithInvalidId_ReturnsOkWithEmptyQueryable() + { + int invalidUserId = 999; + var result = _controller.GetUser(invalidUserId); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + var okResult = (OkObjectResult)result; + var userQuery = (IQueryable)okResult.Value!; + var userDto = userQuery.FirstOrDefault(); + + Assert.IsNull(userDto); + } + + [TestMethod] + public void GetUser_NullContext_ThrowsNullReferenceException() + { + var controller = new UserController(null); + + Assert.ThrowsException(() => controller.GetUser(1)); + } + + [TestMethod] + public void GetUser_VerifyTransactionCalculations_AreCorrect() + { + var testUser = new User + { + Id = 3, + Username = "calculationtest", + Email = "calc@test.com", + Transactions = new List + { + new Transaction + { + Id = 10, + Name = "Test Stock", + Symbol = "TEST", + Quantity = 100, + SharePriceAtPurchase = 50.0, + CurrentSharePrice = 60.0, + TransactionTime = DateTime.UtcNow, + UserId = 3, + Type = TransactionType.Buy + } + } + }; + + _context.Users.Add(testUser); + _context.SaveChanges(); + + var result = _controller.GetUser(3); + + var okResult = (OkObjectResult)result; + var userQuery = (IQueryable)okResult.Value!; + var userDto = userQuery.FirstOrDefault(); + Assert.IsNotNull(userDto); + var transaction = userDto.Transactions.First(); + + Assert.AreEqual(6000.0, transaction.CurrentValue); + Assert.AreEqual(1000.0, transaction.TotalGain); + Assert.AreEqual(50.0, transaction.CostBasisPerShare); + Assert.AreEqual(60.0, transaction.CurrentPrice); + Assert.AreEqual(100, transaction.Shares); + } + + [TestMethod] + public void GetUser_LossTransaction_CalculatesNegativeGain() + { + var userWithLoss = new User + { + Id = 4, + Username = "lossuser", + Email = "loss@example.com", + Transactions = new List + { + new Transaction + { + Id = 20, + Name = "Loss Stock", + Symbol = "LOSS", + Quantity = 50, + SharePriceAtPurchase = 100.0, + CurrentSharePrice = 80.0, + TransactionTime = DateTime.UtcNow, + UserId = 4, + Type = TransactionType.Buy + } + } + }; + + _context.Users.Add(userWithLoss); + _context.SaveChanges(); + + var result = _controller.GetUser(4); + + var okResult = (OkObjectResult)result; + var userQuery = (IQueryable)okResult.Value!; + var userDto = userQuery.FirstOrDefault(); + Assert.IsNotNull(userDto); + var transaction = userDto.Transactions.First(); + + Assert.AreEqual(4000.0, transaction.CurrentValue); + Assert.AreEqual(-1000.0, transaction.TotalGain); + } + } +} \ No newline at end of file diff --git a/ReadMe.md b/ReadMe.md index ad2afefb..ed967d1c 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,3 +1,21 @@ +# Executing the code + +Open "InvestmentPerformanceWebAPI.sln" + +You can build and run the code in visual studio or by the .exe in the bin/debug / bin/release folder. + +It will open up the browser and go to the swagger rest api. You can then hit the end points there in order to test the uesr stories. + +The url for swagger that opens up should be https://localhost:7005/index.html + +# Assumptions Made + +- Populated some data with a simple sql lite database using C# entity framework code first. I know in a real world example there would be data already there, or technically we could have used a third party API to pull stock information, but felt that was to easy to do. + +- There is currently one user (id: 1) + +- Five transactions for that user. (id: 1-5) + # Coding Exercise > This repository holds coding exercises for candidates going through the hiring process.