fix(expense-snapshot): validate functional class + stamp DeletedBy on soft delete
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,12 @@ public class ExpenseSnapshotServiceTests
|
|||||||
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Facilities" });
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Facilities" });
|
||||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Rent" });
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Rent" });
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
return (new ExpenseSnapshotService(db), db);
|
|
||||||
|
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||||
|
var http = new Mock<IHttpContextAccessor>();
|
||||||
|
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return (new ExpenseSnapshotService(db, http.Object), db);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CreateExpenseSnapshotRequest Rent() => new()
|
private static CreateExpenseSnapshotRequest Rent() => new()
|
||||||
@@ -62,6 +67,15 @@ public class ExpenseSnapshotServiceTests
|
|||||||
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithInvalidFunctionalClass_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var r = Rent();
|
||||||
|
r.Lines[0].FunctionalClass = "NotAClass";
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetById_ReturnsLines_TotalsAndCreatorName()
|
public async Task GetById_ReturnsLines_TotalsAndCreatorName()
|
||||||
{
|
{
|
||||||
@@ -138,4 +152,14 @@ public class ExpenseSnapshotServiceTests
|
|||||||
Assert.Empty(await svc.GetAllAsync());
|
Assert.Empty(await svc.GetAllAsync());
|
||||||
Assert.Null(await db.ExpenseSnapshots.FirstOrDefaultAsync(s => s.Id == id));
|
Assert.Null(await db.ExpenseSnapshots.FirstOrDefaultAsync(s => s.Id == id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_StampsDeletedBy()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build("deleter-1");
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
await svc.DeleteAsync(id);
|
||||||
|
var row = await db.ExpenseSnapshots.IgnoreQueryFilters().FirstAsync(s => s.Id == id);
|
||||||
|
Assert.Equal("deleter-1", row.DeletedBy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
@@ -8,7 +10,17 @@ namespace ROLAC.API.Services;
|
|||||||
public class ExpenseSnapshotService : IExpenseSnapshotService
|
public class ExpenseSnapshotService : IExpenseSnapshotService
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
public ExpenseSnapshotService(AppDbContext db) => _db = db;
|
private readonly IHttpContextAccessor _http;
|
||||||
|
public ExpenseSnapshotService(AppDbContext db, IHttpContextAccessor http)
|
||||||
|
{ _db = db; _http = http; }
|
||||||
|
|
||||||
|
// The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false),
|
||||||
|
// so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it),
|
||||||
|
// then fall back to "sub" (real tokens). Required for the self-ownership guard to work in production.
|
||||||
|
private string CurrentUserId =>
|
||||||
|
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
?? _http.HttpContext?.User.FindFirstValue("sub")
|
||||||
|
?? "system";
|
||||||
|
|
||||||
public async Task<List<ExpenseSnapshotDto>> GetAllAsync()
|
public async Task<List<ExpenseSnapshotDto>> GetAllAsync()
|
||||||
{
|
{
|
||||||
@@ -101,7 +113,7 @@ public class ExpenseSnapshotService : IExpenseSnapshotService
|
|||||||
{
|
{
|
||||||
var s = await _db.ExpenseSnapshots.FirstOrDefaultAsync(x => x.Id == id)
|
var s = await _db.ExpenseSnapshots.FirstOrDefaultAsync(x => x.Id == id)
|
||||||
?? throw new KeyNotFoundException($"Snapshot {id} not found.");
|
?? throw new KeyNotFoundException($"Snapshot {id} not found.");
|
||||||
s.IsDeleted = true; s.DeletedAt = DateTimeOffset.UtcNow;
|
s.IsDeleted = true; s.DeletedAt = DateTimeOffset.UtcNow; s.DeletedBy = CurrentUserId;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +127,8 @@ public class ExpenseSnapshotService : IExpenseSnapshotService
|
|||||||
throw new InvalidOperationException("Each snapshot line needs a category group and subcategory.");
|
throw new InvalidOperationException("Each snapshot line needs a category group and subcategory.");
|
||||||
if (l.Amount <= 0)
|
if (l.Amount <= 0)
|
||||||
throw new InvalidOperationException("Each snapshot line amount must be greater than zero.");
|
throw new InvalidOperationException("Each snapshot line amount must be greater than zero.");
|
||||||
|
if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass))
|
||||||
|
throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user