Skip to content

Commit 10f25ed

Browse files
committed
.NET: more first code, WireMock example
1 parent aaef325 commit 10f25ed

19 files changed

Lines changed: 492 additions & 78 deletions

SockstoreNet/Application/Ports/IProductPort.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ public interface IProductPort
99
Task<ProductAggregate?> FindById(ProductId id, CancellationToken cancellationToken);
1010
Task<IEnumerable<ProductAggregate>> FindAll(CancellationToken cancellationToken);
1111
Task Update(ProductAggregate product);
12+
Task<decimal> GetGlobalDiscount();
1213
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using Application.Query;
2+
using System.Net.Http.Json;
3+
using Vocabulary;
4+
5+
namespace Application.UseCases;
6+
7+
public class Cart
8+
{
9+
public CartItem[] Items { get; set; } = [];
10+
}
11+
12+
public class CartItem
13+
{
14+
public int ProductId { get; set; }
15+
public int Amount { get; set; }
16+
}
17+
18+
public class OrderProductsUseCase(IProductQuery productQuery, HttpClient httpClient)
19+
{
20+
public async Task Order(Cart cart)
21+
{
22+
decimal cost = 0;
23+
foreach (var item in cart.Items)
24+
{
25+
var product = await productQuery.FindById(new ProductId(item.ProductId), CancellationToken.None);
26+
cost += product!.Price.Value * item.Amount;
27+
}
28+
29+
var paymentRequest = new { amount = cost, currency = "usd" };
30+
var response = await httpClient.PostAsJsonAsync("/v1/payment_intents", paymentRequest);
31+
response.EnsureSuccessStatusCode();
32+
33+
// TODO: should probably do something depending on the response
34+
string responseBody = await response.Content.ReadAsStringAsync();
35+
36+
// TODO: email client
37+
// TODO: check if stock is sufficient & update stock
38+
// TODO: send event to start shipping
39+
}
40+
}

SockstoreNet/Infrastructure/Db/ProductDbContext.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ namespace Infrastructure.Db;
66
public class ProductDbContext(DbContextOptions<ProductDbContext> options) : DbContext(options)
77
{
88
public DbSet<ProductEntity> Products => Set<ProductEntity>();
9-
}
9+
public DbSet<ParameterEntity> Parameters => Set<ParameterEntity>();
10+
}

SockstoreNet/Infrastructure/Db/ProductRepository.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,39 @@ public async Task Update(ProductAggregate product)
2222
await db.SaveChangesAsync();
2323
}
2424

25+
public async Task<decimal> GetGlobalDiscount()
26+
{
27+
var discount = await db.Parameters.FirstOrDefaultAsync(x => x.Key == "GlobalDiscount");
28+
if (discount == null)
29+
return 0;
30+
31+
if (decimal.TryParse(discount.Value, out decimal result))
32+
return result;
33+
34+
return 0;
35+
}
36+
2537
public async Task<ProductAggregate?> FindById(ProductId id, CancellationToken cancellationToken)
2638
{
2739
var entity = await db.Products.FindAsync([id.Value], cancellationToken);
28-
return entity is null ? null : ToAggregate(entity);
40+
return entity is null ? null : await ToAggregate(entity);
2941
}
3042

3143
public async Task<IEnumerable<ProductAggregate>> FindAll(CancellationToken cancellationToken)
3244
{
3345
var products = await db.Products.ToListAsync(cancellationToken: cancellationToken);
34-
return products.Select(ToAggregate);
46+
var result = products.Select(async prod => await ToAggregate(prod));
47+
return await Task.WhenAll(result);
3548
}
3649

