d29de83116
Adds POST/GET payee-1099/{id}/w9, mirroring the expense-receipt upload:
IFileStorage saves to finance/w9/{id}{ext}, content-type derived from the
blob extension. Frontend dialog (edit mode) gains a W-9 file input and an
auth-correct blob "View W-9" link. Payee1099Service ctor now takes
IFileStorage; tests updated with an in-memory FakeStorage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
4.5 KiB
C#
113 lines
4.5 KiB
C#
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Moq;
|
|
using System.Security.Claims;
|
|
using ROLAC.API.Data;
|
|
using ROLAC.API.Data.Interceptors;
|
|
using ROLAC.API.DTOs.Payee;
|
|
using ROLAC.API.Entities;
|
|
using ROLAC.API.Services;
|
|
using ROLAC.API.Services.Logging;
|
|
using ROLAC.API.Services.Security;
|
|
using ROLAC.API.Services.Storage;
|
|
using Xunit;
|
|
|
|
namespace ROLAC.API.Tests.Services;
|
|
|
|
public class Payee1099ServiceTests
|
|
{
|
|
// Minimal in-memory IFileStorage (mirrors the ExpenseServiceTests fake).
|
|
private sealed class FakeStorage : IFileStorage
|
|
{
|
|
public Dictionary<string, byte[]> Files = new();
|
|
public Task<string> SaveAsync(Stream c, string p, CancellationToken ct = default)
|
|
{ using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); }
|
|
public Task<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
|
|
=> Task.FromResult<Stream?>(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null);
|
|
public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; }
|
|
}
|
|
|
|
private static (Payee1099Service svc, AppDbContext db) Build()
|
|
{
|
|
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
|
var accessorMock = new Mock<IHttpContextAccessor>();
|
|
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
|
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(accessorMock.Object))).Options);
|
|
var tin = new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
|
|
return (new Payee1099Service(db, tin, new FakeStorage()), db);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_encrypts_tin_and_stores_last4_only_in_clear()
|
|
{
|
|
var (svc, db) = Build();
|
|
var id = await svc.CreateAsync(new SavePayee1099Request
|
|
{ LegalName = "Pat Player", TinType = "SSN", Tin = "123-45-6789", W9Status = "OnFile" });
|
|
|
|
var saved = await db.Payee1099s.FindAsync(id);
|
|
Assert.NotNull(saved);
|
|
Assert.Equal("6789", saved!.TinLast4);
|
|
Assert.NotNull(saved.TinEncrypted);
|
|
Assert.DoesNotContain("123-45-6789", saved.TinEncrypted!);
|
|
Assert.Equal("123-45-6789", await svc.RevealTinAsync(id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_with_null_tin_keeps_existing_ciphertext()
|
|
{
|
|
var (svc, db) = Build();
|
|
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "X", Tin = "11-2223333" });
|
|
var before = (await db.Payee1099s.FindAsync(id))!.TinEncrypted;
|
|
|
|
await svc.UpdateAsync(id, new SavePayee1099Request { LegalName = "X renamed", Tin = null });
|
|
|
|
var after = await db.Payee1099s.FindAsync(id);
|
|
Assert.Equal("X renamed", after!.LegalName);
|
|
Assert.Equal(before, after.TinEncrypted);
|
|
Assert.Equal("3333", after.TinLast4);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_dto_masks_tin_to_last4()
|
|
{
|
|
var (svc, _) = Build();
|
|
await svc.CreateAsync(new SavePayee1099Request { LegalName = "Y", Tin = "999-88-7777" });
|
|
var list = await svc.GetAllAsync(includeInactive: true);
|
|
var item = Assert.Single(list);
|
|
Assert.Equal("7777", item.TinLast4);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Delete_is_soft_and_hides_from_list()
|
|
{
|
|
var (svc, _) = Build();
|
|
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "Z" });
|
|
await svc.DeleteAsync(id);
|
|
Assert.Empty(await svc.GetAllAsync(includeInactive: true));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveW9_records_document_and_round_trips_bytes()
|
|
{
|
|
var (svc, _) = Build();
|
|
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "W9 Payee" });
|
|
|
|
var bytes = new byte[] { 1, 2, 3, 4, 5 };
|
|
await svc.SaveW9Async(id, new MemoryStream(bytes), "w9.pdf");
|
|
|
|
var dto = await svc.GetByIdAsync(id);
|
|
Assert.NotNull(dto);
|
|
Assert.True(dto!.HasW9Document);
|
|
|
|
var opened = await svc.OpenW9Async(id);
|
|
Assert.NotNull(opened);
|
|
Assert.Equal("application/pdf", opened!.Value.contentType);
|
|
using var ms = new MemoryStream();
|
|
await opened.Value.stream.CopyToAsync(ms);
|
|
Assert.Equal(bytes, ms.ToArray());
|
|
}
|
|
}
|