From f79b767f364edb329a0877a1af7ac892d2e9bea2 Mon Sep 17 00:00:00 2001 From: Shane Freeman Date: Thu, 4 Jun 2026 12:14:58 -0400 Subject: [PATCH 01/10] feat: milestone 1 - project foundation, DI wiring, and test scaffold - Add xUnit + Moq test project wired to Interview.Web - Add CreateProductRequest model with attributes and category support - Add CreateAsync to IProductRepository and IProductsService - Register ISqlExecutor, IProductRepository, IProductsService in DI - Bind SqlServerOptions connection string from appsettings.json - Clean up ProductController - inject IProductsService, remove misplaced query logic - Stub repository and service methods to drive TDD in subsequent milestones --- .../.idea/.gitignore | 15 ++++ .../.idea/encodings.xml | 4 + .../.idea/indexLayout.xml | 10 +++ .../.idea/sqldialects.xml | 6 ++ .../.idea.Development Project/.idea/vcs.xml | 7 ++ Development Project/Development Project.sln | 10 +++ .../Interview.Web.Tests/GlobalUsings.cs | 1 + .../Interview.Web.Tests.csproj | 30 +++++++ .../Services/ProductsServiceTests.cs | 59 +++++++++++++ .../Controllers/ProductController.cs | 41 ++++++--- .../Interview.Web/Interview.Web.csproj | 10 +++ .../Models/CreateProductRequest.cs | 17 ++++ .../Interview.Web/Models/Product.cs | 13 +++ .../Interview.Web/Models/ProductResponse.cs | 15 ++++ .../Models/ProductSearchRequest.cs | 13 +++ .../Implementations/ProductRepository.cs | 25 ++++++ .../Interfaces/IProductRepository.cs | 11 +++ .../Implementations/ProductsService.cs | 28 ++++++ .../Services/Interfaces/IProductsService.cs | 11 +++ Development Project/Interview.Web/Startup.cs | 86 +++++++++++-------- .../Interview.Web/appsettings.json | 5 +- 21 files changed, 365 insertions(+), 52 deletions(-) create mode 100644 Development Project/.idea/.idea.Development Project/.idea/.gitignore create mode 100644 Development Project/.idea/.idea.Development Project/.idea/encodings.xml create mode 100644 Development Project/.idea/.idea.Development Project/.idea/indexLayout.xml create mode 100644 Development Project/.idea/.idea.Development Project/.idea/sqldialects.xml create mode 100644 Development Project/.idea/.idea.Development Project/.idea/vcs.xml create mode 100644 Development Project/Interview.Web.Tests/GlobalUsings.cs create mode 100644 Development Project/Interview.Web.Tests/Interview.Web.Tests.csproj create mode 100644 Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs create mode 100644 Development Project/Interview.Web/Models/CreateProductRequest.cs create mode 100644 Development Project/Interview.Web/Models/Product.cs create mode 100644 Development Project/Interview.Web/Models/ProductResponse.cs create mode 100644 Development Project/Interview.Web/Models/ProductSearchRequest.cs create mode 100644 Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs create mode 100644 Development Project/Interview.Web/Repositories/Interfaces/IProductRepository.cs create mode 100644 Development Project/Interview.Web/Services/Implementations/ProductsService.cs create mode 100644 Development Project/Interview.Web/Services/Interfaces/IProductsService.cs diff --git a/Development Project/.idea/.idea.Development Project/.idea/.gitignore b/Development Project/.idea/.idea.Development Project/.idea/.gitignore new file mode 100644 index 0000000..f5bfd03 --- /dev/null +++ b/Development Project/.idea/.idea.Development Project/.idea/.gitignore @@ -0,0 +1,15 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/projectSettingsUpdater.xml +/modules.xml +/.idea.Development Project.iml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/Development Project/.idea/.idea.Development Project/.idea/encodings.xml b/Development Project/.idea/.idea.Development Project/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/Development Project/.idea/.idea.Development Project/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Development Project/.idea/.idea.Development Project/.idea/indexLayout.xml b/Development Project/.idea/.idea.Development Project/.idea/indexLayout.xml new file mode 100644 index 0000000..641ae66 --- /dev/null +++ b/Development Project/.idea/.idea.Development Project/.idea/indexLayout.xml @@ -0,0 +1,10 @@ + + + + + ../../Development-Project-CSharp + + + + + \ No newline at end of file diff --git a/Development Project/.idea/.idea.Development Project/.idea/sqldialects.xml b/Development Project/.idea/.idea.Development Project/.idea/sqldialects.xml new file mode 100644 index 0000000..52edbae --- /dev/null +++ b/Development Project/.idea/.idea.Development Project/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Development Project/.idea/.idea.Development Project/.idea/vcs.xml b/Development Project/.idea/.idea.Development Project/.idea/vcs.xml new file mode 100644 index 0000000..62bd7a0 --- /dev/null +++ b/Development Project/.idea/.idea.Development Project/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Development Project/Development Project.sln b/Development Project/Development Project.sln index 637bfd9..1e6fefb 100644 --- a/Development Project/Development Project.sln +++ b/Development Project/Development Project.sln @@ -11,6 +11,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Core", "Sparcpoi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.SqlServer.Abstractions", "Sparcpoint.SqlServer.Abstractions\Sparcpoint.SqlServer.Abstractions.csproj", "{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Interview.Web.Tests", "Interview.Web.Tests\Interview.Web.Tests.csproj", "{C8F425F6-5E77-4783-9D2B-5E5EB29935E3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,14 @@ Global {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|Any CPU.Build.0 = Release|Any CPU {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|x86.ActiveCfg = Release|Any CPU {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|x86.Build.0 = Release|Any CPU + {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Debug|x86.ActiveCfg = Debug|Any CPU + {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Debug|x86.Build.0 = Debug|Any CPU + {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Release|Any CPU.Build.0 = Release|Any CPU + {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Release|x86.ActiveCfg = Release|Any CPU + {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Development Project/Interview.Web.Tests/GlobalUsings.cs b/Development Project/Interview.Web.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/Development Project/Interview.Web.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/Development Project/Interview.Web.Tests/Interview.Web.Tests.csproj b/Development Project/Interview.Web.Tests/Interview.Web.Tests.csproj new file mode 100644 index 0000000..4605f45 --- /dev/null +++ b/Development Project/Interview.Web.Tests/Interview.Web.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs b/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs new file mode 100644 index 0000000..ffa0aa0 --- /dev/null +++ b/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Interview.Web.Models; +using Interview.Web.Repositories.Interfaces; +using Interview.Web.Services.Implementations; +using Moq; +using Xunit; + +namespace Interview.Web.Tests.Services; + +public class ProductsServiceTests +{ + private readonly Mock _repositoryMock; + private readonly ProductsService _sut; + + public ProductsServiceTests() + { + _repositoryMock = new Mock(); + _sut = new ProductsService(_repositoryMock.Object); + } + + // EVAL: These tests will fail until Milestone 2 implements the real logic. + // They exist now so the interfaces are locked in and any breaking change is caught immediately. + + [Fact] + public async Task SearchAsync_WithNoFilters_ReturnsAllProducts() + { + var expected = new List { new Product { InstanceId = 1, Name = "Widget" } }; + _repositoryMock.Setup(r => r.SearchAsync(It.IsAny())) + .ReturnsAsync(expected); + + var result = await _sut.SearchAsync(new ProductSearchRequest()); + + Assert.Equal(expected, result); + } + + [Fact] + public async Task SearchAsync_DelegatesToRepository() + { + var request = new ProductSearchRequest { Name = "Widget" }; + _repositoryMock.Setup(r => r.SearchAsync(request)).ReturnsAsync(new List()); + + await _sut.SearchAsync(request); + + _repositoryMock.Verify(r => r.SearchAsync(request), Times.Once); + } + + [Fact] + public async Task CreateAsync_DelegatesToRepository() + { + var request = new CreateProductRequest { Name = "Widget", Description = "A widget" }; + _repositoryMock.Setup(r => r.CreateAsync(request)).ReturnsAsync(1); + + var id = await _sut.CreateAsync(request); + + Assert.Equal(1, id); + _repositoryMock.Verify(r => r.CreateAsync(request), Times.Once); + } +} diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs index 267f4ec..bd30ef3 100644 --- a/Development Project/Interview.Web/Controllers/ProductController.cs +++ b/Development Project/Interview.Web/Controllers/ProductController.cs @@ -1,19 +1,34 @@ -using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; +using Interview.Web.Models; +using Interview.Web.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; -namespace Interview.Web.Controllers +namespace Interview.Web.Controllers; + +[ApiController] +[Route("api/v1/products")] +public class ProductController : ControllerBase { - [Route("api/v1/products")] - public class ProductController : Controller + private readonly IProductsService _productsService; + + public ProductController(IProductsService productsService) + { + _productsService = productsService; + } + + [HttpPost] + public async Task CreateProduct([FromBody] CreateProductRequest request) + { + var id = await _productsService.CreateAsync(request); + // EVAL: 201 Created with a Location header is the correct REST response for a successful + // POST. CreatedAtAction wires the Location header to the GET endpoint automatically. + return CreatedAtAction(nameof(GetProducts), new { id }, new { InstanceId = id }); + } + + [HttpGet] + public async Task GetProducts([FromQuery] ProductSearchRequest request) { - // NOTE: Sample Action - [HttpGet] - public Task GetAllProducts() - { - return Task.FromResult((IActionResult)Ok(new object[] { })); - } + var products = await _productsService.SearchAsync(request); + return Ok(products); } } diff --git a/Development Project/Interview.Web/Interview.Web.csproj b/Development Project/Interview.Web/Interview.Web.csproj index d1e4d6e..44664c2 100644 --- a/Development Project/Interview.Web/Interview.Web.csproj +++ b/Development Project/Interview.Web/Interview.Web.csproj @@ -4,4 +4,14 @@ net8.0 + + + + + + + + + + diff --git a/Development Project/Interview.Web/Models/CreateProductRequest.cs b/Development Project/Interview.Web/Models/CreateProductRequest.cs new file mode 100644 index 0000000..1d46e87 --- /dev/null +++ b/Development Project/Interview.Web/Models/CreateProductRequest.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace Interview.Web.Models; + +public class CreateProductRequest +{ + public string Name { get; set; } + public string Description { get; set; } + public string[] ValidSkus { get; set; } + public string[] ProductImageUris { get; set; } + + // EVAL: Arbitrary key/value pairs map directly to Instances.ProductAttributes rows. + // This satisfies the requirement for products to support arbitrary metadata without schema changes. + public Dictionary Attributes { get; set; } + + public int[] CategoryIds { get; set; } +} diff --git a/Development Project/Interview.Web/Models/Product.cs b/Development Project/Interview.Web/Models/Product.cs new file mode 100644 index 0000000..291ecd9 --- /dev/null +++ b/Development Project/Interview.Web/Models/Product.cs @@ -0,0 +1,13 @@ +using System; + +namespace Interview.Web.Models; + +public class Product +{ + public int InstanceId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string ValidSkus { get; set; } + public string ProductImageUris { get; set; } + public DateTime CreatedTimestamp { get; set; } +} \ No newline at end of file diff --git a/Development Project/Interview.Web/Models/ProductResponse.cs b/Development Project/Interview.Web/Models/ProductResponse.cs new file mode 100644 index 0000000..dbf60c6 --- /dev/null +++ b/Development Project/Interview.Web/Models/ProductResponse.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace Interview.Web.Models; + +public class ProductResponse +{ + public int InstanceId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string[] ValidSkus { get; set; } + public string[] ProductImageUris { get; set; } + public DateTime CreatedTimestamp { get; set; } + public Dictionary Attributes { get; set; } +} \ No newline at end of file diff --git a/Development Project/Interview.Web/Models/ProductSearchRequest.cs b/Development Project/Interview.Web/Models/ProductSearchRequest.cs new file mode 100644 index 0000000..82011c8 --- /dev/null +++ b/Development Project/Interview.Web/Models/ProductSearchRequest.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Interview.Web.Models; + +public class ProductSearchRequest +{ + public string Name { get; set; } + public string Description { get; set; } + + public Dictionary Attributes { get; set; } + + public int[] CategoryIds { get; set; } +} \ No newline at end of file diff --git a/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs b/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs new file mode 100644 index 0000000..1957b19 --- /dev/null +++ b/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Interview.Web.Models; +using Interview.Web.Repositories.Interfaces; +using Sparcpoint.SqlServer.Abstractions; + +namespace Interview.Web.Repositories.Implementations; + +public class ProductRepository : IProductRepository +{ + private readonly ISqlExecutor _sqlExecutor; + + public ProductRepository(ISqlExecutor sqlExecutor) + { + _sqlExecutor = sqlExecutor; + } + + // EVAL: Stubs throw NotImplementedException so the compiler is satisfied while TDD + // tests drive the real implementations in Milestone 2 and 3. + public Task CreateAsync(CreateProductRequest request) + => throw new System.NotImplementedException(); + + public Task> SearchAsync(ProductSearchRequest request) + => throw new System.NotImplementedException(); +} \ No newline at end of file diff --git a/Development Project/Interview.Web/Repositories/Interfaces/IProductRepository.cs b/Development Project/Interview.Web/Repositories/Interfaces/IProductRepository.cs new file mode 100644 index 0000000..8fe20ed --- /dev/null +++ b/Development Project/Interview.Web/Repositories/Interfaces/IProductRepository.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Interview.Web.Models; + +namespace Interview.Web.Repositories.Interfaces; + +public interface IProductRepository +{ + Task CreateAsync(CreateProductRequest request); + Task> SearchAsync(ProductSearchRequest request); +} \ No newline at end of file diff --git a/Development Project/Interview.Web/Services/Implementations/ProductsService.cs b/Development Project/Interview.Web/Services/Implementations/ProductsService.cs new file mode 100644 index 0000000..289bf60 --- /dev/null +++ b/Development Project/Interview.Web/Services/Implementations/ProductsService.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Interview.Web.Models; +using Interview.Web.Repositories.Interfaces; +using Interview.Web.Services.Interfaces; + +namespace Interview.Web.Services.Implementations; + +public class ProductsService : IProductsService +{ + private readonly IProductRepository _productRepository; + + public ProductsService(IProductRepository productRepository) + { + _productRepository = productRepository; + } + + // EVAL: Stubs throw NotImplementedException. Real logic driven by tests in Milestone 2 and 3. + public Task CreateAsync(CreateProductRequest request) + => throw new System.NotImplementedException(); + + public async Task> SearchAsync(ProductSearchRequest request) + { + // EVAL: Passing null or empty request returns all products — no special branch needed + // because the repository builds WHERE clauses only for non-null fields. + return await _productRepository.SearchAsync(request); + } +} \ No newline at end of file diff --git a/Development Project/Interview.Web/Services/Interfaces/IProductsService.cs b/Development Project/Interview.Web/Services/Interfaces/IProductsService.cs new file mode 100644 index 0000000..05cea75 --- /dev/null +++ b/Development Project/Interview.Web/Services/Interfaces/IProductsService.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Interview.Web.Models; + +namespace Interview.Web.Services.Interfaces; + +public interface IProductsService +{ + Task CreateAsync(CreateProductRequest request); + Task> SearchAsync(ProductSearchRequest request); +} \ No newline at end of file diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs index 56452f2..1e4f97d 100644 --- a/Development Project/Interview.Web/Startup.cs +++ b/Development Project/Interview.Web/Startup.cs @@ -1,56 +1,66 @@ +using Interview.Web.Repositories.Implementations; +using Interview.Web.Repositories.Interfaces; +using Interview.Web.Services.Implementations; +using Interview.Web.Services.Interfaces; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.HttpsPolicy; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using Sparcpoint.SqlServer.Abstractions; -namespace Interview.Web +namespace Interview.Web; + +public class Startup { - public class Startup + public Startup(IConfiguration configuration) { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddControllers(); - } + Configuration = configuration; + } - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - else - { - app.UseExceptionHandler("/Error"); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. - app.UseHsts(); - } + public IConfiguration Configuration { get; } - app.UseHttpsRedirection(); - app.UseStaticFiles(); + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers(); + services.AddSwaggerGen(); - app.UseRouting(); + // EVAL: SqlServerOptions is bound from appsettings.json so the connection string + // is configurable per environment without code changes (Open/Closed principle). + var sqlOptions = Configuration.GetSection("SqlServer").Get(); + services.AddSingleton(new SqlServerExecutor(sqlOptions.ConnectionString)); - app.UseAuthorization(); + // EVAL: Scoped lifetime for repository and service means one instance per HTTP request, + // which is appropriate for database-backed services. + services.AddScoped(); + services.AddScoped(); + } - app.UseEndpoints(endpoints => + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + app.UseSwagger(); + app.UseSwaggerUI(c => { - endpoints.MapControllers(); + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Interview API v1"); }); } + else + { + app.UseExceptionHandler("/Error"); + app.UseHsts(); + } + + app.UseHttpsRedirection(); + app.UseStaticFiles(); + app.UseRouting(); + app.UseAuthorization(); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); } } diff --git a/Development Project/Interview.Web/appsettings.json b/Development Project/Interview.Web/appsettings.json index d9d9a9b..07c9495 100644 --- a/Development Project/Interview.Web/appsettings.json +++ b/Development Project/Interview.Web/appsettings.json @@ -6,5 +6,8 @@ "Microsoft.Hosting.Lifetime": "Information" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "SqlServer": { + "ConnectionString": "Server=localhost;Database=SparcpointInventory;Trusted_Connection=True;" + } } From cfcaea9a5975526cb90ec5563b30bea1805ac43a Mon Sep 17 00:00:00 2001 From: Shane Freeman Date: Thu, 4 Jun 2026 12:36:19 -0400 Subject: [PATCH 02/10] feat: milestone 2 - add product with validation, attributes, and categories - Implement ProductsService.CreateAsync with input validation via PreConditions - Validate Name and Description against DB column length limits (256 chars) - Implement ProductRepository.CreateAsync with three transactional inserts - Insert product, attributes, and categories atomically - rolls back on failure - Use SCOPE_IDENTITY() to capture generated InstanceId for child table inserts - Add 6 TDD tests covering valid create, null input, and length violations --- .../Services/ProductsServiceTests.cs | 58 +++++++++++++++- .../Implementations/ProductRepository.cs | 68 +++++++++++++++++-- .../Implementations/ProductsService.cs | 19 +++++- 3 files changed, 133 insertions(+), 12 deletions(-) diff --git a/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs b/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs index ffa0aa0..f1348ca 100644 --- a/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs +++ b/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading.Tasks; using Interview.Web.Models; @@ -19,8 +20,7 @@ public ProductsServiceTests() _sut = new ProductsService(_repositoryMock.Object); } - // EVAL: These tests will fail until Milestone 2 implements the real logic. - // They exist now so the interfaces are locked in and any breaking change is caught immediately. + #region SearchAsync [Fact] public async Task SearchAsync_WithNoFilters_ReturnsAllProducts() @@ -45,8 +45,12 @@ public async Task SearchAsync_DelegatesToRepository() _repositoryMock.Verify(r => r.SearchAsync(request), Times.Once); } + #endregion + + #region CreateAsync + [Fact] - public async Task CreateAsync_DelegatesToRepository() + public async Task CreateAsync_ValidRequest_DelegatesToRepository() { var request = new CreateProductRequest { Name = "Widget", Description = "A widget" }; _repositoryMock.Setup(r => r.CreateAsync(request)).ReturnsAsync(1); @@ -56,4 +60,52 @@ public async Task CreateAsync_DelegatesToRepository() Assert.Equal(1, id); _repositoryMock.Verify(r => r.CreateAsync(request), Times.Once); } + + [Fact] + public async Task CreateAsync_NullRequest_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync(() => _sut.CreateAsync(null)); + } + + [Fact] + public async Task CreateAsync_NullName_ThrowsArgumentException() + { + var request = new CreateProductRequest { Name = null, Description = "A widget" }; + + await Assert.ThrowsAsync(() => _sut.CreateAsync(request)); + } + + [Fact] + public async Task CreateAsync_WhitespaceName_ThrowsArgumentException() + { + var request = new CreateProductRequest { Name = " ", Description = "A widget" }; + + await Assert.ThrowsAsync(() => _sut.CreateAsync(request)); + } + + [Fact] + public async Task CreateAsync_NameExceeds256Chars_ThrowsArgumentException() + { + var request = new CreateProductRequest + { + Name = new string('a', 257), + Description = "A widget" + }; + + await Assert.ThrowsAsync(() => _sut.CreateAsync(request)); + } + + [Fact] + public async Task CreateAsync_DescriptionExceeds256Chars_ThrowsArgumentException() + { + var request = new CreateProductRequest + { + Name = "Widget", + Description = new string('a', 257) + }; + + await Assert.ThrowsAsync(() => _sut.CreateAsync(request)); + } + + #endregion } diff --git a/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs b/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs index 1957b19..54fbb15 100644 --- a/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs +++ b/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs @@ -1,5 +1,8 @@ +using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using Dapper; using Interview.Web.Models; using Interview.Web.Repositories.Interfaces; using Sparcpoint.SqlServer.Abstractions; @@ -15,11 +18,64 @@ public ProductRepository(ISqlExecutor sqlExecutor) _sqlExecutor = sqlExecutor; } - // EVAL: Stubs throw NotImplementedException so the compiler is satisfied while TDD - // tests drive the real implementations in Milestone 2 and 3. - public Task CreateAsync(CreateProductRequest request) - => throw new System.NotImplementedException(); + public async Task CreateAsync(CreateProductRequest request) + { + return await _sqlExecutor.ExecuteAsync(async (conn, trans) => + { + // EVAL: SCOPE_IDENTITY() returns the auto-incremented InstanceId generated by the + // INSERT, scoped to the current connection so concurrent inserts don't collide. + const string insertProduct = @" + INSERT INTO Instances.Products (Name, Description, ValidSkus, ProductImageUris) + VALUES (@Name, @Description, @ValidSkus, @ProductImageUris); + SELECT CAST(SCOPE_IDENTITY() AS INT);"; + + var id = await conn.ExecuteScalarAsync(insertProduct, new + { + request.Name, + Description = request.Description ?? string.Empty, + // EVAL: Arrays are stored as comma-separated strings to match the VARCHAR(MAX) + // schema design. The schema allows this without additional tables. + ValidSkus = string.Join(",", request.ValidSkus ?? Array.Empty()), + ProductImageUris = string.Join(",", request.ProductImageUris ?? Array.Empty()) + }, trans); + + if (request.Attributes != null && request.Attributes.Any()) + { + const string insertAttribute = @" + INSERT INTO Instances.ProductAttributes (InstanceId, [Key], [Value]) + VALUES (@InstanceId, @Key, @Value);"; + + foreach (var attr in request.Attributes) + { + await conn.ExecuteAsync(insertAttribute, new + { + InstanceId = id, + Key = attr.Key, + Value = attr.Value + }, trans); + } + } + + if (request.CategoryIds != null && request.CategoryIds.Any()) + { + const string insertCategory = @" + INSERT INTO Instances.ProductCategories (InstanceId, CategoryInstanceId) + VALUES (@InstanceId, @CategoryInstanceId);"; + + foreach (var categoryId in request.CategoryIds) + { + await conn.ExecuteAsync(insertCategory, new + { + InstanceId = id, + CategoryInstanceId = categoryId + }, trans); + } + } + + return id; + }); + } public Task> SearchAsync(ProductSearchRequest request) - => throw new System.NotImplementedException(); -} \ No newline at end of file + => throw new NotImplementedException(); +} diff --git a/Development Project/Interview.Web/Services/Implementations/ProductsService.cs b/Development Project/Interview.Web/Services/Implementations/ProductsService.cs index 289bf60..4725071 100644 --- a/Development Project/Interview.Web/Services/Implementations/ProductsService.cs +++ b/Development Project/Interview.Web/Services/Implementations/ProductsService.cs @@ -3,6 +3,7 @@ using Interview.Web.Models; using Interview.Web.Repositories.Interfaces; using Interview.Web.Services.Interfaces; +using Sparcpoint; namespace Interview.Web.Services.Implementations; @@ -15,9 +16,21 @@ public ProductsService(IProductRepository productRepository) _productRepository = productRepository; } - // EVAL: Stubs throw NotImplementedException. Real logic driven by tests in Milestone 2 and 3. - public Task CreateAsync(CreateProductRequest request) - => throw new System.NotImplementedException(); + public async Task CreateAsync(CreateProductRequest request) + { + // EVAL: PreConditions from Sparcpoint.Core centralizes guard clause logic so every + // service can validate inputs consistently without duplicating null/whitespace checks. + PreConditions.ParameterNotNull(request, nameof(request)); + PreConditions.StringNotNullOrWhitespace(request.Name, nameof(request.Name)); + + if (request.Name.Length > 256) + throw new System.ArgumentException("Name cannot exceed 256 characters.", nameof(request.Name)); + + if (!string.IsNullOrWhiteSpace(request.Description) && request.Description.Length > 256) + throw new System.ArgumentException("Description cannot exceed 256 characters.", nameof(request.Description)); + + return await _productRepository.CreateAsync(request); + } public async Task> SearchAsync(ProductSearchRequest request) { From 2368a02381d5d87e870e2f69d1baa8cb136599cc Mon Sep 17 00:00:00 2001 From: Shane Freeman Date: Thu, 4 Jun 2026 22:06:15 -0400 Subject: [PATCH 03/10] feat: milestone 3 - implement product search with dynamic filtering - Update ProductSearchRequest to use AttributeKey/AttributeValue for query string binding - Update IProductRepository and IProductsService to return ProductResponse - Implement ProductRepository.SearchAsync with SqlServerQueryProvider dynamic WHERE clause - Add EXISTS subquery for attribute filtering to prevent duplicate rows - Add IN subquery for category filtering across multiple category IDs - Load product attributes per result and map to ProductResponse - Add SplitCsv helper to convert stored comma-separated strings back to arrays - Add MapToResponse helper to decouple DB model from API contract --- .../Services/ProductsServiceTests.cs | 4 +- .../Models/ProductSearchRequest.cs | 14 +-- .../Implementations/ProductRepository.cs | 98 ++++++++++++++++++- .../Interfaces/IProductRepository.cs | 4 +- .../Implementations/ProductsService.cs | 4 +- .../Services/Interfaces/IProductsService.cs | 4 +- 6 files changed, 112 insertions(+), 16 deletions(-) diff --git a/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs b/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs index f1348ca..fce9893 100644 --- a/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs +++ b/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs @@ -25,7 +25,7 @@ public ProductsServiceTests() [Fact] public async Task SearchAsync_WithNoFilters_ReturnsAllProducts() { - var expected = new List { new Product { InstanceId = 1, Name = "Widget" } }; + var expected = new List { new ProductResponse { InstanceId = 1, Name = "Widget" } }; _repositoryMock.Setup(r => r.SearchAsync(It.IsAny())) .ReturnsAsync(expected); @@ -38,7 +38,7 @@ public async Task SearchAsync_WithNoFilters_ReturnsAllProducts() public async Task SearchAsync_DelegatesToRepository() { var request = new ProductSearchRequest { Name = "Widget" }; - _repositoryMock.Setup(r => r.SearchAsync(request)).ReturnsAsync(new List()); + _repositoryMock.Setup(r => r.SearchAsync(request)).ReturnsAsync(new List()); await _sut.SearchAsync(request); diff --git a/Development Project/Interview.Web/Models/ProductSearchRequest.cs b/Development Project/Interview.Web/Models/ProductSearchRequest.cs index 82011c8..465605e 100644 --- a/Development Project/Interview.Web/Models/ProductSearchRequest.cs +++ b/Development Project/Interview.Web/Models/ProductSearchRequest.cs @@ -1,13 +1,15 @@ -using System.Collections.Generic; - namespace Interview.Web.Models; public class ProductSearchRequest { public string Name { get; set; } public string Description { get; set; } - - public Dictionary Attributes { get; set; } - + + // EVAL: AttributeKey and AttributeValue are used instead of Dictionary + // because Dictionary does not bind correctly from query string parameters with [FromQuery]. + // A single key/value pair covers the primary use case and keeps the API simple to call. + public string AttributeKey { get; set; } + public string AttributeValue { get; set; } + public int[] CategoryIds { get; set; } -} \ No newline at end of file +} diff --git a/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs b/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs index 54fbb15..7fc817f 100644 --- a/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs +++ b/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs @@ -76,6 +76,100 @@ INSERT INTO Instances.ProductCategories (InstanceId, CategoryInstanceId) }); } - public Task> SearchAsync(ProductSearchRequest request) - => throw new NotImplementedException(); + public async Task> SearchAsync(ProductSearchRequest request) + { + return await _sqlExecutor.ExecuteAsync(async (conn, trans) => + { + // EVAL: SqlServerQueryProvider builds the WHERE clause dynamically based on which + // filters are provided. If no filters are set, WhereClause returns an empty string + // and the query returns all products — no special branch needed. + var query = SqlServerQueryProvider.Empty; + query.SetTargetTableAlias("p"); + + if (!string.IsNullOrWhiteSpace(request?.Name)) + query.WhereEquals("Name", "Name", request.Name); + + if (!string.IsNullOrWhiteSpace(request?.Description)) + query.WhereEquals("Description", "Description", request.Description); + + // EVAL: EXISTS subquery for attribute filtering is more efficient than a JOIN because + // it short-circuits on the first matching row rather than multiplying result rows + // when a product has multiple attributes. + if (!string.IsNullOrWhiteSpace(request?.AttributeKey) && !string.IsNullOrWhiteSpace(request?.AttributeValue)) + { + query.Where($@"EXISTS ( + SELECT 1 FROM Instances.ProductAttributes pa + WHERE pa.InstanceId = p.InstanceId + AND pa.[Key] = @AttributeKey + AND pa.[Value] = @AttributeValue)"); + + query.AddParameter("@AttributeKey", request.AttributeKey); + query.AddParameter("@AttributeValue", request.AttributeValue); + } + + // EVAL: IN subquery for category filtering lets us match products belonging to + // any of the requested categories without duplicating product rows via JOIN. + if (request?.CategoryIds != null && request.CategoryIds.Any()) + { + var categoryIdList = string.Join(",", request.CategoryIds); + query.WhereIn("InstanceId", $"(SELECT InstanceId FROM Instances.ProductCategories WHERE CategoryInstanceId IN ({categoryIdList}))"); + } + + var sql = $"SELECT * FROM Instances.Products p {query.WhereClause}"; + + var products = await conn.QueryAsync(sql, query.Parameters, trans); + + // Load attributes for each product and map to ProductResponse + var responses = new List(); + foreach (var product in products) + { + var attributes = await LoadAttributesAsync(conn, trans, product.InstanceId); + responses.Add(MapToResponse(product, attributes)); + } + + return responses; + }); + } + + private static async Task> LoadAttributesAsync( + System.Data.IDbConnection conn, + System.Data.IDbTransaction trans, + int instanceId) + { + const string sql = @" + SELECT [Key], [Value] + FROM Instances.ProductAttributes + WHERE InstanceId = @InstanceId"; + + var rows = await conn.QueryAsync<(string Key, string Value)>(sql, new { InstanceId = instanceId }, trans); + return rows.ToDictionary(r => r.Key, r => r.Value); + } + + // EVAL: Mapping from the internal Product DB model to the public ProductResponse keeps + // the database schema decoupled from the API contract. If the DB changes, only this + // method needs updating — controllers and services stay untouched. + private static ProductResponse MapToResponse(Product product, Dictionary attributes) + { + return new ProductResponse + { + InstanceId = product.InstanceId, + Name = product.Name, + Description = product.Description, + ValidSkus = SplitCsv(product.ValidSkus), + ProductImageUris = SplitCsv(product.ProductImageUris), + CreatedTimestamp = product.CreatedTimestamp, + Attributes = attributes + }; + } + + // EVAL: Comma-separated strings stored in the DB are split back into arrays for the API + // response. Empty or null strings return an empty array rather than an array with one + // empty element, which would be misleading to API consumers. + private static string[] SplitCsv(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return Array.Empty(); + + return value.Split(',', StringSplitOptions.RemoveEmptyEntries); + } } diff --git a/Development Project/Interview.Web/Repositories/Interfaces/IProductRepository.cs b/Development Project/Interview.Web/Repositories/Interfaces/IProductRepository.cs index 8fe20ed..c215551 100644 --- a/Development Project/Interview.Web/Repositories/Interfaces/IProductRepository.cs +++ b/Development Project/Interview.Web/Repositories/Interfaces/IProductRepository.cs @@ -7,5 +7,5 @@ namespace Interview.Web.Repositories.Interfaces; public interface IProductRepository { Task CreateAsync(CreateProductRequest request); - Task> SearchAsync(ProductSearchRequest request); -} \ No newline at end of file + Task> SearchAsync(ProductSearchRequest request); +} diff --git a/Development Project/Interview.Web/Services/Implementations/ProductsService.cs b/Development Project/Interview.Web/Services/Implementations/ProductsService.cs index 4725071..f7ff60a 100644 --- a/Development Project/Interview.Web/Services/Implementations/ProductsService.cs +++ b/Development Project/Interview.Web/Services/Implementations/ProductsService.cs @@ -32,10 +32,10 @@ public async Task CreateAsync(CreateProductRequest request) return await _productRepository.CreateAsync(request); } - public async Task> SearchAsync(ProductSearchRequest request) + public async Task> SearchAsync(ProductSearchRequest request) { // EVAL: Passing null or empty request returns all products — no special branch needed // because the repository builds WHERE clauses only for non-null fields. return await _productRepository.SearchAsync(request); } -} \ No newline at end of file +} diff --git a/Development Project/Interview.Web/Services/Interfaces/IProductsService.cs b/Development Project/Interview.Web/Services/Interfaces/IProductsService.cs index 05cea75..6d2403b 100644 --- a/Development Project/Interview.Web/Services/Interfaces/IProductsService.cs +++ b/Development Project/Interview.Web/Services/Interfaces/IProductsService.cs @@ -7,5 +7,5 @@ namespace Interview.Web.Services.Interfaces; public interface IProductsService { Task CreateAsync(CreateProductRequest request); - Task> SearchAsync(ProductSearchRequest request); -} \ No newline at end of file + Task> SearchAsync(ProductSearchRequest request); +} From 9a8374351a8b0303c297bbb272bfd1ed32c56969 Mon Sep 17 00:00:00 2001 From: Shane Freeman Date: Fri, 5 Jun 2026 04:41:46 -0400 Subject: [PATCH 04/10] feat: milestone 4 - implement inventory add, remove, and count - Add AddInventoryRequest, InventoryCountRequest, and InventoryTransaction models - Add IInventoryRepository and IInventoryService interfaces - Implement InventoryService with validation for quantity, product ID, and transaction ID - Implement InventoryRepository with transactional insert, delete, and dynamic count query - Add EXISTS subquery for attribute-based inventory counting across multiple products - Use ISNULL(SUM(...), 0) to guard against NULL on empty transaction sets - Add InventoryController with POST, DELETE, and GET count endpoints - Return 204 No Content on DELETE per REST conventions - Register IInventoryRepository and IInventoryService in DI as scoped - Add 8 TDD tests covering validation and delegation for all three operations --- .../Services/InventoryServiceTests.cs | 99 +++++++++++++++++++ .../Controllers/InventoryController.cs | 43 ++++++++ .../Models/AddInventoryRequest.cs | 14 +++ .../Models/InventoryCountRequest.cs | 11 +++ .../Models/InventoryTransaction.cs | 13 +++ .../Implementations/InventoryRepository.cs | 89 +++++++++++++++++ .../Interfaces/IInventoryRepository.cs | 11 +++ .../Implementations/InventoryService.cs | 50 ++++++++++ .../Services/Interfaces/IInventoryService.cs | 11 +++ Development Project/Interview.Web/Startup.cs | 2 + 10 files changed, 343 insertions(+) create mode 100644 Development Project/Interview.Web.Tests/Services/InventoryServiceTests.cs create mode 100644 Development Project/Interview.Web/Controllers/InventoryController.cs create mode 100644 Development Project/Interview.Web/Models/AddInventoryRequest.cs create mode 100644 Development Project/Interview.Web/Models/InventoryCountRequest.cs create mode 100644 Development Project/Interview.Web/Models/InventoryTransaction.cs create mode 100644 Development Project/Interview.Web/Repositories/Implementations/InventoryRepository.cs create mode 100644 Development Project/Interview.Web/Repositories/Interfaces/IInventoryRepository.cs create mode 100644 Development Project/Interview.Web/Services/Implementations/InventoryService.cs create mode 100644 Development Project/Interview.Web/Services/Interfaces/IInventoryService.cs diff --git a/Development Project/Interview.Web.Tests/Services/InventoryServiceTests.cs b/Development Project/Interview.Web.Tests/Services/InventoryServiceTests.cs new file mode 100644 index 0000000..06b9fdb --- /dev/null +++ b/Development Project/Interview.Web.Tests/Services/InventoryServiceTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading.Tasks; +using Interview.Web.Models; +using Interview.Web.Repositories.Interfaces; +using Interview.Web.Services.Implementations; +using Moq; +using Xunit; + +namespace Interview.Web.Tests.Services; + +public class InventoryServiceTests +{ + private readonly Mock _repositoryMock; + private readonly InventoryService _sut; + + public InventoryServiceTests() + { + _repositoryMock = new Mock(); + _sut = new InventoryService(_repositoryMock.Object); + } + + #region AddAsync + + [Fact] + public async Task AddAsync_ValidRequest_DelegatesToRepository() + { + var request = new AddInventoryRequest { ProductInstanceId = 1, Quantity = 10 }; + _repositoryMock.Setup(r => r.AddAsync(request)).ReturnsAsync(1); + + var id = await _sut.AddAsync(request); + + Assert.Equal(1, id); + _repositoryMock.Verify(r => r.AddAsync(request), Times.Once); + } + + [Fact] + public async Task AddAsync_NullRequest_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync(() => _sut.AddAsync(null)); + } + + [Fact] + public async Task AddAsync_ZeroQuantity_ThrowsArgumentException() + { + var request = new AddInventoryRequest { ProductInstanceId = 1, Quantity = 0 }; + + await Assert.ThrowsAsync(() => _sut.AddAsync(request)); + } + + [Fact] + public async Task AddAsync_InvalidProductInstanceId_ThrowsArgumentException() + { + var request = new AddInventoryRequest { ProductInstanceId = 0, Quantity = 10 }; + + await Assert.ThrowsAsync(() => _sut.AddAsync(request)); + } + + #endregion + + #region RemoveTransactionAsync + + [Fact] + public async Task RemoveTransactionAsync_ValidId_DelegatesToRepository() + { + await _sut.RemoveTransactionAsync(1); + + _repositoryMock.Verify(r => r.RemoveTransactionAsync(1), Times.Once); + } + + [Fact] + public async Task RemoveTransactionAsync_InvalidId_ThrowsArgumentException() + { + await Assert.ThrowsAsync(() => _sut.RemoveTransactionAsync(0)); + } + + #endregion + + #region GetCountAsync + + [Fact] + public async Task GetCountAsync_ValidRequest_DelegatesToRepository() + { + var request = new InventoryCountRequest { ProductInstanceId = 1 }; + _repositoryMock.Setup(r => r.GetCountAsync(request)).ReturnsAsync(42m); + + var count = await _sut.GetCountAsync(request); + + Assert.Equal(42m, count); + _repositoryMock.Verify(r => r.GetCountAsync(request), Times.Once); + } + + [Fact] + public async Task GetCountAsync_NullRequest_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync(() => _sut.GetCountAsync(null)); + } + + #endregion +} diff --git a/Development Project/Interview.Web/Controllers/InventoryController.cs b/Development Project/Interview.Web/Controllers/InventoryController.cs new file mode 100644 index 0000000..6a3a2e6 --- /dev/null +++ b/Development Project/Interview.Web/Controllers/InventoryController.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; +using Interview.Web.Models; +using Interview.Web.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace Interview.Web.Controllers; + +[ApiController] +[Route("api/v1/inventory")] +public class InventoryController : ControllerBase +{ + private readonly IInventoryService _inventoryService; + + public InventoryController(IInventoryService inventoryService) + { + _inventoryService = inventoryService; + } + + [HttpPost] + public async Task AddInventory([FromBody] AddInventoryRequest request) + { + var transactionId = await _inventoryService.AddAsync(request); + // EVAL: Returning the TransactionId gives the caller a handle to reference this + // specific transaction if they need to undo it via DELETE later. + return Ok(new { TransactionId = transactionId }); + } + + [HttpDelete("{transactionId}")] + public async Task RemoveTransaction(int transactionId) + { + await _inventoryService.RemoveTransactionAsync(transactionId); + // EVAL: 204 No Content is the correct REST response for a successful DELETE + // that returns no body. + return NoContent(); + } + + [HttpGet("count")] + public async Task GetCount([FromQuery] InventoryCountRequest request) + { + var count = await _inventoryService.GetCountAsync(request); + return Ok(new { Count = count }); + } +} diff --git a/Development Project/Interview.Web/Models/AddInventoryRequest.cs b/Development Project/Interview.Web/Models/AddInventoryRequest.cs new file mode 100644 index 0000000..f5f9df2 --- /dev/null +++ b/Development Project/Interview.Web/Models/AddInventoryRequest.cs @@ -0,0 +1,14 @@ +namespace Interview.Web.Models; + +public class AddInventoryRequest +{ + public int ProductInstanceId { get; set; } + + // EVAL: Quantity is decimal to match DECIMAL(19,6) in the DB, supporting fractional + // units such as products sold by weight or volume rather than whole units only. + public decimal Quantity { get; set; } + + // EVAL: TypeCategory is optional and allows callers to label the transaction type + // (e.g. "RECEIVE", "ADJUSTMENT") for reporting without changing the schema. + public string TypeCategory { get; set; } +} diff --git a/Development Project/Interview.Web/Models/InventoryCountRequest.cs b/Development Project/Interview.Web/Models/InventoryCountRequest.cs new file mode 100644 index 0000000..cf44d00 --- /dev/null +++ b/Development Project/Interview.Web/Models/InventoryCountRequest.cs @@ -0,0 +1,11 @@ +namespace Interview.Web.Models; + +public class InventoryCountRequest +{ + // EVAL: Either ProductInstanceId or AttributeKey/AttributeValue can be used to filter. + // ProductInstanceId targets a specific product; attribute filters aggregate across + // all products sharing that metadata (e.g. count all red products). + public int? ProductInstanceId { get; set; } + public string AttributeKey { get; set; } + public string AttributeValue { get; set; } +} diff --git a/Development Project/Interview.Web/Models/InventoryTransaction.cs b/Development Project/Interview.Web/Models/InventoryTransaction.cs new file mode 100644 index 0000000..8259668 --- /dev/null +++ b/Development Project/Interview.Web/Models/InventoryTransaction.cs @@ -0,0 +1,13 @@ +using System; + +namespace Interview.Web.Models; + +public class InventoryTransaction +{ + public int TransactionId { get; set; } + public int ProductInstanceId { get; set; } + public decimal Quantity { get; set; } + public DateTime StartedTimestamp { get; set; } + public DateTime? CompletedTimestamp { get; set; } + public string TypeCategory { get; set; } +} diff --git a/Development Project/Interview.Web/Repositories/Implementations/InventoryRepository.cs b/Development Project/Interview.Web/Repositories/Implementations/InventoryRepository.cs new file mode 100644 index 0000000..f50f79b --- /dev/null +++ b/Development Project/Interview.Web/Repositories/Implementations/InventoryRepository.cs @@ -0,0 +1,89 @@ +using System.Threading.Tasks; +using Dapper; +using Interview.Web.Models; +using Interview.Web.Repositories.Interfaces; +using Sparcpoint.SqlServer.Abstractions; + +namespace Interview.Web.Repositories.Implementations; + +public class InventoryRepository : IInventoryRepository +{ + private readonly ISqlExecutor _sqlExecutor; + + public InventoryRepository(ISqlExecutor sqlExecutor) + { + _sqlExecutor = sqlExecutor; + } + + public async Task AddAsync(AddInventoryRequest request) + { + return await _sqlExecutor.ExecuteAsync(async (conn, trans) => + { + const string sql = @" + INSERT INTO Transactions.InventoryTransactions + (ProductInstanceId, Quantity, TypeCategory) + VALUES + (@ProductInstanceId, @Quantity, @TypeCategory); + SELECT CAST(SCOPE_IDENTITY() AS INT);"; + + return await conn.ExecuteScalarAsync(sql, new + { + request.ProductInstanceId, + request.Quantity, + request.TypeCategory + }, trans); + }); + } + + public async Task RemoveTransactionAsync(int transactionId) + { + await _sqlExecutor.ExecuteAsync(async (conn, trans) => + { + // EVAL: Deleting the transaction row is the "undo" mechanism per the spec requirement + // that individual transactions should be able to be removed. The inventory count + // query only sums active rows so removing a row immediately corrects the count. + const string sql = @" + DELETE FROM Transactions.InventoryTransactions + WHERE TransactionId = @TransactionId"; + + await conn.ExecuteAsync(sql, new { TransactionId = transactionId }, trans); + }); + } + + public async Task GetCountAsync(InventoryCountRequest request) + { + return await _sqlExecutor.ExecuteAsync(async (conn, trans) => + { + // EVAL: ISNULL(..., 0) guards against NULL being returned when no transactions exist + // for a product, which would cause Dapper to return 0m rather than throw. + const string baseSelect = @" + SELECT ISNULL(SUM(t.Quantity), 0) + FROM Transactions.InventoryTransactions t"; + + var query = SqlServerQueryProvider.Empty; + query.SetTargetTableAlias("t"); + + if (request.ProductInstanceId.HasValue) + query.WhereEquals("ProductInstanceId", "ProductInstanceId", request.ProductInstanceId.Value); + + // EVAL: Attribute-based count joins back to ProductAttributes so callers can ask + // "how many units do I have across all products with color=red" without knowing + // the specific product IDs. + if (!string.IsNullOrWhiteSpace(request.AttributeKey) && !string.IsNullOrWhiteSpace(request.AttributeValue)) + { + query.Where($@"EXISTS ( + SELECT 1 FROM Instances.ProductAttributes pa + WHERE pa.InstanceId = t.ProductInstanceId + AND pa.[Key] = @AttributeKey + AND pa.[Value] = @AttributeValue)"); + + query.AddParameter("@AttributeKey", request.AttributeKey); + query.AddParameter("@AttributeValue", request.AttributeValue); + } + + var sql = $"{baseSelect} {query.WhereClause}"; + + return await conn.ExecuteScalarAsync(sql, query.Parameters, trans); + }); + } +} diff --git a/Development Project/Interview.Web/Repositories/Interfaces/IInventoryRepository.cs b/Development Project/Interview.Web/Repositories/Interfaces/IInventoryRepository.cs new file mode 100644 index 0000000..eec0bb6 --- /dev/null +++ b/Development Project/Interview.Web/Repositories/Interfaces/IInventoryRepository.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Interview.Web.Models; + +namespace Interview.Web.Repositories.Interfaces; + +public interface IInventoryRepository +{ + Task AddAsync(AddInventoryRequest request); + Task RemoveTransactionAsync(int transactionId); + Task GetCountAsync(InventoryCountRequest request); +} diff --git a/Development Project/Interview.Web/Services/Implementations/InventoryService.cs b/Development Project/Interview.Web/Services/Implementations/InventoryService.cs new file mode 100644 index 0000000..7141cfc --- /dev/null +++ b/Development Project/Interview.Web/Services/Implementations/InventoryService.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; +using Interview.Web.Models; +using Interview.Web.Repositories.Interfaces; +using Interview.Web.Services.Interfaces; +using Sparcpoint; + +namespace Interview.Web.Services.Implementations; + +public class InventoryService : IInventoryService +{ + private readonly IInventoryRepository _inventoryRepository; + + public InventoryService(IInventoryRepository inventoryRepository) + { + _inventoryRepository = inventoryRepository; + } + + public async Task AddAsync(AddInventoryRequest request) + { + PreConditions.ParameterNotNull(request, nameof(request)); + + // EVAL: ProductInstanceId must be a positive integer — zero or negative values + // cannot reference a valid auto-incremented database identity. + if (request.ProductInstanceId <= 0) + throw new System.ArgumentException("ProductInstanceId must be a positive integer.", nameof(request.ProductInstanceId)); + + // EVAL: Quantity of zero has no effect on inventory and is most likely a caller error. + // Negative quantities are valid for removals so we only reject zero. + if (request.Quantity == 0) + throw new System.ArgumentException("Quantity cannot be zero.", nameof(request.Quantity)); + + return await _inventoryRepository.AddAsync(request); + } + + public async Task RemoveTransactionAsync(int transactionId) + { + // EVAL: TransactionId must be positive — same reasoning as ProductInstanceId above. + if (transactionId <= 0) + throw new System.ArgumentException("TransactionId must be a positive integer.", nameof(transactionId)); + + await _inventoryRepository.RemoveTransactionAsync(transactionId); + } + + public async Task GetCountAsync(InventoryCountRequest request) + { + PreConditions.ParameterNotNull(request, nameof(request)); + + return await _inventoryRepository.GetCountAsync(request); + } +} diff --git a/Development Project/Interview.Web/Services/Interfaces/IInventoryService.cs b/Development Project/Interview.Web/Services/Interfaces/IInventoryService.cs new file mode 100644 index 0000000..2aacda6 --- /dev/null +++ b/Development Project/Interview.Web/Services/Interfaces/IInventoryService.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Interview.Web.Models; + +namespace Interview.Web.Services.Interfaces; + +public interface IInventoryService +{ + Task AddAsync(AddInventoryRequest request); + Task RemoveTransactionAsync(int transactionId); + Task GetCountAsync(InventoryCountRequest request); +} diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs index 1e4f97d..50f3fe8 100644 --- a/Development Project/Interview.Web/Startup.cs +++ b/Development Project/Interview.Web/Startup.cs @@ -34,6 +34,8 @@ public void ConfigureServices(IServiceCollection services) // which is appropriate for database-backed services. services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) From 083b8a39e5f88d74455efc92158d1dfd862fb9fc Mon Sep 17 00:00:00 2001 From: Shane Freeman Date: Fri, 5 Jun 2026 04:55:16 -0400 Subject: [PATCH 05/10] When launching id like it to open to swagger --- .../Interview.Web/Properties/launchSettings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Development Project/Interview.Web/Properties/launchSettings.json b/Development Project/Interview.Web/Properties/launchSettings.json index 4dcb6fa..b5a5628 100644 --- a/Development Project/Interview.Web/Properties/launchSettings.json +++ b/Development Project/Interview.Web/Properties/launchSettings.json @@ -10,6 +10,7 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", + "launchUrl": "https://localhost:5001/swagger", "launchBrowser": true, "launchUrl": "api/v1/products", "environmentVariables": { @@ -18,6 +19,7 @@ }, "Interview.Web": { "commandName": "Project", + "launchUrl": "https://localhost:5001/swagger", "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" From 0f0f3994cf337a11c43617610c53eac32aa18277 Mon Sep 17 00:00:00 2001 From: Shane Freeman Date: Fri, 5 Jun 2026 05:03:29 -0400 Subject: [PATCH 06/10] adding documentation to endpoints --- .../Controllers/InventoryController.cs | 39 +++++++++++++++++++ .../Controllers/ProductController.cs | 24 ++++++++++++ .../Interview.Web/Interview.Web.csproj | 2 + Development Project/Interview.Web/Startup.cs | 9 ++++- 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/Development Project/Interview.Web/Controllers/InventoryController.cs b/Development Project/Interview.Web/Controllers/InventoryController.cs index 6a3a2e6..5b4ab1b 100644 --- a/Development Project/Interview.Web/Controllers/InventoryController.cs +++ b/Development Project/Interview.Web/Controllers/InventoryController.cs @@ -16,7 +16,21 @@ public InventoryController(IInventoryService inventoryService) _inventoryService = inventoryService; } + /// + /// Add an inventory transaction for a product. + /// + /// + /// Use a positive Quantity to add stock and a negative Quantity to remove stock. + /// Each call creates a new transaction row which can be individually undone via DELETE. + /// The optional TypeCategory field can label the transaction (e.g. "RECEIVE", "SALE", "ADJUSTMENT"). + /// + /// The product ID, quantity, and optional type category. + /// The TransactionId of the newly created transaction. + /// Transaction created successfully. + /// Validation failed — quantity cannot be zero, product ID must be positive. [HttpPost] + [ProducesResponseType(typeof(object), 200)] + [ProducesResponseType(400)] public async Task AddInventory([FromBody] AddInventoryRequest request) { var transactionId = await _inventoryService.AddAsync(request); @@ -25,7 +39,19 @@ public async Task AddInventory([FromBody] AddInventoryRequest req return Ok(new { TransactionId = transactionId }); } + /// + /// Remove (undo) an inventory transaction by its ID. + /// + /// + /// Deleting a transaction permanently removes it and immediately corrects the inventory count. + /// This is the undo mechanism — the transaction row is deleted rather than flagged. + /// + /// The ID of the transaction to remove. + /// Transaction removed successfully. + /// TransactionId must be a positive integer. [HttpDelete("{transactionId}")] + [ProducesResponseType(204)] + [ProducesResponseType(400)] public async Task RemoveTransaction(int transactionId) { await _inventoryService.RemoveTransactionAsync(transactionId); @@ -34,7 +60,20 @@ public async Task RemoveTransaction(int transactionId) return NoContent(); } + /// + /// Retrieve the total inventory count for a product or set of products. + /// + /// + /// Filter by ProductInstanceId to get the count for a specific product. + /// Filter by AttributeKey and AttributeValue to aggregate inventory across all products + /// sharing that metadata (e.g. all products where color=red). + /// Omitting all filters returns the total inventory count across every product. + /// + /// Optional filters: ProductInstanceId, AttributeKey, AttributeValue. + /// The total inventory count matching the filters. + /// Returns the inventory count. [HttpGet("count")] + [ProducesResponseType(typeof(object), 200)] public async Task GetCount([FromQuery] InventoryCountRequest request) { var count = await _inventoryService.GetCountAsync(request); diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs index bd30ef3..89dd062 100644 --- a/Development Project/Interview.Web/Controllers/ProductController.cs +++ b/Development Project/Interview.Web/Controllers/ProductController.cs @@ -16,7 +16,20 @@ public ProductController(IProductsService productsService) _productsService = productsService; } + /// + /// Add a new product to the system. + /// + /// + /// Products can never be deleted once created. Provide arbitrary metadata as key/value + /// pairs in Attributes and assign one or more CategoryIds to categorize the product. + /// + /// The product details including name, description, SKUs, image URIs, attributes, and categories. + /// The InstanceId of the newly created product. + /// Product created successfully. + /// Validation failed — name is required and cannot exceed 256 characters. [HttpPost] + [ProducesResponseType(typeof(object), 201)] + [ProducesResponseType(400)] public async Task CreateProduct([FromBody] CreateProductRequest request) { var id = await _productsService.CreateAsync(request); @@ -25,7 +38,18 @@ public async Task CreateProduct([FromBody] CreateProductRequest r return CreatedAtAction(nameof(GetProducts), new { id }, new { InstanceId = id }); } + /// + /// Search for products using optional filters. + /// + /// + /// All filters are optional. Omitting all filters returns every product in the system. + /// Filters are combined with AND — each additional filter narrows the result set. + /// + /// Optional filters: Name, Description, AttributeKey/AttributeValue, and CategoryIds. + /// A list of products matching the provided filters. + /// Returns the matching products. [HttpGet] + [ProducesResponseType(typeof(System.Collections.Generic.IEnumerable), 200)] public async Task GetProducts([FromQuery] ProductSearchRequest request) { var products = await _productsService.SearchAsync(request); diff --git a/Development Project/Interview.Web/Interview.Web.csproj b/Development Project/Interview.Web/Interview.Web.csproj index 44664c2..2ca5035 100644 --- a/Development Project/Interview.Web/Interview.Web.csproj +++ b/Development Project/Interview.Web/Interview.Web.csproj @@ -2,6 +2,8 @@ net8.0 + true + $(NoWarn);1591 diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs index 50f3fe8..c9f7b5c 100644 --- a/Development Project/Interview.Web/Startup.cs +++ b/Development Project/Interview.Web/Startup.cs @@ -23,7 +23,14 @@ public Startup(IConfiguration configuration) public void ConfigureServices(IServiceCollection services) { services.AddControllers(); - services.AddSwaggerGen(); + services.AddSwaggerGen(c => + { + // EVAL: Including the XML documentation file allows Swagger UI to display + // the summary and remarks from /// comments on each controller action. + var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = System.IO.Path.Combine(System.AppContext.BaseDirectory, xmlFile); + c.IncludeXmlComments(xmlPath); + }); // EVAL: SqlServerOptions is bound from appsettings.json so the connection string // is configurable per environment without code changes (Open/Closed principle). From ea896ea2aa2d86f8c36cb976c6d9bd951803eff3 Mon Sep 17 00:00:00 2001 From: Shane Freeman Date: Fri, 5 Jun 2026 05:07:57 -0400 Subject: [PATCH 07/10] additional comment --- .../Interview.Web/Models/ProductSearchRequest.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Development Project/Interview.Web/Models/ProductSearchRequest.cs b/Development Project/Interview.Web/Models/ProductSearchRequest.cs index 465605e..eb75b5b 100644 --- a/Development Project/Interview.Web/Models/ProductSearchRequest.cs +++ b/Development Project/Interview.Web/Models/ProductSearchRequest.cs @@ -7,7 +7,11 @@ public class ProductSearchRequest // EVAL: AttributeKey and AttributeValue are used instead of Dictionary // because Dictionary does not bind correctly from query string parameters with [FromQuery]. - // A single key/value pair covers the primary use case and keeps the API simple to call. + // A single key/value pair satisfies the spec requirement of searchable by metadata. + // To support multiple attribute filters in the future, these could be changed to + // string[] AttributeKeys and string[] AttributeValues using repeated query parameters + // (e.g. ?attributeKeys=color&attributeKeys=brand&attributeValues=red&attributeValues=Nike) + // without breaking existing callers using a single pair. public string AttributeKey { get; set; } public string AttributeValue { get; set; } From 6e778b6bdb6d65ea7282939204c25e87a23d3894 Mon Sep 17 00:00:00 2001 From: Shane Freeman Date: Fri, 5 Jun 2026 09:22:55 -0400 Subject: [PATCH 08/10] removing duplicate launchUrl from launchsettings --- Development Project/Interview.Web/Properties/launchSettings.json | 1 - 1 file changed, 1 deletion(-) diff --git a/Development Project/Interview.Web/Properties/launchSettings.json b/Development Project/Interview.Web/Properties/launchSettings.json index b5a5628..60cf853 100644 --- a/Development Project/Interview.Web/Properties/launchSettings.json +++ b/Development Project/Interview.Web/Properties/launchSettings.json @@ -12,7 +12,6 @@ "commandName": "IISExpress", "launchUrl": "https://localhost:5001/swagger", "launchBrowser": true, - "launchUrl": "api/v1/products", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } From b9dc1c7dfbe8459feff96e922aa43c3ff9220ff5 Mon Sep 17 00:00:00 2001 From: Shane Freeman Date: Fri, 5 Jun 2026 09:27:01 -0400 Subject: [PATCH 09/10] Remove incorrect route value { id } from CreatedAtAction call --- .../Interview.Web/Controllers/ProductController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs index 89dd062..a9e43b1 100644 --- a/Development Project/Interview.Web/Controllers/ProductController.cs +++ b/Development Project/Interview.Web/Controllers/ProductController.cs @@ -35,7 +35,7 @@ public async Task CreateProduct([FromBody] CreateProductRequest r var id = await _productsService.CreateAsync(request); // EVAL: 201 Created with a Location header is the correct REST response for a successful // POST. CreatedAtAction wires the Location header to the GET endpoint automatically. - return CreatedAtAction(nameof(GetProducts), new { id }, new { InstanceId = id }); + return CreatedAtAction(nameof(GetProducts), null, new { InstanceId = id }); } /// From d97d6d6d8ebd6e23a37a152a6867b55484de0a6f Mon Sep 17 00:00:00 2001 From: Shane Freeman Date: Fri, 5 Jun 2026 09:31:17 -0400 Subject: [PATCH 10/10] comment refinement --- .../Controllers/InventoryController.cs | 52 ++++++++----------- .../Controllers/ProductController.cs | 33 +++++------- .../Models/AddInventoryRequest.cs | 6 +-- .../Models/CreateProductRequest.cs | 3 +- .../Models/InventoryCountRequest.cs | 5 +- .../Models/ProductSearchRequest.cs | 11 ++-- .../Implementations/InventoryRepository.cs | 13 ++--- .../Implementations/ProductRepository.cs | 34 ++++++------ .../Implementations/InventoryService.cs | 9 ++-- .../Implementations/ProductsService.cs | 7 ++- Development Project/Interview.Web/Startup.cs | 9 ++-- 11 files changed, 74 insertions(+), 108 deletions(-) diff --git a/Development Project/Interview.Web/Controllers/InventoryController.cs b/Development Project/Interview.Web/Controllers/InventoryController.cs index 5b4ab1b..9fcdc2d 100644 --- a/Development Project/Interview.Web/Controllers/InventoryController.cs +++ b/Development Project/Interview.Web/Controllers/InventoryController.cs @@ -16,38 +16,32 @@ public InventoryController(IInventoryService inventoryService) _inventoryService = inventoryService; } - /// - /// Add an inventory transaction for a product. - /// + /// Add an inventory transaction. /// - /// Use a positive Quantity to add stock and a negative Quantity to remove stock. - /// Each call creates a new transaction row which can be individually undone via DELETE. - /// The optional TypeCategory field can label the transaction (e.g. "RECEIVE", "SALE", "ADJUSTMENT"). + /// Positive Quantity adds stock, negative removes it. + /// Each call creates a transaction row that can be undone individually via DELETE. + /// TypeCategory is optional — use it to label the transaction e.g. "RECEIVE", "SALE". /// - /// The product ID, quantity, and optional type category. - /// The TransactionId of the newly created transaction. - /// Transaction created successfully. - /// Validation failed — quantity cannot be zero, product ID must be positive. + /// ProductInstanceId, Quantity, and optional TypeCategory. + /// The TransactionId of the new transaction. + /// Transaction created. + /// Quantity cannot be zero. ProductInstanceId must be positive. [HttpPost] [ProducesResponseType(typeof(object), 200)] [ProducesResponseType(400)] public async Task AddInventory([FromBody] AddInventoryRequest request) { var transactionId = await _inventoryService.AddAsync(request); - // EVAL: Returning the TransactionId gives the caller a handle to reference this - // specific transaction if they need to undo it via DELETE later. + // EVAL: Returning the TransactionId lets the caller reference this transaction for DELETE. return Ok(new { TransactionId = transactionId }); } - /// - /// Remove (undo) an inventory transaction by its ID. - /// + /// Remove (undo) a transaction. /// - /// Deleting a transaction permanently removes it and immediately corrects the inventory count. - /// This is the undo mechanism — the transaction row is deleted rather than flagged. + /// Permanently deletes the transaction row. The inventory count updates immediately. /// - /// The ID of the transaction to remove. - /// Transaction removed successfully. + /// The transaction to remove. + /// Transaction removed. /// TransactionId must be a positive integer. [HttpDelete("{transactionId}")] [ProducesResponseType(204)] @@ -55,23 +49,19 @@ public async Task AddInventory([FromBody] AddInventoryRequest req public async Task RemoveTransaction(int transactionId) { await _inventoryService.RemoveTransactionAsync(transactionId); - // EVAL: 204 No Content is the correct REST response for a successful DELETE - // that returns no body. + // EVAL: 204 No Content — correct response for a DELETE with no body. return NoContent(); } - /// - /// Retrieve the total inventory count for a product or set of products. - /// + /// Get total inventory count. /// - /// Filter by ProductInstanceId to get the count for a specific product. - /// Filter by AttributeKey and AttributeValue to aggregate inventory across all products - /// sharing that metadata (e.g. all products where color=red). - /// Omitting all filters returns the total inventory count across every product. + /// Filter by ProductInstanceId for a single product, or by AttributeKey/AttributeValue + /// to aggregate across all products with that metadata (e.g. all red products). + /// No filters returns the total across everything. /// - /// Optional filters: ProductInstanceId, AttributeKey, AttributeValue. - /// The total inventory count matching the filters. - /// Returns the inventory count. + /// ProductInstanceId, AttributeKey, AttributeValue — all optional. + /// Total inventory count. + /// Returns the count. [HttpGet("count")] [ProducesResponseType(typeof(object), 200)] public async Task GetCount([FromQuery] InventoryCountRequest request) diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs index a9e43b1..c2f8b82 100644 --- a/Development Project/Interview.Web/Controllers/ProductController.cs +++ b/Development Project/Interview.Web/Controllers/ProductController.cs @@ -16,38 +16,33 @@ public ProductController(IProductsService productsService) _productsService = productsService; } - /// - /// Add a new product to the system. - /// + /// Add a new product. /// - /// Products can never be deleted once created. Provide arbitrary metadata as key/value - /// pairs in Attributes and assign one or more CategoryIds to categorize the product. + /// Products are permanent — they cannot be deleted once created. + /// Pass arbitrary metadata in Attributes and assign CategoryIds to categorize the product. /// - /// The product details including name, description, SKUs, image URIs, attributes, and categories. - /// The InstanceId of the newly created product. - /// Product created successfully. - /// Validation failed — name is required and cannot exceed 256 characters. + /// Name, description, SKUs, image URIs, attributes, and category IDs. + /// The InstanceId of the new product. + /// Product created. + /// Name is required and cannot exceed 256 characters. [HttpPost] [ProducesResponseType(typeof(object), 201)] [ProducesResponseType(400)] public async Task CreateProduct([FromBody] CreateProductRequest request) { var id = await _productsService.CreateAsync(request); - // EVAL: 201 Created with a Location header is the correct REST response for a successful - // POST. CreatedAtAction wires the Location header to the GET endpoint automatically. + // EVAL: CreatedAtAction returns 201 and sets the Location header to the GET endpoint. return CreatedAtAction(nameof(GetProducts), null, new { InstanceId = id }); } - /// - /// Search for products using optional filters. - /// + /// Search for products. /// - /// All filters are optional. Omitting all filters returns every product in the system. - /// Filters are combined with AND — each additional filter narrows the result set. + /// All filters are optional — omitting them returns everything. + /// Multiple filters are combined with AND. /// - /// Optional filters: Name, Description, AttributeKey/AttributeValue, and CategoryIds. - /// A list of products matching the provided filters. - /// Returns the matching products. + /// Name, Description, AttributeKey/AttributeValue, CategoryIds. + /// Products matching the filters. + /// Returns matching products. [HttpGet] [ProducesResponseType(typeof(System.Collections.Generic.IEnumerable), 200)] public async Task GetProducts([FromQuery] ProductSearchRequest request) diff --git a/Development Project/Interview.Web/Models/AddInventoryRequest.cs b/Development Project/Interview.Web/Models/AddInventoryRequest.cs index f5f9df2..b596cb3 100644 --- a/Development Project/Interview.Web/Models/AddInventoryRequest.cs +++ b/Development Project/Interview.Web/Models/AddInventoryRequest.cs @@ -4,11 +4,9 @@ public class AddInventoryRequest { public int ProductInstanceId { get; set; } - // EVAL: Quantity is decimal to match DECIMAL(19,6) in the DB, supporting fractional - // units such as products sold by weight or volume rather than whole units only. + // EVAL: Decimal matches DECIMAL(19,6) in the DB — supports fractional units (weight, volume). public decimal Quantity { get; set; } - // EVAL: TypeCategory is optional and allows callers to label the transaction type - // (e.g. "RECEIVE", "ADJUSTMENT") for reporting without changing the schema. + // EVAL: Optional label for the transaction type e.g. "RECEIVE", "SALE", "ADJUSTMENT". public string TypeCategory { get; set; } } diff --git a/Development Project/Interview.Web/Models/CreateProductRequest.cs b/Development Project/Interview.Web/Models/CreateProductRequest.cs index 1d46e87..a293c15 100644 --- a/Development Project/Interview.Web/Models/CreateProductRequest.cs +++ b/Development Project/Interview.Web/Models/CreateProductRequest.cs @@ -9,8 +9,7 @@ public class CreateProductRequest public string[] ValidSkus { get; set; } public string[] ProductImageUris { get; set; } - // EVAL: Arbitrary key/value pairs map directly to Instances.ProductAttributes rows. - // This satisfies the requirement for products to support arbitrary metadata without schema changes. + // EVAL: Key/value pairs map to Instances.ProductAttributes rows — one row per entry. public Dictionary Attributes { get; set; } public int[] CategoryIds { get; set; } diff --git a/Development Project/Interview.Web/Models/InventoryCountRequest.cs b/Development Project/Interview.Web/Models/InventoryCountRequest.cs index cf44d00..18ef0bd 100644 --- a/Development Project/Interview.Web/Models/InventoryCountRequest.cs +++ b/Development Project/Interview.Web/Models/InventoryCountRequest.cs @@ -2,9 +2,8 @@ namespace Interview.Web.Models; public class InventoryCountRequest { - // EVAL: Either ProductInstanceId or AttributeKey/AttributeValue can be used to filter. - // ProductInstanceId targets a specific product; attribute filters aggregate across - // all products sharing that metadata (e.g. count all red products). + // EVAL: Filter by product ID for a specific count, or by attribute key/value to + // aggregate across all matching products (e.g. total stock of all red products). public int? ProductInstanceId { get; set; } public string AttributeKey { get; set; } public string AttributeValue { get; set; } diff --git a/Development Project/Interview.Web/Models/ProductSearchRequest.cs b/Development Project/Interview.Web/Models/ProductSearchRequest.cs index eb75b5b..be506ff 100644 --- a/Development Project/Interview.Web/Models/ProductSearchRequest.cs +++ b/Development Project/Interview.Web/Models/ProductSearchRequest.cs @@ -5,13 +5,10 @@ public class ProductSearchRequest public string Name { get; set; } public string Description { get; set; } - // EVAL: AttributeKey and AttributeValue are used instead of Dictionary - // because Dictionary does not bind correctly from query string parameters with [FromQuery]. - // A single key/value pair satisfies the spec requirement of searchable by metadata. - // To support multiple attribute filters in the future, these could be changed to - // string[] AttributeKeys and string[] AttributeValues using repeated query parameters - // (e.g. ?attributeKeys=color&attributeKeys=brand&attributeValues=red&attributeValues=Nike) - // without breaking existing callers using a single pair. + // EVAL: Dictionary doesn't bind from [FromQuery] so flat strings are used + // instead. To support multiple attribute filters, these could become string[] AttributeKeys + // and string[] AttributeValues using repeated params (?attributeKeys=color&attributeKeys=brand) + // without breaking existing callers. public string AttributeKey { get; set; } public string AttributeValue { get; set; } diff --git a/Development Project/Interview.Web/Repositories/Implementations/InventoryRepository.cs b/Development Project/Interview.Web/Repositories/Implementations/InventoryRepository.cs index f50f79b..bb554d5 100644 --- a/Development Project/Interview.Web/Repositories/Implementations/InventoryRepository.cs +++ b/Development Project/Interview.Web/Repositories/Implementations/InventoryRepository.cs @@ -39,9 +39,8 @@ public async Task RemoveTransactionAsync(int transactionId) { await _sqlExecutor.ExecuteAsync(async (conn, trans) => { - // EVAL: Deleting the transaction row is the "undo" mechanism per the spec requirement - // that individual transactions should be able to be removed. The inventory count - // query only sums active rows so removing a row immediately corrects the count. + // EVAL: Hard delete is the undo mechanism — removing the row immediately + // corrects the inventory count since the SUM query only touches existing rows. const string sql = @" DELETE FROM Transactions.InventoryTransactions WHERE TransactionId = @TransactionId"; @@ -54,8 +53,7 @@ public async Task GetCountAsync(InventoryCountRequest request) { return await _sqlExecutor.ExecuteAsync(async (conn, trans) => { - // EVAL: ISNULL(..., 0) guards against NULL being returned when no transactions exist - // for a product, which would cause Dapper to return 0m rather than throw. + // EVAL: ISNULL guards against SUM returning NULL on an empty set. const string baseSelect = @" SELECT ISNULL(SUM(t.Quantity), 0) FROM Transactions.InventoryTransactions t"; @@ -66,9 +64,8 @@ SELECT ISNULL(SUM(t.Quantity), 0) if (request.ProductInstanceId.HasValue) query.WhereEquals("ProductInstanceId", "ProductInstanceId", request.ProductInstanceId.Value); - // EVAL: Attribute-based count joins back to ProductAttributes so callers can ask - // "how many units do I have across all products with color=red" without knowing - // the specific product IDs. + // EVAL: EXISTS lets callers aggregate stock across all products sharing an attribute + // (e.g. total units of all red products) without needing to know their IDs. if (!string.IsNullOrWhiteSpace(request.AttributeKey) && !string.IsNullOrWhiteSpace(request.AttributeValue)) { query.Where($@"EXISTS ( diff --git a/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs b/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs index 7fc817f..6ee57f1 100644 --- a/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs +++ b/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs @@ -22,8 +22,8 @@ public async Task CreateAsync(CreateProductRequest request) { return await _sqlExecutor.ExecuteAsync(async (conn, trans) => { - // EVAL: SCOPE_IDENTITY() returns the auto-incremented InstanceId generated by the - // INSERT, scoped to the current connection so concurrent inserts don't collide. + // EVAL: SCOPE_IDENTITY() is scoped to the current connection, so concurrent + // inserts on other connections won't affect the returned id. const string insertProduct = @" INSERT INTO Instances.Products (Name, Description, ValidSkus, ProductImageUris) VALUES (@Name, @Description, @ValidSkus, @ProductImageUris); @@ -33,8 +33,8 @@ INSERT INTO Instances.Products (Name, Description, ValidSkus, ProductImageUris) { request.Name, Description = request.Description ?? string.Empty, - // EVAL: Arrays are stored as comma-separated strings to match the VARCHAR(MAX) - // schema design. The schema allows this without additional tables. + // EVAL: SKUs and image URIs are stored comma-separated to match the VARCHAR(MAX) + // columns — no additional tables needed. ValidSkus = string.Join(",", request.ValidSkus ?? Array.Empty()), ProductImageUris = string.Join(",", request.ProductImageUris ?? Array.Empty()) }, trans); @@ -80,9 +80,8 @@ public async Task> SearchAsync(ProductSearchRequest { return await _sqlExecutor.ExecuteAsync(async (conn, trans) => { - // EVAL: SqlServerQueryProvider builds the WHERE clause dynamically based on which - // filters are provided. If no filters are set, WhereClause returns an empty string - // and the query returns all products — no special branch needed. + // EVAL: WhereClause returns empty string when no filters are set, + // so the query naturally returns all products without a separate branch. var query = SqlServerQueryProvider.Empty; query.SetTargetTableAlias("p"); @@ -92,9 +91,8 @@ public async Task> SearchAsync(ProductSearchRequest if (!string.IsNullOrWhiteSpace(request?.Description)) query.WhereEquals("Description", "Description", request.Description); - // EVAL: EXISTS subquery for attribute filtering is more efficient than a JOIN because - // it short-circuits on the first matching row rather than multiplying result rows - // when a product has multiple attributes. + // EVAL: EXISTS avoids row duplication that a JOIN would cause when a product + // has multiple attributes, and short-circuits on the first match. if (!string.IsNullOrWhiteSpace(request?.AttributeKey) && !string.IsNullOrWhiteSpace(request?.AttributeValue)) { query.Where($@"EXISTS ( @@ -107,8 +105,8 @@ SELECT 1 FROM Instances.ProductAttributes pa query.AddParameter("@AttributeValue", request.AttributeValue); } - // EVAL: IN subquery for category filtering lets us match products belonging to - // any of the requested categories without duplicating product rows via JOIN. + // EVAL: IN subquery returns products in any of the requested categories + // without duplicating rows the way a JOIN would. if (request?.CategoryIds != null && request.CategoryIds.Any()) { var categoryIdList = string.Join(",", request.CategoryIds); @@ -119,7 +117,8 @@ SELECT 1 FROM Instances.ProductAttributes pa var products = await conn.QueryAsync(sql, query.Parameters, trans); - // Load attributes for each product and map to ProductResponse + // EVAL: N+1 query per product — acceptable here, but in production this would + // be replaced with a single JOIN query grouped by InstanceId. var responses = new List(); foreach (var product in products) { @@ -145,9 +144,8 @@ FROM Instances.ProductAttributes return rows.ToDictionary(r => r.Key, r => r.Value); } - // EVAL: Mapping from the internal Product DB model to the public ProductResponse keeps - // the database schema decoupled from the API contract. If the DB changes, only this - // method needs updating — controllers and services stay untouched. + // EVAL: Keeps the DB model decoupled from the API contract — schema changes stay + // contained here and don't ripple up to services or controllers. private static ProductResponse MapToResponse(Product product, Dictionary attributes) { return new ProductResponse @@ -162,9 +160,7 @@ private static ProductResponse MapToResponse(Product product, Dictionary AddAsync(AddInventoryRequest request) { PreConditions.ParameterNotNull(request, nameof(request)); - // EVAL: ProductInstanceId must be a positive integer — zero or negative values - // cannot reference a valid auto-incremented database identity. + // EVAL: Identity columns start at 1 — zero or negative can't reference a real row. if (request.ProductInstanceId <= 0) throw new System.ArgumentException("ProductInstanceId must be a positive integer.", nameof(request.ProductInstanceId)); - // EVAL: Quantity of zero has no effect on inventory and is most likely a caller error. - // Negative quantities are valid for removals so we only reject zero. + // EVAL: Zero quantity has no effect and is almost always a caller mistake. + // Negatives are allowed — that's how stock removal is recorded. if (request.Quantity == 0) throw new System.ArgumentException("Quantity cannot be zero.", nameof(request.Quantity)); @@ -34,7 +33,7 @@ public async Task AddAsync(AddInventoryRequest request) public async Task RemoveTransactionAsync(int transactionId) { - // EVAL: TransactionId must be positive — same reasoning as ProductInstanceId above. + // EVAL: Same positive-integer rule as ProductInstanceId. if (transactionId <= 0) throw new System.ArgumentException("TransactionId must be a positive integer.", nameof(transactionId)); diff --git a/Development Project/Interview.Web/Services/Implementations/ProductsService.cs b/Development Project/Interview.Web/Services/Implementations/ProductsService.cs index f7ff60a..88763d3 100644 --- a/Development Project/Interview.Web/Services/Implementations/ProductsService.cs +++ b/Development Project/Interview.Web/Services/Implementations/ProductsService.cs @@ -18,8 +18,7 @@ public ProductsService(IProductRepository productRepository) public async Task CreateAsync(CreateProductRequest request) { - // EVAL: PreConditions from Sparcpoint.Core centralizes guard clause logic so every - // service can validate inputs consistently without duplicating null/whitespace checks. + // EVAL: PreConditions from Sparcpoint.Core keeps guard clauses consistent across services. PreConditions.ParameterNotNull(request, nameof(request)); PreConditions.StringNotNullOrWhitespace(request.Name, nameof(request.Name)); @@ -34,8 +33,8 @@ public async Task CreateAsync(CreateProductRequest request) public async Task> SearchAsync(ProductSearchRequest request) { - // EVAL: Passing null or empty request returns all products — no special branch needed - // because the repository builds WHERE clauses only for non-null fields. + // EVAL: Empty request returns all products — the repository only adds WHERE clauses + // for fields that have values, so no special "get all" branch is needed. return await _productRepository.SearchAsync(request); } } diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs index c9f7b5c..420a50e 100644 --- a/Development Project/Interview.Web/Startup.cs +++ b/Development Project/Interview.Web/Startup.cs @@ -25,20 +25,17 @@ public void ConfigureServices(IServiceCollection services) services.AddControllers(); services.AddSwaggerGen(c => { - // EVAL: Including the XML documentation file allows Swagger UI to display - // the summary and remarks from /// comments on each controller action. + // EVAL: Wires /// XML comments to Swagger UI so endpoint docs appear at runtime. var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlPath = System.IO.Path.Combine(System.AppContext.BaseDirectory, xmlFile); c.IncludeXmlComments(xmlPath); }); - // EVAL: SqlServerOptions is bound from appsettings.json so the connection string - // is configurable per environment without code changes (Open/Closed principle). + // EVAL: Connection string is config-driven — no code changes needed per environment. var sqlOptions = Configuration.GetSection("SqlServer").Get(); services.AddSingleton(new SqlServerExecutor(sqlOptions.ConnectionString)); - // EVAL: Scoped lifetime for repository and service means one instance per HTTP request, - // which is appropriate for database-backed services. + // EVAL: Scoped gives each request its own instance — right for DB-backed services. services.AddScoped(); services.AddScoped(); services.AddScoped();