37-
private static ProductAggregate ToAggregate(ProductEntity e)
50+
private async Task<ProductAggregate> ToAggregate(ProductEntity e)
3851
{
52+
decimal globalDiscount = await GetGlobalDiscount();
3953
return new(
4054
new ProductId(e.Id),
4155
new Name(e.Name),
4256
new Category(e.Category),
43-
new Price(e.Price),
57+
new Price(e.Price, globalDiscount),
4458
new Stock(e.Stock)
4559
);
4660
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace Infrastructure.Entities;
4+
5+
public class ParameterEntity
6+
{
7+
public int Id { get; set; }
8+
[StringLength(100)]
9+
public string Key { get; set; } = "";
10+
[StringLength(100)]
11+
public string Value { get; set; } = "";
12+
13+
public override string ToString() => $"{Key}={Value}";
14+
}

SockstoreNet/Migration/Migrator.cs

Lines changed: 63 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ namespace Migration
1111
public class Migrator
1212
{
1313
private readonly string _dataDirectory;
14+
private readonly ICollection<StockItem> _stock = new List<StockItem>();
1415

1516
public Migrator(string dataDirectory)
1617
{
@@ -20,15 +21,11 @@ public Migrator(string dataDirectory)
2021
public IEnumerable<Product> Migrate()
2122
{
2223
string catalogPath = Path.Combine(_dataDirectory, "socks_catalog.csv");
23-
string stockPath = Path.Combine(_dataDirectory, "stock_inventory.xlsx");
24-
2524
if (!File.Exists(catalogPath))
2625
throw new FileNotFoundException($"Catalog file not found: {catalogPath}");
27-
if (!File.Exists(stockPath))
28-
throw new FileNotFoundException($"Stock file not found: {stockPath}");
2926

30-
var stockData = ReadStockData(stockPath);
31-
var combinedProducts = ReadAndCombineCatalogData(catalogPath, stockData);
27+
ReadStock();
28+
var combinedProducts = ReadAndCombineCatalogData(catalogPath);
3229

3330
var products = combinedProducts
3431
.Where(x => x.Active)
@@ -42,7 +39,7 @@ public IEnumerable<Product> Migrate()
4239
new ProductId(index),
4340
new Name(prod.Name.Replace(" Socks", "")),
4441
new Category("???"), // TODO: we don't have categories in the legacy system
45-
new Price(prod.Price),
42+
new Price(prod.Price, 0),
4643
new Stock(group.Sum(x => x.Stock))
4744
);
4845
})
@@ -51,29 +48,7 @@ public IEnumerable<Product> Migrate()
5148
return products;
5249
}
5350

