From df371973e524d90acc90486c5d5c4c507c967b3f Mon Sep 17 00:00:00 2001 From: Joshua Kennedy Date: Thu, 21 May 2026 20:10:34 -0400 Subject: [PATCH] Implement inventory management API with products, categories, and transactions Adds a new Sparcpoint.Inventory class library and wires it into Interview.Web to satisfy the spec's 7 requirements: - Products created with arbitrary key/value metadata and category links; never deletable (no DELETE endpoint) - Search products by name, category, attribute, or any combination via dynamic WHERE composition using the existing SqlServerQueryProvider - Categories support arbitrary-depth hierarchies via CategoryCategories - Inventory modeled as an append-only signed-quantity ledger; bulk adjustments apply atomically in a single SQL transaction - Counts retrievable by product id, by subset of metadata, or both - Individual transactions are removable; DELETE removes the row's contribution from SUM-based counts (undo) - All access through REST controllers under /api/v1/ Re-uses ISqlExecutor, SqlServerQueryProvider, IDataSerializer, and PreConditions from the existing Sparcpoint.* libraries. No new tables or schema changes - only the provided objects are used. Adds Sparcpoint.Inventory.IntegrationTests (xUnit) - 20 integration tests that spin up an isolated LocalDB database per run, exercise the full HTTP-to-SQL stack, and tear down. See its README for run instructions. EVAL: comments mark key design decisions per the spec's recommendation. --- Development Project/Development Project.sln | 24 +- .../Controllers/CategoryController.cs | 53 ++++ .../Controllers/InventoryController.cs | 77 ++++++ .../Controllers/ProductController.cs | 70 +++++- .../Interview.Web/Interview.Web.csproj | 4 + Development Project/Interview.Web/Startup.cs | 19 +- .../Interview.Web/appsettings.json | 5 +- .../Infrastructure/TestDatabaseFixture.cs | 170 +++++++++++++ .../README.md | 63 +++++ ...arcpoint.Inventory.IntegrationTests.csproj | 24 ++ .../Tests/CategoryRepositoryTests.cs | 76 ++++++ .../Tests/InventoryRepositoryTests.cs | 168 +++++++++++++ .../Tests/ProductRepositoryTests.cs | 185 ++++++++++++++ .../Abstract/ICategoryRepository.cs | 13 + .../Abstract/IInventoryRepository.cs | 13 + .../Abstract/IProductRepository.cs | 13 + .../Extensions/ServiceCollectionExtensions.cs | 31 +++ .../Extensions/StringExtensions.cs | 12 + .../Implementations/SqlCategoryRepository.cs | 163 ++++++++++++ .../Implementations/SqlInventoryRepository.cs | 148 +++++++++++ .../Implementations/SqlProductRepository.cs | 233 ++++++++++++++++++ .../Sparcpoint.Inventory/Models/Category.cs | 15 ++ .../Models/CreateCategoryRequest.cs | 13 + .../Models/CreateProductRequest.cs | 15 ++ .../Models/InventoryAdjustment.cs | 9 + .../Models/InventoryCountQuery.cs | 10 + .../Models/InventoryTransaction.cs | 14 ++ .../Sparcpoint.Inventory/Models/Product.cs | 17 ++ .../Models/ProductSearchCriteria.cs | 12 + .../Sparcpoint.Inventory.csproj | 26 ++ 30 files changed, 1669 insertions(+), 26 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.IntegrationTests/Infrastructure/TestDatabaseFixture.cs create mode 100644 Development Project/Sparcpoint.Inventory.IntegrationTests/README.md create mode 100644 Development Project/Sparcpoint.Inventory.IntegrationTests/Sparcpoint.Inventory.IntegrationTests.csproj create mode 100644 Development Project/Sparcpoint.Inventory.IntegrationTests/Tests/CategoryRepositoryTests.cs create mode 100644 Development Project/Sparcpoint.Inventory.IntegrationTests/Tests/InventoryRepositoryTests.cs create mode 100644 Development Project/Sparcpoint.Inventory.IntegrationTests/Tests/ProductRepositoryTests.cs create mode 100644 Development Project/Sparcpoint.Inventory/Abstract/ICategoryRepository.cs create mode 100644 Development Project/Sparcpoint.Inventory/Abstract/IInventoryRepository.cs create mode 100644 Development Project/Sparcpoint.Inventory/Abstract/IProductRepository.cs create mode 100644 Development Project/Sparcpoint.Inventory/Extensions/ServiceCollectionExtensions.cs create mode 100644 Development Project/Sparcpoint.Inventory/Extensions/StringExtensions.cs create mode 100644 Development Project/Sparcpoint.Inventory/Implementations/SqlCategoryRepository.cs create mode 100644 Development Project/Sparcpoint.Inventory/Implementations/SqlInventoryRepository.cs create mode 100644 Development Project/Sparcpoint.Inventory/Implementations/SqlProductRepository.cs create mode 100644 Development Project/Sparcpoint.Inventory/Models/Category.cs create mode 100644 Development Project/Sparcpoint.Inventory/Models/CreateCategoryRequest.cs create mode 100644 Development Project/Sparcpoint.Inventory/Models/CreateProductRequest.cs create mode 100644 Development Project/Sparcpoint.Inventory/Models/InventoryAdjustment.cs create mode 100644 Development Project/Sparcpoint.Inventory/Models/InventoryCountQuery.cs create mode 100644 Development Project/Sparcpoint.Inventory/Models/InventoryTransaction.cs create mode 100644 Development Project/Sparcpoint.Inventory/Models/Product.cs create mode 100644 Development Project/Sparcpoint.Inventory/Models/ProductSearchCriteria.cs create mode 100644 Development Project/Sparcpoint.Inventory/Sparcpoint.Inventory.csproj diff --git a/Development Project/Development Project.sln b/Development Project/Development Project.sln index 637bfd9..ed78a00 100644 --- a/Development Project/Development Project.sln +++ b/Development Project/Development Project.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.31410.357 +# Visual Studio Version 18 +VisualStudioVersion = 18.6.11819.183 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Interview.Web", "Interview.Web\Interview.Web.csproj", "{EE17B748-4D84-46AE-9E83-8D04B92DD6A9}" EndProject @@ -11,6 +11,10 @@ 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", "Sparcpoint.Inventory\Sparcpoint.Inventory.csproj", "{B7F9E5D2-3A8C-4F1B-9E2D-5C7A6F8B3D14}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Inventory.IntegrationTests", "Sparcpoint.Inventory.IntegrationTests\Sparcpoint.Inventory.IntegrationTests.csproj", "{C8E2F4A6-5B9D-4A2E-B3F8-7D6E1C9B4F25}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +59,22 @@ 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 + {B7F9E5D2-3A8C-4F1B-9E2D-5C7A6F8B3D14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7F9E5D2-3A8C-4F1B-9E2D-5C7A6F8B3D14}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7F9E5D2-3A8C-4F1B-9E2D-5C7A6F8B3D14}.Debug|x86.ActiveCfg = Debug|Any CPU + {B7F9E5D2-3A8C-4F1B-9E2D-5C7A6F8B3D14}.Debug|x86.Build.0 = Debug|Any CPU + {B7F9E5D2-3A8C-4F1B-9E2D-5C7A6F8B3D14}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7F9E5D2-3A8C-4F1B-9E2D-5C7A6F8B3D14}.Release|Any CPU.Build.0 = Release|Any CPU + {B7F9E5D2-3A8C-4F1B-9E2D-5C7A6F8B3D14}.Release|x86.ActiveCfg = Release|Any CPU + {B7F9E5D2-3A8C-4F1B-9E2D-5C7A6F8B3D14}.Release|x86.Build.0 = Release|Any CPU + {C8E2F4A6-5B9D-4A2E-B3F8-7D6E1C9B4F25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8E2F4A6-5B9D-4A2E-B3F8-7D6E1C9B4F25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8E2F4A6-5B9D-4A2E-B3F8-7D6E1C9B4F25}.Debug|x86.ActiveCfg = Debug|Any CPU + {C8E2F4A6-5B9D-4A2E-B3F8-7D6E1C9B4F25}.Debug|x86.Build.0 = Debug|Any CPU + {C8E2F4A6-5B9D-4A2E-B3F8-7D6E1C9B4F25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8E2F4A6-5B9D-4A2E-B3F8-7D6E1C9B4F25}.Release|Any CPU.Build.0 = Release|Any CPU + {C8E2F4A6-5B9D-4A2E-B3F8-7D6E1C9B4F25}.Release|x86.ActiveCfg = Release|Any CPU + {C8E2F4A6-5B9D-4A2E-B3F8-7D6E1C9B4F25}.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..f3d3ac8 --- /dev/null +++ b/Development Project/Interview.Web/Controllers/CategoryController.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc; +using Sparcpoint.Inventory.Abstract; +using Sparcpoint.Inventory.Models; +using System; +using System.Threading.Tasks; + +namespace Interview.Web.Controllers +{ + [ApiController] + [Route("api/v1/categories")] + public class CategoryController : ControllerBase + { + private readonly ICategoryRepository _Categories; + + public CategoryController(ICategoryRepository categories) + { + _Categories = categories; + } + + [HttpGet] + public async Task GetAll() + { + var categories = await _Categories.GetAllAsync(); + return Ok(categories); + } + + [HttpGet("{categoryId:int}")] + public async Task GetById(int categoryId) + { + var category = await _Categories.GetByIdAsync(categoryId); + if (category == null) return NotFound(); + return Ok(category); + } + + [HttpPost] + public async Task Create([FromBody] CreateCategoryRequest request) + { + if (string.IsNullOrWhiteSpace(request?.Name)) + return BadRequest("Name is required."); + + try + { + var categoryId = await _Categories.CreateAsync(request); + var created = await _Categories.GetByIdAsync(categoryId); + return CreatedAtAction(nameof(GetById), new { categoryId }, created); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/Development Project/Interview.Web/Controllers/InventoryController.cs b/Development Project/Interview.Web/Controllers/InventoryController.cs new file mode 100644 index 0000000..9ae9d96 --- /dev/null +++ b/Development Project/Interview.Web/Controllers/InventoryController.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Mvc; +using Sparcpoint.Inventory.Abstract; +using Sparcpoint.Inventory.Models; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Interview.Web.Controllers +{ + // EVAL: Three endpoints map directly to inventory requirements: POST /transactions covers + // requirement #4 (bulk add/remove via signed quantities), DELETE /transactions/{id} covers + // requirement #6 (undo a transaction), and GET /count covers requirement #5 (count by product + // id and/or subset of metadata). + [ApiController] + [Route("api/v1/inventory")] + public class InventoryController : ControllerBase + { + private readonly IInventoryRepository _Inventory; + + public InventoryController(IInventoryRepository inventory) + { + _Inventory = inventory; + } + + [HttpPost("transactions")] + public async Task CreateTransactions([FromBody] IEnumerable adjustments) + { + if (adjustments == null) + return BadRequest("At least one adjustment is required."); + + try + { + var transactions = await _Inventory.CreateTransactionsAsync(adjustments); + return Ok(transactions); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpDelete("transactions/{transactionId:int}")] + public async Task DeleteTransaction(int transactionId) + { + try + { + await _Inventory.DeleteTransactionAsync(transactionId); + return NoContent(); + } + catch (KeyNotFoundException) + { + return NotFound(); + } + } + + [HttpGet("count")] + public async Task GetCount([FromQuery] int? productId, [FromQuery] string attributes) + { + var query = new InventoryCountQuery { ProductId = productId }; + + if (!string.IsNullOrWhiteSpace(attributes)) + { + try + { + query.AttributeFilters = Newtonsoft.Json.JsonConvert.DeserializeObject>(attributes); + } + catch + { + return BadRequest("'attributes' must be a JSON object of key/value pairs."); + } + } + + var count = await _Inventory.GetCountAsync(query); + return Ok(new { Count = count }); + } + } +} diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs index 267f4ec..e1b65ce 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.Abstract; +using Sparcpoint.Inventory.Models; using System; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; namespace Interview.Web.Controllers { + // EVAL: Route is explicitly versioned (/api/v1/...) so future breaking changes can ship as v2 + // without disrupting existing clients (per the recommendation about extending the API without + // impacting older customers). Per requirement #1, there is intentionally no DELETE endpoint — + // products cannot be deleted from the system. + [ApiController] [Route("api/v1/products")] - public class ProductController : Controller + public class ProductController : ControllerBase { - // NOTE: Sample Action + private readonly IProductRepository _Products; + + public ProductController(IProductRepository products) + { + _Products = products; + } + [HttpGet] - public Task GetAllProducts() + public async Task Search([FromQuery] string name, [FromQuery] int[] categoryIds, [FromQuery] string attributes) { - return Task.FromResult((IActionResult)Ok(new object[] { })); + var criteria = new ProductSearchCriteria + { + NameContains = name, + CategoryIds = categoryIds + }; + + if (!string.IsNullOrWhiteSpace(attributes)) + { + try + { + criteria.AttributeFilters = Newtonsoft.Json.JsonConvert.DeserializeObject>(attributes); + } + catch + { + return BadRequest("'attributes' must be a JSON object of key/value pairs."); + } + } + + var results = await _Products.SearchAsync(criteria); + return Ok(results); + } + + [HttpGet("{productId:int}")] + public async Task GetById(int productId) + { + var product = await _Products.GetByIdAsync(productId); + if (product == null) return NotFound(); + return Ok(product); + } + + [HttpPost] + public async Task Create([FromBody] CreateProductRequest request) + { + if (string.IsNullOrWhiteSpace(request?.Name)) + return BadRequest("Name is required."); + + try + { + var productId = await _Products.CreateAsync(request); + var created = await _Products.GetByIdAsync(productId); + return CreatedAtAction(nameof(GetById), new { productId }, created); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } } } } diff --git a/Development Project/Interview.Web/Interview.Web.csproj b/Development Project/Interview.Web/Interview.Web.csproj index d1e4d6e..7930763 100644 --- a/Development Project/Interview.Web/Interview.Web.csproj +++ b/Development Project/Interview.Web/Interview.Web.csproj @@ -4,4 +4,8 @@ net8.0 + + + + diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs index 56452f2..8fa9553 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.Extensions; namespace Interview.Web { @@ -20,13 +16,12 @@ 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(); + services.AddInventoryServices(Configuration.GetConnectionString("InventoryDatabase")); } - // 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()) @@ -36,21 +31,13 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 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 => - { - endpoints.MapControllers(); - }); + app.UseEndpoints(endpoints => endpoints.MapControllers()); } } } diff --git a/Development Project/Interview.Web/appsettings.json b/Development Project/Interview.Web/appsettings.json index d9d9a9b..fd9ba35 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=(localdb)\\MSSQLLocalDB;Database=SparcpointInventory;Integrated Security=true;" + } } diff --git a/Development Project/Sparcpoint.Inventory.IntegrationTests/Infrastructure/TestDatabaseFixture.cs b/Development Project/Sparcpoint.Inventory.IntegrationTests/Infrastructure/TestDatabaseFixture.cs new file mode 100644 index 0000000..a78fb1d --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.IntegrationTests/Infrastructure/TestDatabaseFixture.cs @@ -0,0 +1,170 @@ +using Dapper; +using Sparcpoint; +using Sparcpoint.Inventory.Abstract; +using Sparcpoint.Inventory.Implementations; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Data.SqlClient; +using System.Threading.Tasks; +using Xunit; + +namespace Sparcpoint.Inventory.IntegrationTests.Infrastructure +{ + // EVAL: Spins up a fresh, isolated LocalDB database for each test run, applies the same schema + // defined in the Sparcpoint.Inventory.Database project, and tears it down on disposal. Tests + // remain hermetic and reviewers can simply run `dotnet test` from the solution root — no + // separate publish step required for the test database. + public sealed class TestDatabaseFixture : IAsyncLifetime + { + private const string MasterConnectionString = + "Server=(localdb)\\MSSQLLocalDB;Database=master;Integrated Security=true;"; + + private readonly string _DatabaseName; + public string ConnectionString { get; } + public ISqlExecutor Executor { get; private set; } + public IDataSerializer Serializer { get; } = new JsonDataSerializer(); + + public TestDatabaseFixture() + { + _DatabaseName = "SparcpointInventory_Test_" + Guid.NewGuid().ToString("N").Substring(0, 12); + ConnectionString = $"Server=(localdb)\\MSSQLLocalDB;Database={_DatabaseName};Integrated Security=true;"; + } + + public IProductRepository CreateProductRepository() => new SqlProductRepository(Executor, Serializer); + public ICategoryRepository CreateCategoryRepository() => new SqlCategoryRepository(Executor); + public IInventoryRepository CreateInventoryRepository() => new SqlInventoryRepository(Executor); + + // EVAL: Called from each test's InitializeAsync so tests never see data from a sibling + // test. Cheaper than rebuilding the schema, and CASCADE constraints handle the dependent + // rows so explicit deletion order isn't required. + public async Task ResetAsync() + { + using (var conn = new SqlConnection(ConnectionString)) + { + await conn.OpenAsync(); + await conn.ExecuteAsync(@" + DELETE FROM [Transactions].[InventoryTransactions]; + DELETE FROM [Instances].[ProductAttributes]; + DELETE FROM [Instances].[CategoryAttributes]; + DELETE FROM [Instances].[ProductCategories]; + DELETE FROM [Instances].[CategoryCategories]; + DELETE FROM [Instances].[Products]; + DELETE FROM [Instances].[Categories];"); + } + } + + public async Task InitializeAsync() + { + using (var conn = new SqlConnection(MasterConnectionString)) + { + await conn.OpenAsync(); + await conn.ExecuteAsync($"CREATE DATABASE [{_DatabaseName}]"); + } + + Executor = new SqlServerExecutor(ConnectionString); + await ApplySchemaAsync(); + } + + public async Task DisposeAsync() + { + using (var conn = new SqlConnection(MasterConnectionString)) + { + await conn.OpenAsync(); + await conn.ExecuteAsync( + $"ALTER DATABASE [{_DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_DatabaseName}];"); + } + } + + private async Task ApplySchemaAsync() + { + // EVAL: Schema is embedded here (rather than loaded from the .sqlproj at runtime) so + // tests are self-contained. If the production schema evolves, update this single block + // — kept minimal: only the objects the inventory layer actually reads from or writes to. + using (var conn = new SqlConnection(ConnectionString)) + { + await conn.OpenAsync(); + + foreach (var batch in SchemaBatches) + await conn.ExecuteAsync(batch); + } + } + + private static readonly string[] SchemaBatches = new[] + { + "CREATE SCHEMA [Instances]", + "CREATE SCHEMA [Transactions]", + + @"CREATE TYPE [dbo].[IntegerList] AS TABLE ([Value] INT NOT NULL)", + @"CREATE TYPE [dbo].[CustomAttributeList] AS TABLE ( + [Key] VARCHAR(64) NOT NULL, + [Value] VARCHAR(512) NOT NULL)", + + @"CREATE TABLE [Instances].[Products] ( + [InstanceId] INT NOT NULL PRIMARY KEY IDENTITY(1,1), + [Name] VARCHAR(256) NOT NULL, + [Description] VARCHAR(256) NOT NULL, + [ProductImageUris] VARCHAR(MAX) NOT NULL, + [ValidSkus] VARCHAR(MAX) NOT NULL, + [CreatedTimestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME())", + + @"CREATE TABLE [Instances].[Categories] ( + [InstanceId] INT NOT NULL PRIMARY KEY IDENTITY(1,1), + [Name] VARCHAR(64) NOT NULL, + [Description] VARCHAR(256) NOT NULL, + [CreatedTimestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME())", + + @"CREATE TABLE [Instances].[ProductAttributes] ( + [InstanceId] INT NOT NULL, + [Key] VARCHAR(64) NOT NULL, + [Value] VARCHAR(512) NOT NULL, + CONSTRAINT [PK_ProductAttributes] PRIMARY KEY ([InstanceId], [Key]), + CONSTRAINT [FK_ProductAttributes_Products] FOREIGN KEY ([InstanceId]) + REFERENCES [Instances].[Products]([InstanceId]) ON DELETE CASCADE)", + + @"CREATE INDEX [IX_ProductAttributes_Key_Value] + ON [Instances].[ProductAttributes] ([Key] ASC, [Value] ASC)", + + @"CREATE TABLE [Instances].[CategoryAttributes] ( + [InstanceId] INT NOT NULL, + [Key] VARCHAR(64) NOT NULL, + [Value] VARCHAR(512) NOT NULL, + CONSTRAINT [PK_CategoryAttributes] PRIMARY KEY ([InstanceId], [Key]), + CONSTRAINT [FK_CategoryAttributes_Categories] FOREIGN KEY ([InstanceId]) + REFERENCES [Instances].[Categories]([InstanceId]) ON DELETE CASCADE)", + + @"CREATE TABLE [Instances].[ProductCategories] ( + [InstanceId] INT NOT NULL, + [CategoryInstanceId] INT NOT NULL, + CONSTRAINT [PK_ProductCategories] PRIMARY KEY ([InstanceId], [CategoryInstanceId]), + CONSTRAINT [FK_ProductCategories_Products] FOREIGN KEY ([InstanceId]) + REFERENCES [Instances].[Products]([InstanceId]) ON DELETE CASCADE, + CONSTRAINT [FK_ProductCategories_Categories] FOREIGN KEY ([CategoryInstanceId]) + REFERENCES [Instances].[Categories]([InstanceId]) ON DELETE CASCADE)", + + @"CREATE TABLE [Instances].[CategoryCategories] ( + [InstanceId] INT NOT NULL, + [CategoryInstanceId] INT NOT NULL, + CONSTRAINT [PK_CategoryCategories] PRIMARY KEY ([InstanceId], [CategoryInstanceId]), + CONSTRAINT [FK_CategoryCategories_Categories] FOREIGN KEY ([InstanceId]) + REFERENCES [Instances].[Categories]([InstanceId]) ON DELETE CASCADE, + CONSTRAINT [FK_CategoryCategories_Categories_Categories] FOREIGN KEY ([CategoryInstanceId]) + REFERENCES [Instances].[Categories]([InstanceId]))", + + @"CREATE TABLE [Transactions].[InventoryTransactions] ( + [TransactionId] INT NOT NULL PRIMARY KEY IDENTITY(1,1), + [ProductInstanceId] INT NOT NULL, + [Quantity] DECIMAL(19,6) NOT NULL, + [StartedTimestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [CompletedTimestamp] DATETIME2(7) NULL, + [TypeCategory] VARCHAR(32) NULL, + CONSTRAINT [FK_InventoryTransactions_Products] FOREIGN KEY ([ProductInstanceId]) + REFERENCES [Instances].[Products]([InstanceId]) ON DELETE CASCADE)" + }; + } + + [CollectionDefinition(Name)] + public class TestDatabaseCollection : ICollectionFixture + { + public const string Name = "Integration database collection"; + } +} diff --git a/Development Project/Sparcpoint.Inventory.IntegrationTests/README.md b/Development Project/Sparcpoint.Inventory.IntegrationTests/README.md new file mode 100644 index 0000000..f612745 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.IntegrationTests/README.md @@ -0,0 +1,63 @@ +# Sparcpoint.Inventory.IntegrationTests + +Integration tests for the inventory layer. Each test spins up a fresh isolated database in LocalDB, exercises the real `ISqlExecutor` against real SQL, then tears the database down — no mocks. + +## Prerequisites + +You already have these if you can run the main `Interview.Web` project: + +- .NET 8 SDK +- SQL Server LocalDB (ships with Visual Studio's *.NET desktop development* and *Data storage and processing* workloads) + +No separate database deployment is needed for tests — the fixture creates a unique database on each run (`SparcpointInventory_Test_`) and drops it when finished. + +## Running the tests + +### From the command line + +From the solution root (`Development Project/`): + +``` +dotnet test "Sparcpoint.Inventory.IntegrationTests/Sparcpoint.Inventory.IntegrationTests.csproj" +``` + +Or to run the whole solution's tests: + +``` +dotnet test +``` + +### From Visual Studio + +1. Open `Development Project.sln` +2. Open **Test → Test Explorer** +3. Click **Run All Tests** + +Tests appear under `Sparcpoint.Inventory.IntegrationTests` grouped by class: +- `ProductRepositoryTests` — create, search by name / category / attribute / combination, edge cases +- `CategoryRepositoryTests` — create, hierarchies, retrieval +- `InventoryRepositoryTests` — bulk add/remove, undo via delete, count by id and/or attribute filter, negative quantities + +## What gets tested + +| Requirement | Covered by | +|---|---| +| #1 Products never deleted | No DELETE in `IProductRepository` (compile-time guarantee) | +| #2 Arbitrary metadata + categories | `ProductRepositoryTests.Create_Then_GetById_RoundTripsAllFields` | +| #3 Searchable by metadata + categories | `Search_ByAttribute_*`, `Search_ByCategory_*`, `Search_ByCategoryAndAttribute_*` | +| #4 Bulk inventory adjustments | `InventoryRepositoryTests.BulkTransactions_ApplyAtomically_AcrossMultipleProducts` | +| #5 Count by product or metadata subset | `GetCount_ByAttributeFilter_*`, `GetCount_ByProductIdAndAttribute_*` | +| #6 Individual transactions removable | `DeleteTransaction_UndoesItsEffectOnCount` | +| #7 API-driven | The full stack from `ISqlExecutor` through the repositories is exercised | + +Edge cases verified: +- Missing record retrieval (returns null) +- Deleting a non-existent transaction (throws `KeyNotFoundException` → controller maps to 404) +- VARCHAR overrun (long names truncated, no `SqlException`) +- Null/empty descriptions +- Negative quantities (inventory removal) +- Empty product with no transactions (count = 0) + +## Test isolation + +Tests within the suite share a single LocalDB database (created once per run, dropped at end) but each test resets the data via `TestDatabaseFixture.ResetAsync()` in its `InitializeAsync`. Test order and parallelism cannot leak state between tests. diff --git a/Development Project/Sparcpoint.Inventory.IntegrationTests/Sparcpoint.Inventory.IntegrationTests.csproj b/Development Project/Sparcpoint.Inventory.IntegrationTests/Sparcpoint.Inventory.IntegrationTests.csproj new file mode 100644 index 0000000..a344a77 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.IntegrationTests/Sparcpoint.Inventory.IntegrationTests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + false + true + Sparcpoint.Inventory.IntegrationTests + + + + + + + + + + + + + + + + + diff --git a/Development Project/Sparcpoint.Inventory.IntegrationTests/Tests/CategoryRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.IntegrationTests/Tests/CategoryRepositoryTests.cs new file mode 100644 index 0000000..db0fa67 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.IntegrationTests/Tests/CategoryRepositoryTests.cs @@ -0,0 +1,76 @@ +using Sparcpoint.Inventory.IntegrationTests.Infrastructure; +using Sparcpoint.Inventory.Models; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace Sparcpoint.Inventory.IntegrationTests.Tests +{ + [Collection(TestDatabaseCollection.Name)] + public class CategoryRepositoryTests : IAsyncLifetime + { + private readonly TestDatabaseFixture _Fixture; + public CategoryRepositoryTests(TestDatabaseFixture fixture) { _Fixture = fixture; } + public Task InitializeAsync() => _Fixture.ResetAsync(); + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task Create_Then_GetById_RoundTripsAllFields() + { + var repo = _Fixture.CreateCategoryRepository(); + + var id = await repo.CreateAsync(new CreateCategoryRequest + { + Name = "Hardware", + Description = "Top-level hardware", + Attributes = new Dictionary { { "ShelfPrefix", "HW" } } + }); + + var category = await repo.GetByIdAsync(id); + + Assert.NotNull(category); + Assert.Equal("Hardware", category.Name); + Assert.Equal("HW", category.Attributes["ShelfPrefix"]); + } + + [Fact] + public async Task Create_WithParentCategories_PersistsHierarchy() + { + // EVAL: Verifies requirement on hierarchies (Goal #3). A child category can reference + // multiple parents via [Instances].[CategoryCategories]. + var repo = _Fixture.CreateCategoryRepository(); + + var parentId = await repo.CreateAsync(new CreateCategoryRequest { Name = "Hardware" }); + var childId = await repo.CreateAsync(new CreateCategoryRequest + { + Name = "Power Tools", + ParentCategoryIds = new[] { parentId } + }); + + var child = await repo.GetByIdAsync(childId); + Assert.Contains(parentId, child.ParentCategoryIds); + } + + [Fact] + public async Task GetAll_ReturnsAllCategories() + { + var repo = _Fixture.CreateCategoryRepository(); + await repo.CreateAsync(new CreateCategoryRequest { Name = "Cat A" }); + await repo.CreateAsync(new CreateCategoryRequest { Name = "Cat B" }); + + var all = (await repo.GetAllAsync()).ToList(); + + Assert.True(all.Count >= 2); + Assert.Contains(all, c => c.Name == "Cat A"); + Assert.Contains(all, c => c.Name == "Cat B"); + } + + [Fact] + public async Task GetById_ReturnsNull_WhenMissing() + { + var repo = _Fixture.CreateCategoryRepository(); + Assert.Null(await repo.GetByIdAsync(999999)); + } + } +} diff --git a/Development Project/Sparcpoint.Inventory.IntegrationTests/Tests/InventoryRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.IntegrationTests/Tests/InventoryRepositoryTests.cs new file mode 100644 index 0000000..682fe1b --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.IntegrationTests/Tests/InventoryRepositoryTests.cs @@ -0,0 +1,168 @@ +using Sparcpoint.Inventory.IntegrationTests.Infrastructure; +using Sparcpoint.Inventory.Models; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace Sparcpoint.Inventory.IntegrationTests.Tests +{ + [Collection(TestDatabaseCollection.Name)] + public class InventoryRepositoryTests : IAsyncLifetime + { + private readonly TestDatabaseFixture _Fixture; + public InventoryRepositoryTests(TestDatabaseFixture fixture) { _Fixture = fixture; } + public Task InitializeAsync() => _Fixture.ResetAsync(); + public Task DisposeAsync() => Task.CompletedTask; + + private async Task CreateProductAsync(IReadOnlyDictionary attributes = null) + { + var productRepo = _Fixture.CreateProductRepository(); + return await productRepo.CreateAsync(new CreateProductRequest + { + Name = "P-" + System.Guid.NewGuid().ToString("N").Substring(0, 6), + Description = "test", + Attributes = attributes ?? new Dictionary() + }); + } + + [Fact] + public async Task Adding_Then_Removing_NetsToZero() + { + var inventory = _Fixture.CreateInventoryRepository(); + var productId = await CreateProductAsync(); + + await inventory.CreateTransactionsAsync(new[] + { + new InventoryAdjustment { ProductId = productId, Quantity = 10 }, + new InventoryAdjustment { ProductId = productId, Quantity = -10 } + }); + + var count = await inventory.GetCountAsync(new InventoryCountQuery { ProductId = productId }); + Assert.Equal(0m, count); + } + + [Fact] + public async Task BulkTransactions_ApplyAtomically_AcrossMultipleProducts() + { + // EVAL: Requirement #4 — multiple products in a single call. Each adjustment must be + // persisted in the same transaction. + var inventory = _Fixture.CreateInventoryRepository(); + var p1 = await CreateProductAsync(); + var p2 = await CreateProductAsync(); + + await inventory.CreateTransactionsAsync(new[] + { + new InventoryAdjustment { ProductId = p1, Quantity = 5 }, + new InventoryAdjustment { ProductId = p2, Quantity = 8 } + }); + + Assert.Equal(5m, await inventory.GetCountAsync(new InventoryCountQuery { ProductId = p1 })); + Assert.Equal(8m, await inventory.GetCountAsync(new InventoryCountQuery { ProductId = p2 })); + } + + [Fact] + public async Task DeleteTransaction_UndoesItsEffectOnCount() + { + // EVAL: Requirement #6 — individual transactions removable. Because count = SUM(Quantity), + // deleting the row removes its contribution from every subsequent count query. + var inventory = _Fixture.CreateInventoryRepository(); + var productId = await CreateProductAsync(); + + var created = (await inventory.CreateTransactionsAsync(new[] + { + new InventoryAdjustment { ProductId = productId, Quantity = 25 } + })).ToList(); + + Assert.Equal(25m, await inventory.GetCountAsync(new InventoryCountQuery { ProductId = productId })); + + await inventory.DeleteTransactionAsync(created[0].TransactionId); + + Assert.Equal(0m, await inventory.GetCountAsync(new InventoryCountQuery { ProductId = productId })); + } + + [Fact] + public async Task DeleteTransaction_Throws_WhenNotFound() + { + var inventory = _Fixture.CreateInventoryRepository(); + await Assert.ThrowsAsync( + () => inventory.DeleteTransactionAsync(999999)); + } + + [Fact] + public async Task GetCount_ByAttributeFilter_SumsAcrossMatchingProducts() + { + // EVAL: Requirement #5 — count by subset of metadata. Verifies that the SUM aggregates + // across every product whose attributes match, not just one specific product id. + var inventory = _Fixture.CreateInventoryRepository(); + + var redA = await CreateProductAsync(new Dictionary { { "Color", "Red" } }); + var redB = await CreateProductAsync(new Dictionary { { "Color", "Red" } }); + var blue = await CreateProductAsync(new Dictionary { { "Color", "Blue" } }); + + await inventory.CreateTransactionsAsync(new[] + { + new InventoryAdjustment { ProductId = redA, Quantity = 3 }, + new InventoryAdjustment { ProductId = redB, Quantity = 7 }, + new InventoryAdjustment { ProductId = blue, Quantity = 100 } + }); + + var redCount = await inventory.GetCountAsync(new InventoryCountQuery + { + AttributeFilters = new Dictionary { { "Color", "Red" } } + }); + + Assert.Equal(10m, redCount); + } + + [Fact] + public async Task GetCount_ForProductWithoutTransactions_ReturnsZero() + { + var inventory = _Fixture.CreateInventoryRepository(); + var productId = await CreateProductAsync(); + + var count = await inventory.GetCountAsync(new InventoryCountQuery { ProductId = productId }); + + Assert.Equal(0m, count); + } + + [Fact] + public async Task NegativeQuantity_RemovesInventory() + { + var inventory = _Fixture.CreateInventoryRepository(); + var productId = await CreateProductAsync(); + + await inventory.CreateTransactionsAsync(new[] + { + new InventoryAdjustment { ProductId = productId, Quantity = 50 }, + new InventoryAdjustment { ProductId = productId, Quantity = -20 } + }); + + var count = await inventory.GetCountAsync(new InventoryCountQuery { ProductId = productId }); + Assert.Equal(30m, count); + } + + [Fact] + public async Task GetCount_ByProductIdAndAttribute_CombinesFilters() + { + var inventory = _Fixture.CreateInventoryRepository(); + + var redA = await CreateProductAsync(new Dictionary { { "Color", "Red" } }); + var redB = await CreateProductAsync(new Dictionary { { "Color", "Red" } }); + + await inventory.CreateTransactionsAsync(new[] + { + new InventoryAdjustment { ProductId = redA, Quantity = 4 }, + new InventoryAdjustment { ProductId = redB, Quantity = 9 } + }); + + var count = await inventory.GetCountAsync(new InventoryCountQuery + { + ProductId = redA, + AttributeFilters = new Dictionary { { "Color", "Red" } } + }); + + Assert.Equal(4m, count); + } + } +} diff --git a/Development Project/Sparcpoint.Inventory.IntegrationTests/Tests/ProductRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.IntegrationTests/Tests/ProductRepositoryTests.cs new file mode 100644 index 0000000..6a0003d --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.IntegrationTests/Tests/ProductRepositoryTests.cs @@ -0,0 +1,185 @@ +using Sparcpoint.Inventory.IntegrationTests.Infrastructure; +using Sparcpoint.Inventory.Models; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace Sparcpoint.Inventory.IntegrationTests.Tests +{ + [Collection(TestDatabaseCollection.Name)] + public class ProductRepositoryTests : IAsyncLifetime + { + private readonly TestDatabaseFixture _Fixture; + public ProductRepositoryTests(TestDatabaseFixture fixture) { _Fixture = fixture; } + public Task InitializeAsync() => _Fixture.ResetAsync(); + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task Create_Then_GetById_RoundTripsAllFields() + { + var repo = _Fixture.CreateProductRepository(); + + var productId = await repo.CreateAsync(new CreateProductRequest + { + Name = "Acme Widget", + Description = "A widget for acme purposes.", + ImageUris = new[] { "https://cdn.example/widget-1.png", "https://cdn.example/widget-2.png" }, + ValidSkus = new[] { "ACME-001", "ACME-001-RED" }, + Attributes = new Dictionary + { + { "Color", "Red" }, + { "Brand", "Acme" } + } + }); + + var product = await repo.GetByIdAsync(productId); + + Assert.NotNull(product); + Assert.Equal("Acme Widget", product.Name); + Assert.Equal("A widget for acme purposes.", product.Description); + Assert.Equal(2, product.ImageUris.Count); + Assert.Equal(2, product.ValidSkus.Count); + Assert.Equal("Red", product.Attributes["Color"]); + Assert.Equal("Acme", product.Attributes["Brand"]); + } + + [Fact] + public async Task GetById_ReturnsNull_WhenProductMissing() + { + var repo = _Fixture.CreateProductRepository(); + var product = await repo.GetByIdAsync(999999); + Assert.Null(product); + } + + [Fact] + public async Task Search_ByAttribute_ReturnsOnlyMatchingProducts() + { + var repo = _Fixture.CreateProductRepository(); + + await repo.CreateAsync(new CreateProductRequest + { + Name = "Red Widget", + Description = "Red", + Attributes = new Dictionary { { "Color", "Red" } } + }); + await repo.CreateAsync(new CreateProductRequest + { + Name = "Blue Widget", + Description = "Blue", + Attributes = new Dictionary { { "Color", "Blue" } } + }); + + var results = (await repo.SearchAsync(new ProductSearchCriteria + { + AttributeFilters = new Dictionary { { "Color", "Red" } } + })).ToList(); + + Assert.Single(results); + Assert.Equal("Red Widget", results[0].Name); + } + + [Fact] + public async Task Search_ByCategory_ReturnsOnlyMatchingProducts() + { + var productRepo = _Fixture.CreateProductRepository(); + var categoryRepo = _Fixture.CreateCategoryRepository(); + + var toolsCategoryId = await categoryRepo.CreateAsync(new CreateCategoryRequest { Name = "Tools" }); + var toysCategoryId = await categoryRepo.CreateAsync(new CreateCategoryRequest { Name = "Toys" }); + + await productRepo.CreateAsync(new CreateProductRequest + { + Name = "Hammer", + Description = "A hammer", + CategoryIds = new[] { toolsCategoryId } + }); + await productRepo.CreateAsync(new CreateProductRequest + { + Name = "Teddy Bear", + Description = "A bear", + CategoryIds = new[] { toysCategoryId } + }); + + var results = (await productRepo.SearchAsync(new ProductSearchCriteria + { + CategoryIds = new[] { toolsCategoryId } + })).ToList(); + + Assert.Single(results); + Assert.Equal("Hammer", results[0].Name); + } + + [Fact] + public async Task Search_ByCategoryAndAttribute_AppliesAllFilters() + { + var productRepo = _Fixture.CreateProductRepository(); + var categoryRepo = _Fixture.CreateCategoryRepository(); + + var paintsCategoryId = await categoryRepo.CreateAsync(new CreateCategoryRequest { Name = "Paints" }); + + await productRepo.CreateAsync(new CreateProductRequest + { + Name = "Red Paint", + Description = "Red", + CategoryIds = new[] { paintsCategoryId }, + Attributes = new Dictionary { { "Color", "Red" } } + }); + await productRepo.CreateAsync(new CreateProductRequest + { + Name = "Blue Paint", + Description = "Blue", + CategoryIds = new[] { paintsCategoryId }, + Attributes = new Dictionary { { "Color", "Blue" } } + }); + + var results = (await productRepo.SearchAsync(new ProductSearchCriteria + { + CategoryIds = new[] { paintsCategoryId }, + AttributeFilters = new Dictionary { { "Color", "Red" } } + })).ToList(); + + Assert.Single(results); + Assert.Equal("Red Paint", results[0].Name); + } + + [Fact] + public async Task Search_NoCriteria_ReturnsAllProducts() + { + var repo = _Fixture.CreateProductRepository(); + await repo.CreateAsync(new CreateProductRequest { Name = "P1", Description = "d" }); + await repo.CreateAsync(new CreateProductRequest { Name = "P2", Description = "d" }); + + var results = await repo.SearchAsync(new ProductSearchCriteria()); + + Assert.True(results.Count() >= 2); + } + + [Fact] + public async Task Create_ShouldTruncate_OvelyLongStrings() + { + // EVAL: Confirms the "string overruns" edge case the spec calls out is handled — a + // 1000-char name is truncated to fit VARCHAR(256) rather than throwing SqlException. + var repo = _Fixture.CreateProductRepository(); + var longName = new string('X', 1000); + + var id = await repo.CreateAsync(new CreateProductRequest + { + Name = longName, + Description = "d" + }); + + var product = await repo.GetByIdAsync(id); + Assert.Equal(256, product.Name.Length); + } + + [Fact] + public async Task Create_WithEmptyDescription_PersistsEmptyString() + { + var repo = _Fixture.CreateProductRepository(); + var id = await repo.CreateAsync(new CreateProductRequest { Name = "X", Description = null }); + var product = await repo.GetByIdAsync(id); + Assert.Equal(string.Empty, product.Description); + } + } +} diff --git a/Development Project/Sparcpoint.Inventory/Abstract/ICategoryRepository.cs b/Development Project/Sparcpoint.Inventory/Abstract/ICategoryRepository.cs new file mode 100644 index 0000000..2421d57 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Abstract/ICategoryRepository.cs @@ -0,0 +1,13 @@ +using Sparcpoint.Inventory.Models; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Sparcpoint.Inventory.Abstract +{ + public interface ICategoryRepository + { + Task GetByIdAsync(int categoryId); + Task> GetAllAsync(); + Task CreateAsync(CreateCategoryRequest request); + } +} diff --git a/Development Project/Sparcpoint.Inventory/Abstract/IInventoryRepository.cs b/Development Project/Sparcpoint.Inventory/Abstract/IInventoryRepository.cs new file mode 100644 index 0000000..ea27749 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Abstract/IInventoryRepository.cs @@ -0,0 +1,13 @@ +using Sparcpoint.Inventory.Models; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Sparcpoint.Inventory.Abstract +{ + public interface IInventoryRepository + { + Task> CreateTransactionsAsync(IEnumerable adjustments); + Task DeleteTransactionAsync(int transactionId); + Task GetCountAsync(InventoryCountQuery query); + } +} diff --git a/Development Project/Sparcpoint.Inventory/Abstract/IProductRepository.cs b/Development Project/Sparcpoint.Inventory/Abstract/IProductRepository.cs new file mode 100644 index 0000000..a53dfc4 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Abstract/IProductRepository.cs @@ -0,0 +1,13 @@ +using Sparcpoint.Inventory.Models; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Sparcpoint.Inventory.Abstract +{ + public interface IProductRepository + { + Task GetByIdAsync(int productId); + Task> SearchAsync(ProductSearchCriteria criteria); + Task CreateAsync(CreateProductRequest request); + } +} diff --git a/Development Project/Sparcpoint.Inventory/Extensions/ServiceCollectionExtensions.cs b/Development Project/Sparcpoint.Inventory/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..252b45f --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.DependencyInjection; +using Sparcpoint.Inventory.Abstract; +using Sparcpoint.Inventory.Implementations; +using Sparcpoint.SqlServer.Abstractions; + +namespace Sparcpoint.Inventory.Extensions +{ + // EVAL: Surfaces the inventory layer through a single AddInventoryServices() extension method + // (per the grading criterion on extension methods). Any front-end — this MVC project, a future + // gRPC host, a console tool — wires in identically with one call. Swapping SqlServerExecutor + // for an alternate ISqlExecutor (e.g., a test stub) is the only change needed. + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddInventoryServices(this IServiceCollection services, string connectionString) + { + PreConditions.StringNotNullOrWhitespace(connectionString, nameof(connectionString)); + + // EVAL: Reuse JsonDataSerializer from Sparcpoint.Core instead of taking a direct + // Newtonsoft.Json dependency in this library. + services.AddSingleton(); + // EVAL: SqlServerExecutor opens a fresh connection per call, so it's safe (and cheap) + // to register as a singleton. Lambda factory defers construction to first resolve. + services.AddSingleton(_ => new SqlServerExecutor(connectionString)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + } +} diff --git a/Development Project/Sparcpoint.Inventory/Extensions/StringExtensions.cs b/Development Project/Sparcpoint.Inventory/Extensions/StringExtensions.cs new file mode 100644 index 0000000..1b618da --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Extensions/StringExtensions.cs @@ -0,0 +1,12 @@ +namespace Sparcpoint.Inventory.Extensions +{ + internal static class StringExtensions + { + public static string Truncate(this string value, int maxLength) + { + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) + return value; + return value.Substring(0, maxLength); + } + } +} diff --git a/Development Project/Sparcpoint.Inventory/Implementations/SqlCategoryRepository.cs b/Development Project/Sparcpoint.Inventory/Implementations/SqlCategoryRepository.cs new file mode 100644 index 0000000..f31934d --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Implementations/SqlCategoryRepository.cs @@ -0,0 +1,163 @@ +using Dapper; +using Sparcpoint.Inventory.Abstract; +using Sparcpoint.Inventory.Extensions; +using Sparcpoint.Inventory.Models; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; + +namespace Sparcpoint.Inventory.Implementations +{ + // EVAL: Categories support arbitrary-depth hierarchies via the existing CategoryCategories + // junction table. A child category can have multiple parents (DAG), which is more flexible than + // a strict tree and matches what the schema allows. + public class SqlCategoryRepository : ICategoryRepository + { + private readonly ISqlExecutor _Executor; + + public SqlCategoryRepository(ISqlExecutor executor) + { + _Executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public Task CreateAsync(CreateCategoryRequest request) + { + PreConditions.ParameterNotNull(request, nameof(request)); + PreConditions.StringNotNullOrWhitespace(request.Name, nameof(request.Name)); + + return _Executor.ExecuteAsync(async (conn, trans) => + { + var categoryId = await conn.QuerySingleAsync(@" + INSERT INTO [Instances].[Categories] ([Name], [Description]) + VALUES (@Name, @Description); + SELECT SCOPE_IDENTITY();", + new + { + Name = request.Name.Truncate(64), + Description = (request.Description ?? string.Empty).Truncate(256) + }, + trans); + + if (request.Attributes != null && request.Attributes.Count > 0) + { + var attrTable = new DataTable(); + attrTable.Columns.Add("Key", typeof(string)); + attrTable.Columns.Add("Value", typeof(string)); + + foreach (var kvp in request.Attributes) + attrTable.Rows.Add(kvp.Key.Truncate(64), kvp.Value.Truncate(512)); + + await conn.ExecuteAsync(@" + INSERT INTO [Instances].[CategoryAttributes] ([InstanceId], [Key], [Value]) + SELECT @CategoryId, [Key], [Value] FROM @Attributes", + new { CategoryId = categoryId, Attributes = attrTable.AsTableValuedParameter("dbo.CustomAttributeList") }, + trans); + } + + if (request.ParentCategoryIds != null && request.ParentCategoryIds.Count > 0) + { + var parentTable = new DataTable(); + parentTable.Columns.Add("Value", typeof(int)); + + foreach (var parentId in request.ParentCategoryIds) + parentTable.Rows.Add(parentId); + + await conn.ExecuteAsync(@" + INSERT INTO [Instances].[CategoryCategories] ([InstanceId], [CategoryInstanceId]) + SELECT @CategoryId, [Value] FROM @ParentIds", + new { CategoryId = categoryId, ParentIds = parentTable.AsTableValuedParameter("dbo.IntegerList") }, + trans); + } + + return categoryId; + }); + } + + public Task> GetAllAsync() + { + return _Executor.ExecuteAsync>(async (conn, trans) => + { + var rows = (await conn.QueryAsync(@" + SELECT [InstanceId], [Name], [Description], [CreatedTimestamp] + FROM [Instances].[Categories] + ORDER BY [Name]", + transaction: trans)).ToList(); + + return await HydrateAsync(conn, trans, rows); + }); + } + + public Task GetByIdAsync(int categoryId) + { + return _Executor.ExecuteAsync(async (conn, trans) => + { + var row = await conn.QuerySingleOrDefaultAsync(@" + SELECT [InstanceId], [Name], [Description], [CreatedTimestamp] + FROM [Instances].[Categories] + WHERE [InstanceId] = @CategoryId", + new { CategoryId = categoryId }, trans); + + if (row == null) return null; + + var results = await HydrateAsync(conn, trans, new[] { row }); + return results.FirstOrDefault(); + }); + } + + private async Task> HydrateAsync(IDbConnection conn, IDbTransaction trans, IList rows) + { + if (rows.Count == 0) return Enumerable.Empty(); + + var ids = rows.Select(r => r.InstanceId).ToList(); + var idTable = new DataTable(); + idTable.Columns.Add("Value", typeof(int)); + foreach (var id in ids) idTable.Rows.Add(id); + + var attributes = (await conn.QueryAsync(@" + SELECT [InstanceId], [Key], [Value] + FROM [Instances].[CategoryAttributes] + WHERE [InstanceId] IN (SELECT [Value] FROM @Ids)", + new { Ids = idTable.AsTableValuedParameter("dbo.IntegerList") }, trans)).ToLookup(a => a.InstanceId); + + var parents = (await conn.QueryAsync(@" + SELECT [InstanceId], [CategoryInstanceId] + FROM [Instances].[CategoryCategories] + WHERE [InstanceId] IN (SELECT [Value] FROM @Ids)", + new { Ids = idTable.AsTableValuedParameter("dbo.IntegerList") }, trans)).ToLookup(p => p.InstanceId); + + return rows.Select(r => new Category + { + CategoryId = r.InstanceId, + Name = r.Name, + Description = r.Description, + CreatedTimestamp = r.CreatedTimestamp, + Attributes = attributes[r.InstanceId].ToDictionary(a => a.Key, a => a.Value), + ParentCategoryIds = parents[r.InstanceId].Select(p => p.CategoryInstanceId).ToList() + }); + } + + private class CategoryRow + { + public int InstanceId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public DateTime CreatedTimestamp { get; set; } + } + + private class CategoryAttributeRow + { + public int InstanceId { get; set; } + public string Key { get; set; } + public string Value { get; set; } + } + + private class CategoryParentRow + { + public int InstanceId { get; set; } + public int CategoryInstanceId { get; set; } + } + } +} diff --git a/Development Project/Sparcpoint.Inventory/Implementations/SqlInventoryRepository.cs b/Development Project/Sparcpoint.Inventory/Implementations/SqlInventoryRepository.cs new file mode 100644 index 0000000..e639101 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Implementations/SqlInventoryRepository.cs @@ -0,0 +1,148 @@ +using Dapper; +using Sparcpoint.Inventory.Abstract; +using Sparcpoint.Inventory.Extensions; +using Sparcpoint.Inventory.Models; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; + +namespace Sparcpoint.Inventory.Implementations +{ + // EVAL: Inventory is modeled as an append-only ledger of signed-quantity transactions. The + // "current count" of a product is SUM(Quantity), so "add 5" is a +5 row and "remove 3" is a -3 + // row. This makes requirement #6 (undo) trivially achievable: deleting the transaction row + // removes its effect from the sum. No mutable stock-on-hand column needs to stay in sync. + public class SqlInventoryRepository : IInventoryRepository + { + private readonly ISqlExecutor _Executor; + + public SqlInventoryRepository(ISqlExecutor executor) + { + _Executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + // EVAL: Accepts an IEnumerable of adjustments so the API can add/remove one product or many + // in a single call (requirement #4). The entire batch runs inside one ISqlExecutor transaction + // — partial failures roll back, so the inventory ledger is never left half-applied. + public Task> CreateTransactionsAsync(IEnumerable adjustments) + { + PreConditions.ParameterNotNull(adjustments, nameof(adjustments)); + + return _Executor.ExecuteAsync>(async (conn, trans) => + { + var results = new List(); + + foreach (var adjustment in adjustments) + { + var transaction = await conn.QuerySingleAsync(@" + INSERT INTO [Transactions].[InventoryTransactions] + ([ProductInstanceId], [Quantity], [TypeCategory]) + VALUES (@ProductId, @Quantity, @TypeCategory); + SELECT [TransactionId], [ProductInstanceId], [Quantity], [StartedTimestamp], [CompletedTimestamp], [TypeCategory] + FROM [Transactions].[InventoryTransactions] + WHERE [TransactionId] = SCOPE_IDENTITY();", + new + { + ProductId = adjustment.ProductId, + Quantity = adjustment.Quantity, + TypeCategory = adjustment.TypeCategory?.Truncate(32) + }, + trans); + + results.Add(MapTransaction(transaction)); + } + + return results; + }); + } + + // EVAL: Satisfies requirement #6. Because counts are SUM-based, removing the row removes + // its effect on every count query that follows. Throws KeyNotFoundException when no row was + // affected so the controller can translate it to HTTP 404. + public Task DeleteTransactionAsync(int transactionId) + { + return _Executor.ExecuteAsync(async (conn, trans) => + { + var affected = await conn.ExecuteAsync(@" + DELETE FROM [Transactions].[InventoryTransactions] + WHERE [TransactionId] = @TransactionId", + new { TransactionId = transactionId }, trans); + + if (affected == 0) + throw new KeyNotFoundException($"Transaction {transactionId} not found."); + }); + } + + // EVAL: Satisfies requirement #5 — count by product id, by subset of metadata, or both. + // Two code paths: a hand-written fast path for the common id-only lookup (single seek on + // the IX_InventoryTransactions_ProductInstanceId_Quantity index), and a dynamic path using + // SqlServerQueryProvider when attribute filters are involved. + public Task GetCountAsync(InventoryCountQuery query) + { + PreConditions.ParameterNotNull(query, nameof(query)); + + return _Executor.ExecuteAsync(async (conn, trans) => + { + if (query.ProductId.HasValue && (query.AttributeFilters == null || query.AttributeFilters.Count == 0)) + { + return await conn.QuerySingleAsync(@" + SELECT ISNULL(SUM([Quantity]), 0) + FROM [Transactions].[InventoryTransactions] + WHERE [ProductInstanceId] = @ProductId", + new { ProductId = query.ProductId.Value }, trans) ?? 0m; + } + + var provider = new SqlServerQueryProvider(); + + if (query.ProductId.HasValue) + { + var idParam = provider.GetNextParameterName("@ProductId"); + provider.Where($"it.[ProductInstanceId] = {idParam}") + .AddParameter(idParam, query.ProductId.Value); + } + + if (query.AttributeFilters != null) + { + foreach (var kvp in query.AttributeFilters) + { + var keyParam = provider.GetNextParameterName("@AttrKey"); + var valParam = provider.GetNextParameterName("@AttrVal"); + provider.Where($"EXISTS (SELECT 1 FROM [Instances].[ProductAttributes] pa WHERE pa.[InstanceId] = it.[ProductInstanceId] AND pa.[Key] = {keyParam} AND pa.[Value] = {valParam})") + .AddParameter(keyParam, kvp.Key) + .AddParameter(valParam, kvp.Value); + } + } + + var sql = $@" + SELECT ISNULL(SUM(it.[Quantity]), 0) + FROM [Transactions].[InventoryTransactions] it + {provider.WhereClause}"; + + return await conn.QuerySingleAsync(sql, provider.Parameters, trans) ?? 0m; + }); + } + + private static InventoryTransaction MapTransaction(InventoryTransactionRow row) => new InventoryTransaction + { + TransactionId = row.TransactionId, + ProductId = row.ProductInstanceId, + Quantity = row.Quantity, + StartedTimestamp = row.StartedTimestamp, + CompletedTimestamp = row.CompletedTimestamp, + TypeCategory = row.TypeCategory + }; + + private class InventoryTransactionRow + { + 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/Implementations/SqlProductRepository.cs b/Development Project/Sparcpoint.Inventory/Implementations/SqlProductRepository.cs new file mode 100644 index 0000000..30ac761 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Implementations/SqlProductRepository.cs @@ -0,0 +1,233 @@ +using Dapper; +using Sparcpoint; +using Sparcpoint.Inventory.Abstract; +using Sparcpoint.Inventory.Extensions; +using Sparcpoint.Inventory.Models; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; + +namespace Sparcpoint.Inventory.Implementations +{ + // EVAL: Implements IProductRepository against the provided [Instances] schema. Uses ISqlExecutor + // from Sparcpoint.SqlServer.Abstractions for connection/transaction management and IDataSerializer + // from Sparcpoint.Core for the VARCHAR(MAX) JSON fields on Products — both per the recommendation + // to re-use existing system code rather than introduce new dependencies. + public class SqlProductRepository : IProductRepository + { + private readonly ISqlExecutor _Executor; + private readonly IDataSerializer _Serializer; + + public SqlProductRepository(ISqlExecutor executor, IDataSerializer serializer) + { + _Executor = executor ?? throw new ArgumentNullException(nameof(executor)); + _Serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + } + + // EVAL: Product creation is one logical operation but three SQL statements (product row + + // attributes TVP + categories TVP). Wrapping the lambda in ISqlExecutor.ExecuteAsync gives us + // a single transaction — if any step fails, none commit. Returns the new product id. + public Task CreateAsync(CreateProductRequest request) + { + PreConditions.ParameterNotNull(request, nameof(request)); + PreConditions.StringNotNullOrWhitespace(request.Name, nameof(request.Name)); + + return _Executor.ExecuteAsync(async (conn, trans) => + { + // EVAL: Truncate() defensively caps strings at the column's VARCHAR length so a long + // input never produces a SqlException (covers the "string overruns" edge case in the spec). + // ImageUris and ValidSkus are serialized to JSON because the provided schema stores them + // as VARCHAR(MAX) — re-using IDataSerializer keeps the choice of serializer swappable. + var productId = await conn.QuerySingleAsync(@" + INSERT INTO [Instances].[Products] ([Name], [Description], [ProductImageUris], [ValidSkus]) + VALUES (@Name, @Description, @ProductImageUris, @ValidSkus); + SELECT SCOPE_IDENTITY();", + new + { + Name = request.Name.Truncate(256), + Description = (request.Description ?? string.Empty).Truncate(256), + ProductImageUris = _Serializer.Serialize(request.ImageUris ?? Array.Empty()), + ValidSkus = _Serializer.Serialize(request.ValidSkus ?? Array.Empty()) + }, + trans); + + // EVAL: Uses the existing dbo.CustomAttributeList table-valued parameter rather than + // emitting N individual INSERTs. One round-trip regardless of attribute count, and the + // attribute key set is open — any client-supplied (Key, Value) pair is persisted + // verbatim, satisfying requirement #2 (arbitrary metadata). + if (request.Attributes != null && request.Attributes.Count > 0) + { + var attrTable = new DataTable(); + attrTable.Columns.Add("Key", typeof(string)); + attrTable.Columns.Add("Value", typeof(string)); + + foreach (var kvp in request.Attributes) + attrTable.Rows.Add(kvp.Key.Truncate(64), kvp.Value.Truncate(512)); + + await conn.ExecuteAsync(@" + INSERT INTO [Instances].[ProductAttributes] ([InstanceId], [Key], [Value]) + SELECT @ProductId, [Key], [Value] FROM @Attributes", + new { ProductId = productId, Attributes = attrTable.AsTableValuedParameter("dbo.CustomAttributeList") }, + trans); + } + + if (request.CategoryIds != null && request.CategoryIds.Count > 0) + { + var catTable = new DataTable(); + catTable.Columns.Add("Value", typeof(int)); + + foreach (var catId in request.CategoryIds) + catTable.Rows.Add(catId); + + await conn.ExecuteAsync(@" + INSERT INTO [Instances].[ProductCategories] ([InstanceId], [CategoryInstanceId]) + SELECT @ProductId, [Value] FROM @CategoryIds", + new { ProductId = productId, CategoryIds = catTable.AsTableValuedParameter("dbo.IntegerList") }, + trans); + } + + return productId; + }); + } + + public Task GetByIdAsync(int productId) + { + return _Executor.ExecuteAsync(async (conn, trans) => + { + var row = await conn.QuerySingleOrDefaultAsync(@" + SELECT [InstanceId], [Name], [Description], [ProductImageUris], [ValidSkus], [CreatedTimestamp] + FROM [Instances].[Products] + WHERE [InstanceId] = @ProductId", + new { ProductId = productId }, trans); + + if (row == null) return null; + + var results = await HydrateAsync(conn, trans, new[] { row }); + return results.FirstOrDefault(); + }); + } + + // EVAL: Search composes a dynamic WHERE clause using SqlServerQueryProvider from + // Sparcpoint.SqlServer.Abstractions. Any combination of name/category/attribute filters is + // supported (requirement #3), and the provider sanitizes column/parameter names to prevent + // SQL injection. EXISTS subqueries are used per category/attribute rather than JOINs so the + // result set isn't duplicated when a product matches multiple categories or attributes. + public Task> SearchAsync(ProductSearchCriteria criteria) + { + PreConditions.ParameterNotNull(criteria, nameof(criteria)); + + return _Executor.ExecuteAsync>(async (conn, trans) => + { + var query = new SqlServerQueryProvider(); + + if (!string.IsNullOrWhiteSpace(criteria.NameContains)) + { + var nameParam = query.GetNextParameterName("@NameFilter"); + query.Where($"p.[Name] LIKE {nameParam}") + .AddParameter(nameParam, $"%{criteria.NameContains}%"); + } + + if (criteria.CategoryIds != null && criteria.CategoryIds.Count > 0) + { + foreach (var catId in criteria.CategoryIds) + { + var paramName = query.GetNextParameterName("@CatId"); + query.Where($"EXISTS (SELECT 1 FROM [Instances].[ProductCategories] pc WHERE pc.[InstanceId] = p.[InstanceId] AND pc.[CategoryInstanceId] = {paramName})") + .AddParameter(paramName, catId); + } + } + + if (criteria.AttributeFilters != null) + { + foreach (var kvp in criteria.AttributeFilters) + { + var keyParam = query.GetNextParameterName("@AttrKey"); + var valParam = query.GetNextParameterName("@AttrVal"); + query.Where($"EXISTS (SELECT 1 FROM [Instances].[ProductAttributes] pa WHERE pa.[InstanceId] = p.[InstanceId] AND pa.[Key] = {keyParam} AND pa.[Value] = {valParam})") + .AddParameter(keyParam, kvp.Key) + .AddParameter(valParam, kvp.Value); + } + } + + var sql = $@" + SELECT p.[InstanceId], p.[Name], p.[Description], p.[ProductImageUris], p.[ValidSkus], p.[CreatedTimestamp] + FROM [Instances].[Products] p + {query.WhereClause} + ORDER BY p.[CreatedTimestamp] DESC"; + + var rows = (await conn.QueryAsync(sql, query.Parameters, trans)).ToList(); + return await HydrateAsync(conn, trans, rows); + }); + } + + // EVAL: Hydration is the N+1-avoidance step: one batch query loads all attributes for all + // matched product ids, one batch query loads all category links, and both are looked up by + // id in memory. Without this, a search that returns 100 products would issue 200 extra + // round-trips. + private async Task> HydrateAsync(IDbConnection conn, IDbTransaction trans, IList rows) + { + if (rows.Count == 0) return Enumerable.Empty(); + + var idTable = new DataTable(); + idTable.Columns.Add("Value", typeof(int)); + foreach (var r in rows) idTable.Rows.Add(r.InstanceId); + + var attributes = (await conn.QueryAsync(@" + SELECT [InstanceId], [Key], [Value] + FROM [Instances].[ProductAttributes] + WHERE [InstanceId] IN (SELECT [Value] FROM @Ids)", + new { Ids = idTable.AsTableValuedParameter("dbo.IntegerList") }, trans)).ToLookup(a => a.InstanceId); + + var categories = (await conn.QueryAsync(@" + SELECT [InstanceId], [CategoryInstanceId] + FROM [Instances].[ProductCategories] + WHERE [InstanceId] IN (SELECT [Value] FROM @Ids)", + new { Ids = idTable.AsTableValuedParameter("dbo.IntegerList") }, trans)).ToLookup(c => c.InstanceId); + + return rows.Select(r => new Product + { + ProductId = r.InstanceId, + Name = r.Name, + Description = r.Description, + ImageUris = SafeDeserializeList(r.ProductImageUris), + ValidSkus = SafeDeserializeList(r.ValidSkus), + CreatedTimestamp = r.CreatedTimestamp, + Attributes = attributes[r.InstanceId].ToDictionary(a => a.Key, a => a.Value), + CategoryIds = categories[r.InstanceId].Select(c => c.CategoryInstanceId).ToList() + }).ToList(); + } + + private IReadOnlyList SafeDeserializeList(string json) + { + if (string.IsNullOrEmpty(json)) return Array.Empty(); + try { return _Serializer.Deserialize>(json) ?? (IReadOnlyList)Array.Empty(); } + catch { return Array.Empty(); } + } + + private class ProductRow + { + public int InstanceId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string ProductImageUris { get; set; } + public string ValidSkus { get; set; } + public DateTime CreatedTimestamp { get; set; } + } + + private class ProductAttributeRow + { + public int InstanceId { get; set; } + public string Key { get; set; } + public string Value { get; set; } + } + + private class ProductCategoryRow + { + public int InstanceId { get; set; } + public int CategoryInstanceId { get; set; } + } + } +} diff --git a/Development Project/Sparcpoint.Inventory/Models/Category.cs b/Development Project/Sparcpoint.Inventory/Models/Category.cs new file mode 100644 index 0000000..de7aa44 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Models/Category.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace Sparcpoint.Inventory.Models +{ + public class Category + { + public int CategoryId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public IReadOnlyList ParentCategoryIds { get; set; } = Array.Empty(); + public IReadOnlyDictionary Attributes { get; set; } = new Dictionary(); + public DateTime CreatedTimestamp { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Inventory/Models/CreateCategoryRequest.cs b/Development Project/Sparcpoint.Inventory/Models/CreateCategoryRequest.cs new file mode 100644 index 0000000..720d0b5 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Models/CreateCategoryRequest.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace Sparcpoint.Inventory.Models +{ + public class CreateCategoryRequest + { + public string Name { get; set; } + public string Description { get; set; } + public IReadOnlyList ParentCategoryIds { get; set; } = Array.Empty(); + public IReadOnlyDictionary Attributes { get; set; } = new Dictionary(); + } +} diff --git a/Development Project/Sparcpoint.Inventory/Models/CreateProductRequest.cs b/Development Project/Sparcpoint.Inventory/Models/CreateProductRequest.cs new file mode 100644 index 0000000..ae8442e --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Models/CreateProductRequest.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace Sparcpoint.Inventory.Models +{ + public class CreateProductRequest + { + public string Name { get; set; } + public string Description { get; set; } + public IReadOnlyList ImageUris { get; set; } = Array.Empty(); + public IReadOnlyList ValidSkus { get; set; } = Array.Empty(); + public IReadOnlyDictionary Attributes { get; set; } = new Dictionary(); + public IReadOnlyList CategoryIds { get; set; } = Array.Empty(); + } +} diff --git a/Development Project/Sparcpoint.Inventory/Models/InventoryAdjustment.cs b/Development Project/Sparcpoint.Inventory/Models/InventoryAdjustment.cs new file mode 100644 index 0000000..f088697 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Models/InventoryAdjustment.cs @@ -0,0 +1,9 @@ +namespace Sparcpoint.Inventory.Models +{ + public class InventoryAdjustment + { + public int ProductId { get; set; } + public decimal Quantity { get; set; } + public string TypeCategory { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Inventory/Models/InventoryCountQuery.cs b/Development Project/Sparcpoint.Inventory/Models/InventoryCountQuery.cs new file mode 100644 index 0000000..1adb547 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Models/InventoryCountQuery.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace Sparcpoint.Inventory.Models +{ + public class InventoryCountQuery + { + public int? ProductId { get; set; } + public IReadOnlyDictionary AttributeFilters { get; set; } = new Dictionary(); + } +} diff --git a/Development Project/Sparcpoint.Inventory/Models/InventoryTransaction.cs b/Development Project/Sparcpoint.Inventory/Models/InventoryTransaction.cs new file mode 100644 index 0000000..9d73fe1 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Models/InventoryTransaction.cs @@ -0,0 +1,14 @@ +using System; + +namespace Sparcpoint.Inventory.Models +{ + public class InventoryTransaction + { + public int TransactionId { get; set; } + public int ProductId { 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/Models/Product.cs b/Development Project/Sparcpoint.Inventory/Models/Product.cs new file mode 100644 index 0000000..d93c223 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Models/Product.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Sparcpoint.Inventory.Models +{ + public class Product + { + public int ProductId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public IReadOnlyList ImageUris { get; set; } = Array.Empty(); + public IReadOnlyList ValidSkus { get; set; } = Array.Empty(); + public IReadOnlyDictionary Attributes { get; set; } = new Dictionary(); + public IReadOnlyList CategoryIds { get; set; } = Array.Empty(); + public DateTime CreatedTimestamp { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Inventory/Models/ProductSearchCriteria.cs b/Development Project/Sparcpoint.Inventory/Models/ProductSearchCriteria.cs new file mode 100644 index 0000000..4cc784d --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Models/ProductSearchCriteria.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; + +namespace Sparcpoint.Inventory.Models +{ + public class ProductSearchCriteria + { + public string NameContains { get; set; } + public IReadOnlyList CategoryIds { get; set; } = Array.Empty(); + public IReadOnlyDictionary AttributeFilters { get; set; } = new Dictionary(); + } +} diff --git a/Development Project/Sparcpoint.Inventory/Sparcpoint.Inventory.csproj b/Development Project/Sparcpoint.Inventory/Sparcpoint.Inventory.csproj new file mode 100644 index 0000000..891bd49 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory/Sparcpoint.Inventory.csproj @@ -0,0 +1,26 @@ + + + + netstandard2.0 + Sparcpoint.Inventory + true + Sparcpoint, LLC + Sparcpoint, LLC + Sparcpoint Framework + (c) 2021 Sparcpoint, LLC + Inventory domain library: products, categories, attributes, and inventory transactions. + 1.0.0 + + + + + + + + + + + + + +