Files
Chris Chen d29de83116 feat(1099): wire W-9 document upload/view for recipients
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>
2026-06-25 18:11:11 -07:00

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());
}
}