From f807d46ff0bf8c481075d3944d0e9ac389432ff2 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 11 Nov 2026 18:17:08 +0100 Subject: [PATCH] fix(documents): bind submitted CV captures to their saved revision --- .../SubmittedApplicationPackageTests.cs | 52 +++++++++++++++++-- .../SubmittedPackageConflictTests.cs | 44 ++++++++++++++++ .../ApplicationAssetsController.cs | 11 +++- .../SubmittedApplicationPackageService.cs | 11 +++- 4 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 JobTrackerApi.Tests/SubmittedPackageConflictTests.cs diff --git a/JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs b/JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs index f3a6d196..99fd5e53 100644 --- a/JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs +++ b/JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs @@ -90,16 +90,60 @@ public sealed class SubmittedApplicationPackageTests Assert.Empty(db.SubmittedApplicationPackages); } - private static (JobTrackerContext Db, SubmittedApplicationPackageService Service, Mock Storage) New(string userId) + [Theory] + [InlineData(false, 4)] + [InlineData(true, 4)] + [InlineData(false, 0)] + [InlineData(true, 0)] + public async Task Cv_changed_between_metadata_and_render_aborts_without_a_partial_package(bool deleted, int version) + { + var renderer = new Mock(); + var (db, service, storage) = New("owner-1", renderer); + await using var dispose = db; + var job = await SeedAsync(db, "owner-1", null); + (await db.CvVariants.SingleAsync()).Version = version; + await db.SaveChangesAsync(); + var real = new CvVariantService(db, new CareerProfileService(db), new ThemedCvRenderer()); + async Task RenderAfterChange(string owner, int id, CvRenderPerson person, CancellationToken ct, int? expected) + { + var current = await db.CvVariants.SingleAsync(x => x.Id == id, ct); + if (deleted) db.CvVariants.Remove(current); + else + { + current.Version += 1; + current.SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings { ThemeId = "nordic", Headline = "Newer CV content" }); + } + await db.SaveChangesAsync(ct); + return await real.RenderAsync(owner, id, person, ct, expected); + } + renderer.Setup(x => x.RenderAsync("owner-1", It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string owner, int id, CvRenderPerson person, CancellationToken ct) => RenderAfterChange(owner, id, person, ct, null)); + renderer.Setup(x => x.RenderAsync("owner-1", It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string owner, int id, CvRenderPerson person, CancellationToken ct, int? expected) => RenderAfterChange(owner, id, person, ct, expected)); + + await Assert.ThrowsAsync(() => service.CaptureAsync("owner-1", job.Id, new CvRenderPerson("Ada", null), default)); + + Assert.Empty(db.SubmittedApplicationPackages); + Assert.Empty(db.SubmittedPackageAttachments); + renderer.Verify(x => x.RenderAsync("owner-1", It.IsAny(), It.IsAny(), It.IsAny(), version > 0 ? version : null), Times.Once); + storage.Verify(x => x.SnapshotAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + private static (JobTrackerContext Db, SubmittedApplicationPackageService Service, Mock Storage) New(string userId, Mock? renderOverride = null) { var currentUser = new Mock(); currentUser.SetupGet(x => x.UserId).Returns(userId); var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; var db = new JobTrackerContext(options, currentUser.Object); - var variants = new Mock(); - variants.Setup(x => x.RenderAsync(userId, It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ThemedCvRenderResult("nordic", "cv.pdf", "Frozen CV")); + var variants = renderOverride ?? new Mock(); + if (renderOverride is null) + { + variants.Setup(x => x.RenderAsync(userId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ThemedCvRenderResult("nordic", "cv.pdf", "Frozen CV")); + variants.Setup(x => x.RenderAsync(userId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ThemedCvRenderResult("nordic", "cv.pdf", "Frozen CV")); + } var storage = new Mock(); storage.Setup(x => x.IsManagedPath(It.IsAny())).Returns(true); diff --git a/JobTrackerApi.Tests/SubmittedPackageConflictTests.cs b/JobTrackerApi.Tests/SubmittedPackageConflictTests.cs new file mode 100644 index 00000000..46fb6122 --- /dev/null +++ b/JobTrackerApi.Tests/SubmittedPackageConflictTests.cs @@ -0,0 +1,44 @@ +using System.Security.Claims; +using System.Text.Json; +using JobTrackerApi.Controllers; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class SubmittedPackageConflictTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Capture_reports_revision_conflict_only_for_an_authenticated_caller(bool authenticated) + { + var user = new ApplicationUser { Id = "owner" }; + var users = TestHostFactory.CreateUserManager(user); + users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(authenticated ? user : null); + var packages = new Mock(); + packages.Setup(x => x.CaptureAsync("owner", 7, It.IsAny(), It.IsAny())) + .ThrowsAsync(new DbUpdateConcurrencyException("CV changed")); + var controller = new ApplicationAssetsController(users.Object, Mock.Of(), packages.Object) + { ControllerContext = new() { HttpContext = new DefaultHttpContext() } }; + + var result = await controller.CaptureSubmittedPackage(7, default); + + if (authenticated) + { + var conflict = Assert.IsType(result.Result); + Assert.Equal("revision_conflict", JsonSerializer.SerializeToElement(conflict.Value).GetProperty("code").GetString()); + } + else + { + Assert.IsType(result.Result); + packages.Verify(x => x.CaptureAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/JobTrackerApi/Controllers/ApplicationAssetsController.cs b/JobTrackerApi/Controllers/ApplicationAssetsController.cs index bb1aaccb..7a7d3dbf 100644 --- a/JobTrackerApi/Controllers/ApplicationAssetsController.cs +++ b/JobTrackerApi/Controllers/ApplicationAssetsController.cs @@ -148,8 +148,15 @@ public sealed class ApplicationAssetsController : ControllerBase { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); - var result = await _packages.CaptureAsync(user.Id, jobId, Person(user), ct); - return result is null ? NotFound() : Ok(result); + try + { + var result = await _packages.CaptureAsync(user.Id, jobId, Person(user), ct); + return result is null ? NotFound() : Ok(result); + } + catch (DbUpdateConcurrencyException) + { + return Conflict(new { code = "revision_conflict", message = "The application documents changed. Reload and review them before capturing a submitted package." }); + } } [HttpGet("submitted-packages/{packageId:int}")] diff --git a/JobTrackerApi/Services/SubmittedApplicationPackageService.cs b/JobTrackerApi/Services/SubmittedApplicationPackageService.cs index f7ce3c77..3c605d31 100644 --- a/JobTrackerApi/Services/SubmittedApplicationPackageService.cs +++ b/JobTrackerApi/Services/SubmittedApplicationPackageService.cs @@ -72,7 +72,16 @@ public sealed class SubmittedApplicationPackageService : ISubmittedApplicationPa ? (await variantQuery.ToListAsync(ct)).MaxBy(v => v.UpdatedAtUtc) : await variantQuery.OrderByDescending(v => v.UpdatedAtUtc).FirstOrDefaultAsync(ct); var profile = await _db.CareerProfiles.AsNoTracking().FirstOrDefaultAsync(p => p.OwnerUserId == ownerUserId, ct); - var render = variant is null ? null : await _variants.RenderAsync(ownerUserId, variant.Id, person, ct); + var render = variant is null ? null : await _variants.RenderAsync(ownerUserId, variant.Id, person, ct, variant.Version > 0 ? variant.Version : null); + if (variant is not null) + { + // Legacy version-zero variants remain capturable; recheck the stored snapshot after rendering. + var current = await _db.CvVariants.AsNoTracking() + .Where(v => v.Id == variant.Id && v.OwnerUserId == ownerUserId) + .Select(v => new { v.Version, v.SettingsJson }).SingleOrDefaultAsync(ct); + if (render is null || current is null || current.Version != variant.Version || current.SettingsJson != variant.SettingsJson) + throw new DbUpdateConcurrencyException("The selected CV changed before the submitted package could be captured."); + } var version = (await _db.SubmittedApplicationPackages.AsNoTracking() .Where(x => x.OwnerUserId == ownerUserId && x.JobApplicationId == jobApplicationId) .Select(x => (int?)x.Version)