54-
private static Dictionary<Guid, int> ReadStockData(string stockPath)
55-
{
56-
var stockData = new Dictionary<Guid, int>();
57-
58-
using var workbook = new XLWorkbook(stockPath);
59-
var worksheet = workbook.Worksheet(1);
60-
var rowCount = worksheet.LastRowUsed()?.RowNumber();
61-
62-
for (int row = 2; row <= rowCount; row++)
63-
{
64-
var productIdCell = worksheet.Cell(row, 1).GetValue<string>();
65-
var stockQuantityCell = worksheet.Cell(row, 2).GetValue<int>();
66-
67-
if (Guid.TryParse(productIdCell, out var productId))
68-
{
69-
stockData[productId] = stockQuantityCell;
70-
}
71-
}
72-
73-
return stockData;
74-
}
75-
76-
private static List<LegacyCombinedProduct> ReadAndCombineCatalogData(string catalogPath, Dictionary<Guid, int> stockData)
51+
private List<LegacyCombinedProduct> ReadAndCombineCatalogData(string catalogPath)
7752
{
7853
var products = new List<LegacyCombinedProduct>();
7954

@@ -96,7 +71,7 @@ private static List<LegacyCombinedProduct> ReadAndCombineCatalogData(string cata
9671
Material = columns[8],
9772
Description = columns[9].Trim('"'),
9873
Brand = columns[10],
99-
Stock = stockData.GetValueOrDefault(productId, 0)
74+
Stock = GetStock(productId),
10075
};
10176

10277
products.Add(product);
@@ -105,6 +80,63 @@ private static List<LegacyCombinedProduct> ReadAndCombineCatalogData(string cata
10580
return products;
10681
}
10782

83+
private record StockItem(Guid ProductId, int Stock);
84+
85+
private void ReadStock()
86+
{
87+
string stockPath = Path.Combine(_dataDirectory, "stock_inventory.xlsx");
88+
89+
if (!File.Exists(stockPath))
90+
throw new FileNotFoundException($"Stock file not found: {stockPath}");
91+
92+
using var workbook = new XLWorkbook(stockPath);
93+
var worksheet = workbook.Worksheet(1);
94+
var rowCount = worksheet.LastRowUsed()?.RowNumber();
95+
96+
for (int row = 2; row <= rowCount; row++)
97+
{
98+
var productIdCell = worksheet.Cell(row, 1).GetValue<string>();
99+
var stockQuantity = worksheet.Cell(row, 2).GetValue<int>();
100+
101+
if (Guid.TryParse(productIdCell, out var productId))
102+
{
103+
_stock.Add(new StockItem(productId, stockQuantity));
104+
}
105+
}
106+
}
107+
108+
private int GetStock(Guid productIdToFind)
109+
{
110+
return _stock.FirstOrDefault(x => x.ProductId == productIdToFind)?.Stock ?? 0;
111+
112+
// ATTN: This code would read the Excel every time we need a Stock
113+
// But that just took way too long for a single test to run.
114+
//string stockPath = Path.Combine(_dataDirectory, "stock_inventory.xlsx");
115+
116+
//if (!File.Exists(stockPath))
117+
// throw new FileNotFoundException($"Stock file not found: {stockPath}");
118+
119+
//using var workbook = new XLWorkbook(stockPath);
120+
//var worksheet = workbook.Worksheet(1);
121+
//var rowCount = worksheet.LastRowUsed()?.RowNumber();
122+
123+
//for (int row = 2; row <= rowCount; row++)
124+
//{
125+
// var productIdCell = worksheet.Cell(row, 1).GetValue<string>();
126+
// var stockQuantityCell = worksheet.Cell(row, 2).GetValue<int>();
127+
128+
// if (Guid.TryParse(productIdCell, out var productId))
129+
// {
130+
// if (productId == productIdToFind)
131+
// {
132+
// return stockQuantityCell;
133+
// }
134+
// }
135+
//}
136+
137+
//return 0;
138+
}
139+
108140
private static string[] ParseCsvLine(string line)
109141
{
110142
var result = new List<string>();

SockstoreNet/SOLUTIONS.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
Solutions
2+
=========
3+
4+
0.FirstTestsFast
5+
----------------
6+
7+
- Read the Excel once instead of every time (and put in a `Dictionary<Guid, int>`)
8+
- Work with different smaller sets of data instead of the actual production data for testing
9+
- Have a few tests that work with the files that test reading the files into memory correctly
10+
- And then have most tests work setup the in-memory structures and test from there
11+
- Consider if it's worthwhile writing tests for a one-time migration
12+
- Ask when this migration will be executed? And will it really be executed only once?
13+
14+
15+
16+
17+
1.FirstTestsIndependent
18+
-----------------------
19+
20+
Typical reasons tests interact with each other:
21+
- Statics are changed in a test and used in another.
22+
- Database records are updated in a test and not expected to have changed in another.
23+
24+
In this case the GlobalDiscount is set in one test and influences other tests.
25+
26+
The easiest solution here is probably to start a transaction and not commit it at
27+
the end of the test.
28+
29+
30+
31+
32+
2.FirstTests
33+
------------
34+
35+
### Repeatable
36+
37+
Typically a dependency on `DateTime.Now`. In this case there is a weekend discount.
38+
We need to introduce an interface `IDateTimeProvider` so that we can control the
39+
"current date" in the tests.
40+
41+
42+
### SelfValidating
43+
44+
An Excel is created and then needs to be manually evaluated.
45+
46+
The same nuget package can be used to validate the resulting Excel.
47+
In case that is not possible, it would also be possible to have an intermediate state
48+
and assert against that state. This is called introducing a `sensing variable` (Working Effectively with Legacy Code)
49+
50+
51+
52+
3.FirstTestsThorough
53+
--------------------
54+
55+
56+
57+
58+
59+
4.FirstTestsTimely
60+
------------------
61+
62+
63+
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using Application.Commands;
2+
using Application.Interfaces;
3+
using Microsoft.AspNetCore.Mvc;
4+
using SockStoreApi.Response;
5+
6+
namespace SockStoreApi.Controllers;
7+
8+
[ApiController]
9+
[Route("api/[controller]")]
10+
public class AdminController(ICreateProduct createProduct, IUpdateProduct updateProduct, IUpdateStock updateStock) : ControllerBase
11+
{
12+
[HttpPost]
13+
public async Task<ActionResult<ProductResponse>> Create(CreateProductCommand command)
14+
{
15+
var result = await createProduct.Create(command);
16+
return CreatedAtAction(nameof(Create), new { id = result.Id }, result);
17+
}
18+
19+
[HttpPut("{id:int}")]
20+
public async Task<ActionResult<ProductResponse>> Update(int id, UpdateProductCommand command)
21+
{
22+
if (id != command.Id.Value) return BadRequest("Id mismatch");
23+
var result = await updateProduct.Update(command);
24+
return Ok(ProductResponse.FromProduct(result));
25+
}
26+
27+
[HttpPatch("{id:int}/stock")]
28+
public async Task<ActionResult<ProductResponse>> UpdateStock(int id, UpdateStockCommand command)
29+
{
30+
if (id != command.Id.Value) return BadRequest("Id mismatch");
31+
var result = await updateStock.UpdateStock(command);
32+
return Ok(ProductResponse.FromProduct(result));
33+
}
34+
}
Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
using Application.Commands;
2-
using Application.Interfaces;
31
using Application.Query;
42
using Microsoft.AspNetCore.Mvc;
53
using SockStoreApi.Response;
@@ -9,15 +7,8 @@ namespace SockStoreApi.Controllers;
97

108
[ApiController]
119
[Route("api/[controller]")]
12-
public class ProductController(ICreateProduct createProduct, IUpdateProduct updateProduct, IUpdateStock updateStock, IProductQuery productQuery) : ControllerBase
10+
public class ProductController(IProductQuery productQuery) : ControllerBase
1311
{
14-
[HttpPost]
15-
public async Task<ActionResult<ProductResponse>> Create(CreateProductCommand command)
16-
{
17-
var result = await createProduct.Create(command);
18-
return CreatedAtAction(nameof(Create), new { id = result.Id }, result);
19-
}
20-
2112
[HttpGet("{id:int}")]
2213
public async Task<ActionResult<ProductResponse>> GetById(int id, CancellationToken cancellationToken)
2314
{
@@ -32,20 +23,4 @@ public async Task<IEnumerable<ProductResponse>> GetAll(CancellationToken cancell
3223
var products = await productQuery.FindAll(cancellationToken);
3324
return products.Select(ProductResponse.FromProduct);
3425
}
35-
36-
[HttpPut("{id:int}")]
37-
public async Task<ActionResult<ProductResponse>> Update(int id, UpdateProductCommand command)
38-
{
39-
if (id != command.Id.Value) return BadRequest("Id mismatch");
40-
var result = await updateProduct.Update(command);
41-
return Ok(ProductResponse.FromProduct(result));
42-
}
43-
44-
[HttpPatch("{id:int}/stock")]
45-
public async Task<ActionResult<ProductResponse>> UpdateStock(int id, UpdateStockCommand command)
46-
{
47-
if (id != command.Id.Value) return BadRequest("Id mismatch");
48-
var result = await updateStock.UpdateStock(command);
49-
return Ok(ProductResponse.FromProduct(result));
50-
}
5126
}

0 commit comments

Comments
 (0)