diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs index be9b344..0c46148 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs @@ -76,42 +76,54 @@ namespace SeaHaven.Services.Implementation RejectTerminalTarget(workOrder, dispatch); var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken); - await EnsureWithinMediaCountsAsync(workOrderId, contentType, file.FileName, cancellationToken); var latest = await _documentData.GetLatestForDispatchAsync(dispatch.Id, cancellationToken); var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}"; - var now = DateTime.UtcNow; var passWithoutScanner = _documentOptions.PassWhenScannerUnavailable; - var document = new VendorCompletionDocument - { - VendorId = dispatch.VendorId, - DispatchId = dispatch.Id, - WorkOrderId = workOrderId, - OriginalFileName = Path.GetFileName(file.FileName), - StoredFileName = storedFileName, - ContentType = contentType, - SizeBytes = bytes.Length, - ScanStatus = passWithoutScanner ? "Passed" : "Pending", - ReviewStatus = "Processing", - ScannedAt = passWithoutScanner ? now : null, - Version = version, - Purpose = VendorDocumentPurpose.UpliftEvidence, - CreatedDate = now - }; + var actorId = user.FindFirstValue(ClaimTypes.NameIdentifier); - await _documentData.AddAsync(document, cancellationToken); - await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog - { - WorkOrderId = workOrderId, - DispatchId = dispatch.Id, - UserId = user.FindFirstValue(ClaimTypes.NameIdentifier), - FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift Evidence", - NewValue = document.OriginalFileName, - Action = "uplift_evidence_uploaded", - ActorType = "internal", - CreatedAt = now - }, cancellationToken); - await _documentData.SaveChangesAsync(cancellationToken); + // Photo/video caps are a work-order aggregate shared with dispatcher media + // and the vendor portal, so the count and the insert share that mutation lock. + var document = await _upliftData.ExecuteWorkOrderMutationAsync( + workOrderId, + async ct => + { + await EnsureWithinMediaCountsAsync(workOrderId, contentType, file.FileName, ct); + + var now = DateTime.UtcNow; + var created = new VendorCompletionDocument + { + VendorId = dispatch.VendorId, + DispatchId = dispatch.Id, + WorkOrderId = workOrderId, + OriginalFileName = Path.GetFileName(file.FileName), + StoredFileName = storedFileName, + ContentType = contentType, + SizeBytes = bytes.Length, + ScanStatus = passWithoutScanner ? "Passed" : "Pending", + ReviewStatus = "Processing", + ScannedAt = passWithoutScanner ? now : null, + Version = version, + Purpose = VendorDocumentPurpose.UpliftEvidence, + CreatedDate = now + }; + + await _documentData.AddAsync(created, ct); + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = workOrderId, + DispatchId = dispatch.Id, + UserId = actorId, + FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift Evidence", + NewValue = created.OriginalFileName, + Action = "uplift_evidence_uploaded", + ActorType = "internal", + CreatedAt = now + }, ct); + await _documentData.SaveChangesAsync(ct); + return created; + }, + cancellationToken); using var stored = new MemoryStream(bytes); await _documentStorage.SaveAsync( diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs index 60a5e14..3c0e506 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs @@ -26,7 +26,8 @@ public sealed class WorkOrderUpliftEvidenceServiceTests private static async Task SeedWorkOrderAsync( ApplicationDbContext context, - string role = "Admin") + string role = "Admin", + int workOrderId = 1) { await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); context.Users.Add(new ApplicationUser @@ -48,14 +49,14 @@ public sealed class WorkOrderUpliftEvidenceServiceTests { Id = 10, VendorId = 1, - WorkOrderId = 1, + WorkOrderId = workOrderId, NTEAmount = 1000m, DispatchNumber = "DIS-10", Status = "Scheduled", }); var workOrder = new WorkOrder { - Id = 1, + Id = workOrderId, InternalWONumber = "10000000001", PrimaryDispatchId = 10, AccountId = 1, @@ -207,6 +208,110 @@ public sealed class WorkOrderUpliftEvidenceServiceTests Assert.Empty(storage.Saved); } + [Fact] + public async Task UploadAsync_ConcurrentPhotos_PersistOnlyTheLastOpenSlot() + { + // Distinct id: the mutation gate is process-wide per work order. Nine photos + // leave one slot. Both uploads must wait on that gate; otherwise each reads 9 + // and both persist. + const int workOrderId = 388_171; + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + await using (var seed = new ApplicationDbContext(options)) + { + await SeedWorkOrderAsync(seed, workOrderId: workOrderId); + for (var i = 0; i < 9; i++) + { + seed.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = 1, + DispatchId = 10, + WorkOrderId = workOrderId, + OriginalFileName = $"photo-{i}.jpg", + StoredFileName = $"photo-{i}.jpg", + ContentType = "image/jpeg", + Purpose = "Completion", + ScanStatus = "Passed", + Version = 1, + }); + } + + await seed.SaveChangesAsync(); + } + + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var holderContext = new ApplicationDbContext(options); + var holder = new UpliftDataService(holderContext).ExecuteWorkOrderMutationAsync( + workOrderId, + async _ => + { + held.SetResult(); + await release.Task; + return 0; + }, + CancellationToken.None); + await held.Task; + + await using var firstContext = new ApplicationDbContext(options); + await using var secondContext = new ApplicationDbContext(options); + var (first, firstStorage) = NewService(firstContext); + var (second, secondStorage) = NewService(secondContext); + var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }; + var firstUpload = first.UploadAsync( + workOrderId, + FormFile(jpeg, "a.jpg", "image/jpeg"), + Dispatcher(), + CancellationToken.None); + var secondUpload = second.UploadAsync( + workOrderId, + FormFile(jpeg, "b.jpg", "image/jpeg"), + Dispatcher(), + CancellationToken.None); + + try + { + await Task.Delay(200); + Assert.False(firstUpload.IsCompleted); + Assert.False(secondUpload.IsCompleted); + await using var during = new ApplicationDbContext(options); + Assert.Equal(9, await during.VendorCompletionDocuments.CountAsync()); + } + finally + { + release.TrySetResult(); + } + + await holder; + var outcomes = await Task.WhenAll(Observe(firstUpload), Observe(secondUpload)); + + await using var verify = new ApplicationDbContext(options); + Assert.Equal(10, await verify.VendorCompletionDocuments.CountAsync()); + Assert.Equal( + 1, + await verify.VendorCompletionDocuments.CountAsync( + document => document.Purpose == VendorDocumentPurpose.UpliftEvidence)); + Assert.Equal(1, outcomes.Count(outcome => outcome is null)); + var rejected = Assert.Single(outcomes.OfType()); + Assert.Equal("A work order can have at most 10 photos.", rejected.Message); + Assert.Equal(1, firstStorage.Saved.Count + secondStorage.Saved.Count); + + static async Task Observe(Task upload) + { + try + { + await upload; + return null; + } + catch (Exception ex) + { + return ex; + } + } + } + [Fact] public async Task UploadAsync_RequestUpliftsDenied_PersistsNothing() {