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]
public async Task UploadCompletionDocument_RejectsMismatchedFileSignature()
{
@ -131,6 +153,7 @@ public sealed class VendorPortalDocumentTests : IDisposable
var document = await context.VendorCompletionDocuments.SingleAsync();
document.ScanStatus.Should().Be("Pending");
document.ReviewStatus.Should().Be("Processing");
document.ReplacesDocumentId.Should().BeNull();
File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue();
var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id);
@ -154,6 +177,106 @@ public sealed class VendorPortalDocumentTests : IDisposable
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]
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}/documents")]
[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);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
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 });
}
catch (KeyNotFoundException)
@ -250,6 +260,7 @@ namespace Api.SeaHavenIndustries.Controllers
}
[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)
{
var session = await ResolveSessionAsync(cancellationToken);

View file

@ -510,7 +510,11 @@ namespace SeaHaven.Services.Implementation
}
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)
{
@ -544,6 +548,20 @@ namespace SeaHaven.Services.Implementation
}
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 storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}";
@ -560,7 +578,7 @@ namespace SeaHaven.Services.Implementation
ScanStatus = "Pending",
ReviewStatus = "Processing",
Version = version,
ReplacesDocumentId = latest?.Id,
ReplacesDocumentId = replacedDocument?.Id,
CreatedDate = now
};
@ -570,7 +588,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderId = dispatch.WorkOrderId ?? 0,
DispatchId = dispatchId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document",
OldValue = latest == null ? null : $"v{latest.Version}",
OldValue = replacedDocument == null ? null : $"v{replacedDocument.Version}",
NewValue = $"v{version}",
Action = "vendor_completion_document_uploaded",
ActorType = "vendor",

View file

@ -15,7 +15,7 @@ namespace SeaHaven.Services.Interfaces
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<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);
}
}

View file

@ -5,8 +5,8 @@
#
# Checks:
# 1. swagger.json is reachable (HTTP 200)
# 2. swagger advertises POST /api/webhooks/work-orders
# 3. swagger still advertises POST /api/Authentication/login
# 2. swagger advertises release-critical webhook, login, and vendor routes
# 3. protected vendor routes reject unauthenticated callers rather than 404
# 4. an unauthenticated JSON POST to the webhook returns 401 or 503 (disabled),
# 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"
ok "swagger.json HTTP 200"
log "swagger advertises webhook and login routes"
grep -q '"/api/webhooks/work-orders"' "$swagger_file" \
|| die "swagger.json missing /api/webhooks/work-orders route."
grep -q '"/api/Authentication/login"' "$swagger_file" \
|| die "swagger.json missing /api/Authentication/login route."
ok "webhook and login routes present"
log "swagger advertises release-critical routes"
required_paths=(
"/api/webhooks/work-orders"
"/api/Authentication/login"
"/api/Vendor/facets"
"/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"
webhook_http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \