Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7db3b4a3b6 | |||
| de2fb45221 | |||
| cef4540456 | |||
| 2cd561d71d | |||
| dbae196291 | |||
| f807d46ff0 |
@@ -4,6 +4,7 @@ using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System.Text.Json;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
@@ -90,16 +91,119 @@ public sealed class SubmittedApplicationPackageTests
|
||||
Assert.Empty(db.SubmittedApplicationPackages);
|
||||
}
|
||||
|
||||
private static (JobTrackerContext Db, SubmittedApplicationPackageService Service, Mock<IAttachmentStorage> 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<ICvVariantService>();
|
||||
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<ThemedCvRenderResult?> 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<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((string owner, int id, CvRenderPerson person, CancellationToken ct) => RenderAfterChange(owner, id, person, ct, null));
|
||||
renderer.Setup(x => x.RenderAsync("owner-1", It.IsAny<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||
.Returns((string owner, int id, CvRenderPerson person, CancellationToken ct, int? expected) => RenderAfterChange(owner, id, person, ct, expected));
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => 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<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>(), version > 0 ? version : null), Times.Once);
|
||||
storage.Verify(x => x.SnapshotAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Profile_changed_during_render_cannot_publish_old_profile_metadata_with_new_html()
|
||||
{
|
||||
var renderer = new Mock<ICvVariantService>();
|
||||
var (db, service, _) = New("owner-1", renderer);
|
||||
await using var dispose = db;
|
||||
var job = await SeedAsync(db, "owner-1", null);
|
||||
var career = new CareerProfileService(db);
|
||||
var real = new CvVariantService(db, career, new ThemedCvRenderer());
|
||||
renderer.Setup(x => x.RenderAsync("owner-1", It.IsAny<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||
.Returns(async (string owner, int id, CvRenderPerson person, CancellationToken ct, int? expected) =>
|
||||
{
|
||||
var changed = JsonSerializer.Deserialize<StructuredCvProfile>("{\"version\":\"1\",\"contact\":{\"fullName\":\"New during capture\",\"headline\":\"Updated canonical profile\"}}", new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
await career.SaveVersionAsync(owner, changed, "manual", 0, ct);
|
||||
var rendered = await real.RenderAsync(owner, id, person, ct, expected);
|
||||
Assert.Contains("New during capture", rendered!.Html);
|
||||
return rendered;
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => service.CaptureAsync("owner-1", job.Id, new CvRenderPerson("Ada", null), default));
|
||||
|
||||
Assert.Empty(db.SubmittedApplicationPackages);
|
||||
Assert.Equal(1, (await db.CareerProfiles.SingleAsync()).Version);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Profile_created_or_removed_during_render_aborts_capture(bool created)
|
||||
{
|
||||
var renderer = new Mock<ICvVariantService>();
|
||||
var (db, service, _) = New("owner-1", renderer);
|
||||
await using var dispose = db;
|
||||
var job = await SeedAsync(db, "owner-1", null);
|
||||
if (created) { db.CareerProfiles.Remove(await db.CareerProfiles.SingleAsync()); await db.SaveChangesAsync(); }
|
||||
var real = new CvVariantService(db, new CareerProfileService(db), new ThemedCvRenderer());
|
||||
renderer.Setup(x => x.RenderAsync("owner-1", It.IsAny<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||
.Returns(async (string owner, int id, CvRenderPerson person, CancellationToken ct, int? expected) =>
|
||||
{
|
||||
if (created) db.CareerProfiles.Add(new CareerProfile { OwnerUserId = owner, Version = 1, ProfileJson = "{}" });
|
||||
else db.CareerProfiles.Remove(await db.CareerProfiles.SingleAsync(ct));
|
||||
await db.SaveChangesAsync(ct);
|
||||
return await real.RenderAsync(owner, id, person, ct, expected);
|
||||
});
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => service.CaptureAsync("owner-1", job.Id, new CvRenderPerson("Ada", null), default));
|
||||
Assert.Empty(db.SubmittedApplicationPackages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unchanged_absent_legacy_profile_remains_capturable()
|
||||
{
|
||||
var (db, service, _) = New("owner-1");
|
||||
await using var dispose = db;
|
||||
var job = await SeedAsync(db, "owner-1", null);
|
||||
db.CareerProfiles.Remove(await db.CareerProfiles.SingleAsync()); await db.SaveChangesAsync();
|
||||
Assert.NotNull(await service.CaptureAsync("owner-1", job.Id, new CvRenderPerson("Ada", null), default));
|
||||
Assert.Null((await db.SubmittedApplicationPackages.SingleAsync()).CareerProfileJson);
|
||||
}
|
||||
|
||||
private static (JobTrackerContext Db, SubmittedApplicationPackageService Service, Mock<IAttachmentStorage> Storage) New(string userId, Mock<ICvVariantService>? renderOverride = null)
|
||||
{
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(x => x.UserId).Returns(userId);
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
||||
var db = new JobTrackerContext(options, currentUser.Object);
|
||||
|
||||
var variants = new Mock<ICvVariantService>();
|
||||
variants.Setup(x => x.RenderAsync(userId, It.IsAny<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ThemedCvRenderResult("nordic", "cv.pdf", "<html>Frozen CV</html>"));
|
||||
var variants = renderOverride ?? new Mock<ICvVariantService>();
|
||||
if (renderOverride is null)
|
||||
{
|
||||
variants.Setup(x => x.RenderAsync(userId, It.IsAny<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ThemedCvRenderResult("nordic", "cv.pdf", "<html>Frozen CV</html>"));
|
||||
variants.Setup(x => x.RenderAsync(userId, It.IsAny<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||
.ReturnsAsync(new ThemedCvRenderResult("nordic", "cv.pdf", "<html>Frozen CV</html>"));
|
||||
}
|
||||
|
||||
var storage = new Mock<IAttachmentStorage>();
|
||||
storage.Setup(x => x.IsManagedPath(It.IsAny<string>())).Returns(true);
|
||||
|
||||
@@ -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<ClaimsPrincipal>())).ReturnsAsync(authenticated ? user : null);
|
||||
var packages = new Mock<ISubmittedApplicationPackageService>();
|
||||
packages.Setup(x => x.CaptureAsync("owner", 7, It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new DbUpdateConcurrencyException("CV changed"));
|
||||
var controller = new ApplicationAssetsController(users.Object, Mock.Of<IApplicationAssetsService>(), packages.Object)
|
||||
{ ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
|
||||
|
||||
var result = await controller.CaptureSubmittedPackage(7, default);
|
||||
|
||||
if (authenticated)
|
||||
{
|
||||
var conflict = Assert.IsType<ConflictObjectResult>(result.Result);
|
||||
Assert.Equal("revision_conflict", JsonSerializer.SerializeToElement(conflict.Value).GetProperty("code").GetString());
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.IsType<UnauthorizedResult>(result.Result);
|
||||
packages.Verify(x => x.CaptureAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}")]
|
||||
|
||||
@@ -72,7 +72,20 @@ 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 currentProfile = await _db.CareerProfiles.AsNoTracking().Where(p => p.OwnerUserId == ownerUserId)
|
||||
.Select(p => new { p.Version, p.ProfileJson }).FirstOrDefaultAsync(ct);
|
||||
if (currentProfile?.Version != profile?.Version || currentProfile?.ProfileJson != profile?.ProfileJson)
|
||||
throw new DbUpdateConcurrencyException("The Career Profile 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)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Canonical profile binding during submitted CV capture
|
||||
|
||||
Follow-up to PACKAGE_REVISION_CAPTURE.md. The CV variant guard alone did not bind the canonical profile metadata to the profile used by rendering. A deterministic test used the real CareerProfileService save and CvVariantService render to change the profile during capture; the resulting HTML contained the new name while capture could still publish the previously selected projection.
|
||||
|
||||
Capture now rechecks the owner's canonical profile revision/projection after rendering and rejects a changed, created or removed row through the existing revision_conflict path. A genuinely absent legacy profile remains capturable. Canonical save/render ownership, null-as-inherit selections, renderer code/styles and historical packages are unchanged. No new profile store, schema, API field or migration.
|
||||
|
||||
RED:one focused test failed with no conflict. GREEN:26 targeted cases passed/no skips across package/controller/export-revision/native SQLite suites;22 overlap the preceding run. Four added cases cover real profile save during render, creation, removal and unchanged absence. Logs and raw/gzip TRX/source receipts are in evidence/package-profile. Build/temp/results used E; no live DB/models/providers, physical PDF or production services were used.
|
||||
|
||||
This guards the canonical row/projection used by ordinary writers. It is not proof of a transactionally atomic snapshot across every source, arbitrary direct database mutations, legacy user-only fallback writes, or filesystem races. AttachmentStorage destination ownership remains a separate source-backed/unreproduced concern. Native/MariaDB/browser, full integrated security/recovery and final milestone gates remain open.
|
||||
|
||||
Rollback reverts only this follow-up source change and reruns affected package tests; no data repair required. Existing accepted PDF metrics remain historical and source-bound, not newly executed.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Submitted CV snapshot revision integrity
|
||||
|
||||
Baseline9f341750; sourcee161ea33 on codex/frozen-document-integrity. This is a bounded integrity fix, not Phase10/A3/security/release acceptance.
|
||||
|
||||
The package service selected CV metadata and then rendered through the unversioned overload. A deterministic interleaving with the real CvVariantService reproduced publishing newer render data with older selected metadata, or continuing after deletion. Capture now passes the selected positive revision and rechecks the stored revision/settings after render. A missing selected CV aborts. The controller returns the existing409 revision_conflict code for this condition, preserving unauthenticated rejection and owner filtering.
|
||||
|
||||
An initial positive-only guard broke the native SQLite legacy version-zero fixture. The final path preserves zero-version capture through the existing unversioned compatibility path plus the post-render snapshot check. The public renderer's positive-revision validation was not relaxed. No fixture was weakened or renderer/physical PDF style changed.
|
||||
|
||||
Verification:RED8 cases (three failed/five passed); intermediate broader20 cases (one legacy compatibility failure/19 passed); final22 cases across package, controller conflict/auth, CV export revision and native SQLite compatibility suites passed with no skips. Four changed/deleted interleavings cover revisions4 and0; controller cases cover authenticated conflict/unauthenticated refusal. Existing frozen bytes/hash, tenant rejection, missing-file cleanup, later source mutation, account-export and native SQLite workflows remain covered. Native TRX gzip/raw receipts and source hashes are in evidence/package-revision.
|
||||
|
||||
The first build could not execute tests because C: was full. Only this run's fresh ignored bin/obj directories were removed after absolute-path/Git checks; generated output/temp/results were redirected to E using command flags, with offline cached restore. No source/evidence/dependency/user work was deleted. Delegated workers hit usage limits; root continued sequentially. C: capacity remains constrained.
|
||||
|
||||
Remaining investigation:concurrent Career Profile changes during render may require their own snapshot binding. AttachmentStorage.SnapshotAsync has a separate source-backed destination-ownership cleanup concern; no deterministic race reproduction or fix is claimed here. MariaDB, actual HTTP/browser capture, real models/providers, native device/AT, physical PDF acceptance, integrated security and recovery were not rerun. Existing accepted PDF metrics/visuals remain source-bound.
|
||||
|
||||
Rollback:revert the source commit, rebuild on the isolated development branch and rerun the affected suite. No migration/data repair is involved. UI callers retain the existing revision-conflict/review flow; an aborted capture publishes no package. This does not establish atomic consistency across every package source entity or filesystem race.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"sourceCommit": "b9c0b6186871d31a494c05cabc5e6361dccb851f",
|
||||
"sourcesLfSha256": {
|
||||
"JobTrackerApi/Services/SubmittedApplicationPackageService.cs": "75a4aabb02d21cc860cfa05006defaddeb8ef5918eb973e624690b0d8da42e70",
|
||||
"JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs": "9f737f14852a1bb93e36913407aaed9d2939207a4f0806d1086d8cc456350af3",
|
||||
"JobTrackerApi/Services/CareerProfileService.cs": "dc91aca78069feff24eecf796c1fac459af9f5564e61d7918822f45b81807fef",
|
||||
"JobTrackerApi/Services/CvVariantService.cs": "da85153ff2199a4410df91807c6073aa19736676332266a227149d6d089ea841"
|
||||
},
|
||||
"receipts": [
|
||||
{
|
||||
"name": "profile-red",
|
||||
"total": 1,
|
||||
"passed": 0,
|
||||
"failed": 1,
|
||||
"rawSha256": "b63f144ec7c99a3eb40841ac7e5cc59ae84d71017e388afccb4cf441e3a9fcba",
|
||||
"gzipSha256": "482b6a8b235ce988134cbaebe52de232b3c469dcd02f535156a05a3bf5a2fa82"
|
||||
},
|
||||
{
|
||||
"name": "profile-green",
|
||||
"total": 26,
|
||||
"passed": 26,
|
||||
"failed": 0,
|
||||
"rawSha256": "7eb74ef3e0aca92ae83dd673026cdb1bfc45da3b976732ad6f043ae83f9b6ea9",
|
||||
"gzipSha256": "eb5251eda46e66feb96d69711757c19aea2bef1da70c9e404321b8827910301a"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
JobTrackerApi -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi\debug\JobTrackerApi.dll
|
||||
PdfBrowserStub -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\PdfBrowserStub\debug\PdfBrowserStub.dll
|
||||
JobTrackerApi.Tests -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi.Tests\debug\JobTrackerApi.Tests.dll
|
||||
Test run for E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi.Tests\debug\JobTrackerApi.Tests.dll (.NETCoreApp,Version=v9.0)
|
||||
VSTest version 17.14.1 (x64)
|
||||
|
||||
Starting test execution, please wait...
|
||||
A total of 1 test files matched the specified pattern.
|
||||
Results File: E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\profile-green\profile-green.trx
|
||||
|
||||
Passed! - Failed: 0, Passed: 26, Skipped: 0, Total: 26, Duration: 4 s - JobTrackerApi.Tests.dll (net9.0)
|
||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
JobTrackerApi -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi\debug\JobTrackerApi.dll
|
||||
PdfBrowserStub -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\PdfBrowserStub\debug\PdfBrowserStub.dll
|
||||
JobTrackerApi.Tests -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi.Tests\debug\JobTrackerApi.Tests.dll
|
||||
Test run for E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi.Tests\debug\JobTrackerApi.Tests.dll (.NETCoreApp,Version=v9.0)
|
||||
VSTest version 17.14.1 (x64)
|
||||
|
||||
Starting test execution, please wait...
|
||||
A total of 1 test files matched the specified pattern.
|
||||
[xUnit.net 00:00:03.18] JobTrackerApi.Tests.SubmittedApplicationPackageTests.Profile_changed_during_render_cannot_publish_old_profile_metadata_with_new_html [FAIL]
|
||||
Failed JobTrackerApi.Tests.SubmittedApplicationPackageTests.Profile_changed_during_render_cannot_publish_old_profile_metadata_with_new_html [2 s]
|
||||
Error Message:
|
||||
Assert.Throws() Failure: No exception was thrown
|
||||
Expected: typeof(Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException)
|
||||
Stack Trace:
|
||||
at JobTrackerApi.Tests.SubmittedApplicationPackageTests.Profile_changed_during_render_cannot_publish_old_profile_metadata_with_new_html() in C:\Users\Connor\.codex\worktrees\frozen-document-integrity\Job tracker\JobTrackerApi.Tests\SubmittedApplicationPackageTests.cs:line 152
|
||||
at JobTrackerApi.Tests.SubmittedApplicationPackageTests.Profile_changed_during_render_cannot_publish_old_profile_metadata_with_new_html() in C:\Users\Connor\.codex\worktrees\frozen-document-integrity\Job tracker\JobTrackerApi.Tests\SubmittedApplicationPackageTests.cs:line 155
|
||||
--- End of stack trace from previous location ---
|
||||
Results File: E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\profile-red\profile-red.trx
|
||||
|
||||
Failed! - Failed: 1, Passed: 0, Skipped: 0, Total: 1, Duration: 2 s - JobTrackerApi.Tests.dll (net9.0)
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"baseline": "9f341750ece2a3d2ddb11c14482df1ecc485a5c1",
|
||||
"sourceCommit": "e161ea33461eaefbecf08c96d79984029131e990",
|
||||
"sourcesLfSha256": {
|
||||
"JobTrackerApi/Services/SubmittedApplicationPackageService.cs": "f6315d491ccd5ddffb098ef22f35b0d727485bfe866ea875fc19c33ab897867e",
|
||||
"JobTrackerApi/Controllers/ApplicationAssetsController.cs": "bbed71f315f0be0381e4d6a09a536880f40040ba808b83a9d058b9fb1089bcc2",
|
||||
"JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs": "9073b2ddc1e92e84063e10c071042c408c9eb9ab7f8403b2801bf5bbd121463b",
|
||||
"JobTrackerApi.Tests/SubmittedPackageConflictTests.cs": "e24038cae68c0d034bbcc07605aff09be1e23d4659ce391cf3de16c98f6a2ddf",
|
||||
"JobTrackerApi.Tests/CvExportRevisionTests.cs": "ee19195cec7486dd71d30ce6daa064ad07d5995c1dd97e529761e43511d9235d",
|
||||
"JobTrackerApi.Tests/SqliteDateTimeOffsetCompatibilityTests.cs": "5fb244d50d2378b8d18b14ac260beafc4bfd58190447306244b2916ea808d572",
|
||||
"JobTrackerApi/Services/CvVariantService.cs": "da85153ff2199a4410df91807c6073aa19736676332266a227149d6d089ea841"
|
||||
},
|
||||
"nativeReceipts": [
|
||||
{
|
||||
"name": "red",
|
||||
"artifact": "red.trx.gz",
|
||||
"total": 8,
|
||||
"passed": 5,
|
||||
"failed": 3,
|
||||
"rawSha256": "1c970594d7f3c7e86bc7d15ea7b79967cfcfb2202333d9933d9b70180d680bac",
|
||||
"gzipSha256": "3b69eafa4bda144a132547ec54a8d20b54b14fe263eb5c23ea854afff7f69598"
|
||||
},
|
||||
{
|
||||
"name": "intermediate",
|
||||
"artifact": "intermediate.trx.gz",
|
||||
"total": 20,
|
||||
"passed": 19,
|
||||
"failed": 1,
|
||||
"rawSha256": "26968e1cf8b575ba37de5f57f2898e4542427cf03a5136e7df351b9c4096b99c",
|
||||
"gzipSha256": "8a6021ee11a9dcd77b29f43cc3a146dd75d2f514e74f8650301a6e1cbd4fa0d2"
|
||||
},
|
||||
{
|
||||
"name": "final",
|
||||
"artifact": "final.trx.gz",
|
||||
"total": 22,
|
||||
"passed": 22,
|
||||
"failed": 0,
|
||||
"rawSha256": "faaa712ee6919280cda1e4384746f04ba02c7d807b3d4808b4468815df208062",
|
||||
"gzipSha256": "36e214810c9c840b5335385d1c18786489f57dd6f4812249a7e7c7ecb652f76a"
|
||||
}
|
||||
],
|
||||
"environment": "Pinned .NET9.0.317; existing offline NuGet cache; build/temp/results on E after C-drive exhaustion. No physical PDF/provider/MariaDB/live services."
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
JobTrackerApi -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi\debug\JobTrackerApi.dll
|
||||
PdfBrowserStub -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\PdfBrowserStub\debug\PdfBrowserStub.dll
|
||||
JobTrackerApi.Tests -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi.Tests\debug\JobTrackerApi.Tests.dll
|
||||
Test run for E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi.Tests\debug\JobTrackerApi.Tests.dll (.NETCoreApp,Version=v9.0)
|
||||
VSTest version 17.14.1 (x64)
|
||||
|
||||
Starting test execution, please wait...
|
||||
A total of 1 test files matched the specified pattern.
|
||||
Results File: E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\green-final\green-final.trx
|
||||
|
||||
Passed! - Failed: 0, Passed: 22, Skipped: 0, Total: 22, Duration: 4 s - JobTrackerApi.Tests.dll (net9.0)
|
||||
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
JobTrackerApi -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi\debug\JobTrackerApi.dll
|
||||
PdfBrowserStub -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\PdfBrowserStub\debug\PdfBrowserStub.dll
|
||||
JobTrackerApi.Tests -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi.Tests\debug\JobTrackerApi.Tests.dll
|
||||
Test run for E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi.Tests\debug\JobTrackerApi.Tests.dll (.NETCoreApp,Version=v9.0)
|
||||
VSTest version 17.14.1 (x64)
|
||||
|
||||
Starting test execution, please wait...
|
||||
A total of 1 test files matched the specified pattern.
|
||||
[xUnit.net 00:00:03.64] JobTrackerApi.Tests.SqliteDateTimeOffsetCompatibilityTests.Tailoring_and_submission_capture_work_on_native_sqlite [FAIL]
|
||||
Failed JobTrackerApi.Tests.SqliteDateTimeOffsetCompatibilityTests.Tailoring_and_submission_capture_work_on_native_sqlite [768 ms]
|
||||
Error Message:
|
||||
Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException : The CV changed after it was loaded.
|
||||
Stack Trace:
|
||||
at JobTrackerApi.Services.CvVariantService.RenderAsync(String ownerUserId, Int32 id, CvRenderPerson person, CancellationToken ct, Nullable`1 expectedVersion) in C:\Users\Connor\.codex\worktrees\frozen-document-integrity\Job tracker\JobTrackerApi\Services\CvVariantService.cs:line 188
|
||||
at JobTrackerApi.Services.SubmittedApplicationPackageService.CaptureAsync(String ownerUserId, Int32 jobApplicationId, CvRenderPerson person, CancellationToken ct) in C:\Users\Connor\.codex\worktrees\frozen-document-integrity\Job tracker\JobTrackerApi\Services\SubmittedApplicationPackageService.cs:line 75
|
||||
at JobTrackerApi.Tests.SqliteDateTimeOffsetCompatibilityTests.Tailoring_and_submission_capture_work_on_native_sqlite() in C:\Users\Connor\.codex\worktrees\frozen-document-integrity\Job tracker\JobTrackerApi.Tests\SqliteDateTimeOffsetCompatibilityTests.cs:line 50
|
||||
at JobTrackerApi.Tests.SqliteDateTimeOffsetCompatibilityTests.Tailoring_and_submission_capture_work_on_native_sqlite() in C:\Users\Connor\.codex\worktrees\frozen-document-integrity\Job tracker\JobTrackerApi.Tests\SqliteDateTimeOffsetCompatibilityTests.cs:line 94
|
||||
--- End of stack trace from previous location ---
|
||||
Results File: E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\green\green.trx
|
||||
|
||||
Failed! - Failed: 1, Passed: 19, Skipped: 0, Total: 20, Duration: 3 s - JobTrackerApi.Tests.dll (net9.0)
|
||||
Binary file not shown.
@@ -0,0 +1,37 @@
|
||||
JobTrackerApi -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi\debug\JobTrackerApi.dll
|
||||
PdfBrowserStub -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\PdfBrowserStub\debug\PdfBrowserStub.dll
|
||||
JobTrackerApi.Tests -> E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi.Tests\debug\JobTrackerApi.Tests.dll
|
||||
Test run for E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\artifacts\bin\JobTrackerApi.Tests\debug\JobTrackerApi.Tests.dll (.NETCoreApp,Version=v9.0)
|
||||
VSTest version 17.14.1 (x64)
|
||||
|
||||
Starting test execution, please wait...
|
||||
A total of 1 test files matched the specified pattern.
|
||||
[xUnit.net 00:00:03.04] JobTrackerApi.Tests.SubmittedPackageConflictTests.Capture_reports_revision_conflict_only_for_an_authenticated_caller(authenticated: True) [FAIL]
|
||||
Failed JobTrackerApi.Tests.SubmittedPackageConflictTests.Capture_reports_revision_conflict_only_for_an_authenticated_caller(authenticated: True) [723 ms]
|
||||
Error Message:
|
||||
Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException : CV changed
|
||||
Stack Trace:
|
||||
at JobTrackerApi.Controllers.ApplicationAssetsController.CaptureSubmittedPackage(Int32 jobId, CancellationToken ct) in C:\Users\Connor\.codex\worktrees\frozen-document-integrity\Job tracker\JobTrackerApi\Controllers\ApplicationAssetsController.cs:line 151
|
||||
at JobTrackerApi.Tests.SubmittedPackageConflictTests.Capture_reports_revision_conflict_only_for_an_authenticated_caller(Boolean authenticated) in C:\Users\Connor\.codex\worktrees\frozen-document-integrity\Job tracker\JobTrackerApi.Tests\SubmittedPackageConflictTests.cs:line 31
|
||||
--- End of stack trace from previous location ---
|
||||
[xUnit.net 00:00:05.56] JobTrackerApi.Tests.SubmittedApplicationPackageTests.Cv_changed_between_metadata_and_render_aborts_without_a_partial_package(deleted: False) [FAIL]
|
||||
[xUnit.net 00:00:05.58] JobTrackerApi.Tests.SubmittedApplicationPackageTests.Cv_changed_between_metadata_and_render_aborts_without_a_partial_package(deleted: True) [FAIL]
|
||||
Failed JobTrackerApi.Tests.SubmittedApplicationPackageTests.Cv_changed_between_metadata_and_render_aborts_without_a_partial_package(deleted: False) [137 ms]
|
||||
Error Message:
|
||||
Assert.Throws() Failure: No exception was thrown
|
||||
Expected: typeof(Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException)
|
||||
Stack Trace:
|
||||
at JobTrackerApi.Tests.SubmittedApplicationPackageTests.Cv_changed_between_metadata_and_render_aborts_without_a_partial_package(Boolean deleted) in C:\Users\Connor\.codex\worktrees\frozen-document-integrity\Job tracker\JobTrackerApi.Tests\SubmittedApplicationPackageTests.cs:line 120
|
||||
at JobTrackerApi.Tests.SubmittedApplicationPackageTests.Cv_changed_between_metadata_and_render_aborts_without_a_partial_package(Boolean deleted) in C:\Users\Connor\.codex\worktrees\frozen-document-integrity\Job tracker\JobTrackerApi.Tests\SubmittedApplicationPackageTests.cs:line 124
|
||||
--- End of stack trace from previous location ---
|
||||
Failed JobTrackerApi.Tests.SubmittedApplicationPackageTests.Cv_changed_between_metadata_and_render_aborts_without_a_partial_package(deleted: True) [20 ms]
|
||||
Error Message:
|
||||
Assert.Throws() Failure: No exception was thrown
|
||||
Expected: typeof(Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException)
|
||||
Stack Trace:
|
||||
at JobTrackerApi.Tests.SubmittedApplicationPackageTests.Cv_changed_between_metadata_and_render_aborts_without_a_partial_package(Boolean deleted) in C:\Users\Connor\.codex\worktrees\frozen-document-integrity\Job tracker\JobTrackerApi.Tests\SubmittedApplicationPackageTests.cs:line 120
|
||||
at JobTrackerApi.Tests.SubmittedApplicationPackageTests.Cv_changed_between_metadata_and_render_aborts_without_a_partial_package(Boolean deleted) in C:\Users\Connor\.codex\worktrees\frozen-document-integrity\Job tracker\JobTrackerApi.Tests\SubmittedApplicationPackageTests.cs:line 124
|
||||
--- End of stack trace from previous location ---
|
||||
Results File: E:\Storage\Projects\Active\temp docs\jobjakt-local-build\frozen-document-integrity\red\red.trx
|
||||
|
||||
Failed! - Failed: 3, Passed: 5, Skipped: 0, Total: 8, Duration: 3 s - JobTrackerApi.Tests.dll (net9.0)
|
||||
@@ -0,0 +1,22 @@
|
||||
# CV library action lifetime
|
||||
|
||||
11 November 2026. Source commit `de2fb452`, based on `cef45404`, branch `codex/a3-ui-10`.
|
||||
|
||||
The library allowed a create response to navigate after unmount and a PDF response to trigger a download after leaving or session invalidation. Confirmed delete and a rename read could also continue into a write after session loss. Duplicate completion could start another read and announce success in the stale context.
|
||||
|
||||
Reused the existing session-boundary cookie/context/invalidation functions and the BackupCard mount-generation pattern. Guarded profile-outline completion, create navigation, duplicate reload/feedback, deletion confirmation/completion, rename prompt/read/write completion, PDF download and asynchronous error decoding. Existing action locking, focus, drafts, current-language feedback, revision payloads and confirmations remain. No JSX, route, schema, canonical profile, renderer or PDF style changes.
|
||||
|
||||
## Verification
|
||||
|
||||
- RED: seven new deferred-response cases failed on the original source; 24 other library cases were filtered out. The first wrapper had environment-assignment errors before Jest; Jest still executed and produced the seven expected assertion failures. Its raw receipt is retained, not presented as a clean command execution.
|
||||
- GREEN: 85 distinct cases across five suites passed, no skips: CV library 35, CV API helpers 15, session boundary 15, job-export lifetime 16, backup lifetime four. This includes 11 added library cases. The earlier 31-case library pass overlaps this result.
|
||||
- TypeScript `tsc --noEmit --incremental false`: exit 0. Scoped ESLint on the two changed files: exit 0. `git diff --check`: passed.
|
||||
- Tests use real React/MUI dialogs with mocked API responses and controlled boundary signals. Existing session-boundary suite checks the actual boundary implementation separately. Active PDF test uses synthetic bytes and observes filename and URL revocation.
|
||||
|
||||
## Limits and preservation
|
||||
|
||||
No fresh export build/native browser journey, real API/session/cookie-switch integration, physical PDF or accessibility acceptance is claimed. The previous export is stale for this frontend change. No cancellation or rollback of an already accepted server write is promised; stale completions are discarded. Initial library reads remain owned by the existing useViewResource/API path, which is outside this fix. All final A3, security, Phase 10 and release gates remain open. AttachmentStorage destination-ownership race remains source-backed and unproven; it was not changed.
|
||||
|
||||
Evidence is under [cv-library-lifetime](evidence/cv-library-lifetime/SOURCE_RECEIPTS.json). Normalised source hashes and raw receipt hashes bind this bounded result.
|
||||
|
||||
AST graph refresh PASSED on an exact 1,003-file tracked-code snapshot on E: (E:/Storage/Projects/Active/temp docs/jobjakt-local-build/a3-cv-library-source). SOURCE_MANIFEST.json binds files to source commit de2fb452; 10,345 nodes and 36,111 edges. 51 no-symbol warnings retained. Code-only snapshot excludes documentation. Stale generated integration graph: all 2,721 files (97,063,540 bytes) preserved and SHA256-verified on E before replacing its C directory with a junction; no tracked source or evidence removed.
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"sourceCommit": "de2fb452210b128a8b5a3729e5971b8e2ddbba7c",
|
||||
"sourcesLfSha256": {
|
||||
"job-tracker-ui/src/views/CvBuilderPage.tsx": "63eb7348efcf4aaf5f3cb7e5d98fe6c5223896ac7ad64caa4111e251135f162e",
|
||||
"job-tracker-ui/src/cv-builder-page.test.tsx": "5871bfef73499f2c7b2d9424e245436ded8f2e0838c4d5e6c08b49525f9822b7",
|
||||
"job-tracker-ui/src/sessionBoundary.ts": "09edca77c98294818d2ad0c409ce595f1b6ca0c1f60419fc1078b70f96f5b7b9",
|
||||
"job-tracker-ui/src/cvBuilder.ts": "c021bafeabbc8ad3526d1b10b90ad8f7f0958ded8ed26eac8c1d28b53098b64d",
|
||||
"job-tracker-ui/jest.config.cjs": "de323cd3aa8693b11a028b2c026682f05ab988d182ed860861a051d66e53c286"
|
||||
},
|
||||
"evidenceSha256": {
|
||||
"green.txt": "cdd95bb125faddbc952d3b3588b3329eb8100dda6c6322b04b10ae150393230f",
|
||||
"lint.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"red.txt": "658bccf93a58b3ea639635ccc33810ef7b87163c45ab91425673d0b50afec9c1",
|
||||
"results.json": "f9c9dccfd13445d19aa803467ebd3aa3410a2b10054b278b22dfa90709224c8a",
|
||||
"SOURCE_RECEIPTS.json": "609aade9b200454fc4bf05cc7913615d2faba6718e2a64b74e1533f1311db183",
|
||||
"types.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"graph.txt": "b2b80b86f868b619d66445bd6d71d750ca091f8d6bee5dc40758fade72f22a67"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
Re-extracting code files in . (no LLM needed)...
|
||||
AST cache: 0/1004 files served from cache (0%); 1004 to extract
|
||||
AST extraction: 100/1004 uncached files (9%) [16 workers]
|
||||
AST extraction: 200/1004 uncached files (19%) [16 workers]
|
||||
AST extraction: 300/1004 uncached files (29%) [16 workers]
|
||||
AST extraction: 400/1004 uncached files (39%) [16 workers]
|
||||
AST extraction: 500/1004 uncached files (49%) [16 workers]
|
||||
AST extraction: 600/1004 uncached files (59%) [16 workers]
|
||||
AST extraction: 700/1004 uncached files (69%) [16 workers]
|
||||
AST extraction: 800/1004 uncached files (79%) [16 workers]
|
||||
AST extraction: 900/1004 uncached files (89%) [16 workers]
|
||||
AST extraction: 1000/1004 uncached files (99%) [16 workers]
|
||||
AST extraction: 1004/1004 uncached files (100%) [16 workers]
|
||||
warning: 8 .sql file(s) contributed nothing to the graph because a dependency is missing: tree_sitter_sql not installed. Install it with: pip install "graphifyy[sql]" (#1745)
|
||||
warning: 51 code file(s) yielded no symbols (0.12 MB) — a data file, or source this extractor models no symbols for: JobTrackerApi.Tests/PdfBrowserStub/Program.cs, JobTrackerApi/Program.cs, JobTrackerApi/Properties/AssemblyInfo.cs, browser-extension/tests/capture-url.test.mjs, docs/cv-import/benchmarks/harness/Program.cs (+46 more)
|
||||
Graph has 10345 nodes (above 5000 limit). Building aggregated community view...
|
||||
graph.html written (aggregated: 273 community nodes, 2404 cross-community edges)
|
||||
Tip: run with --obsidian for full node-level detail.
|
||||
[graphify watch] Rebuilt: 10345 nodes, 36111 edges, 273 communities
|
||||
[graphify watch] graph.json, graph.html and GRAPH_REPORT.md updated in graphify-out
|
||||
Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.
|
||||
Tip: set GEMINI_API_KEY or GOOGLE_API_KEY to use Gemini for semantic extraction.
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
Test Suites: 5 passed, 5 total
|
||||
Tests: 85 passed, 85 total
|
||||
Snapshots: 0 total
|
||||
Time: 25.539 s
|
||||
Ran all test suites matching src/cv-builder-page.test.tsx|src/cvBuilder.test.ts|src/session-boundary.test.ts|src/backup-lifetime.test.tsx|src/job-export-lifetime.test.tsx.
|
||||
Test results written to: E:\Storage\Projects\Active\temp docs\jobjakt-local-build\cv-library-results.json
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -13,6 +13,13 @@ import { emptyCvVariantSettings, getCvExportFailureKey } from './cvBuilder';
|
||||
import { translations } from './i18n/translations';
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
let mockSessionActive = true;
|
||||
jest.mock('./sessionBoundary', () => ({
|
||||
...jest.requireActual('./sessionBoundary'),
|
||||
checkSessionCookie: jest.fn(),
|
||||
getSessionContext: () => 'cv-owner',
|
||||
isSessionInvalidated: () => !mockSessionActive,
|
||||
}));
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockNavigate,
|
||||
@@ -50,6 +57,7 @@ function renderPage(extra?: React.ReactNode) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockSessionActive = true;
|
||||
window.localStorage.removeItem('uiLanguage');
|
||||
jest.resetAllMocks();
|
||||
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/themes' ? themes : [] }) as any);
|
||||
@@ -94,6 +102,140 @@ async function selectAction(name: string) {
|
||||
fireEvent.click(item);
|
||||
}
|
||||
|
||||
test.each(['unmount', 'session'] as const)('late CV creation cannot navigate after %s', async boundary => {
|
||||
const write = deferred<any>();
|
||||
mockedApi.post.mockImplementationOnce(() => write.promise);
|
||||
const view = renderPage();
|
||||
await screen.findByText('No CVs yet');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New CV' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Create CV' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
|
||||
if (boundary === 'unmount') view.unmount();
|
||||
else mockSessionActive = false;
|
||||
await act(async () => { write.resolve({ data: { id: 42 } }); });
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(['unmount', 'session'] as const)('late CV PDF cannot download after %s', async boundary => {
|
||||
const read = deferred<any>();
|
||||
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||
mockedApi.post.mockImplementationOnce(() => read.promise);
|
||||
const createUrl = jest.fn(() => 'blob:synthetic-cv');
|
||||
const oldCreate = URL.createObjectURL;
|
||||
URL.createObjectURL = createUrl;
|
||||
try {
|
||||
const view = renderPage();
|
||||
await screen.findByText('Frontend CV');
|
||||
await selectAction('Download PDF');
|
||||
if (boundary === 'unmount') view.unmount();
|
||||
else mockSessionActive = false;
|
||||
await act(async () => { read.resolve({ data: new Blob(['synthetic']) }); });
|
||||
expect(createUrl).not.toHaveBeenCalled();
|
||||
} finally { URL.createObjectURL = oldCreate; }
|
||||
});
|
||||
|
||||
test('session invalidation while confirming deletion prevents the write', async () => {
|
||||
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||
mockedApi.delete.mockResolvedValue({ data: null } as any);
|
||||
renderPage();
|
||||
await screen.findByText('Frontend CV');
|
||||
await selectAction('Delete');
|
||||
const button = await screen.findByRole('button', { name: 'Delete CV' });
|
||||
mockSessionActive = false;
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('session invalidation during the rename read prevents a later revisioned write', async () => {
|
||||
const read = deferred<any>();
|
||||
mockedApi.get.mockImplementation(url => url === '/cv/variants/1' ? read.promise : Promise.resolve({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||
renderPage();
|
||||
await screen.findByText('Frontend CV');
|
||||
await selectAction('Rename');
|
||||
fireEvent.change(await screen.findByRole('textbox', { name: 'Give this CV a clear name.' }), { target: { value: 'Changed CV' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants/1'));
|
||||
mockSessionActive = false;
|
||||
await act(async () => { read.resolve({ data: { ...existingCv, settings: emptyCvVariantSettings('modern') } }); });
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a duplicate completing in an invalid session neither reloads nor announces success', async () => {
|
||||
const write = deferred<any>();
|
||||
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||
mockedApi.post.mockImplementationOnce(() => write.promise);
|
||||
renderPage();
|
||||
await screen.findByText('Frontend CV');
|
||||
await selectAction('Duplicate');
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
mockSessionActive = false;
|
||||
await act(async () => { write.resolve({ data: { id: 2 } }); });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
expect(screen.queryByText(translations.en.cvDashboardDuplicated)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('an active CV PDF download retains its filename and releases the synthetic URL', async () => {
|
||||
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||
const blob = new Blob(['synthetic PDF bytes']);
|
||||
mockedApi.post.mockResolvedValueOnce({ data: blob } as any);
|
||||
const oldCreate = URL.createObjectURL;
|
||||
const oldRevoke = URL.revokeObjectURL;
|
||||
const create = jest.fn(() => 'blob:synthetic-cv');
|
||||
const revoke = jest.fn();
|
||||
URL.createObjectURL = create;
|
||||
URL.revokeObjectURL = revoke;
|
||||
const downloads: string[] = [];
|
||||
const click = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this.download); });
|
||||
try {
|
||||
renderPage();
|
||||
await screen.findByText('Frontend CV');
|
||||
await selectAction('Download PDF');
|
||||
await waitFor(() => expect(downloads).toEqual(['Frontend CV.pdf']));
|
||||
expect(create).toHaveBeenCalledWith(blob);
|
||||
expect(revoke).toHaveBeenCalledWith('blob:synthetic-cv');
|
||||
} finally { URL.createObjectURL = oldCreate; URL.revokeObjectURL = oldRevoke; click.mockRestore(); }
|
||||
});
|
||||
|
||||
test('session invalidation while naming a CV prevents the rename read and write', async () => {
|
||||
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||
renderPage();
|
||||
await screen.findByText('Frontend CV');
|
||||
await selectAction('Rename');
|
||||
fireEvent.change(await screen.findByRole('textbox', { name: 'Give this CV a clear name.' }), { target: { value: 'Changed CV' } });
|
||||
mockSessionActive = false;
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Rename' })); });
|
||||
expect(mockedApi.get).not.toHaveBeenCalledWith('/cv/variants/1');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('an export error decoded after session invalidation cannot show delayed feedback', async () => {
|
||||
const text = deferred<string>();
|
||||
const body = new Blob();
|
||||
Object.defineProperty(body, 'text', { value: () => text.promise });
|
||||
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||
mockedApi.post.mockRejectedValueOnce({ response: { status: 400, data: body } });
|
||||
renderPage();
|
||||
await screen.findByText('Frontend CV');
|
||||
await selectAction('Download PDF');
|
||||
mockSessionActive = false;
|
||||
await act(async () => { text.resolve('{"code":"cv_empty_content"}'); });
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a profile outline completing after session invalidation cannot populate the create draft', async () => {
|
||||
const read = deferred<any>();
|
||||
mockedApi.get.mockImplementation(url => url === '/cv/outline' ? read.promise : Promise.resolve({ data: url === '/cv/themes' ? themes : [] }) as any);
|
||||
renderPage();
|
||||
await screen.findByText('No CVs yet');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New CV' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a CV' });
|
||||
fireEvent.click(within(dialog).getByRole('radio', { name: 'From Career Profile' }));
|
||||
mockSessionActive = false;
|
||||
await act(async () => { read.resolve({ data: { sections: [{ key: 'skills', title: 'Stale profile skills' }] } }); });
|
||||
expect(within(dialog).queryByRole('checkbox', { name: 'Stale profile skills' })).not.toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('button', { name: 'Create CV' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('blank creation explicitly excludes all profile sections and contact even after template selection', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any);
|
||||
renderPage();
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { TranslationKey } from "../i18n/translations";
|
||||
import { PageHeader } from "../layout/PageLayout";
|
||||
import ViewStateNotice from "../components/ViewStateNotice";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary";
|
||||
|
||||
export default function CvBuilderPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -36,6 +37,7 @@ export default function CvBuilderPage() {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const createTitleRef = useRef<HTMLHeadingElement>(null);
|
||||
const actionBusy = useRef(false);
|
||||
const session = useRef({ mounted: true, generation: 0 });
|
||||
const actionAnchor = useRef<HTMLElement | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [actionError, setActionError] = useState<{ error?: unknown; key: TranslationKey } | null>(null);
|
||||
@@ -52,12 +54,33 @@ export default function CvBuilderPage() {
|
||||
const [selectedSections, setSelectedSections] = useState<string[]>([]);
|
||||
const [includeContact, setIncludeContact] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const owner = session.current;
|
||||
owner.mounted = true;
|
||||
owner.generation += 1;
|
||||
return () => { owner.mounted = false; owner.generation += 1; };
|
||||
}, []);
|
||||
|
||||
const captureSession = () => {
|
||||
checkSessionCookie();
|
||||
const owner = session.current;
|
||||
if (!owner.mounted || isSessionInvalidated()) return null;
|
||||
const context = getSessionContext();
|
||||
const generation = owner.generation;
|
||||
return () => {
|
||||
checkSessionCookie();
|
||||
return owner.mounted && owner.generation === generation && !isSessionInvalidated() && getSessionContext() === context;
|
||||
};
|
||||
};
|
||||
|
||||
const loadProfile = async () => {
|
||||
const active = captureSession();
|
||||
if (!active) return;
|
||||
setProfileLoading(true);
|
||||
setProfileFailed(false);
|
||||
try { setProfileOutline(await cvBuilderApi.outline()); }
|
||||
catch { setProfileFailed(true); }
|
||||
finally { setProfileLoading(false); }
|
||||
try { const outline = await cvBuilderApi.outline(); if (active()) setProfileOutline(outline); }
|
||||
catch { if (active()) setProfileFailed(true); }
|
||||
finally { if (active()) setProfileLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,15 +89,16 @@ export default function CvBuilderPage() {
|
||||
}, [pending]);
|
||||
|
||||
const startAction = (showPending = true) => {
|
||||
if (actionBusy.current) return false;
|
||||
const active = captureSession();
|
||||
if (!active || actionBusy.current) return null;
|
||||
actionBusy.current = true;
|
||||
actionAnchor.current = menu?.anchor ?? null;
|
||||
setMenu(null);
|
||||
setActionError(null);
|
||||
if (showPending) setPending(true);
|
||||
return true;
|
||||
return active;
|
||||
};
|
||||
const finishAction = () => { actionBusy.current = false; setPending(false); };
|
||||
const finishAction = (active: () => boolean) => { actionBusy.current = false; if (active()) setPending(false); };
|
||||
const setVariants = (update: (items: CvVariantSummary[]) => CvVariantSummary[]) => {
|
||||
resource.setData((data) => ({ ...data, variants: update(data.variants) }));
|
||||
};
|
||||
@@ -85,7 +109,8 @@ export default function CvBuilderPage() {
|
||||
};
|
||||
|
||||
const confirmCreate = async () => {
|
||||
if (!newName.trim() || actionBusy.current) return;
|
||||
const active = captureSession();
|
||||
if (!active || !newName.trim() || actionBusy.current) return;
|
||||
actionBusy.current = true;
|
||||
createTitleRef.current?.focus();
|
||||
setCreateError(null);
|
||||
@@ -97,65 +122,76 @@ export default function CvBuilderPage() {
|
||||
includeProfileContact: contentSource === "profile" && includeContact,
|
||||
sections: contentSource === "profile" ? selectedSections.map((key) => ({ key })) : [],
|
||||
} });
|
||||
if (!active()) return;
|
||||
setCreateOpen(false);
|
||||
navigate(`/career/builder/${variant.id}`);
|
||||
} catch (err) {
|
||||
setCreateError({ error: err });
|
||||
} finally { actionBusy.current = false; setCreating(false); }
|
||||
if (active()) setCreateError({ error: err });
|
||||
} finally { actionBusy.current = false; if (active()) setCreating(false); }
|
||||
};
|
||||
|
||||
const duplicate = async (id: number) => {
|
||||
if (!startAction()) return;
|
||||
const active = startAction();
|
||||
if (!active) return;
|
||||
try {
|
||||
await cvBuilderApi.duplicate(id);
|
||||
if (!active()) return;
|
||||
await resource.reload();
|
||||
if (!active()) return;
|
||||
toast(localeRef.current("cvDashboardDuplicated"), "success");
|
||||
} catch (err) {
|
||||
setActionError({ error: err, key: "cvDashboardDuplicateFailed" });
|
||||
if (active()) setActionError({ error: err, key: "cvDashboardDuplicateFailed" });
|
||||
} finally {
|
||||
finishAction();
|
||||
finishAction(active);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
if (!startAction(false)) return;
|
||||
const active = startAction(false);
|
||||
if (!active) return;
|
||||
const variant = variants.find((item) => item.id === id);
|
||||
try {
|
||||
if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), {
|
||||
title: t("cvDashboardDeleteCv"),
|
||||
confirmLabel: t("cvDashboardDeleteCv"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
})) || !active()) return;
|
||||
setPending(true);
|
||||
await cvBuilderApi.remove(id);
|
||||
if (!active()) return;
|
||||
setVariants((v) => v.filter((x) => x.id !== id));
|
||||
toast(localeRef.current("cvDashboardDeleted"), "success");
|
||||
} catch (err) {
|
||||
setActionError({ error: err, key: "cvDashboardDeleteFailed" });
|
||||
if (active()) setActionError({ error: err, key: "cvDashboardDeleteFailed" });
|
||||
} finally {
|
||||
finishAction();
|
||||
finishAction(active);
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async (id: number) => {
|
||||
if (!startAction(false)) return;
|
||||
const active = startAction(false);
|
||||
if (!active) return;
|
||||
const current = variants.find((item) => item.id === id);
|
||||
try {
|
||||
const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") });
|
||||
if (!nextName?.trim() || nextName.trim() === current?.name) return;
|
||||
if (!active() || !nextName?.trim() || nextName.trim() === current?.name) return;
|
||||
setPending(true);
|
||||
const variant = await cvBuilderApi.get(id);
|
||||
if (!active()) return;
|
||||
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual", expectedVersion: variant.version });
|
||||
if (!active()) return;
|
||||
setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item));
|
||||
toast(localeRef.current("cvDashboardRenamed"), "success");
|
||||
} catch (err) { setActionError({ error: err, key: "cvDashboardRenameFailed" }); }
|
||||
finally { finishAction(); }
|
||||
} catch (err) { if (active()) setActionError({ error: err, key: "cvDashboardRenameFailed" }); }
|
||||
finally { finishAction(active); }
|
||||
};
|
||||
|
||||
const download = async (id: number, cvName: string) => {
|
||||
if (!startAction()) return;
|
||||
const active = startAction();
|
||||
if (!active) return;
|
||||
try {
|
||||
const response = await api.post(cvBuilderApi.exportPdfUrl(id), {}, { responseType: "blob" });
|
||||
if (!active()) return;
|
||||
const url = URL.createObjectURL(response.data as Blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -164,8 +200,8 @@ export default function CvBuilderPage() {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
const key = await getCvExportFailureKey(err);
|
||||
setActionError({ error: key ? undefined : err, key: key ?? "cvDashboardDownloadFailed" });
|
||||
} finally { finishAction(); }
|
||||
if (active()) setActionError({ error: key ? undefined : err, key: key ?? "cvDashboardDownloadFailed" });
|
||||
} finally { finishAction(active); }
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user