mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +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]
|
[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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue