From 774990b4045fc943a965d6f9c39c504fd945aac1 Mon Sep 17 00:00:00 2001 From: Luis Rodriguez Date: Tue, 28 Apr 2026 18:52:29 -0500 Subject: [PATCH 1/8] feat: implement inventory management system - Add Sparcpoint.Inventory.Abstractions (models, interfaces, filters, requests) - Add Sparcpoint.Inventory.SqlServer (Dapper repos, DI extension) - Add Sparcpoint.Inventory.Tests (xUnit + Moq, guard clause coverage) - Fix SqlServerExecutor: add rollback on exception - Fix SqlServerExecutor: migrate to Microsoft.Data.SqlClient - Fix JoinTables: sanitize tableName/abbv inputs - Fix SqlServerValidation: use RegexOptions.Compiled - Wire Startup.cs: DI, Swagger - Implement ProductController, InventoryController, CategoryController --- Development Project/Development Project.sln | 30 ++++ .../Controllers/CategoryController.cs | 48 +++++ .../Controllers/InventoryController.cs | 154 ++++++++++++++++ .../Controllers/ProductController.cs | 70 +++++++- .../Interview.Web/Interview.Web.csproj | 9 + Development Project/Interview.Web/Startup.cs | 32 ++-- .../Interview.Web/appsettings.json | 5 +- .../Filters/ProductSearchFilter.cs | 15 ++ .../Interfaces/ICategoryRepository.cs | 19 ++ .../Interfaces/IInventoryRepository.cs | 46 +++++ .../Interfaces/IProductRepository.cs | 24 +++ .../Models/Category.cs | 18 ++ .../Models/InventoryTransaction.cs | 19 ++ .../Models/Product.cs | 26 +++ .../Models/ProductAttribute.cs | 12 ++ .../Models/ProductInventoryCount.cs | 12 ++ .../Requests/CreateCategoryRequest.cs | 17 ++ .../Requests/CreateProductRequest.cs | 22 +++ .../Requests/InventoryBatchItem.cs | 12 ++ .../Sparcpoint.Inventory.Abstractions.csproj | 6 + .../Extensions/ServiceCollectionExtensions.cs | 32 ++++ .../Sparcpoint.Inventory.SqlServer.csproj | 18 ++ .../SqlCategoryRepository.cs | 92 ++++++++++ .../SqlInventoryRepository.cs | 163 +++++++++++++++++ .../SqlProductRepository.cs | 164 ++++++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 32 ++++ .../Sparcpoint.Inventory.Tests.csproj | 27 +++ .../SqlInventoryRepositoryTests.cs | 56 ++++++ .../SqlProductRepositoryTests.cs | 54 ++++++ .../ISqlExecutor.cs | 1 - .../Provider/JoinTables.cs | 6 +- .../Provider/SqlServerValidation.cs | 4 +- .../Sparcpoint.SqlServer.Abstractions.csproj | 2 +- .../SqlServerExecutor.cs | 50 ++++-- .../TransactionSqlServerExecutor.cs | 2 +- 35 files changed, 1262 insertions(+), 37 deletions(-) create mode 100644 Development Project/Interview.Web/Controllers/CategoryController.cs create mode 100644 Development Project/Interview.Web/Controllers/InventoryController.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/ICategoryRepository.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IInventoryRepository.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IProductRepository.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Models/Category.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Models/InventoryTransaction.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Models/Product.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductAttribute.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductInventoryCount.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateProductRequest.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryBatchItem.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Sparcpoint.Inventory.Abstractions.csproj create mode 100644 Development Project/Sparcpoint.Inventory.SqlServer/Extensions/ServiceCollectionExtensions.cs create mode 100644 Development Project/Sparcpoint.Inventory.SqlServer/Sparcpoint.Inventory.SqlServer.csproj create mode 100644 Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs create mode 100644 Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs create mode 100644 Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs create mode 100644 Development Project/Sparcpoint.Inventory.Tests/ServiceCollectionExtensionsTests.cs create mode 100644 Development Project/Sparcpoint.Inventory.Tests/Sparcpoint.Inventory.Tests.csproj create mode 100644 Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs create mode 100644 Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositoryTests.cs diff --git a/Development Project/Development Project.sln b/Development Project/Development Project.sln index 637bfd9..1c6fa33 100644 --- a/Development Project/Development Project.sln +++ b/Development Project/Development Project.sln @@ -11,6 +11,12 @@ 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("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Inventory.Abstractions", "Sparcpoint.Inventory.Abstractions\Sparcpoint.Inventory.Abstractions.csproj", "{D1E2F3A4-B5C6-7890-ABCD-EF1234567891}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Inventory.SqlServer", "Sparcpoint.Inventory.SqlServer\Sparcpoint.Inventory.SqlServer.csproj", "{E2F3A4B5-C6D7-8901-BCDE-F12345678902}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Inventory.Tests", "Sparcpoint.Inventory.Tests\Sparcpoint.Inventory.Tests.csproj", "{F3A4B5C6-D7E8-9012-CDEF-123456789013}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +61,30 @@ 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 + {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Debug|x86.Build.0 = Debug|Any CPU + {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Release|Any CPU.Build.0 = Release|Any CPU + {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Release|x86.ActiveCfg = Release|Any CPU + {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Release|x86.Build.0 = Release|Any CPU + {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Debug|x86.ActiveCfg = Debug|Any CPU + {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Debug|x86.Build.0 = Debug|Any CPU + {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Release|Any CPU.Build.0 = Release|Any CPU + {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Release|x86.ActiveCfg = Release|Any CPU + {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Release|x86.Build.0 = Release|Any CPU + {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Debug|x86.ActiveCfg = Debug|Any CPU + {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Debug|x86.Build.0 = Debug|Any CPU + {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Release|Any CPU.Build.0 = Release|Any CPU + {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Release|x86.ActiveCfg = Release|Any CPU + {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Development Project/Interview.Web/Controllers/CategoryController.cs b/Development Project/Interview.Web/Controllers/CategoryController.cs new file mode 100644 index 0000000..160a015 --- /dev/null +++ b/Development Project/Interview.Web/Controllers/CategoryController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Sparcpoint.Inventory.Abstractions; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Interview.Web.Controllers +{ + /// + /// Manages product categories including hierarchical parent-child relationships. + /// + [ApiController] + [Route("api/v1/categories")] + public class CategoryController : ControllerBase + { + private readonly ICategoryRepository _Categories; + + public CategoryController(ICategoryRepository categories) + { + _Categories = categories ?? throw new ArgumentNullException(nameof(categories)); + } + + /// + /// Creates a new category. Optionally nest it under existing parent categories + /// by supplying ParentCategoryIds. Hierarchy is stored as an adjacency list. + /// + [HttpPost] + [ProducesResponseType(typeof(int), 201)] + [ProducesResponseType(400)] + public async Task AddCategory([FromBody] CreateCategoryRequest request) + { + var categoryId = await _Categories.AddAsync(request); + return StatusCode(201, categoryId); + } + + /// + /// Returns all categories. Hierarchy is represented via ParentCategoryIds on each entry; + /// clients reconstruct the tree from this flat list. + /// + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), 200)] + public async Task GetAll() + { + var categories = await _Categories.GetAllAsync(); + return Ok(categories); + } + } +} diff --git a/Development Project/Interview.Web/Controllers/InventoryController.cs b/Development Project/Interview.Web/Controllers/InventoryController.cs new file mode 100644 index 0000000..e2285ad --- /dev/null +++ b/Development Project/Interview.Web/Controllers/InventoryController.cs @@ -0,0 +1,154 @@ +using Microsoft.AspNetCore.Mvc; +using Sparcpoint.Inventory.Abstractions; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Interview.Web.Controllers +{ + /// + /// Manages inventory transactions: adding stock, removing stock, + /// undoing individual transactions, and querying counts. + /// + [ApiController] + [Route("api/v1/inventory")] + public class InventoryController : ControllerBase + { + private readonly IInventoryRepository _Inventory; + + public InventoryController(IInventoryRepository inventory) + { + _Inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); + } + + /// + /// Adds stock for a single product. Returns the new TransactionId. + /// + [HttpPost("{productId:int}/add")] + [ProducesResponseType(typeof(int), 201)] + [ProducesResponseType(400)] + public async Task AddInventory( + [FromRoute] int productId, + [FromBody] InventoryAdjustRequest request) + { + var transactionId = await _Inventory.AddAsync(productId, request.Quantity, request.TypeCategory); + return StatusCode(201, transactionId); + } + + /// + /// Adds stock for multiple products in a single atomic operation. + /// All items succeed or all roll back. + /// + [HttpPost("batch/add")] + [ProducesResponseType(204)] + [ProducesResponseType(400)] + public async Task AddInventoryBatch([FromBody] IEnumerable items) + { + await _Inventory.AddBatchAsync(items); + return NoContent(); + } + + /// + /// Removes stock for a single product. Returns the new TransactionId. + /// + [HttpPost("{productId:int}/remove")] + [ProducesResponseType(typeof(int), 201)] + [ProducesResponseType(400)] + public async Task RemoveInventory( + [FromRoute] int productId, + [FromBody] InventoryAdjustRequest request) + { + var transactionId = await _Inventory.RemoveAsync(productId, request.Quantity, request.TypeCategory); + return StatusCode(201, transactionId); + } + + /// + /// Removes stock for multiple products in a single atomic operation. + /// + [HttpPost("batch/remove")] + [ProducesResponseType(204)] + [ProducesResponseType(400)] + public async Task RemoveInventoryBatch([FromBody] IEnumerable items) + { + await _Inventory.RemoveBatchAsync(items); + return NoContent(); + } + + /// + /// Deletes a specific transaction, effectively undoing its inventory effect. + /// EVAL: Deleting the row is the undo mechanism — cleaner than a compensating transaction + /// because it leaves no audit noise for a user-initiated correction. + /// + [HttpDelete("transactions/{transactionId:int}")] + [ProducesResponseType(204)] + [ProducesResponseType(404)] + public async Task DeleteTransaction([FromRoute] int transactionId) + { + try + { + await _Inventory.DeleteTransactionAsync(transactionId); + return NoContent(); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + /// + /// Returns the current net inventory count (sum of all completed transactions) for a product. + /// + [HttpGet("{productId:int}/count")] + [ProducesResponseType(typeof(decimal), 200)] + public async Task GetCount([FromRoute] int productId) + { + var count = await _Inventory.GetCountAsync(productId); + return Ok(count); + } + + /// + /// Returns inventory counts for all products matching the given filter. + /// Useful for querying stock levels across a category or metadata subset. + /// + [HttpGet("count")] + [ProducesResponseType(typeof(IEnumerable), 200)] + public async Task GetCountByFilter( + [FromQuery] string name = null, + [FromQuery] int[] categoryIds = null, + [FromQuery] string attributeKey = null, + [FromQuery] string attributeValue = null) + { + var filter = new ProductSearchFilter + { + Name = name, + CategoryIds = categoryIds?.Length > 0 ? categoryIds : null + }; + + if (!string.IsNullOrWhiteSpace(attributeKey) && !string.IsNullOrWhiteSpace(attributeValue)) + { + filter.Attributes = new Dictionary + { + [attributeKey] = attributeValue + }; + } + + var counts = await _Inventory.GetCountByFilterAsync(filter); + return Ok(counts); + } + } + + /// + /// Request body for a single-product inventory adjustment. + /// + public class InventoryAdjustRequest + { + /// Must be a positive value regardless of operation direction. + public decimal Quantity { get; set; } + + /// + /// Optional classification (e.g. "SALE", "RETURN", "ADJUSTMENT"). + /// Maps to Transactions.InventoryTransactions.TypeCategory. + /// + public string TypeCategory { get; set; } + } +} diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs index 267f4ec..e4509e2 100644 --- a/Development Project/Interview.Web/Controllers/ProductController.cs +++ b/Development Project/Interview.Web/Controllers/ProductController.cs @@ -1,19 +1,75 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; +using Sparcpoint.Inventory.Abstractions; using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; namespace Interview.Web.Controllers { + /// + /// Manages product catalog operations: creating and searching products. + /// + // EVAL: [ApiController] enables automatic model validation (400 on invalid requests) + // and removes the need for explicit [FromBody] on complex POST parameters. + // ControllerBase is used instead of Controller — no Razor View support needed for a pure API. + [ApiController] [Route("api/v1/products")] - public class ProductController : Controller + public class ProductController : ControllerBase { - // NOTE: Sample Action - [HttpGet] - public Task GetAllProducts() + private readonly IProductRepository _Products; + + public ProductController(IProductRepository products) { - return Task.FromResult((IActionResult)Ok(new object[] { })); + _Products = products ?? throw new ArgumentNullException(nameof(products)); + } + + /// + /// Adds a new product with its metadata and category assignments. + /// Products cannot be deleted once added (by design per requirements). + /// + [HttpPost] + [ProducesResponseType(typeof(int), 201)] + [ProducesResponseType(400)] + public async Task AddProduct([FromBody] CreateProductRequest request) + { + var productId = await _Products.AddAsync(request); + return CreatedAtAction(nameof(SearchProducts), new { }, productId); + } + + /// + /// Searches products by any combination of name, metadata attributes, and categories. + /// All supplied fields are combined with AND semantics. + /// + /// Optional partial name match (case-insensitive LIKE). + /// Optional category IDs — product must belong to at least one. + /// Optional metadata key to filter by. + /// Optional metadata value to filter by (paired with attributeKey). + [HttpGet("search")] + [ProducesResponseType(typeof(IEnumerable), 200)] + public async Task SearchProducts( + [FromQuery] string name = null, + [FromQuery] int[] categoryIds = null, + [FromQuery] string attributeKey = null, + [FromQuery] string attributeValue = null) + { + var filter = new ProductSearchFilter + { + Name = name, + CategoryIds = categoryIds?.Length > 0 ? categoryIds : null + }; + + // EVAL: Single key/value attribute filter via query string for simplicity. + // A production API would use a POST body for richer multi-attribute searches. + if (!string.IsNullOrWhiteSpace(attributeKey) && !string.IsNullOrWhiteSpace(attributeValue)) + { + filter.Attributes = new Dictionary + { + [attributeKey] = attributeValue + }; + } + + var results = await _Products.SearchAsync(filter); + return Ok(results); } } } diff --git a/Development Project/Interview.Web/Interview.Web.csproj b/Development Project/Interview.Web/Interview.Web.csproj index d1e4d6e..d48fa40 100644 --- a/Development Project/Interview.Web/Interview.Web.csproj +++ b/Development Project/Interview.Web/Interview.Web.csproj @@ -4,4 +4,13 @@ net8.0 + + + + + + + + + diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs index 56452f2..3108842 100644 --- a/Development Project/Interview.Web/Startup.cs +++ b/Development Project/Interview.Web/Startup.cs @@ -1,13 +1,9 @@ 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.Inventory.SqlServer; namespace Interview.Web { @@ -20,31 +16,45 @@ public Startup(IConfiguration 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(); + + // EVAL: Swagger is registered to allow interactive API exploration during evaluation. + // In a production deployment, this would be gated behind an environment check. + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo + { + Title = "Inventory Management API", + Version = "v1" + }); + }); + + // EVAL: All inventory repositories are wired via a single extension method. + // Adding a new repository only requires a change in ServiceCollectionExtensions, + // not in every host project's Startup. + var connectionString = Configuration.GetConnectionString("InventoryDatabase"); + services.AddSqlInventoryServices(connectionString); } - // 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(); + app.UseSwagger(); + app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Inventory API v1")); } 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(); } app.UseHttpsRedirection(); - app.UseStaticFiles(); - app.UseRouting(); - app.UseAuthorization(); app.UseEndpoints(endpoints => diff --git a/Development Project/Interview.Web/appsettings.json b/Development Project/Interview.Web/appsettings.json index d9d9a9b..15b8778 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": "*", + "ConnectionStrings": { + "InventoryDatabase": "Server=.;Database=SparcpointInventory;Trusted_Connection=True;TrustServerCertificate=True;" + } } diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs new file mode 100644 index 0000000..81f4500 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Sparcpoint.Inventory.Abstractions +{ + /// + /// All fields are optional. Only non-null/non-empty fields are applied as filters. + /// Multiple attribute entries are combined with AND semantics. + /// + public class ProductSearchFilter + { + public string Name { get; set; } + public IDictionary Attributes { get; set; } + public IEnumerable CategoryIds { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/ICategoryRepository.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/ICategoryRepository.cs new file mode 100644 index 0000000..bee05b1 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/ICategoryRepository.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Sparcpoint.Inventory.Abstractions +{ + public interface ICategoryRepository + { + /// + /// Creates a category, optionally linking it to parent categories. + /// Returns the new category's InstanceId. + /// + Task AddAsync(CreateCategoryRequest request); + + /// + /// Returns all categories in the system. + /// + Task> GetAllAsync(); + } +} diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IInventoryRepository.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IInventoryRepository.cs new file mode 100644 index 0000000..f6f350f --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IInventoryRepository.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Sparcpoint.Inventory.Abstractions +{ + public interface IInventoryRepository + { + /// + /// Records stock being added for a product. Quantity must be positive. + /// Returns the new TransactionId. + /// + Task AddAsync(int productInstanceId, decimal quantity, string typeCategory = null); + + /// + /// Bulk add — all items processed within a single transaction. + /// + Task AddBatchAsync(IEnumerable items); + + /// + /// Records stock being removed. Quantity must be positive (stored as negative internally). + /// Returns the new TransactionId. + /// + Task RemoveAsync(int productInstanceId, decimal quantity, string typeCategory = null); + + /// + /// Bulk remove — all items processed within a single transaction. + /// + Task RemoveBatchAsync(IEnumerable items); + + /// + /// EVAL: "Undo" mechanism — deleting the transaction row reverses its effect + /// on inventory count without requiring a compensating transaction. + /// + Task DeleteTransactionAsync(int transactionId); + + /// + /// Returns current net quantity for a single product. + /// + Task GetCountAsync(int productInstanceId); + + /// + /// Returns net quantities for all products matching the given filter. + /// + Task> GetCountByFilterAsync(ProductSearchFilter filter); + } +} diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IProductRepository.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IProductRepository.cs new file mode 100644 index 0000000..7cada7d --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IProductRepository.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Sparcpoint.Inventory.Abstractions +{ + /// + /// EVAL: Repository pattern — isolates data access behind a clean interface, + /// allowing SQL Server to be swapped for another store without touching callers. + /// + public interface IProductRepository + { + /// + /// Adds a new product with its metadata and category assignments. + /// Returns the new product's InstanceId. + /// + Task AddAsync(CreateProductRequest request); + + /// + /// Searches products by any combination of name, attributes, and categories. + /// All supplied filter fields are applied with AND semantics. + /// + Task> SearchAsync(ProductSearchFilter filter); + } +} diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Models/Category.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Models/Category.cs new file mode 100644 index 0000000..bd5241d --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Models/Category.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; + +namespace Sparcpoint.Inventory.Abstractions +{ + /// + /// Represents a product category. Categories support hierarchy via + /// Instances.CategoryCategories (adjacency-list pattern). + /// + public class Category + { + public int InstanceId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public DateTime CreatedTimestamp { get; set; } + public List ParentCategoryIds { get; set; } = new List(); + } +} diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Models/InventoryTransaction.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Models/InventoryTransaction.cs new file mode 100644 index 0000000..4ed4e38 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Models/InventoryTransaction.cs @@ -0,0 +1,19 @@ +using System; + +namespace Sparcpoint.Inventory.Abstractions +{ + /// + /// Represents a single inventory movement. + /// Positive Quantity = stock added; Negative Quantity = stock removed. + /// Deleting a transaction row is the "undo" mechanism (requirement 6). + /// + 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/Sparcpoint.Inventory.Abstractions/Models/Product.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Models/Product.cs new file mode 100644 index 0000000..63afc03 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Models/Product.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace Sparcpoint.Inventory.Abstractions +{ + /// + /// Represents a product instance in the inventory system. + /// + public class Product + { + public int InstanceId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + + // EVAL: ProductImageUris and ValidSkus stored as raw strings (JSON/CSV). + // A future improvement would normalize these into child tables for indexability. + public string ProductImageUris { get; set; } + public string ValidSkus { get; set; } + public DateTime CreatedTimestamp { get; set; } + + // EVAL: Arbitrary metadata — loaded separately after the main product query + // to avoid a Cartesian join explosion on multi-attribute products. + public List Attributes { get; set; } = new List(); + public List CategoryIds { get; set; } = new List(); + } +} diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductAttribute.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductAttribute.cs new file mode 100644 index 0000000..bfaa0d7 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductAttribute.cs @@ -0,0 +1,12 @@ +namespace Sparcpoint.Inventory.Abstractions +{ + /// + /// A single key/value metadata entry attached to a product. + /// Maps to Instances.ProductAttributes table. + /// + public class ProductAttribute + { + public string Key { get; set; } + public string Value { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductInventoryCount.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductInventoryCount.cs new file mode 100644 index 0000000..7db1026 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductInventoryCount.cs @@ -0,0 +1,12 @@ +namespace Sparcpoint.Inventory.Abstractions +{ + /// + /// Aggregated inventory count for a single product. + /// + public class ProductInventoryCount + { + public int ProductInstanceId { get; set; } + public string ProductName { get; set; } + public decimal TotalQuantity { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs new file mode 100644 index 0000000..319b3fe --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace Sparcpoint.Inventory.Abstractions +{ + public class CreateCategoryRequest + { + public string Name { get; set; } + public string Description { get; set; } + + /// + /// Parent category IDs — supports hierarchical categorization. + /// Stored in Instances.CategoryCategories. + /// + public IEnumerable ParentCategoryIds { get; set; } = new List(); + public IDictionary Attributes { get; set; } = new Dictionary(); + } +} diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateProductRequest.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateProductRequest.cs new file mode 100644 index 0000000..7abf4f7 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateProductRequest.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace Sparcpoint.Inventory.Abstractions +{ + public class CreateProductRequest + { + public string Name { get; set; } + public string Description { get; set; } + public string ProductImageUris { get; set; } = string.Empty; + public string ValidSkus { get; set; } = string.Empty; + + /// + /// Arbitrary key/value metadata for this product (e.g. Color, Brand, SKU). + /// + public IDictionary Attributes { get; set; } = new Dictionary(); + + /// + /// IDs of categories this product belongs to. + /// + public IEnumerable CategoryIds { get; set; } = new List(); + } +} diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryBatchItem.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryBatchItem.cs new file mode 100644 index 0000000..eb66d5d --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryBatchItem.cs @@ -0,0 +1,12 @@ +namespace Sparcpoint.Inventory.Abstractions +{ + /// + /// A single item in a bulk inventory operation. + /// + public class InventoryBatchItem + { + public int ProductInstanceId { get; set; } + public decimal Quantity { get; set; } + public string TypeCategory { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Sparcpoint.Inventory.Abstractions.csproj b/Development Project/Sparcpoint.Inventory.Abstractions/Sparcpoint.Inventory.Abstractions.csproj new file mode 100644 index 0000000..dcf06bf --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Sparcpoint.Inventory.Abstractions.csproj @@ -0,0 +1,6 @@ + + + netstandard2.0 + 8.0 + + diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/Extensions/ServiceCollectionExtensions.cs b/Development Project/Sparcpoint.Inventory.SqlServer/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..f354feb --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.SqlServer/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using Sparcpoint.Inventory.Abstractions; +using Sparcpoint.SqlServer.Abstractions; +using System; + +namespace Sparcpoint.Inventory.SqlServer +{ + /// + /// EVAL: Extension method pattern keeps Startup.cs clean and makes the inventory + /// feature a single line of registration that any host project can call. + /// New repositories added in the future only require changes here. + /// + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddSqlInventoryServices( + this IServiceCollection services, + string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("A SQL Server connection string is required.", nameof(connectionString)); + + // ISqlExecutor is registered as Scoped so each HTTP request gets its own connection. + services.AddScoped(_ => new SqlServerExecutor(connectionString)); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + } +} diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/Sparcpoint.Inventory.SqlServer.csproj b/Development Project/Sparcpoint.Inventory.SqlServer/Sparcpoint.Inventory.SqlServer.csproj new file mode 100644 index 0000000..521be02 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.SqlServer/Sparcpoint.Inventory.SqlServer.csproj @@ -0,0 +1,18 @@ + + + netstandard2.0 + 8.0 + + + + + + + + + + + + + + diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs b/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs new file mode 100644 index 0000000..1672788 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs @@ -0,0 +1,92 @@ +using Dapper; +using Sparcpoint.Inventory.Abstractions; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Sparcpoint.Inventory.SqlServer +{ + public class SqlCategoryRepository : ICategoryRepository + { + private readonly ISqlExecutor _Executor; + + public SqlCategoryRepository(ISqlExecutor executor) + { + _Executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task AddAsync(CreateCategoryRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + PreConditions.StringNotNullOrWhitespace(request.Name, nameof(request.Name)); + PreConditions.StringNotNullOrWhitespace(request.Description, nameof(request.Description)); + + return await _Executor.ExecuteAsync(async (conn, tx) => + { + var categoryId = await conn.ExecuteScalarAsync(@" + INSERT INTO [Instances].[Categories] ([Name], [Description]) + VALUES (@Name, @Description); + SELECT CAST(SCOPE_IDENTITY() AS INT);", + new { request.Name, request.Description }, tx); + + // Link to parent categories for hierarchy support + if (request.ParentCategoryIds != null) + { + foreach (var parentId in request.ParentCategoryIds) + { + await conn.ExecuteAsync(@" + INSERT INTO [Instances].[CategoryCategories] ([InstanceId], [CategoryInstanceId]) + VALUES (@InstanceId, @CategoryInstanceId)", + new { InstanceId = categoryId, CategoryInstanceId = parentId }, tx); + } + } + + if (request.Attributes != null) + { + foreach (var attr in request.Attributes) + { + await conn.ExecuteAsync(@" + INSERT INTO [Instances].[CategoryAttributes] ([InstanceId], [Key], [Value]) + VALUES (@InstanceId, @Key, @Value)", + new { InstanceId = categoryId, attr.Key, attr.Value }, tx); + } + } + + return categoryId; + }); + } + + public async Task> GetAllAsync() + { + return await _Executor.ExecuteAsync(async (conn, tx) => + { + var categories = (await conn.QueryAsync(@" + SELECT [InstanceId], [Name], [Description], [CreatedTimestamp] + FROM [Instances].[Categories] + ORDER BY [Name]", null, tx)).ToList(); + + if (categories.Any()) + { + var ids = categories.Select(c => c.InstanceId).ToArray(); + var parentLinks = await conn.QueryAsync<(int InstanceId, int CategoryInstanceId)>( + "SELECT [InstanceId], [CategoryInstanceId] FROM [Instances].[CategoryCategories] WHERE [InstanceId] IN @Ids", + new { Ids = ids }, tx); + + var parentLookup = parentLinks + .GroupBy(p => p.InstanceId) + .ToDictionary(g => g.Key, g => g.Select(p => p.CategoryInstanceId).ToList()); + + foreach (var cat in categories) + { + cat.ParentCategoryIds = parentLookup.TryGetValue(cat.InstanceId, out var parents) + ? parents : new List(); + } + } + + return categories; + }); + } + } +} diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs b/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs new file mode 100644 index 0000000..977bb81 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs @@ -0,0 +1,163 @@ +using Dapper; +using Sparcpoint.Inventory.Abstractions; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Sparcpoint.Inventory.SqlServer +{ + public class SqlInventoryRepository : IInventoryRepository + { + private readonly ISqlExecutor _Executor; + + public SqlInventoryRepository(ISqlExecutor executor) + { + _Executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task AddAsync(int productInstanceId, decimal quantity, string typeCategory = null) + { + if (quantity <= 0) + throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity to add must be positive."); + + return await _Executor.ExecuteAsync(async (conn, tx) => + await InsertTransactionAsync(conn, tx, productInstanceId, quantity, typeCategory)); + } + + public async Task AddBatchAsync(IEnumerable items) + { + if (items == null) throw new ArgumentNullException(nameof(items)); + + // EVAL: All batch items share a single transaction — either all succeed or all roll back. + await _Executor.ExecuteAsync(async (conn, tx) => + { + foreach (var item in items) + { + if (item.Quantity <= 0) + throw new ArgumentOutOfRangeException(nameof(item.Quantity), "Batch add quantity must be positive."); + await InsertTransactionAsync(conn, tx, item.ProductInstanceId, item.Quantity, item.TypeCategory); + } + }); + } + + public async Task RemoveAsync(int productInstanceId, decimal quantity, string typeCategory = null) + { + if (quantity <= 0) + throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity to remove must be positive."); + + // Store removals as negative quantities — net SUM gives the true on-hand count. + return await _Executor.ExecuteAsync(async (conn, tx) => + await InsertTransactionAsync(conn, tx, productInstanceId, -quantity, typeCategory)); + } + + public async Task RemoveBatchAsync(IEnumerable items) + { + if (items == null) throw new ArgumentNullException(nameof(items)); + + await _Executor.ExecuteAsync(async (conn, tx) => + { + foreach (var item in items) + { + if (item.Quantity <= 0) + throw new ArgumentOutOfRangeException(nameof(item.Quantity), "Batch remove quantity must be positive."); + await InsertTransactionAsync(conn, tx, item.ProductInstanceId, -item.Quantity, item.TypeCategory); + } + }); + } + + public async Task DeleteTransactionAsync(int transactionId) + { + await _Executor.ExecuteAsync(async (conn, tx) => + { + var affected = await conn.ExecuteAsync( + "DELETE FROM [Transactions].[InventoryTransactions] WHERE [TransactionId] = @TransactionId", + new { TransactionId = transactionId }, tx); + + if (affected == 0) + throw new InvalidOperationException($"Transaction {transactionId} not found."); + }); + } + + public async Task GetCountAsync(int productInstanceId) + { + return await _Executor.ExecuteAsync(async (conn, tx) => + await conn.ExecuteScalarAsync(@" + SELECT ISNULL(SUM([Quantity]), 0) + FROM [Transactions].[InventoryTransactions] + WHERE [ProductInstanceId] = @ProductInstanceId + AND [CompletedTimestamp] IS NOT NULL", + new { ProductInstanceId = productInstanceId }, tx)); + } + + public async Task> GetCountByFilterAsync(ProductSearchFilter filter) + { + return await _Executor.ExecuteAsync(async (conn, tx) => + { + var sql = new StringBuilder(@" + SELECT p.[InstanceId] AS ProductInstanceId, + p.[Name] AS ProductName, + ISNULL(SUM(t.[Quantity]), 0) AS TotalQuantity + FROM [Instances].[Products] p + LEFT JOIN [Transactions].[InventoryTransactions] t + ON t.[ProductInstanceId] = p.[InstanceId] + AND t.[CompletedTimestamp] IS NOT NULL + WHERE 1=1"); + + var parameters = new DynamicParameters(); + + if (!string.IsNullOrWhiteSpace(filter?.Name)) + { + sql.Append(" AND p.[Name] LIKE @Name"); + parameters.Add("Name", $"%{filter.Name}%"); + } + + if (filter?.Attributes != null) + { + int idx = 0; + foreach (var attr in filter.Attributes) + { + var k = $"AttrKey{idx}"; + var v = $"AttrVal{idx}"; + sql.Append($@" AND EXISTS ( + SELECT 1 FROM [Instances].[ProductAttributes] pa{idx} + WHERE pa{idx}.[InstanceId] = p.[InstanceId] + AND pa{idx}.[Key] = @{k} AND pa{idx}.[Value] = @{v})"); + parameters.Add(k, attr.Key); + parameters.Add(v, attr.Value); + idx++; + } + } + + if (filter?.CategoryIds != null && filter.CategoryIds.Any()) + { + sql.Append(@" AND EXISTS ( + SELECT 1 FROM [Instances].[ProductCategories] pc + WHERE pc.[InstanceId] = p.[InstanceId] + AND pc.[CategoryInstanceId] IN @CategoryIds)"); + parameters.Add("CategoryIds", filter.CategoryIds); + } + + sql.Append(" GROUP BY p.[InstanceId], p.[Name] ORDER BY p.[Name]"); + + return await conn.QueryAsync(sql.ToString(), parameters, tx); + }); + } + + private static async Task InsertTransactionAsync( + IDbConnection conn, IDbTransaction tx, + int productInstanceId, decimal quantity, string typeCategory) + { + return await conn.ExecuteScalarAsync(@" + INSERT INTO [Transactions].[InventoryTransactions] + ([ProductInstanceId], [Quantity], [TypeCategory], [CompletedTimestamp]) + VALUES (@ProductInstanceId, @Quantity, @TypeCategory, SYSUTCDATETIME()); + SELECT CAST(SCOPE_IDENTITY() AS INT);", + new { ProductInstanceId = productInstanceId, Quantity = quantity, TypeCategory = typeCategory }, + tx); + } + } +} diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs b/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs new file mode 100644 index 0000000..c06d04e --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs @@ -0,0 +1,164 @@ +using Dapper; +using Sparcpoint.Inventory.Abstractions; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Sparcpoint.Inventory.SqlServer +{ + /// + /// EVAL: Dapper is chosen over EF Core deliberately — the schema uses EAV (ProductAttributes) + /// and dynamic WHERE clause composition that benefit from explicit SQL control. + /// Dapper gives full visibility into every query sent to the database. + /// + public class SqlProductRepository : IProductRepository + { + private readonly ISqlExecutor _Executor; + + public SqlProductRepository(ISqlExecutor executor) + { + _Executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task AddAsync(CreateProductRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + PreConditions.StringNotNullOrWhitespace(request.Name, nameof(request.Name)); + PreConditions.StringNotNullOrWhitespace(request.Description, nameof(request.Description)); + + return await _Executor.ExecuteAsync(async (conn, tx) => + { + // Insert the base product record + var productId = await conn.ExecuteScalarAsync(@" + INSERT INTO [Instances].[Products] ([Name], [Description], [ProductImageUris], [ValidSkus]) + VALUES (@Name, @Description, @ProductImageUris, @ValidSkus); + SELECT CAST(SCOPE_IDENTITY() AS INT);", + new + { + request.Name, + request.Description, + ProductImageUris = request.ProductImageUris ?? string.Empty, + ValidSkus = request.ValidSkus ?? string.Empty + }, tx); + + // Insert arbitrary metadata attributes + // EVAL: Iterated inserts here are acceptable for write operations. + // For bulk product imports, a TVP (Table-Valued Parameter) would be more efficient. + if (request.Attributes != null) + { + foreach (var attr in request.Attributes) + { + await conn.ExecuteAsync(@" + INSERT INTO [Instances].[ProductAttributes] ([InstanceId], [Key], [Value]) + VALUES (@InstanceId, @Key, @Value)", + new { InstanceId = productId, attr.Key, attr.Value }, tx); + } + } + + // Link product to categories + if (request.CategoryIds != null) + { + foreach (var categoryId in request.CategoryIds) + { + await conn.ExecuteAsync(@" + INSERT INTO [Instances].[ProductCategories] ([InstanceId], [CategoryInstanceId]) + VALUES (@InstanceId, @CategoryInstanceId)", + new { InstanceId = productId, CategoryInstanceId = categoryId }, tx); + } + } + + return productId; + }); + } + + public async Task> SearchAsync(ProductSearchFilter filter) + { + return await _Executor.ExecuteAsync(async (conn, tx) => + { + var sql = new StringBuilder(@" + SELECT p.[InstanceId], p.[Name], p.[Description], p.[ProductImageUris], p.[ValidSkus], p.[CreatedTimestamp] + FROM [Instances].[Products] p + WHERE 1=1"); + + var parameters = new DynamicParameters(); + + // Name filter: partial match + if (!string.IsNullOrWhiteSpace(filter?.Name)) + { + sql.Append(" AND p.[Name] LIKE @Name"); + parameters.Add("Name", $"%{filter.Name}%"); + } + + // EVAL: Each attribute filter becomes an EXISTS subquery rather than a JOIN. + // Reason: a JOIN on EAV tables causes row multiplication — one product with + // N attributes matching M filter criteria would produce N*M rows before DISTINCT. + // EXISTS is cleaner and more predictable in its query plan. + if (filter?.Attributes != null) + { + int attrIndex = 0; + foreach (var attr in filter.Attributes) + { + var keyParam = $"AttrKey{attrIndex}"; + var valParam = $"AttrVal{attrIndex}"; + sql.Append($@" AND EXISTS ( + SELECT 1 FROM [Instances].[ProductAttributes] pa{attrIndex} + WHERE pa{attrIndex}.[InstanceId] = p.[InstanceId] + AND pa{attrIndex}.[Key] = @{keyParam} + AND pa{attrIndex}.[Value] = @{valParam})"); + parameters.Add(keyParam, attr.Key); + parameters.Add(valParam, attr.Value); + attrIndex++; + } + } + + // Category filter: product must belong to at least one of the specified categories + if (filter?.CategoryIds != null && filter.CategoryIds.Any()) + { + sql.Append(@" AND EXISTS ( + SELECT 1 FROM [Instances].[ProductCategories] pc + WHERE pc.[InstanceId] = p.[InstanceId] + AND pc.[CategoryInstanceId] IN @CategoryIds)"); + parameters.Add("CategoryIds", filter.CategoryIds); + } + + var products = (await conn.QueryAsync(sql.ToString(), parameters, tx)).ToList(); + + // Load attributes and category links for all returned products in two bulk queries + // to avoid N+1 queries + if (products.Any()) + { + var ids = products.Select(p => p.InstanceId).ToArray(); + + var attributes = await conn.QueryAsync<(int InstanceId, string Key, string Value)>( + "SELECT [InstanceId], [Key], [Value] FROM [Instances].[ProductAttributes] WHERE [InstanceId] IN @Ids", + new { Ids = ids }, tx); + + var categoryLinks = await conn.QueryAsync<(int InstanceId, int CategoryInstanceId)>( + "SELECT [InstanceId], [CategoryInstanceId] FROM [Instances].[ProductCategories] WHERE [InstanceId] IN @Ids", + new { Ids = ids }, tx); + + var attrLookup = attributes + .GroupBy(a => a.InstanceId) + .ToDictionary(g => g.Key, g => g.Select(a => new ProductAttribute { Key = a.Key, Value = a.Value }).ToList()); + + var catLookup = categoryLinks + .GroupBy(c => c.InstanceId) + .ToDictionary(g => g.Key, g => g.Select(c => c.CategoryInstanceId).ToList()); + + foreach (var product in products) + { + product.Attributes = attrLookup.TryGetValue(product.InstanceId, out var attrs) + ? attrs : new List(); + product.CategoryIds = catLookup.TryGetValue(product.InstanceId, out var cats) + ? cats : new List(); + } + } + + return products; + }); + } + } +} diff --git a/Development Project/Sparcpoint.Inventory.Tests/ServiceCollectionExtensionsTests.cs b/Development Project/Sparcpoint.Inventory.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..6a6cc3f --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using Sparcpoint.Inventory.Abstractions; +using Sparcpoint.Inventory.SqlServer; +using System; +using Xunit; + +namespace Sparcpoint.Inventory.Tests +{ + public class ServiceCollectionExtensionsTests + { + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void AddSqlInventoryServices_ThrowsOnBlankConnectionString(string cs) + { + var services = new ServiceCollection(); + Assert.Throws(() => services.AddSqlInventoryServices(cs)); + } + + [Fact] + public void AddSqlInventoryServices_RegistersAllRepositories() + { + var services = new ServiceCollection(); + services.AddSqlInventoryServices("Server=.;Database=Test;Trusted_Connection=True;TrustServerCertificate=True;"); + var provider = services.BuildServiceProvider(); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + } + } +} diff --git a/Development Project/Sparcpoint.Inventory.Tests/Sparcpoint.Inventory.Tests.csproj b/Development Project/Sparcpoint.Inventory.Tests/Sparcpoint.Inventory.Tests.csproj new file mode 100644 index 0000000..1c0fd9f --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Tests/Sparcpoint.Inventory.Tests.csproj @@ -0,0 +1,27 @@ + + + net8.0 + false + latest + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs new file mode 100644 index 0000000..e0912e7 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs @@ -0,0 +1,56 @@ +using Moq; +using Sparcpoint.Inventory.Abstractions; +using Sparcpoint.Inventory.SqlServer; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Sparcpoint.Inventory.Tests +{ + public class SqlInventoryRepositoryTests + { + [Fact] + public void Constructor_ThrowsOnNullExecutor() + { + Assert.Throws(() => new SqlInventoryRepository(null)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public async Task AddAsync_ThrowsOnNonPositiveQuantity(decimal qty) + { + var executor = new Mock(); + var repo = new SqlInventoryRepository(executor.Object); + await Assert.ThrowsAsync(() => repo.AddAsync(1, qty)); + } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public async Task RemoveAsync_ThrowsOnNonPositiveQuantity(decimal qty) + { + var executor = new Mock(); + var repo = new SqlInventoryRepository(executor.Object); + await Assert.ThrowsAsync(() => repo.RemoveAsync(1, qty)); + } + + [Fact] + public async Task AddBatchAsync_ThrowsOnNullItems() + { + var executor = new Mock(); + var repo = new SqlInventoryRepository(executor.Object); + await Assert.ThrowsAsync(() => repo.AddBatchAsync(null)); + } + + [Fact] + public async Task RemoveBatchAsync_ThrowsOnNullItems() + { + var executor = new Mock(); + var repo = new SqlInventoryRepository(executor.Object); + await Assert.ThrowsAsync(() => repo.RemoveBatchAsync(null)); + } + } +} diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositoryTests.cs new file mode 100644 index 0000000..3d067b0 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositoryTests.cs @@ -0,0 +1,54 @@ +using Moq; +using Sparcpoint.Inventory.Abstractions; +using Sparcpoint.Inventory.SqlServer; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Sparcpoint.Inventory.Tests +{ + // EVAL: Unit tests use Moq to isolate repositories from a real SQL Server instance. + // These tests validate guard clauses and request validation. + // Integration tests (hitting a real DB) would be the next layer. + public class SqlProductRepositoryTests + { + [Fact] + public void Constructor_ThrowsOnNullExecutor() + { + Assert.Throws(() => new SqlProductRepository(null)); + } + + [Fact] + public async Task AddAsync_ThrowsOnNullRequest() + { + var executor = new Mock(); + var repo = new SqlProductRepository(executor.Object); + await Assert.ThrowsAsync(() => repo.AddAsync(null)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public async Task AddAsync_ThrowsOnBlankName(string name) + { + var executor = new Mock(); + var repo = new SqlProductRepository(executor.Object); + var request = new CreateProductRequest { Name = name, Description = "Desc" }; + await Assert.ThrowsAsync(() => repo.AddAsync(request)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public async Task AddAsync_ThrowsOnBlankDescription(string description) + { + var executor = new Mock(); + var repo = new SqlProductRepository(executor.Object); + var request = new CreateProductRequest { Name = "Valid Name", Description = description }; + await Assert.ThrowsAsync(() => repo.AddAsync(request)); + } + } +} diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/ISqlExecutor.cs b/Development Project/Sparcpoint.SqlServer.Abstractions/ISqlExecutor.cs index cca814f..8cedcba 100644 --- a/Development Project/Sparcpoint.SqlServer.Abstractions/ISqlExecutor.cs +++ b/Development Project/Sparcpoint.SqlServer.Abstractions/ISqlExecutor.cs @@ -1,6 +1,5 @@ using System; using System.Data; -using System.Data.SqlClient; using System.Threading.Tasks; namespace Sparcpoint.SqlServer.Abstractions diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/JoinTables.cs b/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/JoinTables.cs index 56a2dc8..13d078b 100644 --- a/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/JoinTables.cs +++ b/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/JoinTables.cs @@ -11,7 +11,11 @@ public void Add(string clause) => _Backer.Add(clause); public void Add(string tableName, string abbv, string onClause) - => _Backer.Add($"JOIN {tableName} {abbv} ON {onClause}"); + { + tableName = SqlServerValidation.SanitizeTableName(tableName); + abbv = SqlServerValidation.SanitizeTableName(abbv); + _Backer.Add($"JOIN {tableName} {abbv} ON {onClause}"); + } public override string ToString() { diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/SqlServerValidation.cs b/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/SqlServerValidation.cs index eb0c5d5..d47153b 100644 --- a/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/SqlServerValidation.cs +++ b/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/SqlServerValidation.cs @@ -5,7 +5,7 @@ namespace Sparcpoint.SqlServer.Abstractions { internal static class SqlServerValidation { - private static Regex SQL_OBJECT_NAME_PATTERN = new Regex(@"^\s*(([a-zA-Z][a-zA-Z0-9]*\.)?[a-zA-Z][a-zA-Z0-9_]*|(\[[a-zA-Z0-9_\s]+\]\.)?\[[a-zA-Z0-9_\s]+\])\s*$"); + private static Regex SQL_OBJECT_NAME_PATTERN = new Regex(@"^\s*(([a-zA-Z][a-zA-Z0-9]*\.)?[a-zA-Z][a-zA-Z0-9_]*|(\[[a-zA-Z0-9_\s]+\]\.)?\[[a-zA-Z0-9_\s]+\])\s*$", RegexOptions.Compiled); public static string SanitizeColumnName(string columnName) { if (!SQL_OBJECT_NAME_PATTERN.IsMatch(columnName)) @@ -22,7 +22,7 @@ public static string SanitizeTableName(string tableName) return tableName.Trim(); } - private static Regex PARAMETER_NAME_PATTERN = new Regex(@"^\s*\@?[a-zA-Z][a-zA-Z0-9_]*\s*$"); + private static Regex PARAMETER_NAME_PATTERN = new Regex(@"^\s*\@?[a-zA-Z][a-zA-Z0-9_]*\s*$", RegexOptions.Compiled); public static string SanitizeParameterName(string parameterName) { if (!PARAMETER_NAME_PATTERN.IsMatch(parameterName)) diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/Sparcpoint.SqlServer.Abstractions.csproj b/Development Project/Sparcpoint.SqlServer.Abstractions/Sparcpoint.SqlServer.Abstractions.csproj index 6ee3f78..0319b59 100644 --- a/Development Project/Sparcpoint.SqlServer.Abstractions/Sparcpoint.SqlServer.Abstractions.csproj +++ b/Development Project/Sparcpoint.SqlServer.Abstractions/Sparcpoint.SqlServer.Abstractions.csproj @@ -7,7 +7,7 @@ - + diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/SqlServerExecutor.cs b/Development Project/Sparcpoint.SqlServer.Abstractions/SqlServerExecutor.cs index 3035408..2eb0146 100644 --- a/Development Project/Sparcpoint.SqlServer.Abstractions/SqlServerExecutor.cs +++ b/Development Project/Sparcpoint.SqlServer.Abstractions/SqlServerExecutor.cs @@ -1,6 +1,6 @@ -using System; +using Microsoft.Data.SqlClient; +using System; using System.Data; -using System.Data.SqlClient; using System.Threading.Tasks; namespace Sparcpoint.SqlServer.Abstractions @@ -19,9 +19,19 @@ public T Execute(Func command) using (var sqlConn = Open()) using (var sqlTrans = sqlConn.BeginTransaction()) { - var result = command(sqlConn, sqlTrans); - sqlTrans.Commit(); - return result; + try + { + var result = command(sqlConn, sqlTrans); + sqlTrans.Commit(); + return result; + } + catch + { + // EVAL: Rollback is critical — without this, an exception leaves the transaction + // open until the connection is disposed, which can cause deadlocks under load. + sqlTrans.Rollback(); + throw; + } } } @@ -30,8 +40,16 @@ public async Task ExecuteAsync(Func command using (var sqlConn = await OpenAsync()) using (var sqlTrans = sqlConn.BeginTransaction()) { - await command(sqlConn, sqlTrans); - sqlTrans.Commit(); + try + { + await command(sqlConn, sqlTrans); + sqlTrans.Commit(); + } + catch + { + sqlTrans.Rollback(); + throw; + } } } @@ -40,22 +58,30 @@ public async Task ExecuteAsync(Func using (var sqlConn = await OpenAsync()) using (var sqlTrans = sqlConn.BeginTransaction()) { - var result = await command(sqlConn, sqlTrans); - sqlTrans.Commit(); - return result; + try + { + var result = await command(sqlConn, sqlTrans); + sqlTrans.Commit(); + return result; + } + catch + { + sqlTrans.Rollback(); + throw; + } } } private SqlConnection Open() { - SqlConnection connection = new SqlConnection(_ConnectionString); + var connection = new SqlConnection(_ConnectionString); connection.Open(); return connection; } private async Task OpenAsync() { - SqlConnection connection = new SqlConnection(_ConnectionString); + var connection = new SqlConnection(_ConnectionString); await connection.OpenAsync(); return connection; } diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/TransactionSqlServerExecutor.cs b/Development Project/Sparcpoint.SqlServer.Abstractions/TransactionSqlServerExecutor.cs index 008deb5..c270020 100644 --- a/Development Project/Sparcpoint.SqlServer.Abstractions/TransactionSqlServerExecutor.cs +++ b/Development Project/Sparcpoint.SqlServer.Abstractions/TransactionSqlServerExecutor.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Data; -using System.Data.SqlClient; +using Microsoft.Data.SqlClient; using System.Threading.Tasks; namespace Sparcpoint.SqlServer.Abstractions From 39e6bb2fa4e5dea2c6b633b77c1465165874b817 Mon Sep 17 00:00:00 2001 From: Luis Rodriguez Date: Tue, 28 Apr 2026 19:16:30 -0500 Subject: [PATCH 2/8] -feat/inventory-management-implementation Enable Swagger UI by default and update connection string - Changed IIS Express launch URL to open Swagger UI by default. - Registered SwaggerGen instead of EndpointsApiExplorer for controllers. - Made Swagger UI available in all environments for evaluation. - Updated SQL Server connection string to use (localdb)\MSSQLLocalDB. - Added Microsoft.Extensions.DependencyInjection to test project dependencies. --- .../Interview.Web/Properties/launchSettings.json | 2 +- Development Project/Interview.Web/Startup.cs | 12 +++++++----- Development Project/Interview.Web/appsettings.json | 2 +- .../Sparcpoint.Inventory.Tests.csproj | 1 + 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/Development Project/Interview.Web/Properties/launchSettings.json b/Development Project/Interview.Web/Properties/launchSettings.json index 4dcb6fa..1d45878 100644 --- a/Development Project/Interview.Web/Properties/launchSettings.json +++ b/Development Project/Interview.Web/Properties/launchSettings.json @@ -11,7 +11,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/v1/products", + "launchUrl": "swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs index 3108842..bf13373 100644 --- a/Development Project/Interview.Web/Startup.cs +++ b/Development Project/Interview.Web/Startup.cs @@ -20,9 +20,8 @@ public void ConfigureServices(IServiceCollection services) { services.AddControllers(); - // EVAL: Swagger is registered to allow interactive API exploration during evaluation. - // In a production deployment, this would be gated behind an environment check. - services.AddEndpointsApiExplorer(); + // EVAL: AddSwaggerGen is the correct registration for controller-based APIs with Swashbuckle. + // AddEndpointsApiExplorer() is only for Minimal APIs and is intentionally omitted here. services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo @@ -44,8 +43,6 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); - app.UseSwagger(); - app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Inventory API v1")); } else { @@ -53,6 +50,11 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseHsts(); } + // EVAL: Swagger is available in all environments for evaluation purposes. + // In a production system, restrict this behind env.IsDevelopment() or a feature flag. + app.UseSwagger(); + app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Inventory API v1")); + app.UseHttpsRedirection(); app.UseRouting(); app.UseAuthorization(); diff --git a/Development Project/Interview.Web/appsettings.json b/Development Project/Interview.Web/appsettings.json index 15b8778..3863ff5 100644 --- a/Development Project/Interview.Web/appsettings.json +++ b/Development Project/Interview.Web/appsettings.json @@ -8,6 +8,6 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "InventoryDatabase": "Server=.;Database=SparcpointInventory;Trusted_Connection=True;TrustServerCertificate=True;" + "InventoryDatabase": "Server=(localdb)\\MSSQLLocalDB;Database=SparcpointInventory;Trusted_Connection=True;TrustServerCertificate=True;" } } diff --git a/Development Project/Sparcpoint.Inventory.Tests/Sparcpoint.Inventory.Tests.csproj b/Development Project/Sparcpoint.Inventory.Tests/Sparcpoint.Inventory.Tests.csproj index 1c0fd9f..7978bd6 100644 --- a/Development Project/Sparcpoint.Inventory.Tests/Sparcpoint.Inventory.Tests.csproj +++ b/Development Project/Sparcpoint.Inventory.Tests/Sparcpoint.Inventory.Tests.csproj @@ -17,6 +17,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + From 7e34e5728f2ca9a1417ec15a3e0135c6c454a290 Mon Sep 17 00:00:00 2001 From: Luis Rodriguez Date: Tue, 28 Apr 2026 20:51:00 -0500 Subject: [PATCH 3/8] Centralize exception handling and add input validation - Add global ApiExceptionFilter to map common exceptions to HTTP 400/404 - Add data annotations to DTOs for input validation - Pre-validate foreign keys in repositories to avoid SQL errors - Move InventoryAdjustRequest to Abstractions with validation - Simplify controllers by removing redundant try/catch - Update ProductSearchFilter.CategoryIds to int[] for binding - Add System.ComponentModel.Annotations to support validation attributes --- .../Controllers/CategoryController.cs | 1 + .../Controllers/InventoryController.cs | 26 +-------- .../Controllers/ProductController.cs | 4 +- .../Filters/ApiExceptionFilter.cs | 58 +++++++++++++++++++ Development Project/Interview.Web/Startup.cs | 8 ++- .../Filters/ProductSearchFilter.cs | 2 +- .../Requests/CreateCategoryRequest.cs | 6 ++ .../Requests/CreateProductRequest.cs | 6 ++ .../Requests/InventoryAdjustRequest.cs | 23 ++++++++ .../Requests/InventoryBatchItem.cs | 4 ++ .../Sparcpoint.Inventory.Abstractions.csproj | 4 ++ .../SqlCategoryRepository.cs | 25 ++++++-- .../SqlInventoryRepository.cs | 17 ++++++ .../SqlProductRepository.cs | 28 +++++++-- 14 files changed, 176 insertions(+), 36 deletions(-) create mode 100644 Development Project/Interview.Web/Filters/ApiExceptionFilter.cs create mode 100644 Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryAdjustRequest.cs diff --git a/Development Project/Interview.Web/Controllers/CategoryController.cs b/Development Project/Interview.Web/Controllers/CategoryController.cs index 160a015..3f10537 100644 --- a/Development Project/Interview.Web/Controllers/CategoryController.cs +++ b/Development Project/Interview.Web/Controllers/CategoryController.cs @@ -23,6 +23,7 @@ public CategoryController(ICategoryRepository categories) /// /// Creates a new category. Optionally nest it under existing parent categories /// by supplying ParentCategoryIds. Hierarchy is stored as an adjacency list. + /// Returns 400 if any ParentCategoryId does not reference an existing category. /// [HttpPost] [ProducesResponseType(typeof(int), 201)] diff --git a/Development Project/Interview.Web/Controllers/InventoryController.cs b/Development Project/Interview.Web/Controllers/InventoryController.cs index e2285ad..80a9f93 100644 --- a/Development Project/Interview.Web/Controllers/InventoryController.cs +++ b/Development Project/Interview.Web/Controllers/InventoryController.cs @@ -78,21 +78,15 @@ public async Task RemoveInventoryBatch([FromBody] IEnumerable [HttpDelete("transactions/{transactionId:int}")] [ProducesResponseType(204)] [ProducesResponseType(404)] public async Task DeleteTransaction([FromRoute] int transactionId) { - try - { - await _Inventory.DeleteTransactionAsync(transactionId); - return NoContent(); - } - catch (InvalidOperationException) - { - return NotFound(); - } + await _Inventory.DeleteTransactionAsync(transactionId); + return NoContent(); } /// @@ -137,18 +131,4 @@ public async Task GetCountByFilter( } } - /// - /// Request body for a single-product inventory adjustment. - /// - public class InventoryAdjustRequest - { - /// Must be a positive value regardless of operation direction. - public decimal Quantity { get; set; } - - /// - /// Optional classification (e.g. "SALE", "RETURN", "ADJUSTMENT"). - /// Maps to Transactions.InventoryTransactions.TypeCategory. - /// - public string TypeCategory { get; set; } - } } diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs index e4509e2..71387fe 100644 --- a/Development Project/Interview.Web/Controllers/ProductController.cs +++ b/Development Project/Interview.Web/Controllers/ProductController.cs @@ -33,7 +33,9 @@ public ProductController(IProductRepository products) public async Task AddProduct([FromBody] CreateProductRequest request) { var productId = await _Products.AddAsync(request); - return CreatedAtAction(nameof(SearchProducts), new { }, productId); + // EVAL: Returning 201 with the new ID. A full REST implementation would point + // to a GET /products/{id} endpoint; that endpoint is out of scope for this assignment. + return StatusCode(201, productId); } /// diff --git a/Development Project/Interview.Web/Filters/ApiExceptionFilter.cs b/Development Project/Interview.Web/Filters/ApiExceptionFilter.cs new file mode 100644 index 0000000..6b01548 --- /dev/null +++ b/Development Project/Interview.Web/Filters/ApiExceptionFilter.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Data.SqlClient; +using System; + +namespace Interview.Web.Filters +{ + /// + /// EVAL: Global exception filter centralizes HTTP status mapping for domain exceptions. + /// Without this, any unhandled exception thrown by a repository bubbles up as a 500. + /// Adding it once here keeps every controller clean — no per-endpoint try/catch boilerplate. + /// + /// Mapping rationale: + /// ArgumentException (incl. ArgumentNullException, + /// ArgumentOutOfRangeException) → 400 Bad Request (invalid caller input; + /// ArgumentOutOfRangeException is a subclass of ArgumentException so the same case arm + /// handles it automatically — no separate branch needed) + /// InvalidOperationException → 404 Not Found (entity not found) + /// SqlException error 547 (FK violation) → 400 Bad Request (TOCTOU fallback — + /// repositories pre-validate FKs but a concurrent delete between the check and the + /// INSERT can still produce a 547; we catch it here so it never surfaces as a 500) + /// + /// To add a new mapping (e.g. ConflictException → 409), extend the switch here + /// without touching any controller. + /// + public class ApiExceptionFilter : IActionFilter + { + // SQL Server error number for foreign key constraint violation. + private const int SqlForeignKeyViolation = 547; + + public void OnActionExecuting(ActionExecutingContext context) { } + + public void OnActionExecuted(ActionExecutedContext context) + { + if (context.Exception == null) + return; + + switch (context.Exception) + { + case ArgumentException ex: + context.Result = new BadRequestObjectResult(new { error = ex.Message }); + context.ExceptionHandled = true; + break; + + case InvalidOperationException ex: + context.Result = new NotFoundObjectResult(new { error = ex.Message }); + context.ExceptionHandled = true; + break; + + case SqlException ex when ex.Number == SqlForeignKeyViolation: + context.Result = new BadRequestObjectResult( + new { error = "A referenced entity does not exist or has been removed." }); + context.ExceptionHandled = true; + break; + } + } + } +} diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs index bf13373..6aee0bd 100644 --- a/Development Project/Interview.Web/Startup.cs +++ b/Development Project/Interview.Web/Startup.cs @@ -1,3 +1,4 @@ +using Interview.Web.Filters; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -18,7 +19,12 @@ public Startup(IConfiguration configuration) public void ConfigureServices(IServiceCollection services) { - services.AddControllers(); + services.AddControllers(options => + { + // EVAL: ApiExceptionFilter registered globally — maps ArgumentException → 400 + // and InvalidOperationException → 404 without per-controller try/catch. + options.Filters.Add(); + }); // EVAL: AddSwaggerGen is the correct registration for controller-based APIs with Swashbuckle. // AddEndpointsApiExplorer() is only for Minimal APIs and is intentionally omitted here. diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs index 81f4500..a97417b 100644 --- a/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs @@ -10,6 +10,6 @@ public class ProductSearchFilter { public string Name { get; set; } public IDictionary Attributes { get; set; } - public IEnumerable CategoryIds { get; set; } + public int[] CategoryIds { get; set; } } } diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs index 319b3fe..dd6e2df 100644 --- a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs @@ -1,10 +1,16 @@ using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; namespace Sparcpoint.Inventory.Abstractions { public class CreateCategoryRequest { + [Required] + [MaxLength(64)] public string Name { get; set; } + + [Required] + [MaxLength(256)] public string Description { get; set; } /// diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateProductRequest.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateProductRequest.cs index 7abf4f7..f7aecb4 100644 --- a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateProductRequest.cs +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateProductRequest.cs @@ -1,10 +1,16 @@ using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; namespace Sparcpoint.Inventory.Abstractions { public class CreateProductRequest { + [Required] + [MaxLength(64)] public string Name { get; set; } + + [Required] + [MaxLength(256)] public string Description { get; set; } public string ProductImageUris { get; set; } = string.Empty; public string ValidSkus { get; set; } = string.Empty; diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryAdjustRequest.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryAdjustRequest.cs new file mode 100644 index 0000000..903160c --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryAdjustRequest.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Sparcpoint.Inventory.Abstractions +{ + /// + /// Request body for a single-product inventory adjustment (add or remove). + /// + public class InventoryAdjustRequest + { + /// + /// Must be a positive value regardless of operation direction. + /// The direction (add vs. remove) is determined by the endpoint called. + /// + [Range(0.001, double.MaxValue, ErrorMessage = "Quantity must be greater than zero.")] + public decimal Quantity { get; set; } + + /// + /// Optional classification for this transaction (e.g. "SALE", "RETURN", "ADJUSTMENT"). + /// Maps to Transactions.InventoryTransactions.TypeCategory. + /// + public string TypeCategory { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryBatchItem.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryBatchItem.cs index eb66d5d..cdc3905 100644 --- a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryBatchItem.cs +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryBatchItem.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace Sparcpoint.Inventory.Abstractions { /// @@ -6,6 +8,8 @@ namespace Sparcpoint.Inventory.Abstractions public class InventoryBatchItem { public int ProductInstanceId { get; set; } + + [Range(0.001, double.MaxValue, ErrorMessage = "Quantity must be greater than zero.")] public decimal Quantity { get; set; } public string TypeCategory { get; set; } } diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Sparcpoint.Inventory.Abstractions.csproj b/Development Project/Sparcpoint.Inventory.Abstractions/Sparcpoint.Inventory.Abstractions.csproj index dcf06bf..84fa098 100644 --- a/Development Project/Sparcpoint.Inventory.Abstractions/Sparcpoint.Inventory.Abstractions.csproj +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Sparcpoint.Inventory.Abstractions.csproj @@ -3,4 +3,8 @@ netstandard2.0 8.0 + + + + diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs b/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs index 1672788..67748e4 100644 --- a/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs +++ b/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs @@ -34,12 +34,27 @@ INSERT INTO [Instances].[Categories] ([Name], [Description]) // Link to parent categories for hierarchy support if (request.ParentCategoryIds != null) { - foreach (var parentId in request.ParentCategoryIds) + var parentIds = request.ParentCategoryIds.ToList(); + if (parentIds.Count > 0) { - await conn.ExecuteAsync(@" - INSERT INTO [Instances].[CategoryCategories] ([InstanceId], [CategoryInstanceId]) - VALUES (@InstanceId, @CategoryInstanceId)", - new { InstanceId = categoryId, CategoryInstanceId = parentId }, tx); + // EVAL: Validate all parent IDs exist before inserting to produce a clear + // 400 instead of letting the FK violation surface as a 500. + var existingCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM [Instances].[Categories] WHERE [InstanceId] IN @Ids", + new { Ids = parentIds }, tx); + + if (existingCount != parentIds.Count) + throw new ArgumentException( + "One or more ParentCategoryIds do not reference existing categories.", + nameof(request.ParentCategoryIds)); + + foreach (var parentId in parentIds) + { + await conn.ExecuteAsync(@" + INSERT INTO [Instances].[CategoryCategories] ([InstanceId], [CategoryInstanceId]) + VALUES (@InstanceId, @CategoryInstanceId)", + new { InstanceId = categoryId, CategoryInstanceId = parentId }, tx); + } } } diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs b/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs index 977bb81..fcbc25c 100644 --- a/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs +++ b/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs @@ -84,6 +84,10 @@ await _Executor.ExecuteAsync(async (conn, tx) => public async Task GetCountAsync(int productInstanceId) { + // EVAL: CompletedTimestamp IS NOT NULL is the filter for "committed" transactions. + // All transactions inserted by this repository set CompletedTimestamp = SYSUTCDATETIME() immediately, + // so the filter is effectively "all transactions". The column is preserved to support a future + // "pending transaction" workflow where a transaction is created but not yet confirmed. return await _Executor.ExecuteAsync(async (conn, tx) => await conn.ExecuteScalarAsync(@" SELECT ISNULL(SUM([Quantity]), 0) @@ -151,6 +155,19 @@ private static async Task InsertTransactionAsync( IDbConnection conn, IDbTransaction tx, int productInstanceId, decimal quantity, string typeCategory) { + // EVAL: Guard against FK violation (FK_InventoryTransactions_Products) by checking + // product existence within the same transaction before the INSERT. This produces + // a clear 400 instead of letting SQL Server throw a 547 FK error as a 500. + // Called by AddAsync, RemoveAsync, AddBatchAsync, and RemoveBatchAsync — one check covers all paths. + var productExists = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM [Instances].[Products] WHERE [InstanceId] = @Id", + new { Id = productInstanceId }, tx); + + if (productExists == 0) + throw new ArgumentException( + $"Product with InstanceId {productInstanceId} does not exist.", + nameof(productInstanceId)); + return await conn.ExecuteScalarAsync(@" INSERT INTO [Transactions].[InventoryTransactions] ([ProductInstanceId], [Quantity], [TypeCategory], [CompletedTimestamp]) diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs b/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs index c06d04e..ea546c0 100644 --- a/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs +++ b/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs @@ -61,12 +61,27 @@ INSERT INTO [Instances].[ProductAttributes] ([InstanceId], [Key], [Value]) // Link product to categories if (request.CategoryIds != null) { - foreach (var categoryId in request.CategoryIds) + var categoryIds = request.CategoryIds.ToList(); + if (categoryIds.Count > 0) { - await conn.ExecuteAsync(@" - INSERT INTO [Instances].[ProductCategories] ([InstanceId], [CategoryInstanceId]) - VALUES (@InstanceId, @CategoryInstanceId)", - new { InstanceId = productId, CategoryInstanceId = categoryId }, tx); + // EVAL: Validate all category IDs exist before inserting to produce a 400 + // instead of FK_ProductCategories_Categories bubbling up as a 500. + var existingCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM [Instances].[Categories] WHERE [InstanceId] IN @Ids", + new { Ids = categoryIds }, tx); + + if (existingCount != categoryIds.Count) + throw new ArgumentException( + "One or more CategoryIds do not reference existing categories.", + nameof(request.CategoryIds)); + + foreach (var categoryId in categoryIds) + { + await conn.ExecuteAsync(@" + INSERT INTO [Instances].[ProductCategories] ([InstanceId], [CategoryInstanceId]) + VALUES (@InstanceId, @CategoryInstanceId)", + new { InstanceId = productId, CategoryInstanceId = categoryId }, tx); + } } } @@ -124,6 +139,9 @@ SELECT 1 FROM [Instances].[ProductCategories] pc parameters.Add("CategoryIds", filter.CategoryIds); } + // EVAL: With no filter criteria, this query returns the full product catalog. + // For large catalogs, a caller should supply at least one filter or use pagination. + // Pagination (TOP/OFFSET-FETCH) is the recommended extension point here. var products = (await conn.QueryAsync(sql.ToString(), parameters, tx)).ToList(); // Load attributes and category links for all returned products in two bulk queries From 90fd86b2d5fe8aa4303615f3baa9be038d8d3ab8 Mon Sep 17 00:00:00 2001 From: Luis Rodriguez Date: Tue, 28 Apr 2026 20:51:50 -0500 Subject: [PATCH 4/8] Add unit and integration tests for repository classes Introduce integration tests for product and inventory repositories using TransactionScope for DB cleanup. Add unit tests for category, product, and inventory repositories with Moq, improving input validation and test coverage. Expand SqlInventoryRepositoryTests and add new test files for comprehensive data access layer validation. --- .../IntegrationTests.cs | 131 ++++++++++++++++++ .../SqlCategoryRepositoryTests.cs | 90 ++++++++++++ .../SqlInventoryRepositoryTests.cs | 94 +++++++++++++ .../SqlProductRepositorySearchTests.cs | 86 ++++++++++++ 4 files changed, 401 insertions(+) create mode 100644 Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs create mode 100644 Development Project/Sparcpoint.Inventory.Tests/SqlCategoryRepositoryTests.cs create mode 100644 Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositorySearchTests.cs diff --git a/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs b/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs new file mode 100644 index 0000000..8776e60 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs @@ -0,0 +1,131 @@ +using Sparcpoint.Inventory.Abstractions; +using Sparcpoint.Inventory.SqlServer; +using Sparcpoint.SqlServer.Abstractions; +using System.Collections.Generic; +using System.Linq; +using System.Transactions; +using System.Threading.Tasks; +using Xunit; + +// EVAL: Prerequisite — the database SparcpointInventory must exist on the local SQL Server instance +// (Server=(localdb)\MSSQLLocalDB) and have the full schema published before running these tests. +// The schema is typically applied via the SQL scripts or EF migrations in the project. Without it +// the tests fail immediately with "Invalid object name" SQL errors, which is expected — integration +// tests are not self-bootstrapping by design. + +namespace Sparcpoint.Inventory.Tests +{ + // EVAL: TransactionScope is used here instead of TRUNCATE/DELETE between tests for three reasons: + // 1. Speed — a rollback is a single log operation; truncating multiple tables with FK constraints + // requires disabling constraints or a specific deletion order, both slower and fragile. + // 2. No DDL permissions required — TRUNCATE is a DDL statement that requires ALTER TABLE + // permission; TransactionScope only needs the DML permissions the app already holds. + // 3. Safety on the shared dev database — a test failure or a crash mid-test leaves no orphaned + // data because the scope.Dispose() without scope.Complete() issues an automatic rollback. + // There is zero risk of corrupting the development dataset. + // + // How it interacts with SqlServerExecutor: Microsoft.Data.SqlClient automatically enlists new + // connections in an ambient TransactionScope. When SqlServerExecutor calls conn.BeginTransaction() + // it gets a nested transaction that participates in the ambient scope. Disposing the scope + // without calling Complete() rolls back the ambient transaction and therefore all SQL work done + // within the test, regardless of how many internal commits SqlServerExecutor issued. + [Trait("Category", "Integration")] + public class IntegrationTests + { + private const string ConnectionString = + "Server=(localdb)\\MSSQLLocalDB;Database=SparcpointInventory;Trusted_Connection=True;TrustServerCertificate=True;"; + + // ----------------------------------------------------------------------------------------- + // Test 1: Create a product with a known attribute, then verify SearchAsync finds it + // ----------------------------------------------------------------------------------------- + [Fact] + public async Task AddProduct_ThenSearch_ByAttribute_ReturnsProduct() + { + using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); + + var executor = new SqlServerExecutor(ConnectionString); + var productRepo = new SqlProductRepository(executor); + + var request = new CreateProductRequest + { + Name = "Integration Test Widget", + Description = "Test", + Attributes = new Dictionary + { + { "Brand", "ACME" } + } + }; + + await productRepo.AddAsync(request); + + var results = await productRepo.SearchAsync(new ProductSearchFilter + { + Attributes = new Dictionary + { + { "Brand", "ACME" } + } + }); + + Assert.Contains(results, p => p.Name == "Integration Test Widget"); + + // No scope.Complete() — automatic rollback on dispose keeps the database clean. + } + + // ----------------------------------------------------------------------------------------- + // Test 2: Add inventory for a product and verify GetCountAsync returns the correct quantity + // ----------------------------------------------------------------------------------------- + [Fact] + public async Task AddInventory_ThenGetCount_ReturnsCorrectQuantity() + { + using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); + + var executor = new SqlServerExecutor(ConnectionString); + var productRepo = new SqlProductRepository(executor); + var inventoryRepo = new SqlInventoryRepository(executor); + + // A valid product is required to satisfy the FK on InventoryTransactions. + var productId = await productRepo.AddAsync(new CreateProductRequest + { + Name = "Inventory Count Test Product", + Description = "Test" + }); + + await inventoryRepo.AddAsync(productId, 50m); + + var count = await inventoryRepo.GetCountAsync(productId); + + Assert.Equal(50m, count); + + // No scope.Complete() — automatic rollback. + } + + // ----------------------------------------------------------------------------------------- + // Test 3: Delete a transaction and verify the inventory count returns to zero + // ----------------------------------------------------------------------------------------- + [Fact] + public async Task DeleteTransaction_UndoesInventoryEffect() + { + using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); + + var executor = new SqlServerExecutor(ConnectionString); + var productRepo = new SqlProductRepository(executor); + var inventoryRepo = new SqlInventoryRepository(executor); + + var productId = await productRepo.AddAsync(new CreateProductRequest + { + Name = "Delete Transaction Test Product", + Description = "Test" + }); + + var transactionId = await inventoryRepo.AddAsync(productId, 100m); + + await inventoryRepo.DeleteTransactionAsync(transactionId); + + var count = await inventoryRepo.GetCountAsync(productId); + + Assert.Equal(0m, count); + + // No scope.Complete() — automatic rollback. + } + } +} diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlCategoryRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlCategoryRepositoryTests.cs new file mode 100644 index 0000000..288cc01 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Tests/SqlCategoryRepositoryTests.cs @@ -0,0 +1,90 @@ +using Moq; +using Sparcpoint.Inventory.Abstractions; +using Sparcpoint.Inventory.SqlServer; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Data; +using System.Threading.Tasks; +using Xunit; + +namespace Sparcpoint.Inventory.Tests +{ + public class SqlCategoryRepositoryTests + { + [Fact] + public void Constructor_ThrowsOnNullExecutor() + { + Assert.Throws(() => new SqlCategoryRepository(null)); + } + + [Fact] + public async Task AddAsync_ThrowsOnNullRequest() + { + var executor = new Mock(); + var repo = new SqlCategoryRepository(executor.Object); + await Assert.ThrowsAsync(() => repo.AddAsync(null)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public async Task AddAsync_ThrowsOnBlankName(string name) + { + var executor = new Mock(); + var repo = new SqlCategoryRepository(executor.Object); + var request = new CreateCategoryRequest { Name = name, Description = "Desc" }; + await Assert.ThrowsAsync(() => repo.AddAsync(request)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public async Task AddAsync_ThrowsOnBlankDescription(string description) + { + var executor = new Mock(); + var repo = new SqlCategoryRepository(executor.Object); + var request = new CreateCategoryRequest { Name = "Electronics", Description = description }; + await Assert.ThrowsAsync(() => repo.AddAsync(request)); + } + + [Fact] + public async Task AddAsync_DelegatesToExecutorAndReturnsCategoryId() + { + var mockExecutor = new Mock(); + mockExecutor + .Setup(e => e.ExecuteAsync(It.IsAny>>())) + .ReturnsAsync(7); + + var repo = new SqlCategoryRepository(mockExecutor.Object); + var result = await repo.AddAsync(new CreateCategoryRequest + { + Name = "Electronics", + Description = "Electronic devices" + }); + + Assert.Equal(7, result); + mockExecutor.Verify( + e => e.ExecuteAsync(It.IsAny>>()), + Times.Once); + } + + [Fact] + public async Task GetAllAsync_DelegatesToExecutor() + { + var mockExecutor = new Mock(); + // EVAL: The setup type must match the inferred T in ExecuteAsync. + // GetAllAsync calls .ToList() internally, so T = List, not IEnumerable. + // Using IEnumerable causes Moq to miss the setup and return null. + mockExecutor + .Setup(e => e.ExecuteAsync(It.IsAny>>>())) + .ReturnsAsync(new System.Collections.Generic.List()); + + var repo = new SqlCategoryRepository(mockExecutor.Object); + var result = await repo.GetAllAsync(); + + Assert.NotNull(result); + } + } +} diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs index e0912e7..c4ba7e7 100644 --- a/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs +++ b/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs @@ -3,6 +3,8 @@ using Sparcpoint.Inventory.SqlServer; using Sparcpoint.SqlServer.Abstractions; using System; +using System.Collections.Generic; +using System.Data; using System.Threading.Tasks; using Xunit; @@ -52,5 +54,97 @@ public async Task RemoveBatchAsync_ThrowsOnNullItems() var repo = new SqlInventoryRepository(executor.Object); await Assert.ThrowsAsync(() => repo.RemoveBatchAsync(null)); } + + [Fact] + public async Task GetCountAsync_DelegatesToExecutorAndReturnsResult() + { + var mockExecutor = new Mock(); + mockExecutor + .Setup(e => e.ExecuteAsync(It.IsAny>>())) + .ReturnsAsync(75.5m); + + var repo = new SqlInventoryRepository(mockExecutor.Object); + var result = await repo.GetCountAsync(productInstanceId: 1); + + Assert.Equal(75.5m, result); + mockExecutor.Verify( + e => e.ExecuteAsync(It.IsAny>>()), + Times.Once); + } + + [Fact] + public async Task GetCountAsync_ReturnsZeroWhenExecutorReturnsZero() + { + var mockExecutor = new Mock(); + mockExecutor + .Setup(e => e.ExecuteAsync(It.IsAny>>())) + .ReturnsAsync(0m); + + var repo = new SqlInventoryRepository(mockExecutor.Object); + var result = await repo.GetCountAsync(productInstanceId: 99); + + Assert.Equal(0m, result); + } + + [Fact] + public async Task AddBatchAsync_WithEmptyList_DoesNotThrow() + { + var mockExecutor = new Mock(); + mockExecutor + .Setup(e => e.ExecuteAsync(It.IsAny>())) + .Returns(Task.CompletedTask); + + var repo = new SqlInventoryRepository(mockExecutor.Object); + + // EVAL: Empty batch is valid — no-op, no transactions inserted. + var exception = await Record.ExceptionAsync(() => + repo.AddBatchAsync(new List())); + + Assert.Null(exception); + } + + [Fact] + public async Task RemoveBatchAsync_WithEmptyList_DoesNotThrow() + { + var mockExecutor = new Mock(); + mockExecutor + .Setup(e => e.ExecuteAsync(It.IsAny>())) + .Returns(Task.CompletedTask); + + var repo = new SqlInventoryRepository(mockExecutor.Object); + + var exception = await Record.ExceptionAsync(() => + repo.RemoveBatchAsync(new List())); + + Assert.Null(exception); + } + + [Fact] + public async Task AddAsync_DelegatesToExecutorAndReturnsTransactionId() + { + var mockExecutor = new Mock(); + mockExecutor + .Setup(e => e.ExecuteAsync(It.IsAny>>())) + .ReturnsAsync(42); + + var repo = new SqlInventoryRepository(mockExecutor.Object); + var transactionId = await repo.AddAsync(productInstanceId: 1, quantity: 10m); + + Assert.Equal(42, transactionId); + } + + [Fact] + public async Task RemoveAsync_DelegatesToExecutorAndReturnsTransactionId() + { + var mockExecutor = new Mock(); + mockExecutor + .Setup(e => e.ExecuteAsync(It.IsAny>>())) + .ReturnsAsync(99); + + var repo = new SqlInventoryRepository(mockExecutor.Object); + var transactionId = await repo.RemoveAsync(productInstanceId: 2, quantity: 5m); + + Assert.Equal(99, transactionId); + } } } diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositorySearchTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositorySearchTests.cs new file mode 100644 index 0000000..8411839 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositorySearchTests.cs @@ -0,0 +1,86 @@ +using Moq; +using Sparcpoint.Inventory.Abstractions; +using Sparcpoint.Inventory.SqlServer; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Collections.Generic; +using System.Data; +using System.Threading.Tasks; +using Xunit; + +namespace Sparcpoint.Inventory.Tests +{ + // EVAL: These tests verify that SearchAsync delegates to the executor and + // correctly passes filter state. SQL execution is mocked — integration tests + // would be the next layer to verify actual query correctness against the DB. + public class SqlProductRepositorySearchTests + { + [Fact] + public async Task SearchAsync_WithNullFilter_DelegatesToExecutor() + { + var mockExecutor = new Mock(); + mockExecutor + .Setup(e => e.ExecuteAsync(It.IsAny>>>())) + .ReturnsAsync(new List()); + + var repo = new SqlProductRepository(mockExecutor.Object); + + // Null filter = no criteria = returns all products (unbounded) + var exception = await Record.ExceptionAsync(() => repo.SearchAsync(null)); + Assert.Null(exception); + } + + [Fact] + public async Task SearchAsync_WithEmptyFilter_DelegatesToExecutor() + { + var mockExecutor = new Mock(); + mockExecutor + .Setup(e => e.ExecuteAsync(It.IsAny>>>())) + .ReturnsAsync(new List()); + + var repo = new SqlProductRepository(mockExecutor.Object); + var exception = await Record.ExceptionAsync(() => + repo.SearchAsync(new ProductSearchFilter())); + + Assert.Null(exception); + } + + [Fact] + public async Task AddAsync_CallsExecutorExactlyOnce() + { + var mockExecutor = new Mock(); + mockExecutor + .Setup(e => e.ExecuteAsync(It.IsAny>>())) + .ReturnsAsync(1); + + var repo = new SqlProductRepository(mockExecutor.Object); + await repo.AddAsync(new CreateProductRequest + { + Name = "Test Product", + Description = "A test product" + }); + + mockExecutor.Verify( + e => e.ExecuteAsync(It.IsAny>>()), + Times.Once); + } + + [Fact] + public async Task AddAsync_ReturnsProductIdFromExecutor() + { + var mockExecutor = new Mock(); + mockExecutor + .Setup(e => e.ExecuteAsync(It.IsAny>>())) + .ReturnsAsync(15); + + var repo = new SqlProductRepository(mockExecutor.Object); + var result = await repo.AddAsync(new CreateProductRequest + { + Name = "Widget Pro", + Description = "Professional widget" + }); + + Assert.Equal(15, result); + } + } +} From 154f12a4a54ffabff72bcfe05da7cea04cc27613 Mon Sep 17 00:00:00 2001 From: Luis Rodriguez Date: Wed, 29 Apr 2026 01:24:27 -0500 Subject: [PATCH 5/8] Add pagination support to product search API Introduce optional page and pageSize parameters to the product search endpoint and data layer. Update ProductSearchFilter to support pagination. Refactor SQL query construction to use SqlServerQueryProvider for WHERE and ORDER BY clauses, and add OFFSET-FETCH pagination logic in SqlProductRepository. Clamp pageSize to 200 and validate page >= 1. Update documentation and ensure backward compatibility when pagination is not specified. Refactor SqlInventoryRepository for consistency. --- .../Controllers/InventoryController.cs | 6 +- .../Controllers/ProductController.cs | 26 +++-- .../Filters/ApiExceptionFilter.cs | 21 +--- Development Project/Interview.Web/Startup.cs | 2 +- .../Filters/ProductSearchFilter.cs | 12 ++ .../Interfaces/IInventoryRepository.cs | 6 +- .../Interfaces/IProductRepository.cs | 2 +- .../Models/Product.cs | 2 +- .../Requests/CreateCategoryRequest.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 5 +- .../SqlCategoryRepository.cs | 4 +- .../SqlInventoryRepository.cs | 81 +++++++------ .../SqlProductRepository.cs | 108 +++++++++++------- .../IntegrationTests.cs | 35 +++--- .../SqlCategoryRepositoryTests.cs | 6 +- .../SqlInventoryRepositoryTests.cs | 2 +- .../SqlProductRepositorySearchTests.cs | 5 +- .../SqlProductRepositoryTests.cs | 5 +- .../SqlServerExecutor.cs | 4 +- 19 files changed, 183 insertions(+), 151 deletions(-) diff --git a/Development Project/Interview.Web/Controllers/InventoryController.cs b/Development Project/Interview.Web/Controllers/InventoryController.cs index 80a9f93..779071c 100644 --- a/Development Project/Interview.Web/Controllers/InventoryController.cs +++ b/Development Project/Interview.Web/Controllers/InventoryController.cs @@ -76,9 +76,9 @@ public async Task RemoveInventoryBatch([FromBody] IEnumerable /// Deletes a specific transaction, effectively undoing its inventory effect. - /// EVAL: Deleting the row is the undo mechanism — cleaner than a compensating transaction - /// because it leaves no audit noise for a user-initiated correction. - /// Returns 404 if the transaction does not exist (handled by ApiExceptionFilter). + /// EVAL: deleting the row is the undo mechanism - cleaner than a compensating transaction + /// since it leaves no audit noise for a user correction. Returns 404 if the transaction + /// doesn't exist, caught by ApiExceptionFilter. /// [HttpDelete("transactions/{transactionId:int}")] [ProducesResponseType(204)] diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs index 71387fe..796ee13 100644 --- a/Development Project/Interview.Web/Controllers/ProductController.cs +++ b/Development Project/Interview.Web/Controllers/ProductController.cs @@ -9,9 +9,8 @@ namespace Interview.Web.Controllers /// /// Manages product catalog operations: creating and searching products. /// - // EVAL: [ApiController] enables automatic model validation (400 on invalid requests) - // and removes the need for explicit [FromBody] on complex POST parameters. - // ControllerBase is used instead of Controller — no Razor View support needed for a pure API. + // EVAL: [ApiController] gives us automatic model validation (400 on bad requests) and drops the + // need for explicit [FromBody] on POST params. ControllerBase because there's no Razor views here. [ApiController] [Route("api/v1/products")] public class ProductController : ControllerBase @@ -33,8 +32,8 @@ public ProductController(IProductRepository products) public async Task AddProduct([FromBody] CreateProductRequest request) { var productId = await _Products.AddAsync(request); - // EVAL: Returning 201 with the new ID. A full REST implementation would point - // to a GET /products/{id} endpoint; that endpoint is out of scope for this assignment. + // EVAL: returning 201 with the new ID - a full REST impl would return a Location header + // pointing to GET /products/{id} but that's out of scope here return StatusCode(201, productId); } @@ -43,25 +42,32 @@ public async Task AddProduct([FromBody] CreateProductRequest requ /// All supplied fields are combined with AND semantics. /// /// Optional partial name match (case-insensitive LIKE). - /// Optional category IDs — product must belong to at least one. + /// Optional category IDs - product must belong to at least one. /// Optional metadata key to filter by. /// Optional metadata value to filter by (paired with attributeKey). + /// Optional 1-based page number. Requires pageSize to activate pagination. + /// Optional page size (max 200). Requires page to activate pagination. [HttpGet("search")] [ProducesResponseType(typeof(IEnumerable), 200)] + [ProducesResponseType(400)] public async Task SearchProducts( [FromQuery] string name = null, [FromQuery] int[] categoryIds = null, [FromQuery] string attributeKey = null, - [FromQuery] string attributeValue = null) + [FromQuery] string attributeValue = null, + [FromQuery] int? page = null, + [FromQuery] int? pageSize = null) { var filter = new ProductSearchFilter { Name = name, - CategoryIds = categoryIds?.Length > 0 ? categoryIds : null + CategoryIds = categoryIds?.Length > 0 ? categoryIds : null, + Page = page, + PageSize = pageSize }; - // EVAL: Single key/value attribute filter via query string for simplicity. - // A production API would use a POST body for richer multi-attribute searches. + // EVAL: single key/value attribute filter via query string for simplicity - a proper + // production API would take a POST body for richer multi-attribute searches if (!string.IsNullOrWhiteSpace(attributeKey) && !string.IsNullOrWhiteSpace(attributeValue)) { filter.Attributes = new Dictionary diff --git a/Development Project/Interview.Web/Filters/ApiExceptionFilter.cs b/Development Project/Interview.Web/Filters/ApiExceptionFilter.cs index 6b01548..7f9b9b8 100644 --- a/Development Project/Interview.Web/Filters/ApiExceptionFilter.cs +++ b/Development Project/Interview.Web/Filters/ApiExceptionFilter.cs @@ -6,22 +6,11 @@ namespace Interview.Web.Filters { /// - /// EVAL: Global exception filter centralizes HTTP status mapping for domain exceptions. - /// Without this, any unhandled exception thrown by a repository bubbles up as a 500. - /// Adding it once here keeps every controller clean — no per-endpoint try/catch boilerplate. - /// - /// Mapping rationale: - /// ArgumentException (incl. ArgumentNullException, - /// ArgumentOutOfRangeException) → 400 Bad Request (invalid caller input; - /// ArgumentOutOfRangeException is a subclass of ArgumentException so the same case arm - /// handles it automatically — no separate branch needed) - /// InvalidOperationException → 404 Not Found (entity not found) - /// SqlException error 547 (FK violation) → 400 Bad Request (TOCTOU fallback — - /// repositories pre-validate FKs but a concurrent delete between the check and the - /// INSERT can still produce a 547; we catch it here so it never surfaces as a 500) - /// - /// To add a new mapping (e.g. ConflictException → 409), extend the switch here - /// without touching any controller. + /// EVAL: maps exceptions to HTTP codes in one place so controllers don't need try/catch blocks + /// everywhere. ArgumentException (including Null and OutOfRange subclasses) becomes 400, + /// InvalidOperationException becomes 404, and SQL FK violations (error 547) also get caught + /// as 400 as a TOCTOU fallback - repos pre-validate FKs but a concurrent delete between + /// check and INSERT can still produce a 547. To add a new mapping just extend the switch here. /// public class ApiExceptionFilter : IActionFilter { diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs index 6aee0bd..c9848ad 100644 --- a/Development Project/Interview.Web/Startup.cs +++ b/Development Project/Interview.Web/Startup.cs @@ -21,7 +21,7 @@ public void ConfigureServices(IServiceCollection services) { services.AddControllers(options => { - // EVAL: ApiExceptionFilter registered globally — maps ArgumentException → 400 + // EVAL: ApiExceptionFilter registered globally - maps ArgumentException → 400 // and InvalidOperationException → 404 without per-controller try/catch. options.Filters.Add(); }); diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs index a97417b..9e9a0c6 100644 --- a/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs @@ -11,5 +11,17 @@ public class ProductSearchFilter public string Name { get; set; } public IDictionary Attributes { get; set; } public int[] CategoryIds { get; set; } + + /// + /// 1-based page number. Null disables pagination. Must be >= 1 when supplied. + /// Both Page and PageSize must be non-null for pagination to activate. + /// + public int? Page { get; set; } + + /// + /// Number of rows per page. Null disables pagination. Clamped to a maximum of 200. + /// Both Page and PageSize must be non-null for pagination to activate. + /// + public int? PageSize { get; set; } } } diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IInventoryRepository.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IInventoryRepository.cs index f6f350f..e473360 100644 --- a/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IInventoryRepository.cs +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IInventoryRepository.cs @@ -12,7 +12,7 @@ public interface IInventoryRepository Task AddAsync(int productInstanceId, decimal quantity, string typeCategory = null); /// - /// Bulk add — all items processed within a single transaction. + /// Bulk add - all items processed within a single transaction. /// Task AddBatchAsync(IEnumerable items); @@ -23,12 +23,12 @@ public interface IInventoryRepository Task RemoveAsync(int productInstanceId, decimal quantity, string typeCategory = null); /// - /// Bulk remove — all items processed within a single transaction. + /// Bulk remove - all items processed within a single transaction. /// Task RemoveBatchAsync(IEnumerable items); /// - /// EVAL: "Undo" mechanism — deleting the transaction row reverses its effect + /// EVAL: "Undo" mechanism - deleting the transaction row reverses its effect /// on inventory count without requiring a compensating transaction. /// Task DeleteTransactionAsync(int transactionId); diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IProductRepository.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IProductRepository.cs index 7cada7d..a585f30 100644 --- a/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IProductRepository.cs +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IProductRepository.cs @@ -4,7 +4,7 @@ namespace Sparcpoint.Inventory.Abstractions { /// - /// EVAL: Repository pattern — isolates data access behind a clean interface, + /// EVAL: Repository pattern - isolates data access behind a clean interface, /// allowing SQL Server to be swapped for another store without touching callers. /// public interface IProductRepository diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Models/Product.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Models/Product.cs index 63afc03..77893f1 100644 --- a/Development Project/Sparcpoint.Inventory.Abstractions/Models/Product.cs +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Models/Product.cs @@ -18,7 +18,7 @@ public class Product public string ValidSkus { get; set; } public DateTime CreatedTimestamp { get; set; } - // EVAL: Arbitrary metadata — loaded separately after the main product query + // EVAL: Arbitrary metadata - loaded separately after the main product query // to avoid a Cartesian join explosion on multi-attribute products. public List Attributes { get; set; } = new List(); public List CategoryIds { get; set; } = new List(); diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs index dd6e2df..59c6016 100644 --- a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs +++ b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs @@ -14,7 +14,7 @@ public class CreateCategoryRequest public string Description { get; set; } /// - /// Parent category IDs — supports hierarchical categorization. + /// Parent category IDs supports hierarchical categorization. /// Stored in Instances.CategoryCategories. /// public IEnumerable ParentCategoryIds { get; set; } = new List(); diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/Extensions/ServiceCollectionExtensions.cs b/Development Project/Sparcpoint.Inventory.SqlServer/Extensions/ServiceCollectionExtensions.cs index f354feb..611a150 100644 --- a/Development Project/Sparcpoint.Inventory.SqlServer/Extensions/ServiceCollectionExtensions.cs +++ b/Development Project/Sparcpoint.Inventory.SqlServer/Extensions/ServiceCollectionExtensions.cs @@ -6,9 +6,8 @@ namespace Sparcpoint.Inventory.SqlServer { /// - /// EVAL: Extension method pattern keeps Startup.cs clean and makes the inventory - /// feature a single line of registration that any host project can call. - /// New repositories added in the future only require changes here. + /// EVAL: keeps Startup.cs clean - the whole inventory feature is one line from any host project. + /// New repositories just get added here, nothing else to touch. /// public static class ServiceCollectionExtensions { diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs b/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs index 67748e4..87a0d8d 100644 --- a/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs +++ b/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs @@ -37,8 +37,8 @@ INSERT INTO [Instances].[Categories] ([Name], [Description]) var parentIds = request.ParentCategoryIds.ToList(); if (parentIds.Count > 0) { - // EVAL: Validate all parent IDs exist before inserting to produce a clear - // 400 instead of letting the FK violation surface as a 500. + // EVAL: validate parent IDs before inserting so we return a 400 instead of + // letting the FK violation blow up as a 500 var existingCount = await conn.ExecuteScalarAsync( "SELECT COUNT(*) FROM [Instances].[Categories] WHERE [InstanceId] IN @Ids", new { Ids = parentIds }, tx); diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs b/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs index fcbc25c..d480026 100644 --- a/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs +++ b/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs @@ -32,7 +32,7 @@ public async Task AddBatchAsync(IEnumerable items) { if (items == null) throw new ArgumentNullException(nameof(items)); - // EVAL: All batch items share a single transaction — either all succeed or all roll back. + // EVAL: all batch items run in one transaction - all succeed or all roll back await _Executor.ExecuteAsync(async (conn, tx) => { foreach (var item in items) @@ -49,7 +49,7 @@ public async Task RemoveAsync(int productInstanceId, decimal quantity, stri if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity to remove must be positive."); - // Store removals as negative quantities — net SUM gives the true on-hand count. + // Store removals as negative quantities - net SUM gives the true on-hand count. return await _Executor.ExecuteAsync(async (conn, tx) => await InsertTransactionAsync(conn, tx, productInstanceId, -quantity, typeCategory)); } @@ -84,10 +84,9 @@ await _Executor.ExecuteAsync(async (conn, tx) => public async Task GetCountAsync(int productInstanceId) { - // EVAL: CompletedTimestamp IS NOT NULL is the filter for "committed" transactions. - // All transactions inserted by this repository set CompletedTimestamp = SYSUTCDATETIME() immediately, - // so the filter is effectively "all transactions". The column is preserved to support a future - // "pending transaction" workflow where a transaction is created but not yet confirmed. + // EVAL: CompletedTimestamp IS NOT NULL filters for committed transactions - right now + // every insert sets it immediately so this hits everything, but the column is there + // to support a future pending transaction workflow if we ever need it return await _Executor.ExecuteAsync(async (conn, tx) => await conn.ExecuteScalarAsync(@" SELECT ISNULL(SUM([Quantity]), 0) @@ -101,53 +100,62 @@ public async Task> GetCountByFilterAsync(Prod { return await _Executor.ExecuteAsync(async (conn, tx) => { - var sql = new StringBuilder(@" - SELECT p.[InstanceId] AS ProductInstanceId, - p.[Name] AS ProductName, - ISNULL(SUM(t.[Quantity]), 0) AS TotalQuantity - FROM [Instances].[Products] p - LEFT JOIN [Transactions].[InventoryTransactions] t - ON t.[ProductInstanceId] = p.[InstanceId] - AND t.[CompletedTimestamp] IS NOT NULL - WHERE 1=1"); - - var parameters = new DynamicParameters(); + // EVAL: SqlServerQueryProvider handles dynamic WHERE - SELECT/FROM/JOIN/GROUP BY are + // hardcoded because the provider has no API for those. Same filter approach as + // SqlProductRepository.SearchAsync: Name LIKE uses Where() + AddParameter(), EAV + // attributes use composed EXISTS subqueries with GetNextParameterName() for unique + // param names, CategoryIds uses a raw Where() with an IN list that Dapper expands natively. + var provider = SqlServerQueryProvider.Empty; if (!string.IsNullOrWhiteSpace(filter?.Name)) { - sql.Append(" AND p.[Name] LIKE @Name"); - parameters.Add("Name", $"%{filter.Name}%"); + provider.Where("p.[Name] LIKE @Name"); + provider.AddParameter("Name", $"%{filter.Name}%"); } if (filter?.Attributes != null) { - int idx = 0; foreach (var attr in filter.Attributes) { - var k = $"AttrKey{idx}"; - var v = $"AttrVal{idx}"; - sql.Append($@" AND EXISTS ( - SELECT 1 FROM [Instances].[ProductAttributes] pa{idx} - WHERE pa{idx}.[InstanceId] = p.[InstanceId] - AND pa{idx}.[Key] = @{k} AND pa{idx}.[Value] = @{v})"); - parameters.Add(k, attr.Key); - parameters.Add(v, attr.Value); - idx++; + var keyParam = provider.GetNextParameterName("AttrKey"); // e.g. "AttrKey0" + var valParam = provider.GetNextParameterName("AttrVal"); // e.g. "AttrVal1" + var alias = keyParam.Substring("AttrKey".Length); // e.g. "0" + provider.Where($@"EXISTS ( + SELECT 1 FROM [Instances].[ProductAttributes] pa{alias} + WHERE pa{alias}.[InstanceId] = p.[InstanceId] + AND pa{alias}.[Key] = @{keyParam} AND pa{alias}.[Value] = @{valParam})"); + provider.AddParameter(keyParam, attr.Key); + provider.AddParameter(valParam, attr.Value); } } if (filter?.CategoryIds != null && filter.CategoryIds.Any()) { - sql.Append(@" AND EXISTS ( + provider.Where(@"EXISTS ( SELECT 1 FROM [Instances].[ProductCategories] pc WHERE pc.[InstanceId] = p.[InstanceId] AND pc.[CategoryInstanceId] IN @CategoryIds)"); - parameters.Add("CategoryIds", filter.CategoryIds); + provider.AddParameter("CategoryIds", filter.CategoryIds); } - sql.Append(" GROUP BY p.[InstanceId], p.[Name] ORDER BY p.[Name]"); + // EVAL: "[Name]" without the "p." alias - SanitizeColumnName rejects "p.[Name]" + // (unbracketed prefix + bracketed name), and it's unambiguous anyway since it's the only [Name] in the SELECT + provider.OrderByAscending("[Name]"); + + var sqlBuilder = new StringBuilder(@" + SELECT p.[InstanceId] AS ProductInstanceId, + p.[Name] AS ProductName, + ISNULL(SUM(t.[Quantity]), 0) AS TotalQuantity + FROM [Instances].[Products] p + LEFT JOIN [Transactions].[InventoryTransactions] t + ON t.[ProductInstanceId] = p.[InstanceId] + AND t.[CompletedTimestamp] IS NOT NULL"); + + sqlBuilder.Append($" {provider.WhereClause}"); + sqlBuilder.Append(" GROUP BY p.[InstanceId], p.[Name]"); + sqlBuilder.Append($" {provider.OrderByClause}"); - return await conn.QueryAsync(sql.ToString(), parameters, tx); + return await conn.QueryAsync(sqlBuilder.ToString(), provider.Parameters, tx); }); } @@ -155,10 +163,9 @@ private static async Task InsertTransactionAsync( IDbConnection conn, IDbTransaction tx, int productInstanceId, decimal quantity, string typeCategory) { - // EVAL: Guard against FK violation (FK_InventoryTransactions_Products) by checking - // product existence within the same transaction before the INSERT. This produces - // a clear 400 instead of letting SQL Server throw a 547 FK error as a 500. - // Called by AddAsync, RemoveAsync, AddBatchAsync, and RemoveBatchAsync — one check covers all paths. + // EVAL: check product existence inside the same transaction before the INSERT so we get a + // clean 400 instead of a 547 FK error surfacing as a 500 - this helper is shared by + // AddAsync, RemoveAsync, AddBatchAsync, and RemoveBatchAsync so one check covers all paths var productExists = await conn.ExecuteScalarAsync( "SELECT COUNT(1) FROM [Instances].[Products] WHERE [InstanceId] = @Id", new { Id = productInstanceId }, tx); diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs b/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs index ea546c0..d4bc929 100644 --- a/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs +++ b/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs @@ -10,9 +10,8 @@ namespace Sparcpoint.Inventory.SqlServer { /// - /// EVAL: Dapper is chosen over EF Core deliberately — the schema uses EAV (ProductAttributes) - /// and dynamic WHERE clause composition that benefit from explicit SQL control. - /// Dapper gives full visibility into every query sent to the database. + /// EVAL: went with Dapper instead of EF Core because the EAV schema and dynamic WHERE building + /// would get messy with an ORM - easier to just write the SQL directly /// public class SqlProductRepository : IProductRepository { @@ -45,8 +44,7 @@ INSERT INTO [Instances].[Products] ([Name], [Description], [ProductImageUris], [ }, tx); // Insert arbitrary metadata attributes - // EVAL: Iterated inserts here are acceptable for write operations. - // For bulk product imports, a TVP (Table-Valued Parameter) would be more efficient. + // EVAL: row-by-row inserts are fine for normal writes - for bulk imports a TVP would be faster if (request.Attributes != null) { foreach (var attr in request.Attributes) @@ -64,8 +62,8 @@ INSERT INTO [Instances].[ProductAttributes] ([InstanceId], [Key], [Value]) var categoryIds = request.CategoryIds.ToList(); if (categoryIds.Count > 0) { - // EVAL: Validate all category IDs exist before inserting to produce a 400 - // instead of FK_ProductCategories_Categories bubbling up as a 500. + // EVAL: validate category IDs before inserting so we return a 400 instead of + // letting the FK violation (FK_ProductCategories_Categories) blow up as a 500 var existingCount = await conn.ExecuteScalarAsync( "SELECT COUNT(*) FROM [Instances].[Categories] WHERE [InstanceId] IN @Ids", new { Ids = categoryIds }, tx); @@ -93,56 +91,88 @@ public async Task> SearchAsync(ProductSearchFilter filter) { return await _Executor.ExecuteAsync(async (conn, tx) => { - var sql = new StringBuilder(@" - SELECT p.[InstanceId], p.[Name], p.[Description], p.[ProductImageUris], p.[ValidSkus], p.[CreatedTimestamp] - FROM [Instances].[Products] p - WHERE 1=1"); - - var parameters = new DynamicParameters(); - - // Name filter: partial match + // EVAL: SqlServerQueryProvider handles WHERE and ORDER BY - SELECT/FROM is hardcoded + // because the provider has no API for those clauses, it just adds to a base query. + // Name LIKE uses Where() + AddParameter() because WhereEquals generates "=" not LIKE. + // CategoryIds and EAV filters use raw Where() with composed subquery strings because + // the provider's WhereExists formats as "{col} EXISTS {expr}" which isn't valid SQL here. + // GetNextParameterName() keeps parameter names unique across the whole query and + // AddParameter() registers each value so provider.Parameters has the full bag for Dapper. + var provider = SqlServerQueryProvider.Empty; + + // Name filter: partial match (LIKE, not equality WhereEquals does not apply) if (!string.IsNullOrWhiteSpace(filter?.Name)) { - sql.Append(" AND p.[Name] LIKE @Name"); - parameters.Add("Name", $"%{filter.Name}%"); + provider.Where("p.[Name] LIKE @Name"); + provider.AddParameter("Name", $"%{filter.Name}%"); } - // EVAL: Each attribute filter becomes an EXISTS subquery rather than a JOIN. - // Reason: a JOIN on EAV tables causes row multiplication — one product with - // N attributes matching M filter criteria would produce N*M rows before DISTINCT. - // EXISTS is cleaner and more predictable in its query plan. + // EVAL: each attribute filter is an EXISTS subquery, not a JOIN - joining on EAV tables + // causes row multiplication (N attributes * M filter criteria before DISTINCT), EXISTS + // is cleaner and has a more predictable query plan. + // Key and val get different numeric suffixes from GetNextParameterName() - that's on + // purpose so every param name is globally unique. The table alias uses just the key's + // suffix (via Substring) as the per-iteration discriminator, val suffix only shows up + // as a param name so there's no conflict. if (filter?.Attributes != null) { - int attrIndex = 0; foreach (var attr in filter.Attributes) { - var keyParam = $"AttrKey{attrIndex}"; - var valParam = $"AttrVal{attrIndex}"; - sql.Append($@" AND EXISTS ( - SELECT 1 FROM [Instances].[ProductAttributes] pa{attrIndex} - WHERE pa{attrIndex}.[InstanceId] = p.[InstanceId] - AND pa{attrIndex}.[Key] = @{keyParam} - AND pa{attrIndex}.[Value] = @{valParam})"); - parameters.Add(keyParam, attr.Key); - parameters.Add(valParam, attr.Value); - attrIndex++; + var keyParam = provider.GetNextParameterName("AttrKey"); // e.g. "AttrKey0" + var valParam = provider.GetNextParameterName("AttrVal"); // e.g. "AttrVal1" + var alias = keyParam.Substring("AttrKey".Length); // e.g. "0" - unique per iteration + provider.Where($@"EXISTS ( + SELECT 1 FROM [Instances].[ProductAttributes] pa{alias} + WHERE pa{alias}.[InstanceId] = p.[InstanceId] + AND pa{alias}.[Key] = @{keyParam} + AND pa{alias}.[Value] = @{valParam})"); + provider.AddParameter(keyParam, attr.Key); + provider.AddParameter(valParam, attr.Value); } } - // Category filter: product must belong to at least one of the specified categories + // Category filter: product must belong to at least one of the specified categories. + // The IN list is passed as a parameter; Dapper expands int[] to an IN clause natively. if (filter?.CategoryIds != null && filter.CategoryIds.Any()) { - sql.Append(@" AND EXISTS ( + provider.Where(@"EXISTS ( SELECT 1 FROM [Instances].[ProductCategories] pc WHERE pc.[InstanceId] = p.[InstanceId] AND pc.[CategoryInstanceId] IN @CategoryIds)"); - parameters.Add("CategoryIds", filter.CategoryIds); + provider.AddParameter("CategoryIds", filter.CategoryIds); + } + + // ORDER BY via the provider so the clause is managed consistently + provider.OrderByAscending("[Name]"); + + var sqlBase = @" + SELECT p.[InstanceId], p.[Name], p.[Description], p.[ProductImageUris], p.[ValidSkus], p.[CreatedTimestamp] + FROM [Instances].[Products] p"; + + var sqlBuilder = new StringBuilder(sqlBase); + sqlBuilder.Append($" {provider.WhereClause}"); + sqlBuilder.Append($" {provider.OrderByClause}"); + + // EVAL: OFFSET-FETCH needs ORDER BY which is guaranteed above. Pagination only kicks in + // when both Page and PageSize are supplied - if only one is given we ignore it and return + // everything. PageSize is clamped to 200 instead of throwing because that's friendlier + // for clients that pass a huge value - request stays valid and the DB is protected. + if (filter?.Page != null && filter?.PageSize != null) + { + // EVAL: Page < 1 produces a negative OFFSET which is a SQL error, so we throw + // instead of clamping - this is always a caller bug, not just an oversized value + if (filter.Page < 1) + throw new ArgumentOutOfRangeException(nameof(filter.Page), "Page must be >= 1."); + + var pageSize = Math.Min(filter.PageSize.Value, 200); + provider.AddParameter("Page", filter.Page.Value); + provider.AddParameter("PageSize", pageSize); + sqlBuilder.Append(" OFFSET (@Page - 1) * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY"); } - // EVAL: With no filter criteria, this query returns the full product catalog. - // For large catalogs, a caller should supply at least one filter or use pagination. - // Pagination (TOP/OFFSET-FETCH) is the recommended extension point here. - var products = (await conn.QueryAsync(sql.ToString(), parameters, tx)).ToList(); + // provider.Parameters returns a defensive copy (Dictionary). + // Dapper accepts IDictionary directly - no DynamicParameters wrapper needed. + var products = (await conn.QueryAsync(sqlBuilder.ToString(), provider.Parameters, tx)).ToList(); // Load attributes and category links for all returned products in two bulk queries // to avoid N+1 queries diff --git a/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs b/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs index 8776e60..9b56832 100644 --- a/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs +++ b/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs @@ -7,28 +7,19 @@ using System.Threading.Tasks; using Xunit; -// EVAL: Prerequisite — the database SparcpointInventory must exist on the local SQL Server instance -// (Server=(localdb)\MSSQLLocalDB) and have the full schema published before running these tests. -// The schema is typically applied via the SQL scripts or EF migrations in the project. Without it -// the tests fail immediately with "Invalid object name" SQL errors, which is expected — integration -// tests are not self-bootstrapping by design. +// EVAL: SparcpointInventory must exist on (localdb)\MSSQLLocalDB with the full schema applied +// before running these, use the SQL scripts in the project. Without it you'll get +// "Invalid object name" errors right away, these tests don't self-bootstrap. namespace Sparcpoint.Inventory.Tests { - // EVAL: TransactionScope is used here instead of TRUNCATE/DELETE between tests for three reasons: - // 1. Speed — a rollback is a single log operation; truncating multiple tables with FK constraints - // requires disabling constraints or a specific deletion order, both slower and fragile. - // 2. No DDL permissions required — TRUNCATE is a DDL statement that requires ALTER TABLE - // permission; TransactionScope only needs the DML permissions the app already holds. - // 3. Safety on the shared dev database — a test failure or a crash mid-test leaves no orphaned - // data because the scope.Dispose() without scope.Complete() issues an automatic rollback. - // There is zero risk of corrupting the development dataset. - // - // How it interacts with SqlServerExecutor: Microsoft.Data.SqlClient automatically enlists new - // connections in an ambient TransactionScope. When SqlServerExecutor calls conn.BeginTransaction() - // it gets a nested transaction that participates in the ambient scope. Disposing the scope - // without calling Complete() rolls back the ambient transaction and therefore all SQL work done - // within the test, regardless of how many internal commits SqlServerExecutor issued. + // EVAL: using TransactionScope instead of TRUNCATE/DELETE between tests for a few reasons: + // rollback is one log operation vs truncating tables with FK constraints which needs a specific + // deletion order, TRUNCATE also needs ALTER TABLE perms we don't have, and if a test crashes + // mid-run scope.Dispose() without Complete() auto-rolls back so no orphaned data on the dev DB. + // Works with SqlServerExecutor because Microsoft.Data.SqlClient auto-enlists new connections in + // an ambient TransactionScope - conn.BeginTransaction() gets a nested transaction that + // participates in the scope, so disposing without Complete() rolls back everything. [Trait("Category", "Integration")] public class IntegrationTests { @@ -68,7 +59,7 @@ public async Task AddProduct_ThenSearch_ByAttribute_ReturnsProduct() Assert.Contains(results, p => p.Name == "Integration Test Widget"); - // No scope.Complete() — automatic rollback on dispose keeps the database clean. + // No scope.Complete() - automatic rollback on dispose keeps the database clean. } // ----------------------------------------------------------------------------------------- @@ -96,7 +87,7 @@ public async Task AddInventory_ThenGetCount_ReturnsCorrectQuantity() Assert.Equal(50m, count); - // No scope.Complete() — automatic rollback. + // No scope.Complete() - automatic rollback. } // ----------------------------------------------------------------------------------------- @@ -125,7 +116,7 @@ public async Task DeleteTransaction_UndoesInventoryEffect() Assert.Equal(0m, count); - // No scope.Complete() — automatic rollback. + // No scope.Complete() - automatic rollback. } } } diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlCategoryRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlCategoryRepositoryTests.cs index 288cc01..cc3d74d 100644 --- a/Development Project/Sparcpoint.Inventory.Tests/SqlCategoryRepositoryTests.cs +++ b/Development Project/Sparcpoint.Inventory.Tests/SqlCategoryRepositoryTests.cs @@ -74,9 +74,9 @@ public async Task AddAsync_DelegatesToExecutorAndReturnsCategoryId() public async Task GetAllAsync_DelegatesToExecutor() { var mockExecutor = new Mock(); - // EVAL: The setup type must match the inferred T in ExecuteAsync. - // GetAllAsync calls .ToList() internally, so T = List, not IEnumerable. - // Using IEnumerable causes Moq to miss the setup and return null. + // EVAL: setup type must match the actual T in ExecuteAsync - GetAllAsync calls .ToList() + // internally so T is List not IEnumerable, using the wrong one makes + // Moq miss the setup and return null mockExecutor .Setup(e => e.ExecuteAsync(It.IsAny>>>())) .ReturnsAsync(new System.Collections.Generic.List()); diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs index c4ba7e7..fd4a665 100644 --- a/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs +++ b/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs @@ -96,7 +96,7 @@ public async Task AddBatchAsync_WithEmptyList_DoesNotThrow() var repo = new SqlInventoryRepository(mockExecutor.Object); - // EVAL: Empty batch is valid — no-op, no transactions inserted. + // EVAL: empty batch is valid - just a no-op, nothing gets inserted var exception = await Record.ExceptionAsync(() => repo.AddBatchAsync(new List())); diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositorySearchTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositorySearchTests.cs index 8411839..1931572 100644 --- a/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositorySearchTests.cs +++ b/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositorySearchTests.cs @@ -10,9 +10,8 @@ namespace Sparcpoint.Inventory.Tests { - // EVAL: These tests verify that SearchAsync delegates to the executor and - // correctly passes filter state. SQL execution is mocked — integration tests - // would be the next layer to verify actual query correctness against the DB. + // EVAL: these verify SearchAsync delegates to the executor and passes filter state correctly - + // SQL is mocked here, integration tests handle actual query correctness against the DB public class SqlProductRepositorySearchTests { [Fact] diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositoryTests.cs index 3d067b0..11fe557 100644 --- a/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositoryTests.cs +++ b/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositoryTests.cs @@ -8,9 +8,8 @@ namespace Sparcpoint.Inventory.Tests { - // EVAL: Unit tests use Moq to isolate repositories from a real SQL Server instance. - // These tests validate guard clauses and request validation. - // Integration tests (hitting a real DB) would be the next layer. + // EVAL: using Moq to keep repos isolated from a real SQL Server instance - these just cover + // guard clauses and input validation, integration tests handle actual query correctness public class SqlProductRepositoryTests { [Fact] diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/SqlServerExecutor.cs b/Development Project/Sparcpoint.SqlServer.Abstractions/SqlServerExecutor.cs index 2eb0146..13ccc44 100644 --- a/Development Project/Sparcpoint.SqlServer.Abstractions/SqlServerExecutor.cs +++ b/Development Project/Sparcpoint.SqlServer.Abstractions/SqlServerExecutor.cs @@ -27,8 +27,8 @@ public T Execute(Func command) } catch { - // EVAL: Rollback is critical — without this, an exception leaves the transaction - // open until the connection is disposed, which can cause deadlocks under load. + // EVAL: need the rollback here - if we skip it and something throws, the transaction + // stays open until the connection drops which can deadlock under load sqlTrans.Rollback(); throw; } From 672b644d18ca2c7fe6ffe4059fc0a87bedddd25e Mon Sep 17 00:00:00 2001 From: Luis Rodriguez Date: Wed, 29 Apr 2026 02:05:09 -0500 Subject: [PATCH 6/8] Add XML doc examples and Swagger XML integration Added detailed XML documentation remarks with example requests to key controller actions for improved API documentation. Enabled XML doc file generation in the project and configured Swagger to include XML comments, enhancing API discoverability and usability in the Swagger UI. Suppressed warning 1591 for missing XML comments. --- .../Controllers/CategoryController.cs | 21 +++++++++ .../Controllers/InventoryController.cs | 44 +++++++++++++++++++ .../Controllers/ProductController.cs | 18 ++++++++ .../Interview.Web/Interview.Web.csproj | 2 + Development Project/Interview.Web/Startup.cs | 4 ++ 5 files changed, 89 insertions(+) diff --git a/Development Project/Interview.Web/Controllers/CategoryController.cs b/Development Project/Interview.Web/Controllers/CategoryController.cs index 3f10537..0b83869 100644 --- a/Development Project/Interview.Web/Controllers/CategoryController.cs +++ b/Development Project/Interview.Web/Controllers/CategoryController.cs @@ -25,6 +25,27 @@ public CategoryController(ICategoryRepository categories) /// by supplying ParentCategoryIds. Hierarchy is stored as an adjacency list. /// Returns 400 if any ParentCategoryId does not reference an existing category. /// + /// + /// Example (root category): + /// + /// POST /api/v1/categories + /// { + /// "name": "Electronics", + /// "description": "Electronic devices and accessories", + /// "parentCategoryIds": [], + /// "attributes": {} + /// } + /// + /// To nest under an existing category supply its ID in parentCategoryIds: + /// + /// { + /// "name": "Smartphones", + /// "description": "Mobile phones and accessories", + /// "parentCategoryIds": [1], + /// "attributes": {} + /// } + /// + /// [HttpPost] [ProducesResponseType(typeof(int), 201)] [ProducesResponseType(400)] diff --git a/Development Project/Interview.Web/Controllers/InventoryController.cs b/Development Project/Interview.Web/Controllers/InventoryController.cs index 779071c..0c09983 100644 --- a/Development Project/Interview.Web/Controllers/InventoryController.cs +++ b/Development Project/Interview.Web/Controllers/InventoryController.cs @@ -24,6 +24,18 @@ public InventoryController(IInventoryRepository inventory) /// /// Adds stock for a single product. Returns the new TransactionId. /// + /// + /// Example: + /// + /// POST /api/v1/inventory/1/add + /// { + /// "quantity": 50, + /// "typeCategory": "PURCHASE" + /// } + /// + /// typeCategory is optional. Common values: PURCHASE, RETURN, ADJUSTMENT + /// + /// [HttpPost("{productId:int}/add")] [ProducesResponseType(typeof(int), 201)] [ProducesResponseType(400)] @@ -39,6 +51,16 @@ public async Task AddInventory( /// Adds stock for multiple products in a single atomic operation. /// All items succeed or all roll back. /// + /// + /// Example: + /// + /// POST /api/v1/inventory/batch/add + /// [ + /// { "productInstanceId": 1, "quantity": 100, "typeCategory": "PURCHASE" }, + /// { "productInstanceId": 2, "quantity": 75, "typeCategory": "PURCHASE" } + /// ] + /// + /// [HttpPost("batch/add")] [ProducesResponseType(204)] [ProducesResponseType(400)] @@ -51,6 +73,18 @@ public async Task AddInventoryBatch([FromBody] IEnumerable /// Removes stock for a single product. Returns the new TransactionId. /// + /// + /// Example: + /// + /// POST /api/v1/inventory/1/remove + /// { + /// "quantity": 5, + /// "typeCategory": "SALE" + /// } + /// + /// typeCategory is optional. Common values: SALE, DAMAGE, ADJUSTMENT + /// + /// [HttpPost("{productId:int}/remove")] [ProducesResponseType(typeof(int), 201)] [ProducesResponseType(400)] @@ -65,6 +99,16 @@ public async Task RemoveInventory( /// /// Removes stock for multiple products in a single atomic operation. /// + /// + /// Example: + /// + /// POST /api/v1/inventory/batch/remove + /// [ + /// { "productInstanceId": 1, "quantity": 10, "typeCategory": "SALE" }, + /// { "productInstanceId": 2, "quantity": 5, "typeCategory": "DAMAGE" } + /// ] + /// + /// [HttpPost("batch/remove")] [ProducesResponseType(204)] [ProducesResponseType(400)] diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs index 796ee13..25167dd 100644 --- a/Development Project/Interview.Web/Controllers/ProductController.cs +++ b/Development Project/Interview.Web/Controllers/ProductController.cs @@ -26,6 +26,24 @@ public ProductController(IProductRepository products) /// Adds a new product with its metadata and category assignments. /// Products cannot be deleted once added (by design per requirements). /// + /// + /// Example: + /// + /// POST /api/v1/products + /// { + /// "name": "iPhone 15 Pro", + /// "description": "Apple flagship smartphone", + /// "productImageUris": "https://example.com/iphone15.jpg", + /// "validSkus": "IPH15PRO-128,IPH15PRO-256", + /// "attributes": { + /// "Brand": "Apple", + /// "Color": "Black", + /// "SKU": "IPH15PRO-128" + /// }, + /// "categoryIds": [1] + /// } + /// + /// [HttpPost] [ProducesResponseType(typeof(int), 201)] [ProducesResponseType(400)] diff --git a/Development Project/Interview.Web/Interview.Web.csproj b/Development Project/Interview.Web/Interview.Web.csproj index d48fa40..0e1b788 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 c9848ad..e852e0a 100644 --- a/Development Project/Interview.Web/Startup.cs +++ b/Development Project/Interview.Web/Startup.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Sparcpoint.Inventory.SqlServer; +using System; namespace Interview.Web { @@ -35,6 +36,9 @@ public void ConfigureServices(IServiceCollection services) Title = "Inventory Management API", Version = "v1" }); + var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = System.IO.Path.Combine(AppContext.BaseDirectory, xmlFile); + c.IncludeXmlComments(xmlPath); }); // EVAL: All inventory repositories are wired via a single extension method. From da0fe6b21435c637a37878fd3c5d901e40469ad2 Mon Sep 17 00:00:00 2001 From: Luis Rodriguez Date: Wed, 29 Apr 2026 02:14:51 -0500 Subject: [PATCH 7/8] Make test DB connection string configurable via env var Integration tests now read the connection string from the INVENTORY_TEST_DB environment variable, falling back to LocalDB if not set. This allows easier configuration for CI/CD and other environments without code changes. Added comments to clarify the new behavior. --- .../Sparcpoint.Inventory.Tests/IntegrationTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs b/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs index 9b56832..b744b9e 100644 --- a/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs +++ b/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs @@ -1,6 +1,7 @@ using Sparcpoint.Inventory.Abstractions; using Sparcpoint.Inventory.SqlServer; using Sparcpoint.SqlServer.Abstractions; +using System; using System.Collections.Generic; using System.Linq; using System.Transactions; @@ -23,8 +24,11 @@ namespace Sparcpoint.Inventory.Tests [Trait("Category", "Integration")] public class IntegrationTests { - private const string ConnectionString = - "Server=(localdb)\\MSSQLLocalDB;Database=SparcpointInventory;Trusted_Connection=True;TrustServerCertificate=True;"; + // EVAL: connection string pulled from env var so CI/CD or other environments can override it + // without touching the code - falls back to LocalDB for local dev + private static readonly string ConnectionString = + Environment.GetEnvironmentVariable("INVENTORY_TEST_DB") + ?? "Server=(localdb)\\MSSQLLocalDB;Database=SparcpointInventory;Trusted_Connection=True;TrustServerCertificate=True;"; // ----------------------------------------------------------------------------------------- // Test 1: Create a product with a known attribute, then verify SearchAsync finds it From e85374f91cf2c1b605464b5fe1e802cb4545d70f Mon Sep 17 00:00:00 2001 From: Luis Rodriguez Date: Tue, 5 May 2026 23:19:14 -0500 Subject: [PATCH 8/8] Add detailed README with setup, API, and design docs Comprehensive README covers system overview, setup instructions, API endpoints, project structure, design decisions, and testing guidance. Includes usage examples and error handling details to aid onboarding and extension. --- Development Project (1)/README.md | 152 ++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 Development Project (1)/README.md diff --git a/Development Project (1)/README.md b/Development Project (1)/README.md new file mode 100644 index 0000000..45a941d --- /dev/null +++ b/Development Project (1)/README.md @@ -0,0 +1,152 @@ +# Inventory Management System + +A reusable, API-driven inventory management system built on ASP.NET Core 8. Designed to handle multiple clients with different product metadata needs without rebuilding the core logic each time. + +## What it does + +- Create products with arbitrary key/value metadata and category assignments +- Search products by name, category, or any metadata attribute +- Add and remove inventory per product (or in bulk) +- Query inventory counts by product or filtered subset +- Undo individual transactions by deleting them + +Products are never deleted — once added, they stay in the system. Inventory is tracked as a ledger of transactions, so the on-hand count is always the net SUM. + +## Prerequisites + +- .NET 8 SDK +- SQL Server LocalDB (ships with Visual Studio) or any SQL Server instance +- The `SparcpointInventory` database with the schema applied + +## Database setup + +Run the SQL scripts in the project against your SQL Server instance before starting the API. The schema creates tables under two schemas: `Instances` (products, categories, attributes) and `Transactions` (inventory ledger). + +Default connection string targets `(localdb)\MSSQLLocalDB`. To use a different instance, update `ConnectionStrings:InventoryDatabase` in `Interview.Web/appsettings.json`. + +## Running the API + +```bash +cd "Interview.Web" +dotnet run +``` + +Swagger UI is available at `https://localhost:{port}/swagger` in all environments. Use it to explore and test every endpoint with example payloads. + +## Project structure + +``` +Sparcpoint.Inventory.Abstractions/ Interfaces, models, request/filter types +Sparcpoint.Inventory.SqlServer/ SQL Server implementations (Dapper) +Sparcpoint.SqlServer.Abstractions/ ISqlExecutor, SqlServerQueryProvider (existing) +Interview.Web/ ASP.NET Core API, controllers, filters, Startup +Sparcpoint.Inventory.Tests/ Unit tests (Moq) + integration tests (LocalDB) +``` + +The Abstractions project defines the contracts. The SqlServer project implements them. The web project wires everything together and exposes it over HTTP. Any new host (worker service, CLI tool) just references SqlServer and calls `services.AddSqlInventoryServices(connectionString)`. + +## API overview + +### Products + +| Method | Route | Description | +|--------|-------|-------------| +| `POST` | `/api/v1/products` | Create a product with metadata and categories | +| `GET` | `/api/v1/products/search` | Search by name, categoryIds, attributeKey/Value, page/pageSize | + +### Categories + +| Method | Route | Description | +|--------|-------|-------------| +| `POST` | `/api/v1/categories` | Create a category with optional parent IDs | +| `GET` | `/api/v1/categories` | List all categories | + +### Inventory + +| Method | Route | Description | +|--------|-------|-------------| +| `POST` | `/api/v1/inventory/{productId}/add` | Add stock — returns transactionId | +| `POST` | `/api/v1/inventory/{productId}/remove` | Remove stock — returns transactionId | +| `POST` | `/api/v1/inventory/batch/add` | Add stock for multiple products (atomic) | +| `POST` | `/api/v1/inventory/batch/remove` | Remove stock for multiple products (atomic) | +| `DELETE` | `/api/v1/inventory/transactions/{id}` | Delete a transaction (undo its effect) | +| `GET` | `/api/v1/inventory/{productId}/count` | Net inventory count for a product | +| `GET` | `/api/v1/inventory/count` | Counts filtered by name, category, or attribute | + +Full request/response examples are in the Swagger UI. + +## Example: create a product + +```json +POST /api/v1/products +{ + "name": "Widget Pro", + "description": "Industrial-grade widget", + "validSkus": "WGT-PRO-BLK,WGT-PRO-WHT", + "attributes": { + "Brand": "ACME", + "Color": "Black", + "SKU": "WGT-PRO-BLK" + }, + "categoryIds": [1] +} +``` + +Returns `201` with the new product ID. + +## Example: add inventory + +```json +POST /api/v1/inventory/1/add +{ + "quantity": 50, + "typeCategory": "PURCHASE" +} +``` + +Returns `201` with the transactionId. To undo: `DELETE /api/v1/inventory/transactions/{transactionId}`. + +## Key design decisions + +**Dapper over Entity Framework** — the EAV attribute schema and dynamic WHERE building work better with explicit SQL. Dapper sits cleanly on top of the existing `ISqlExecutor` abstraction without replacing it. + +**Attribute search uses EXISTS subqueries** — joining on EAV tables causes row multiplication before you can deduplicate. One correlated EXISTS block per attribute avoids that entirely and has a more predictable query plan. + +**Inventory as a ledger** — there's no "current stock" column. Adds insert positive quantities, removes insert negative. The count is always `SUM(Quantity) WHERE CompletedTimestamp IS NOT NULL`. Undo = delete the row. Simple, auditable, no compensating transactions needed. + +**FK pre-validation** — referenced IDs (product, category) are validated with an EXISTS check inside the same transaction before each INSERT. This converts SQL error 547 (FK violation) from an unhandled 500 into a clean 400 with a readable message. The global `ApiExceptionFilter` also catches any 547 that slips through as a TOCTOU fallback. + +**Single extension method for DI** — `services.AddSqlInventoryServices(connectionString)` wires all repositories. Adding a new one means touching one file, not every host project. + +## Running the tests + +### Unit tests + +```bash +dotnet test --filter "Category!=Integration" +``` + +These use Moq to mock `ISqlExecutor` — no database needed, runs in CI without any setup. + +### Integration tests + +Require the `SparcpointInventory` database to be up with the schema applied. + +```bash +# optional: point at a different SQL Server +$env:INVENTORY_TEST_DB = "Server=myserver;Database=SparcpointInventory;Trusted_Connection=True;" + +dotnet test --filter "Category=Integration" +``` + +Integration tests use `TransactionScope` without `Complete()` — everything auto-rolls back after each test, so no cleanup scripts are needed and the database stays clean between runs. + +## Error handling + +| HTTP | Cause | +|------|-------| +| `400` | Invalid input (missing required fields, bad values, referenced ID doesn't exist) | +| `404` | Transaction not found on delete | +| `500` | Unexpected — shouldn't happen in normal usage | + +All error responses follow `{ "error": "message" }`.