fix: align vendor document API with frontend (#37)
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions

This commit is contained in:
Alexandre Brandizzi 2026-07-28 13:57:45 -03:00 • committed by GitHub
parent 45ffa68dfa
commit 5ecb377613
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 207 additions and 14 deletions

View file

@ -101,6 +101,28 @@ public sealed class VendorPortalDocumentTests : IDisposable
}; };
} }
[Fact]
public void CompletionDocumentEndpoints_AdvertiseCanonicalAndCompatibilityRoutes()
{
var uploadRoutes = typeof(VendorPortalController)
.GetMethod(nameof(VendorPortalController.UploadCompletionDocument))!
.GetCustomAttributes(typeof(HttpPostAttribute), inherit: false)
.Cast<HttpPostAttribute>()
.Select(attribute => attribute.Template);
var downloadRoutes = typeof(VendorPortalController)
.GetMethod(nameof(VendorPortalController.DownloadCompletionDocument))!
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
.Cast<HttpGetAttribute>()
.Select(attribute => attribute.Template);
uploadRoutes.Should().BeEquivalentTo(
"dispatches/{id:int}/completion-documents",
"dispatches/{id:int}/documents");
downloadRoutes.Should().BeEquivalentTo(
"dispatches/{id:int}/completion-documents/{documentId:int}",
"dispatches/{id:int}/documents/{documentId:int}");
}
[Fact] [Fact]
public async Task UploadCompletionDocument_RejectsMismatchedFileSignature() public async Task UploadCompletionDocument_RejectsMismatchedFileSignature()
{ {
@ -131,6 +153,7 @@ public sealed class VendorPortalDocumentTests : IDisposable
var document = await context.VendorCompletionDocuments.SingleAsync(); var document = await context.VendorCompletionDocuments.SingleAsync();
document.ScanStatus.Should().Be("Pending"); document.ScanStatus.Should().Be("Pending");
document.ReviewStatus.Should().Be("Processing"); document.ReviewStatus.Should().Be("Processing");
document.ReplacesDocumentId.Should().BeNull();
File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue(); File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue();
var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id); var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id);
@ -154,6 +177,106 @@ public sealed class VendorPortalDocumentTests : IDisposable
context.VendorCompletionDocuments.Should().HaveCount(1); context.VendorCompletionDocuments.Should().HaveCount(1);
} }
[Fact]
public async Task UploadCompletionDocument_UsesExplicitOwnedReplacement()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var controller = NewController(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
var firstUpload = await controller.UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion-v1.pdf", "application/pdf"));
firstUpload.Should().BeOfType<OkObjectResult>();
var first = await context.VendorCompletionDocuments.SingleAsync();
var replacementUpload = await controller.UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion-v2.pdf", "application/pdf"),
first.Id);
replacementUpload.Should().BeOfType<OkObjectResult>();
var replacement = await context.VendorCompletionDocuments
.OrderBy(document => document.Version)
.LastAsync();
replacement.Version.Should().Be(2);
replacement.ReplacesDocumentId.Should().Be(first.Id);
}
[Fact]
public async Task UploadCompletionDocument_RejectsReplacementFromAnotherDispatch()
{
using var context = NewContext();
var (vendor, dispatch) = await SeedDispatch(context);
var controller = NewController(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
await controller.UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion-v1.pdf", "application/pdf"));
var existing = await context.VendorCompletionDocuments.SingleAsync();
var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other repair" };
context.Add(otherWorkOrder);
await context.SaveChangesAsync();
var otherDispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = otherWorkOrder.Id,
Status = "Completed"
};
context.Dispatches.Add(otherDispatch);
await context.SaveChangesAsync();
var result = await controller.UploadCompletionDocument(
otherDispatch.Id,
FormFile(pdf, "replacement.pdf", "application/pdf"),
existing.Id);
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().ContainSingle();
}
[Fact]
public async Task UploadCompletionDocument_RejectsReplacementOwnedByAnotherVendor()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var otherVendor = new Vendor { CompanyName = "Other vendor", IsActive = true };
var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other vendor repair" };
context.AddRange(otherVendor, otherWorkOrder);
await context.SaveChangesAsync();
var otherDispatch = new Dispatch
{
VendorId = otherVendor.Id,
WorkOrderId = otherWorkOrder.Id,
Status = "Completed"
};
context.Dispatches.Add(otherDispatch);
await context.SaveChangesAsync();
var otherDocument = new VendorCompletionDocument
{
VendorId = otherVendor.Id,
DispatchId = otherDispatch.Id,
WorkOrderId = otherWorkOrder.Id,
OriginalFileName = "other.pdf",
StoredFileName = "other.pdf",
ContentType = "application/pdf",
SizeBytes = 4,
Version = 1
};
context.VendorCompletionDocuments.Add(otherDocument);
await context.SaveChangesAsync();
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile("%PDF-1.4\nreplacement"u8.ToArray(), "replacement.pdf", "application/pdf"),
otherDocument.Id);
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().ContainSingle();
}
[Fact] [Fact]
public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType() public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType()
{ {

View file

@ -228,15 +228,25 @@ namespace Api.SeaHavenIndustries.Controllers
} }
[HttpPost("dispatches/{id:int}/completion-documents")] [HttpPost("dispatches/{id:int}/completion-documents")]
[HttpPost("dispatches/{id:int}/documents")]
[RequestSizeLimit(10_000_000)] [RequestSizeLimit(10_000_000)]
public async Task<IActionResult> UploadCompletionDocument(int id, [FromForm] IFormFile file, CancellationToken cancellationToken = default) public async Task<IActionResult> UploadCompletionDocument(
int id,
[FromForm] IFormFile file,
[FromForm] int? replacesDocumentId = null,
CancellationToken cancellationToken = default)
{ {
var session = await ResolveSessionAsync(cancellationToken); var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
try try
{ {
var result = await _portalService.UploadCompletionDocumentAsync(session, id, file, cancellationToken); var result = await _portalService.UploadCompletionDocumentAsync(
session,
id,
file,
replacesDocumentId,
cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result }); return Ok(new DataResponse { Status = "Success", Data = result });
} }
catch (KeyNotFoundException) catch (KeyNotFoundException)
@ -250,6 +260,7 @@ namespace Api.SeaHavenIndustries.Controllers
} }
[HttpGet("dispatches/{id:int}/completion-documents/{documentId:int}")] [HttpGet("dispatches/{id:int}/completion-documents/{documentId:int}")]
[HttpGet("dispatches/{id:int}/documents/{documentId:int}")]
public async Task<IActionResult> DownloadCompletionDocument(int id, int documentId, CancellationToken cancellationToken = default) public async Task<IActionResult> DownloadCompletionDocument(int id, int documentId, CancellationToken cancellationToken = default)
{ {
var session = await ResolveSessionAsync(cancellationToken); var session = await ResolveSessionAsync(cancellationToken);

View file

@ -510,7 +510,11 @@ namespace SeaHaven.Services.Implementation
} }
public async Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync( public async Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(
VendorPortalSession session, int dispatchId, IFormFile file, CancellationToken cancellationToken) VendorPortalSession session,
int dispatchId,
IFormFile file,
int? replacesDocumentId,
CancellationToken cancellationToken)
{ {
if (file is null || file.Length == 0) if (file is null || file.Length == 0)
{ {
@ -544,6 +548,20 @@ namespace SeaHaven.Services.Implementation
} }
var latest = await _documentData.GetLatestForDispatchAsync(dispatchId, cancellationToken); var latest = await _documentData.GetLatestForDispatchAsync(dispatchId, cancellationToken);
var replacedDocument = latest;
if (replacesDocumentId.HasValue)
{
replacedDocument = await _documentData.GetForVendorDispatchAsync(
replacesDocumentId.Value,
dispatchId,
session.Id,
cancellationToken);
if (replacedDocument == null)
{
throw new InvalidOperationException(
"The completion document could not be replaced.");
}
}
var version = (latest?.Version ?? 0) + 1; var version = (latest?.Version ?? 0) + 1;
var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}"; var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}";
@ -560,7 +578,7 @@ namespace SeaHaven.Services.Implementation
ScanStatus = "Pending", ScanStatus = "Pending",
ReviewStatus = "Processing", ReviewStatus = "Processing",
Version = version, Version = version,
ReplacesDocumentId = latest?.Id, ReplacesDocumentId = replacedDocument?.Id,
CreatedDate = now CreatedDate = now
}; };
@ -570,7 +588,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderId = dispatch.WorkOrderId ?? 0, WorkOrderId = dispatch.WorkOrderId ?? 0,
DispatchId = dispatchId, DispatchId = dispatchId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document", FieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document",
OldValue = latest == null ? null : $"v{latest.Version}", OldValue = replacedDocument == null ? null : $"v{replacedDocument.Version}",
NewValue = $"v{version}", NewValue = $"v{version}",
Action = "vendor_completion_document_uploaded", Action = "vendor_completion_document_uploaded",
ActorType = "vendor", ActorType = "vendor",

View file

@ -15,7 +15,7 @@ namespace SeaHaven.Services.Interfaces
Task<CommentResultDTO> AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken); Task<CommentResultDTO> AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken);
Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken); Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken);
Task<CancelUpliftResultDTO> CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken); Task<CancelUpliftResultDTO> CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken);
Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, CancellationToken cancellationToken); Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, int? replacesDocumentId, CancellationToken cancellationToken);
Task<DownloadCompletionDocumentResultDTO> DownloadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken); Task<DownloadCompletionDocumentResultDTO> DownloadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken);
} }
} }

View file

@ -5,8 +5,8 @@
# #
# Checks: # Checks:
# 1. swagger.json is reachable (HTTP 200) # 1. swagger.json is reachable (HTTP 200)
# 2. swagger advertises POST /api/webhooks/work-orders # 2. swagger advertises release-critical webhook, login, and vendor routes
# 3. swagger still advertises POST /api/Authentication/login # 3. protected vendor routes reject unauthenticated callers rather than 404
# 4. an unauthenticated JSON POST to the webhook returns 401 or 503 (disabled), # 4. an unauthenticated JSON POST to the webhook returns 401 or 503 (disabled),
# never 404 or a server error other than the intentional 503 # never 404 or a server error other than the intentional 503
# #
@ -37,12 +37,53 @@ swagger_http=$(curl -sS -o .artifacts/elastic-beanstalk/swagger.json \
swagger_file=".artifacts/elastic-beanstalk/swagger.json" swagger_file=".artifacts/elastic-beanstalk/swagger.json"
ok "swagger.json HTTP 200" ok "swagger.json HTTP 200"
log "swagger advertises webhook and login routes" log "swagger advertises release-critical routes"
grep -q '"/api/webhooks/work-orders"' "$swagger_file" \ required_paths=(
|| die "swagger.json missing /api/webhooks/work-orders route." "/api/webhooks/work-orders"
grep -q '"/api/Authentication/login"' "$swagger_file" \ "/api/Authentication/login"
|| die "swagger.json missing /api/Authentication/login route." "/api/Vendor/facets"
ok "webhook and login routes present" "/api/Vendor/{id}/deactivation-impact"
"/api/vendor-operations/notifications"
"/api/vendor-operations/availability"
"/api/vendor-operations/sites/{locationId}/preferred-vendors"
"/api/vendor-operations/work-orders/{workOrderId}/assignment"
"/api/vendor-operations/insights"
"/api/vendor-operations/insights.csv"
"/api/vendor-operations/insights.pdf"
"/api/vendor-portal/dispatches/{id}/completion-documents"
"/api/vendor-portal/dispatches/{id}/completion-documents/{documentId}"
"/api/vendor-portal/dispatches/{id}/documents"
"/api/vendor-portal/dispatches/{id}/documents/{documentId}"
)
for path in "${required_paths[@]}"; do
grep -Fq "\"$path\"" "$swagger_file" \
|| die "swagger.json missing $path route."
done
ok "release-critical webhook, login, and vendor routes present"
assert_protected_route() {
local url="$1"
local route_http
route_http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "$url" || true)
case "$route_http" in
401|403) ok "$url rejected an unauthenticated caller with HTTP $route_http." ;;
404) die "$url returned 404 — route not wired (deployment broken)." ;;
5*) die "$url returned HTTP $route_http — unexpected server error." ;;
*) die "$url returned HTTP $route_http — expected 401 or 403." ;;
esac
}
log "protected vendor routes are wired"
assert_protected_route "$BASE_URL/api/Vendor/facets"
assert_protected_route "$BASE_URL/api/vendor-operations/notifications"
assert_protected_route "$BASE_URL/api/Vendor/1/deactivation-impact"
log "vendor portal rejects a missing token"
portal_session_http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \
"$BASE_URL/api/vendor-portal/session" || true)
[[ "$portal_session_http" == "401" ]] \
|| die "vendor portal session returned HTTP $portal_session_http (expected 401)."
ok "vendor portal session returned 401 without a token"
log "unauthenticated webhook POST: $WEBHOOK_URL" log "unauthenticated webhook POST: $WEBHOOK_URL"
webhook_http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \ webhook_http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \