mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
fix: align vendor document API with frontend (#37)
This commit is contained in:
parent
45ffa68dfa
commit
5ecb377613
5 changed files with 207 additions and 14 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue