diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..edcddb98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Env +**/.idea +*.DotSettings.user + +# Build +**/bin +**/obj + +appsettings.Development.json +appsettings.Production.json \ No newline at end of file diff --git a/Returns.Net/.dockerignore b/Returns.Net/.dockerignore new file mode 100644 index 00000000..cd967fc3 --- /dev/null +++ b/Returns.Net/.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/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs b/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs new file mode 100644 index 00000000..67f3132f --- /dev/null +++ b/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Return.Web.Api.Services; +using Returns.Models; + +namespace Return.Web.Api.Controllers; + +[ApiController] +public class InvestmentController(IInvestmentService investmentService): ControllerBase +{ + [HttpGet(ApiRoutes.User.Base + "/{userId:guid}/Investments")] + [ProducesResponseType>((int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.BadRequest)] + public async Task?>> GetUserInvestments( + Guid userId, + CancellationToken cts = default) + { + try { + //usually should check for existing user + var results = await investmentService.GetByUserId(userId, cts); + return Ok(results); + } + catch (Exception ex) { + return BadRequest(ex.Message); + } + } + + [HttpPost(ApiRoutes.Investment.Query)] + [ProducesResponseType>((int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.BadRequest)] + public async Task>> QueryInvestments( + [FromBody]InvestmentQuery query, + CancellationToken cts = default) + { + try { + var results = await investmentService.Query(query, cts); + return Ok(results); + } + catch (Exception ex) { + return BadRequest(ex.Message); + } + } +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Database/DatabaseOptions.cs b/Returns.Net/Return.Web.Api/Database/DatabaseOptions.cs new file mode 100644 index 00000000..3e0bd9ce --- /dev/null +++ b/Returns.Net/Return.Web.Api/Database/DatabaseOptions.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; + +namespace Return.Web.Api.Database; + +[DebuggerDisplay("{ToString(),nq}")] +public class DatabaseOptions +{ + public const string SectionName = "DatabaseOptions"; + + public string Host { get; set; } + + public int Port { get; set; } + + public string? Username { get; set; } + + public string? Password { get; set; } + + public override string ToString() => $"DatabaseOptions(Host={Host}, Port={Port}, Username={Username})"; + + //options validator +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Database/Investment.cs b/Returns.Net/Return.Web.Api/Database/Investment.cs new file mode 100644 index 00000000..05f3445b --- /dev/null +++ b/Returns.Net/Return.Web.Api/Database/Investment.cs @@ -0,0 +1,30 @@ +using System; +using System.Diagnostics; + +namespace Return.Web.Api.Database; + +//Represents what would be in your dbcontext +[DebuggerDisplay("{ToString(),nq}")] +public class Investment +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string ShareName { get; set; } + public decimal ShareCount { get; set; } + public decimal CostBasisPerShare { get; set; } + public DateTime PurchaseDateTime { get; set; } + + public Investment(){ } + + public Investment(Guid id, Guid userId, string shareName, decimal shareCount, decimal costBasisPerShare, DateTime purchaseDateTime) + { + Id = id; + UserId = userId; + ShareName = shareName; + ShareCount = shareCount; + CostBasisPerShare = costBasisPerShare; + PurchaseDateTime = purchaseDateTime; + } + + public override string ToString() => $"{ShareName} ({ShareCount} shares at {CostBasisPerShare:C} per share purchased on {PurchaseDateTime:d})"; +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Dockerfile b/Returns.Net/Return.Web.Api/Dockerfile new file mode 100644 index 00000000..4aab30a6 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Return.Web.Api/Return.Web.Api.csproj", "Return.Web.Api/"] +RUN dotnet restore "Return.Web.Api/Return.Web.Api.csproj" +COPY . . +WORKDIR "/src/Return.Web.Api" +RUN dotnet build "./Return.Web.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./Return.Web.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Return.Web.Api.dll"] diff --git a/Returns.Net/Return.Web.Api/Extensions.cs b/Returns.Net/Return.Web.Api/Extensions.cs new file mode 100644 index 00000000..3c63d5ab --- /dev/null +++ b/Returns.Net/Return.Web.Api/Extensions.cs @@ -0,0 +1,44 @@ +using System; +using Mapster; +using MapsterMapper; +using Microsoft.Extensions.DependencyInjection; +using Return.Web.Api.Services; +using Returns.Models; + +namespace Return.Web.Api; + +public static class Extensions +{ + extension(IServiceCollection services) + { + //public IServiceCollection AddReturnsDbContextFactory(Func? factory = null) + //{ + // //Would setup ef based on Func or existing database options, but will keep simplistic for now and + // // use hardcoded data in the service + // return services; + //} + + public IServiceCollection AddReturnsService() + { + //if the service required a more complex setup, we could add options and validation here. + // Say you need to retrieve keys from vault for the + services.AddSingleton(); + services.AddSingleton(); + return services; + } + + public IServiceCollection AddMapsterProfiles() + { + var config = new TypeAdapterConfig(); + //usually, mapping from db models to response models, use to using mapster or manual mapping + config.NewConfig() + .ConstructUsing(src => new(src.Id, src.UserId, src.ShareName, src.ShareCount, src.CostBasisPerShare, src.PurchaseDateTime, 0m)); + // CurrentPrice will be updated via injected share service + + config.Compile(); //precompile + services.AddSingleton(config); + services.AddSingleton(sp => new Mapper(config)); + return services; + } + } +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Notes.md b/Returns.Net/Return.Web.Api/Notes.md new file mode 100644 index 00000000..db4821e7 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Notes.md @@ -0,0 +1,19 @@ +# Notes + +-Took about 130m to complete since the prompt specified a couple of hours + +Endpoint post over get - querying usually you want to pass in a body for some complex query system. For this, it needs +to be post as get does not accept a body. + +Logging - using built in ILogger but so can use serilog or nlog if wanting to. For now, will log to console +but would prefer to log to some type of database. should use a generic interceptor for all endpoints and log there + +Exception handling - should wrap results to include api status codes and exceptions. Theres a difference between +breaking and no responses, so if valid always return a result, if not it should return bad status code. + +Other +-Interfaced out share service so other public apis can be used, say one is cheaper, or even start tracking internally +-tend to use IReadOnlyX where possible for read only operations (and records for responses for immutability). +-Models is netstandard 2.0 so any older project that consumes the api can use the model structures. just needs a helper class for the lang version diff +-wouldve used minimal api setup, but controller works fine also +-wouldve liked to add more proper responses and wrap the return in a result object IE Result which would also contain optional error obj' diff --git a/Returns.Net/Return.Web.Api/Program.cs b/Returns.Net/Return.Web.Api/Program.cs new file mode 100644 index 00000000..d0f1bba2 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Program.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Return.Web.Api; +using Return.Web.Api.Database; +using Scalar.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddOptions(DatabaseOptions.SectionName) + .ValidateOnStart(); //add options validation + +builder.Services + .AddLogging(logging => + logging.ClearProviders().AddSimpleConsole()); + +builder.Services.AddControllers(); + +builder.Services + .AddMapsterProfiles() + .AddReturnsService(); + +builder.Services.AddOpenApi(); +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) { + app.MapOpenApi(); + app.MapScalarApiReference(); +} + +app.MapControllers(); + +app.UseHttpsRedirection(); + +app.Run(); \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Properties/launchSettings.json b/Returns.Net/Return.Web.Api/Properties/launchSettings.json new file mode 100644 index 00000000..6e8213b8 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5299", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7061;http://localhost:5299", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Returns.Net/Return.Web.Api/Return.Web.Api.csproj b/Returns.Net/Return.Web.Api/Return.Web.Api.csproj new file mode 100644 index 00000000..3141664f --- /dev/null +++ b/Returns.Net/Return.Web.Api/Return.Web.Api.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + disable + enable + latest + true + + + + + + + + + + + .dockerignore + + + appsettings.json + + + + + + + + diff --git a/Returns.Net/Return.Web.Api/Services/DumbyShareService.cs b/Returns.Net/Return.Web.Api/Services/DumbyShareService.cs new file mode 100644 index 00000000..70394d9c --- /dev/null +++ b/Returns.Net/Return.Web.Api/Services/DumbyShareService.cs @@ -0,0 +1,24 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Return.Web.Api.Services; + +//represents some api service. should include caching to not request current price based every call +public class DumbyShareService: IShareService +{ + public async Task GetShareCostAsync(string name, DateTime asOfDate, CancellationToken ct = default) + { + return name switch { + "Stock1" => 100.00m, + "Stock2" => 50.00m, + "Stock3" => 25.00m, + _ => throw new ArgumentOutOfRangeException(nameof(name), $"No share cost data for share '{name}'") + }; + } + + + public async Task GetCurrentShareCostAsync(string shareId, CancellationToken ct = default) + => await GetShareCostAsync(shareId, DateTime.UtcNow, ct); +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Services/IInvestmentService.cs b/Returns.Net/Return.Web.Api/Services/IInvestmentService.cs new file mode 100644 index 00000000..c8a675ae --- /dev/null +++ b/Returns.Net/Return.Web.Api/Services/IInvestmentService.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Returns.Models; + +namespace Return.Web.Api.Services; + +public interface IInvestmentService +{ + /// + /// UserId to get investments for + /// Returns null if no user found + Task> GetByUserId(Guid userId, CancellationToken cts = default); + + /// + /// + /// + Task> Query(InvestmentQuery query, CancellationToken cts = default); +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Services/IShareService.cs b/Returns.Net/Return.Web.Api/Services/IShareService.cs new file mode 100644 index 00000000..78212454 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Services/IShareService.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Return.Web.Api.Services; + +public interface IShareService +{ + //assumption is that shares change, should be able to get a cost for a certain point in time + current cost. Implementation should + // be some api either public or pay to use + Task GetShareCostAsync(string shareId, DateTime asOfDate, CancellationToken ct = default); + + Task GetCurrentShareCostAsync(string shareId, CancellationToken ct = default); +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Services/InvestmentService.cs b/Returns.Net/Return.Web.Api/Services/InvestmentService.cs new file mode 100644 index 00000000..86f0827b --- /dev/null +++ b/Returns.Net/Return.Web.Api/Services/InvestmentService.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MapsterMapper; +using Microsoft.Extensions.Logging; +using Return.Web.Api.Database; +using Returns.Models; + +namespace Return.Web.Api.Services; + +public class InvestmentService(ILogger logger, IMapper mapper, IShareService shareService): IInvestmentService +{ + private static IReadOnlyList DatabaseData => + TestData.DatabaseData + .Select(d => new Investment(d.Id, d.UserId, d.ShareName, d.ShareCount, d.CostBasisPerShare, d.PurchaseDateTime)) + .ToList(); + + public async Task> GetByUserId(Guid userId, CancellationToken cts = default) + { + var investments = DatabaseData.Where(i => i.UserId == userId).ToList(); + return await MapToInvestmentRes(investments, cts); + } + + public async Task> Query(InvestmentQuery query, CancellationToken cts = default) + { + logger.LogInformation("{InvestmentQuery}", query); + var queryable = DatabaseData.AsQueryable(); + //usually would use expression builder or generic, but this also works as a quick example + if(query.UserId.HasValue) + queryable = queryable.Where(i => i.UserId == query.UserId.Value); + + if (query.Start.HasValue) + queryable = queryable.Skip(query.Start.Value); + + if(query.Amount.HasValue) + queryable = queryable.Take(query.Amount.Value); + + //materialize, dbcontext has tolistasync where cts would be passed + var items = queryable.ToList(); + return await MapToInvestmentRes(items, cts); + } + + + /// + /// Helper to simulate getting actual current share price from a sahre service + /// + /// + /// + /// + private async Task> MapToInvestmentRes(IReadOnlyList investments, CancellationToken cts) + { + var results = new List(); + //usually would involce some type of caching here to avoid multiple calls for same share + foreach (var investment in investments) + { + var investmentRes = mapper.Map(investment); + var currentPrice = await new DumbyShareService().GetCurrentShareCostAsync(investment.ShareName, cts); + var updatedInvestmentRes = investmentRes with { + CurrentPrice = currentPrice + }; + results.Add(updatedInvestmentRes); + } + + return results; + } +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/appsettings.json b/Returns.Net/Return.Web.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/Returns.Net/Return.Web.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Returns.Net/Returns.Models/ApiRoutes.cs b/Returns.Net/Returns.Models/ApiRoutes.cs new file mode 100644 index 00000000..e37b5cf3 --- /dev/null +++ b/Returns.Net/Returns.Models/ApiRoutes.cs @@ -0,0 +1,31 @@ +namespace Returns.Models; + +public static class ApiRoutes +{ + public static class User + { + public const string Base = "user"; + } + public static class Investment + { + public const string Base = "investment"; + public const string Query = Base + "/Query"; + } +} + +/* + + user - has a share + share - has a price, term + + current value and total gain/loss can be calculated props + +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 + +*/ \ No newline at end of file diff --git a/Returns.Net/Returns.Models/Enum/TermEnum.cs b/Returns.Net/Returns.Models/Enum/TermEnum.cs new file mode 100644 index 00000000..908bbae4 --- /dev/null +++ b/Returns.Net/Returns.Models/Enum/TermEnum.cs @@ -0,0 +1,10 @@ +using System; + +namespace Returns.Models.Enum; + +public enum TermEnum +{ + Unknown, + Short, + Long +} \ No newline at end of file diff --git a/Returns.Net/Returns.Models/InvestmentQuery.cs b/Returns.Net/Returns.Models/InvestmentQuery.cs new file mode 100644 index 00000000..231a22f0 --- /dev/null +++ b/Returns.Net/Returns.Models/InvestmentQuery.cs @@ -0,0 +1,16 @@ +using System; +using System.Diagnostics; + +namespace Returns.Models; + +[DebuggerDisplay("{ToString(),nq}")] +public class InvestmentQuery +{ + public Guid? UserId { get; set; } + + public int? Start { get; set; } = 0; + + public int? Amount { get; set; } = 10; + + public override string ToString() => $"InvestmentQuery: UserId={UserId}, Start={Start}, Amount={Amount}"; +} \ No newline at end of file diff --git a/Returns.Net/Returns.Models/InvestmentRes.cs b/Returns.Net/Returns.Models/InvestmentRes.cs new file mode 100644 index 00000000..46a11a67 --- /dev/null +++ b/Returns.Net/Returns.Models/InvestmentRes.cs @@ -0,0 +1,29 @@ +using System; +using System.Diagnostics; +using System.Text.Json.Serialization; +using Returns.Models.Enum; + +namespace Returns.Models; + +[DebuggerDisplay("{ToString(),nq}")] +public sealed record InvestmentRes( + Guid Id, //prefer Guids as easier for scaling + security + Guid UserId, + string Name, + decimal ShareCount, + decimal CostBasisPerShare, + DateTime PurchaseDate, + decimal CurrentPrice) +{ + //usually i would ignore this data on serialization unless its needed since they are all computed + + [JsonConverter(typeof(JsonStringEnumConverter))] + public TermEnum Term => DateTime.UtcNow.Subtract(PurchaseDate).TotalDays > 365 ? TermEnum.Long : TermEnum.Short; + + public decimal TotalGainOrLoss => CurrentValue - (ShareCount * CostBasisPerShare); + + public decimal CurrentValue => ShareCount * CurrentPrice; + + public override string ToString() => $"{Name} ({ShareCount} shares at {CostBasisPerShare:C} per share purchased on {PurchaseDate:d})"; +} + diff --git a/Returns.Net/Returns.Models/IsExternalInitShim.cs b/Returns.Net/Returns.Models/IsExternalInitShim.cs new file mode 100644 index 00000000..a48c8bfb --- /dev/null +++ b/Returns.Net/Returns.Models/IsExternalInitShim.cs @@ -0,0 +1,6 @@ +#if !NET5_0_OR_GREATER && !NETCOREAPP3_1_OR_GREATER +// ReSharper disable once CheckNamespace +namespace System.Runtime.CompilerServices; +// This is needed to support 'init' properties in .NET Standard 2.0. Records use init +public static class IsExternalInit; +#endif \ No newline at end of file diff --git a/Returns.Net/Returns.Models/Returns.Models.csproj b/Returns.Net/Returns.Models/Returns.Models.csproj new file mode 100644 index 00000000..b74d97a5 --- /dev/null +++ b/Returns.Net/Returns.Models/Returns.Models.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0 + disable + enable + latest + true + + + + + + + diff --git a/Returns.Net/Returns.Models/TestData.cs b/Returns.Net/Returns.Models/TestData.cs new file mode 100644 index 00000000..d2e7f896 --- /dev/null +++ b/Returns.Net/Returns.Models/TestData.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +namespace Returns.Models; + +public static class TestData +{ + //temp, only needed due to test data here, but also being used in api. + public static string Stock1Name = "Stock1"; + public static string Stock2Name = "Stock2"; + public static string Stock3Name = "Stock3"; + + public static Guid User1Id = Guid.Parse("a8b9c0d1-e2f3-4345-a678-b9c0d1e2f345"); + public static Guid User2Id = Guid.Parse("b9c0d1e2-f3a4-4456-b789-c0d1e2f3a456"); + public static Guid User3Id = Guid.Parse("c0d1e2f3-a4b5-4567-c890-d1e2f3a4b567"); + + + public record InvestmentData( + Guid Id, + Guid UserId, + string ShareName, + decimal ShareCount, + decimal CostBasisPerShare, + DateTime PurchaseDateTime); + + public static IReadOnlyList DatabaseData = [ + new(Guid.NewGuid(), User1Id, Stock1Name, 5, 10, new(2023, 1, 1)), + new(Guid.NewGuid(), User1Id, Stock2Name, 10, 20, new(2023, 6, 1)), + new(Guid.NewGuid(), User2Id, Stock2Name, 15, 30, new(2024, 1, 1)), + new(Guid.NewGuid(), User2Id, Stock3Name, 20, 40, new(2022, 1, 1)), + ]; +} + diff --git a/Returns.Net/Returns.Net.sln b/Returns.Net/Returns.Net.sln new file mode 100644 index 00000000..9a82c9e5 --- /dev/null +++ b/Returns.Net/Returns.Net.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".SolutionItems", ".SolutionItems", "{11A5F59E-43B5-4273-A73E-04B392467E44}" + ProjectSection(SolutionItems) = preProject + ..\InvestmentPerformanceWebAPI.md = ..\InvestmentPerformanceWebAPI.md + ..\.gitignore = ..\.gitignore + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Return.Web.Api", "Return.Web.Api\Return.Web.Api.csproj", "{44714CFB-D275-43B7-BB83-9C06477C284D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Returns.Web.Api.Tests", "Returns.Web.Api.Tests\Returns.Web.Api.Tests.csproj", "{19F947EE-9404-416F-8DC9-8D1B73327643}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Returns.Models", "Returns.Models\Returns.Models.csproj", "{3D4A069B-6529-4613-9B1A-B95B0011E17F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {44714CFB-D275-43B7-BB83-9C06477C284D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44714CFB-D275-43B7-BB83-9C06477C284D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44714CFB-D275-43B7-BB83-9C06477C284D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44714CFB-D275-43B7-BB83-9C06477C284D}.Release|Any CPU.Build.0 = Release|Any CPU + {19F947EE-9404-416F-8DC9-8D1B73327643}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19F947EE-9404-416F-8DC9-8D1B73327643}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19F947EE-9404-416F-8DC9-8D1B73327643}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19F947EE-9404-416F-8DC9-8D1B73327643}.Release|Any CPU.Build.0 = Release|Any CPU + {3D4A069B-6529-4613-9B1A-B95B0011E17F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D4A069B-6529-4613-9B1A-B95B0011E17F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D4A069B-6529-4613-9B1A-B95B0011E17F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D4A069B-6529-4613-9B1A-B95B0011E17F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Returns.Net/Returns.Web.Api.Tests/InvestmentControllerTests.cs b/Returns.Net/Returns.Web.Api.Tests/InvestmentControllerTests.cs new file mode 100644 index 00000000..8fe5c264 --- /dev/null +++ b/Returns.Net/Returns.Web.Api.Tests/InvestmentControllerTests.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using Returns.Models; + +namespace Returns.Web.Api.Tests; + +public class InvestmentControllerTests +{ + private readonly HttpClient _client; + private readonly JsonSerializerOptions _jsonOptions; + + public InvestmentControllerTests() + { + _client = new(); + _client.BaseAddress = new Uri("http://localhost:5299"); + _jsonOptions = new() { + PropertyNameCaseInsensitive = true + }; + } + + [Fact] + public async Task GetUserInvestments_ReturnsInvestmentsForUser() + { + var userId = TestData.User1Id; + var url = $"/user/{userId}/Investments"; + var response = await _client.GetAsync(url); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var investments = await response.Content.ReadFromJsonAsync>(_jsonOptions); + Assert.NotNull(investments); + Assert.NotEmpty(investments); + Assert.All(investments!, investment => Assert.Equal(userId, investment.UserId)); + } + + [Fact] + public async Task QueryInvestments_WithStartAndAmount_PaginatesCorrectly() + { + var query = new InvestmentQuery { Start = 0, Amount = 2 }; + var url = ApiRoutes.Investment.Query; + var response = await _client.PostAsJsonAsync(url, query); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var investments = await response.Content.ReadFromJsonAsync>(_jsonOptions); + Assert.NotNull(investments); + Assert.True(investments!.Count <= 2); + } + + //can add more tests ad needed, would suggest more complex setup/teardown, logging, etc +} + diff --git a/Returns.Net/Returns.Web.Api.Tests/Notes.md b/Returns.Net/Returns.Web.Api.Tests/Notes.md new file mode 100644 index 00000000..008775b7 --- /dev/null +++ b/Returns.Net/Returns.Web.Api.Tests/Notes.md @@ -0,0 +1,2 @@ +Ideally, i would have time to create a web api client that could be shared/tested alongside the api, but +for times sake, I will be using built in HttpClient for testing. \ No newline at end of file diff --git a/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj b/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj new file mode 100644 index 00000000..8aca55c3 --- /dev/null +++ b/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + disable + enable + latest + true + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + \ No newline at end of file