Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions Development Project/Development Project.sln
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Core", "Sparcpoi
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.SqlServer.Abstractions", "Sparcpoint.SqlServer.Abstractions\Sparcpoint.SqlServer.Abstractions.csproj", "{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Interview.Web.Tests", "Interview.Web.Tests\Interview.Web.Tests.csproj", "{C8F425F6-5E77-4783-9D2B-5E5EB29935E3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -55,6 +57,14 @@ Global
{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|Any CPU.Build.0 = Release|Any CPU
{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|x86.ActiveCfg = Release|Any CPU
{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|x86.Build.0 = Release|Any CPU
{C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Debug|x86.ActiveCfg = Debug|Any CPU
{C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Debug|x86.Build.0 = Debug|Any CPU
{C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Release|Any CPU.Build.0 = Release|Any CPU
{C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Release|x86.ActiveCfg = Release|Any CPU
{C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
1 change: 1 addition & 0 deletions Development Project/Interview.Web.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using Xunit;
30 changes: 30 additions & 0 deletions Development Project/Interview.Web.Tests/Interview.Web.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Interview.Web\Interview.Web.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System;
using System.Threading.Tasks;
using Interview.Web.Models;
using Interview.Web.Repositories.Interfaces;
using Interview.Web.Services.Implementations;
using Moq;
using Xunit;

namespace Interview.Web.Tests.Services;

public class InventoryServiceTests
{
private readonly Mock<IInventoryRepository> _repositoryMock;
private readonly InventoryService _sut;

public InventoryServiceTests()
{
_repositoryMock = new Mock<IInventoryRepository>();
_sut = new InventoryService(_repositoryMock.Object);
}

#region AddAsync

[Fact]
public async Task AddAsync_ValidRequest_DelegatesToRepository()
{
var request = new AddInventoryRequest { ProductInstanceId = 1, Quantity = 10 };
_repositoryMock.Setup(r => r.AddAsync(request)).ReturnsAsync(1);

var id = await _sut.AddAsync(request);

Assert.Equal(1, id);
_repositoryMock.Verify(r => r.AddAsync(request), Times.Once);
}

[Fact]
public async Task AddAsync_NullRequest_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(() => _sut.AddAsync(null));
}

[Fact]
public async Task AddAsync_ZeroQuantity_ThrowsArgumentException()
{
var request = new AddInventoryRequest { ProductInstanceId = 1, Quantity = 0 };

await Assert.ThrowsAsync<ArgumentException>(() => _sut.AddAsync(request));
}

[Fact]
public async Task AddAsync_InvalidProductInstanceId_ThrowsArgumentException()
{
var request = new AddInventoryRequest { ProductInstanceId = 0, Quantity = 10 };

await Assert.ThrowsAsync<ArgumentException>(() => _sut.AddAsync(request));
}

#endregion

#region RemoveTransactionAsync

[Fact]
public async Task RemoveTransactionAsync_ValidId_DelegatesToRepository()
{
await _sut.RemoveTransactionAsync(1);

_repositoryMock.Verify(r => r.RemoveTransactionAsync(1), Times.Once);
}

[Fact]
public async Task RemoveTransactionAsync_InvalidId_ThrowsArgumentException()
{
await Assert.ThrowsAsync<ArgumentException>(() => _sut.RemoveTransactionAsync(0));
}

#endregion

#region GetCountAsync

[Fact]
public async Task GetCountAsync_ValidRequest_DelegatesToRepository()
{
var request = new InventoryCountRequest { ProductInstanceId = 1 };
_repositoryMock.Setup(r => r.GetCountAsync(request)).ReturnsAsync(42m);

var count = await _sut.GetCountAsync(request);

Assert.Equal(42m, count);
_repositoryMock.Verify(r => r.GetCountAsync(request), Times.Once);
}

[Fact]
public async Task GetCountAsync_NullRequest_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(() => _sut.GetCountAsync(null));
}

#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Interview.Web.Models;
using Interview.Web.Repositories.Interfaces;
using Interview.Web.Services.Implementations;
using Moq;
using Xunit;

namespace Interview.Web.Tests.Services;

public class ProductsServiceTests
{
private readonly Mock<IProductRepository> _repositoryMock;
private readonly ProductsService _sut;

public ProductsServiceTests()
{
_repositoryMock = new Mock<IProductRepository>();
_sut = new ProductsService(_repositoryMock.Object);
}

#region SearchAsync

[Fact]
public async Task SearchAsync_WithNoFilters_ReturnsAllProducts()
{
var expected = new List<ProductResponse> { new ProductResponse { InstanceId = 1, Name = "Widget" } };
_repositoryMock.Setup(r => r.SearchAsync(It.IsAny<ProductSearchRequest>()))
.ReturnsAsync(expected);

var result = await _sut.SearchAsync(new ProductSearchRequest());

Assert.Equal(expected, result);
}

[Fact]
public async Task SearchAsync_DelegatesToRepository()
{
var request = new ProductSearchRequest { Name = "Widget" };
_repositoryMock.Setup(r => r.SearchAsync(request)).ReturnsAsync(new List<ProductResponse>());

await _sut.SearchAsync(request);

_repositoryMock.Verify(r => r.SearchAsync(request), Times.Once);
}

#endregion

#region CreateAsync

[Fact]
public async Task CreateAsync_ValidRequest_DelegatesToRepository()
{
var request = new CreateProductRequest { Name = "Widget", Description = "A widget" };
_repositoryMock.Setup(r => r.CreateAsync(request)).ReturnsAsync(1);

var id = await _sut.CreateAsync(request);

Assert.Equal(1, id);
_repositoryMock.Verify(r => r.CreateAsync(request), Times.Once);
}

[Fact]
public async Task CreateAsync_NullRequest_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(() => _sut.CreateAsync(null));
}

[Fact]
public async Task CreateAsync_NullName_ThrowsArgumentException()
{
var request = new CreateProductRequest { Name = null, Description = "A widget" };

await Assert.ThrowsAsync<ArgumentException>(() => _sut.CreateAsync(request));
}

[Fact]
public async Task CreateAsync_WhitespaceName_ThrowsArgumentException()
{
var request = new CreateProductRequest { Name = " ", Description = "A widget" };

await Assert.ThrowsAsync<ArgumentException>(() => _sut.CreateAsync(request));
}

[Fact]
public async Task CreateAsync_NameExceeds256Chars_ThrowsArgumentException()
{
var request = new CreateProductRequest
{
Name = new string('a', 257),
Description = "A widget"
};

await Assert.ThrowsAsync<ArgumentException>(() => _sut.CreateAsync(request));
}

[Fact]
public async Task CreateAsync_DescriptionExceeds256Chars_ThrowsArgumentException()
{
var request = new CreateProductRequest
{
Name = "Widget",
Description = new string('a', 257)
};

await Assert.ThrowsAsync<ArgumentException>(() => _sut.CreateAsync(request));
}

#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Threading.Tasks;
using Interview.Web.Models;
using Interview.Web.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace Interview.Web.Controllers;

[ApiController]
[Route("api/v1/inventory")]
public class InventoryController : ControllerBase
{
private readonly IInventoryService _inventoryService;

public InventoryController(IInventoryService inventoryService)
{
_inventoryService = inventoryService;
}

/// <summary>Add an inventory transaction.</summary>
/// <remarks>
/// Positive Quantity adds stock, negative removes it.
/// Each call creates a transaction row that can be undone individually via DELETE.
/// TypeCategory is optional — use it to label the transaction e.g. "RECEIVE", "SALE".
/// </remarks>
/// <param name="request">ProductInstanceId, Quantity, and optional TypeCategory.</param>
/// <returns>The TransactionId of the new transaction.</returns>
/// <response code="200">Transaction created.</response>
/// <response code="400">Quantity cannot be zero. ProductInstanceId must be positive.</response>
[HttpPost]
[ProducesResponseType(typeof(object), 200)]
[ProducesResponseType(400)]
public async Task<IActionResult> AddInventory([FromBody] AddInventoryRequest request)
{
var transactionId = await _inventoryService.AddAsync(request);
// EVAL: Returning the TransactionId lets the caller reference this transaction for DELETE.
return Ok(new { TransactionId = transactionId });
}

/// <summary>Remove (undo) a transaction.</summary>
/// <remarks>
/// Permanently deletes the transaction row. The inventory count updates immediately.
/// </remarks>
/// <param name="transactionId">The transaction to remove.</param>
/// <response code="204">Transaction removed.</response>
/// <response code="400">TransactionId must be a positive integer.</response>
[HttpDelete("{transactionId}")]
[ProducesResponseType(204)]
[ProducesResponseType(400)]
public async Task<IActionResult> RemoveTransaction(int transactionId)
{
await _inventoryService.RemoveTransactionAsync(transactionId);
// EVAL: 204 No Content — correct response for a DELETE with no body.
return NoContent();
}

/// <summary>Get total inventory count.</summary>
/// <remarks>
/// Filter by ProductInstanceId for a single product, or by AttributeKey/AttributeValue
/// to aggregate across all products with that metadata (e.g. all red products).
/// No filters returns the total across everything.
/// </remarks>
/// <param name="request">ProductInstanceId, AttributeKey, AttributeValue — all optional.</param>
/// <returns>Total inventory count.</returns>
/// <response code="200">Returns the count.</response>
[HttpGet("count")]
[ProducesResponseType(typeof(object), 200)]
public async Task<IActionResult> GetCount([FromQuery] InventoryCountRequest request)
{
var count = await _inventoryService.GetCountAsync(request);
return Ok(new { Count = count });
}
}
Loading