diff --git a/JobTrackerApi.MariaDb.Tests/CoverLetterDocumentPersistenceTests.cs b/JobTrackerApi.MariaDb.Tests/CoverLetterDocumentPersistenceTests.cs new file mode 100644 index 0000000..aa0415a --- /dev/null +++ b/JobTrackerApi.MariaDb.Tests/CoverLetterDocumentPersistenceTests.cs @@ -0,0 +1,193 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Moq; +using Xunit; + +namespace JobTrackerApi.MariaDb.Tests; + +public sealed partial class CoverLetterWriterTests +{ + [Fact] + public async Task Document_schema_upgrade_preserves_legacy_letter_history_and_frozen_rows_on_mariadb() + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var job = await Seed(db, " old exact letter "); + db.CoverLetterVersions.Add(new() { OwnerUserId = Owner, JobApplicationId = job.Id, Version = 7, Text = " old history ", Source = CoverLetterSources.Ai }); + db.SubmittedApplicationPackages.Add(new() { OwnerUserId = Owner, JobApplicationId = job.Id, Version = 1, CoverLetterText = " old frozen letter ", RenderedCvHtml = "

unchanged CV

" }); + await db.SaveChangesAsync(); + await db.GetService().MigrateAsync("20261023090000_AddJobRevision"); + db.ChangeTracker.Clear(); + await db.GetService().MigrateAsync(); + var actual = await db.JobApplications.AsNoTracking().SingleAsync(); + Assert.Equal(" old exact letter ", actual.CoverLetterText); + Assert.Equal(1, actual.Version); + Assert.Null(actual.CoverLetterDocumentSettingsJson); + var historical = await db.CoverLetterVersions.AsNoTracking().SingleAsync(); + Assert.Equal(7, historical.Version); + Assert.Equal(" old history ", historical.Text); + Assert.Equal(CoverLetterSources.Ai, historical.Source); + Assert.Null(historical.DocumentSettingsJson); + var frozen = await db.SubmittedApplicationPackages.AsNoTracking().SingleAsync(); + Assert.Equal(" old frozen letter ", frozen.CoverLetterText); + Assert.Equal("

unchanged CV

", frozen.RenderedCvHtml); + Assert.Null(frozen.CoverLetterDocumentSettingsJson); + Assert.Null(frozen.RenderedCoverLetterHtml); + Assert.Empty(await db.Database.GetPendingMigrationsAsync()); + } + + [Fact] + public async Task Document_settings_only_saves_advance_shared_revision_preserve_omission_and_restore_old_defaults() + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var job = await Seed(db, "same body"); + var service = Assets(db); + var saved = await service.SaveCoverLetterAsync(Owner, job.Id, "same body", CoverLetterSources.Manual, null, job.Version, default, + new() { SenderName = "Reviewed sender", ThemeId = "minimal", AccentColor = "#123456", SourceCvId = 42 }); + Assert.Equal(2, saved!.Revision); + Assert.Equal(2, saved.CurrentVersion); + Assert.Equal("Reviewed sender", saved.DocumentSettings!.SenderName); + Assert.Equal("same body", saved.Text); + var baseline = await db.CoverLetterVersions.SingleAsync(x => x.Version == 1); + Assert.Null(baseline.DocumentSettingsJson); + Assert.Equal(CoverLetterSources.Legacy, baseline.Source); + var settingsJson = job.CoverLetterDocumentSettingsJson; + await Update(db, job, "generic body"); + await Controller(db).SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, "notes", null, ExpectedVersion: job.Version), default); + await service.SaveCoverLetterAsync(Owner, job.Id, "dedicated body", CoverLetterSources.Manual, null, job.Version, default); + Assert.Equal(settingsJson, job.CoverLetterDocumentSettingsJson); + var noOpRevision = job.Version; + var noOp = await service.SaveCoverLetterAsync(Owner, job.Id, "dedicated body", CoverLetterSources.Manual, null, job.Version, default, + CoverLetterDocumentSettingsJson.Deserialize(settingsJson)); + Assert.Equal(noOpRevision + 1, noOp!.Revision); + Assert.Equal(4, noOp.CurrentVersion); + var restored = await service.RestoreCoverLetterAsync(Owner, job.Id, 1, job.Version, default); + Assert.Equal("same body", restored!.Text); + Assert.Equal("modern", restored.DocumentSettings!.ThemeId); + Assert.Equal("", restored.DocumentSettings.SenderName); + Assert.Null(restored.DocumentSettings.SourceCvId); + Assert.Equal(5, restored.CurrentVersion); + Assert.Equal(settingsJson, (await db.CoverLetterVersions.SingleAsync(x => x.Version == 2)).DocumentSettingsJson); + var restoredDesign = await service.RestoreCoverLetterAsync(Owner, job.Id, 2, job.Version, default); + Assert.Equal("same body", restoredDesign!.Text); + Assert.Equal("Reviewed sender", restoredDesign.DocumentSettings!.SenderName); + Assert.Equal(6, restoredDesign.CurrentVersion); + } + + [Fact] + public async Task Preview_is_read_only_and_copied_settings_never_follow_later_cv_changes() + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var job = await Seed(db); + var cv = new CvVariant { OwnerUserId = Owner, Name = "Source CV", PublicSlug = Guid.NewGuid().ToString("N"), SettingsJson = CvVariantSettingsJson.Serialize(new() { AccentColor = "#123456" }) }; + db.CvVariants.Add(cv); + await db.SaveChangesAsync(); + var settings = new CoverLetterDocumentSettings { SenderName = "Copied name", SourceCvId = cv.Id, AccentColor = "#123456" }; + var service = Assets(db); + await service.SaveCoverLetterAsync(Owner, job.Id, "approved body", CoverLetterSources.Manual, null, job.Version, default, settings); + var revision = job.Version; + var before = job.CoverLetterDocumentSettingsJson; + var preview = await service.PreviewCoverLetterAsync(Owner, job.Id, "unapproved preview **bold**", new() { Subject = "Preview only" }, default); + Assert.Contains("unapproved preview bold", preview!.Html); + cv.SettingsJson = CvVariantSettingsJson.Serialize(new() { AccentColor = "#abcdef" }); + await db.SaveChangesAsync(); + var render = await service.RenderSavedCoverLetterAsync(Owner, job.Id, revision, default); + Assert.Contains("#123456", render!.Html); + Assert.Contains("Copied name", render.Html); + Assert.Contains("approved body", render.Html); + Assert.DoesNotContain("unapproved preview", render.Html); + Assert.Equal(before, job.CoverLetterDocumentSettingsJson); + Assert.Equal(revision, job.Version); + Assert.Single(await db.CoverLetterVersions.ToListAsync()); + Assert.Null(await service.PreviewCoverLetterAsync("foreign", job.Id, "stolen", settings, default)); + Assert.Null(await service.RenderSavedCoverLetterAsync("foreign", job.Id, revision, default)); + } + + [Theory] + [InlineData(null)] + [InlineData(0)] + [InlineData(-1)] + [InlineData(99)] + public async Task Export_requires_current_positive_revision_before_allocating_pdf(int? expectedVersion) + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var job = await Seed(db, "saved body"); + var pdf = new Mock(MockBehavior.Strict); + var user = new ApplicationUser { Id = Owner }; + var users = TestHostFactory.CreateUserManager(user); + users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + var controller = new ApplicationAssetsController(users.Object, Assets(db), Mock.Of(), pdf.Object) + { + ControllerContext = new() { HttpContext = new DefaultHttpContext() }, + }; + Assert.IsType(await controller.ExportCoverLetterPdf(job.Id, default, expectedVersion)); + pdf.VerifyNoOtherCalls(); + pdf.Setup(x => x.ExportAsync(Owner, It.Is(r => r.Html.Contains("saved body")), It.IsAny())) + .ReturnsAsync(new CvPdfArtifact("cover-letter.pdf", "unused.pdf", [37, 80, 68, 70])); + Assert.IsType(await controller.ExportCoverLetterPdf(job.Id, default, job.Version)); + pdf.Verify(x => x.ExportAsync(Owner, It.IsAny(), It.IsAny()), Times.Once); + Assert.IsType(await controller.ExportCoverLetterPdf(int.MaxValue, default, job.Version)); + pdf.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Stale_settings_save_and_unsupported_schema_cannot_change_approved_design() + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var job = await Seed(db); + var service = Assets(db); + await service.SaveCoverLetterAsync(Owner, job.Id, "approved", CoverLetterSources.Manual, null, 1, default, new() { Subject = "Approved subject" }); + await Assert.ThrowsAsync(() => service.SaveCoverLetterAsync(Owner, job.Id, "stale", CoverLetterSources.Manual, null, 1, default, new() { Subject = "Stale" })); + Assert.Equal("Approved subject", (await service.GetCoverLetterAsync(Owner, job.Id, default))!.DocumentSettings!.Subject); + var user = new ApplicationUser { Id = Owner }; + var users = TestHostFactory.CreateUserManager(user); + users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + var controller = new ApplicationAssetsController(users.Object, service, Mock.Of()); + Assert.IsType((await controller.SaveCoverLetter(job.Id, + new("bad", null, null, job.Version, new() { SchemaVersion = 2 }), default)).Result); + Assert.IsType((await controller.PreviewCoverLetter(job.Id, + new("bad", new() { SchemaVersion = 2 }), default)).Result); + Assert.Single(await db.CoverLetterVersions.ToListAsync()); + } + + [Fact] + public async Task Submitted_letter_html_and_design_remain_exact_after_live_edits_and_old_packages_stay_null() + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var job = await Seed(db); + var service = Assets(db); + await service.SaveCoverLetterAsync(Owner, job.Id, "frozen body", CoverLetterSources.Manual, null, job.Version, default, + new() { SenderName = "Frozen sender", Subject = "Frozen subject" }); + var old = new SubmittedApplicationPackage { OwnerUserId = Owner, JobApplicationId = job.Id, Version = 1, CoverLetterText = "old text-only" }; + db.Add(old); + await db.SaveChangesAsync(); + var packages = new SubmittedApplicationPackageService(db, Mock.Of(), Mock.Of()); + var captured = await packages.CaptureAsync(Owner, job.Id, new("Profile name must not replace sender", null), default); + var stored = await db.SubmittedApplicationPackages.AsNoTracking().SingleAsync(x => x.Id == captured!.Id); + Assert.Contains("frozen body", stored.RenderedCoverLetterHtml); + Assert.Contains("Frozen sender", stored.RenderedCoverLetterHtml); + Assert.DoesNotContain("Profile name must not replace sender", stored.RenderedCoverLetterHtml); + await service.SaveCoverLetterAsync(Owner, job.Id, "new body", CoverLetterSources.Manual, null, job.Version, default, + new() { SenderName = "New sender", Subject = "New subject" }); + var detail = await packages.GetAsync(Owner, job.Id, captured!.Id, default); + Assert.Equal(stored.RenderedCoverLetterHtml, detail!.RenderedCoverLetterHtml); + Assert.Equal("Frozen sender", detail.CoverLetterDocumentSettings!.SenderName); + Assert.Equal("frozen body", detail.CoverLetterText); + var oldDetail = await packages.GetAsync(Owner, job.Id, old.Id, default); + Assert.Equal("old text-only", oldDetail!.CoverLetterText); + Assert.Null(oldDetail.RenderedCoverLetterHtml); + Assert.Null(oldDetail.CoverLetterDocumentSettings); + } +} diff --git a/JobTrackerApi.MariaDb.Tests/CoverLetterWriterTests.cs b/JobTrackerApi.MariaDb.Tests/CoverLetterWriterTests.cs new file mode 100644 index 0000000..7056d18 --- /dev/null +++ b/JobTrackerApi.MariaDb.Tests/CoverLetterWriterTests.cs @@ -0,0 +1,198 @@ +using System.Security.Claims; +using System.Text.Json; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.MariaDb.Tests; + +public sealed partial class CoverLetterWriterTests +{ + private const string Owner = "letter-owner"; + private static JobTrackerContext Context(MariaDbDatabase connection, string owner = Owner) => connection.Context(owner); + + private static JobApplicationsController Controller(JobTrackerContext db) => new(db, + Mock.Of(), TestHostFactory.CreateUserManager(new ApplicationUser { Id = Owner }).Object) + { + ControllerContext = new() { HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, Owner)], "test")), + } }, + }; + + private static ApplicationAssetsService Assets(JobTrackerContext db) => new(db, Mock.Of(), Mock.Of()); + private static T Request(object value) => JsonSerializer.Deserialize(JsonSerializer.Serialize(value), new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + + private static async Task Seed(JobTrackerContext db, string? text = null) + { + var job = new JobApplication { OwnerUserId = Owner, Company = new Company { OwnerUserId = Owner, Name = "Letters" }, JobTitle = "Role", Status = "Prospect", CoverLetterText = text }; + db.Add(job); + await db.SaveChangesAsync(); + return job; + } + + private static Task Update(JobTrackerContext db, JobApplication job, string? text, int? revision = null) => + Controller(db).Update(job.Id, Request(new + { + jobTitle = job.JobTitle, companyId = job.CompanyId, status = job.Status, coverLetterText = text, + expectedVersion = revision ?? job.Version, + }), default); + + [Fact] + public async Task Create_generic_draft_and_dedicated_writes_share_history_and_restore() + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var seed = await Seed(db); + var created = await Controller(db).Create(Request(new { jobTitle = "Created", companyId = seed.CompanyId, coverLetterText = " initial body " }), default); + Assert.IsType(created.Result); + var job = await db.JobApplications.SingleAsync(x => x.JobTitle == "Created"); + Assert.Equal(" initial body ", (await db.CoverLetterVersions.SingleAsync(x => x.JobApplicationId == job.Id)).Text); + Assert.IsType(await Update(db, job, " generic body ")); + Assert.Equal(" generic body ", job.CoverLetterText); + Assert.IsType(await Controller(db).SaveApplicationDrafts(job.Id, + new SaveApplicationDraftsRequest(" draft body ", null, null, ExpectedVersion: job.Version), default)); + Assert.Equal("draft body", job.CoverLetterText); + var saved = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, " approved AI body ", CoverLetterSources.Ai, "improve", job.Version, default); + Assert.Equal(4, saved!.CurrentVersion); + var restored = await Assets(db).RestoreCoverLetterAsync(Owner, job.Id, 3, job.Version, default); + Assert.Equal("draft body", restored!.Text); + Assert.Equal(5, restored.CurrentVersion); + var history = await db.CoverLetterVersions.Where(x => x.JobApplicationId == job.Id).OrderBy(x => x.Version).ToListAsync(); + Assert.Equal(new[] { " initial body ", " generic body ", "draft body", "approved AI body", "draft body" }, history.Select(x => x.Text)); + Assert.Equal(CoverLetterSources.Ai, history[3].Source); + Assert.Equal("improve", history[3].AiAction); + Assert.Equal(CoverLetterSources.Restore, history[4].Source); + } + + [Fact] + public async Task Draft_null_preserves_generic_null_clears_and_no_ops_acknowledge_without_duplicate_history() + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var job = await Seed(db); + await Update(db, job, "first"); + var firstRevision = job.Version; + await Controller(db).SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, null, null, ExpectedVersion: job.Version), default); + Assert.Equal("first", job.CoverLetterText); + Assert.Equal(firstRevision + 1, job.Version); + await Update(db, job, "first"); + Assert.Single(await db.CoverLetterVersions.ToListAsync()); + await Update(db, job, null); + Assert.Null(job.CoverLetterText); + Assert.Equal(new[] { "first", "" }, await db.CoverLetterVersions.OrderBy(x => x.Version).Select(x => x.Text).ToArrayAsync()); + var revision = job.Version; + var saved = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, " ", CoverLetterSources.Manual, null, revision, default); + Assert.Equal(revision + 1, saved!.Revision); + Assert.Equal(2, saved.CurrentVersion); + await Assert.ThrowsAsync(() => Assets(db).SaveCoverLetterAsync(Owner, job.Id, "stale", CoverLetterSources.Manual, null, revision, default)); + Assert.Equal(2, await db.CoverLetterVersions.CountAsync()); + } + + [Theory] + [InlineData("generic")] + [InlineData("draft")] + [InlineData("dedicated")] + public async Task Legacy_text_gets_a_recoverable_baseline_before_first_replacement(string writer) + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var job = await Seed(db, " legacy original "); + await Write(db, job, writer, "replacement"); + Assert.Equal(new[] { " legacy original ", "replacement" }, await db.CoverLetterVersions.OrderBy(x => x.Version).Select(x => x.Text).ToArrayAsync()); + var restored = await Assets(db).RestoreCoverLetterAsync(Owner, job.Id, 1, job.Version, default); + Assert.Equal("legacy original", restored!.Text); + Assert.Equal(3, restored.CurrentVersion); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Legacy_change_after_existing_history_is_preserved_before_replacement(bool cleared) + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var job = await Seed(db, cleared ? null : "legacy replacement"); + db.CoverLetterVersions.Add(new CoverLetterVersion + { + OwnerUserId = Owner, JobApplicationId = job.Id, Version = 4, Text = "older saved version", + Source = CoverLetterSources.Ai, AiAction = "generate", + }); + await db.SaveChangesAsync(); + await Update(db, job, "new approved version"); + var versions = await db.CoverLetterVersions.OrderBy(x => x.Version).ToListAsync(); + Assert.Equal(new[] { 4, 5, 6 }, versions.Select(x => x.Version)); + Assert.Equal(new[] { "older saved version", cleared ? "" : "legacy replacement", "new approved version" }, versions.Select(x => x.Text)); + Assert.Equal(CoverLetterSources.Ai, versions[0].Source); + Assert.Equal("generate", versions[0].AiAction); + } + + private static async Task Write(JobTrackerContext db, JobApplication job, string writer, string text) + { + if (writer == "generic") Assert.IsType(await Update(db, job, text)); + else if (writer == "draft") Assert.IsType(await Controller(db).SaveApplicationDrafts(job.Id, + new SaveApplicationDraftsRequest(text, "new notes", null, ExpectedVersion: job.Version), default)); + else await Assets(db).SaveCoverLetterAsync(Owner, job.Id, text, CoverLetterSources.Manual, null, job.Version, default); + } + + [Theory] + [InlineData("generic")] + [InlineData("draft")] + [InlineData("dedicated")] + public async Task History_failure_rolls_back_letter_parent_revision_and_other_fields(string writer) + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var job = await Seed(db, "original"); + await db.Database.ExecuteSqlRawAsync("CREATE TRIGGER reject_letter BEFORE INSERT ON CoverLetterVersions FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'letter history failure';"); + await Assert.ThrowsAsync(() => Write(db, job, writer, "replacement")); + Assert.Empty(db.ChangeTracker.Entries()); + await db.SaveChangesAsync(); + await using var verify = Context(connection); + var actual = await verify.JobApplications.SingleAsync(); + Assert.Equal("original", actual.CoverLetterText); + Assert.Null(actual.Notes); + Assert.Equal(1, actual.Version); + Assert.Empty(await verify.CoverLetterVersions.ToListAsync()); + } + + [Fact] + public async Task Create_history_failure_cannot_leave_a_job_without_its_initial_snapshot() + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var seed = await Seed(db); + await db.Database.ExecuteSqlRawAsync("CREATE TRIGGER reject_initial_letter BEFORE INSERT ON CoverLetterVersions FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'initial history failure';"); + await Assert.ThrowsAsync(() => Controller(db).Create(Request(new + { + jobTitle = "Failed create", companyId = seed.CompanyId, coverLetterText = "initial letter", + }), default)); + Assert.Empty(db.ChangeTracker.Entries()); + await db.SaveChangesAsync(); + await using var verify = Context(connection); + Assert.Single(await verify.JobApplications.ToListAsync()); + Assert.Empty(await verify.CoverLetterVersions.ToListAsync()); + Assert.Empty(await verify.JobEvents.ToListAsync()); + } + + [Fact] + public async Task Other_tenant_cannot_write_or_read_letter_history_through_any_writer() + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + var job = await Seed(db, "private"); + await using var other = Context(connection, "other"); + Assert.IsType(await Update(other, job, "stolen")); + Assert.IsType(await Controller(other).SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest("stolen", null, null, ExpectedVersion: 1), default)); + Assert.Null(await Assets(other).SaveCoverLetterAsync("other", job.Id, "stolen", CoverLetterSources.Manual, null, 1, default)); + Assert.Null(await Assets(other).GetCoverLetterAsync("other", job.Id, default)); + Assert.Empty(await db.CoverLetterVersions.ToListAsync()); + Assert.Equal("private", (await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText); + } +} diff --git a/JobTrackerApi.MariaDb.Tests/Rd21JobPersistenceTests.cs b/JobTrackerApi.MariaDb.Tests/Rd21JobPersistenceTests.cs index 7e18096..d58e499 100644 --- a/JobTrackerApi.MariaDb.Tests/Rd21JobPersistenceTests.cs +++ b/JobTrackerApi.MariaDb.Tests/Rd21JobPersistenceTests.cs @@ -64,7 +64,7 @@ public sealed class Rd21JobPersistenceTests User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "test")), } }, }; - var pending = controller.Update(id, Request(companyId, "Delayed title", null, 1) with { Status = "Offer" }, default); + var pending = controller.Update(id, Request(companyId, "Delayed title", null, 1) with { Status = "Offer", CoverLetterText = "Uncommitted letter" }, default); await reached.Task.WaitAsync(TimeSpan.FromSeconds(10)); try { @@ -83,6 +83,8 @@ public sealed class Rd21JobPersistenceTests Assert.Equal("Prospect", saved.Status); Assert.Equal("Original job title", (await verify.Jobs.SingleAsync()).JobTitle); Assert.Empty(await verify.JobEvents.ToListAsync()); + Assert.Empty(await verify.CoverLetterVersions.ToListAsync()); + Assert.Null(saved.CoverLetterText); Assert.Empty(delayed.ChangeTracker.Entries()); // A deliberate reload/review uses the winner's content and new revision, never a blind retry. @@ -242,7 +244,7 @@ public sealed class Rd21JobPersistenceTests await using var verify = Context(connection); var saved = await verify.JobApplications.SingleAsync(); Assert.Equal("First approved text", coverLetter ? saved.CoverLetterText : saved.Notes); - if (coverLetter) Assert.Single(await verify.CoverLetterVersions.ToListAsync()); + if (coverLetter) Assert.Equal(2, await verify.CoverLetterVersions.CountAsync()); // Legacy baseline plus approved replacement. } [Theory] diff --git a/JobTrackerApi.MariaDb.Tests/StartupAndLockTests.cs b/JobTrackerApi.MariaDb.Tests/StartupAndLockTests.cs index d3cfa04..a778e1b 100644 --- a/JobTrackerApi.MariaDb.Tests/StartupAndLockTests.cs +++ b/JobTrackerApi.MariaDb.Tests/StartupAndLockTests.cs @@ -79,10 +79,10 @@ public sealed class StartupAndLockTests(ITestOutputHelper output) db.Add(job); await db.SaveChangesAsync(); var migrations = (await db.Database.GetAppliedMigrationsAsync()).ToArray(); - Assert.Equal("20261023090000_AddJobRevision", migrations[^1]); + Assert.Equal("20261025090000_AddCoverLetterDocumentDesign", migrations[^1]); // Only this test's newly-created schema is downgraded; no existing database is selected. - await db.GetService().MigrateAsync(migrations[^2]); - Assert.Equal(new[] { migrations[^1] }, await db.Database.GetPendingMigrationsAsync()); + await db.GetService().MigrateAsync(migrations[^3]); + Assert.Equal(migrations[^2..], await db.Database.GetPendingMigrationsAsync()); db.ChangeTracker.Clear(); await database.App.InitializeJobTrackerAsync(); await using var verify = database.Context(); diff --git a/JobTrackerApi.Tests/AccountDataExportTests.cs b/JobTrackerApi.Tests/AccountDataExportTests.cs index 6117fb1..c6a0606 100644 --- a/JobTrackerApi.Tests/AccountDataExportTests.cs +++ b/JobTrackerApi.Tests/AccountDataExportTests.cs @@ -56,6 +56,18 @@ public sealed class AccountDataExportTests var otherApplication = new JobApplication { OwnerUserId = other.Id, CompanyId = company.Id, JobTitle = "OTHER_TENANT_PRIVATE" }; db.JobApplications.AddRange(application, otherApplication); await db.SaveChangesAsync(); + application.CoverLetterDocumentSettingsJson = "{\"senderName\":\"live-letter-design\"}"; + db.CoverLetterVersions.Add(new CoverLetterVersion + { + OwnerUserId = owner.Id, JobApplicationId = application.Id, Version = 1, Text = "historical body", + DocumentSettingsJson = "{\"senderName\":\"history-letter-design\"}", + }); + db.SubmittedApplicationPackages.Add(new SubmittedApplicationPackage + { + OwnerUserId = owner.Id, JobApplicationId = application.Id, Version = 1, CoverLetterText = "frozen body", + CoverLetterDocumentSettingsJson = "{\"senderName\":\"frozen-letter-design\"}", + RenderedCoverLetterHtml = "

frozen-letter-render

", + }); var attachmentPath = Path.Combine(paths.AttachmentsRoot, application.Id.ToString(), "evidence.txt"); Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!); @@ -109,6 +121,10 @@ public sealed class AccountDataExportTests Assert.Contains("Synthetic Owner", readable); Assert.Contains("Readable application note", readable); Assert.Contains("export-proof", readable); + Assert.Contains("live-letter-design", readable); + Assert.Contains("history-letter-design", readable); + Assert.Contains("frozen-letter-design", readable); + Assert.Contains("frozen-letter-render", readable); Assert.DoesNotContain("OTHER_TENANT_PRIVATE", readable); Assert.DoesNotContain("PASSWORD_HASH_MUST_NOT_EXPORT", readable); Assert.DoesNotContain("SECURITY_STAMP_MUST_NOT_EXPORT", readable); diff --git a/JobTrackerApi.Tests/AiWorkspaceTests.cs b/JobTrackerApi.Tests/AiWorkspaceTests.cs index 363a802..5351eec 100644 --- a/JobTrackerApi.Tests/AiWorkspaceTests.cs +++ b/JobTrackerApi.Tests/AiWorkspaceTests.cs @@ -66,6 +66,43 @@ public sealed class AiWorkspaceTests private static AiGenerateRequest Req(string module, string? mode = null) => new(module, mode, null); + private static async Task SeedLinkedProfileAsync(JobTrackerContext db, int jobId, StructuredCvProfile? profile = null, CvVariantSettings? settings = null, string profileOwner = "user-1") + { + db.CareerProfiles.Add(new CareerProfile { OwnerUserId = profileOwner, ProfileJson = StructuredCvProfileJson.Serialize(profile ?? new StructuredCvProfile { Skills = { "C#" } }) }); + db.CvVariants.Add(new CvVariant { + OwnerUserId = "user-1", JobApplicationId = jobId, Name = "Source CV", PublicSlug = Guid.NewGuid().ToString("N"), + SettingsJson = CvVariantSettingsJson.Serialize(settings ?? new()), Version = 1, + CreatedAtUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + } + + [Theory] + [InlineData("generate", "no-linked-cv")] + [InlineData("improve", "no-linked-cv")] + [InlineData("grammar", "identity-only")] + [InlineData("shorten", "hidden-facts")] + [InlineData("generate", "foreign-profile")] + public async Task Letter_requires_owned_visible_career_facts_before_inference_even_with_draft_and_advert(string action, string source) + { + var (db, svc, ai) = New("user-1"); + await using var _ = db; + var jobId = await SeedJobAsync(db, "user-1"); + if (source != "no-linked-cv") + { + var profile = new StructuredCvProfile { Contact = new StructuredCvContact { FullName = "Ada Example", Headline = "Engineer" } }; + var settings = new CvVariantSettings(); + if (source == "hidden-facts") { profile.Skills.Add("C#"); settings.Sections.Add(new() { Key = "skills", Hidden = true }); } + if (source == "foreign-profile") profile.Skills.Add("Foreign profile skill"); + await SeedLinkedProfileAsync(db, jobId, profile, settings, source == "foreign-profile" ? "user-2" : "user-1"); + } + var error = await Assert.ThrowsAsync(() => svc.GenerateAsync("user-1", jobId, + "Unverified legacy fallback claiming 99 years experience", "Ada", new("cover-letter", "professional", "Invent impressive results", "en", "An existing unverified letter draft", action), "p", default)); + Assert.Equal("cover_letter_source_required", error.Message); + Assert.Equal(0, ai.Calls); + Assert.Empty(await db.AiInteractions.IgnoreQueryFilters().ToListAsync()); + } + [Fact] public async Task Generate_stores_an_interaction_and_returns_it() { @@ -107,6 +144,7 @@ public sealed class AiWorkspaceTests var (db, svc, _) = New("user-1"); await using var _ = db; var jobId = await SeedJobAsync(db, "user-1"); + await SeedLinkedProfileAsync(db, jobId); var res = await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("cover-letter", "banana"), "p", default); @@ -209,6 +247,7 @@ public sealed class AiWorkspaceTests await using var _ = db; ai.Next = " "; var jobId = await SeedJobAsync(db, "user-1"); + await SeedLinkedProfileAsync(db, jobId); await Assert.ThrowsAsync(() => svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("cover-letter"), "p", default)); } diff --git a/JobTrackerApi.Tests/BackupControllerTests.cs b/JobTrackerApi.Tests/BackupControllerTests.cs index c8b8d4f..d2617a8 100644 --- a/JobTrackerApi.Tests/BackupControllerTests.cs +++ b/JobTrackerApi.Tests/BackupControllerTests.cs @@ -39,6 +39,13 @@ public sealed class BackupControllerTests var otherJob = new JobApplication { JobTitle = "Other tenant role", OwnerUserId = "user-2" }; db.JobApplications.AddRange(job, otherJob); await db.SaveChangesAsync(); + db.CoverLetterVersions.AddRange( + new CoverLetterVersion { OwnerUserId = "user-1", JobApplicationId = job.Id, Version = 1, Text = "Original letter", Source = CoverLetterSources.Manual }, + new CoverLetterVersion { OwnerUserId = "user-1", JobApplicationId = job.Id, Version = 2, Text = "Approved letter", Source = CoverLetterSources.Ai, AiAction = "improve", DocumentSettingsJson = "{\"senderName\":\"Historical sender\"}" }, + new CoverLetterVersion { OwnerUserId = "user-2", JobApplicationId = otherJob.Id, Version = 1, Text = "Other tenant private letter" }); + db.SubmittedApplicationPackages.AddRange( + new SubmittedApplicationPackage { OwnerUserId = "user-1", JobApplicationId = job.Id, Version = 1, CoverLetterText = "Frozen letter", RenderedCoverLetterHtml = "

Exact frozen HTML

", CoverLetterDocumentSettingsJson = "{\"subject\":\"Frozen subject\"}" }, + new SubmittedApplicationPackage { OwnerUserId = "user-2", JobApplicationId = otherJob.Id, Version = 1, RenderedCoverLetterHtml = "Other tenant private frozen HTML" }); db.EmailSendAttempts.Add(new EmailSendAttempt { Id = Guid.NewGuid(), @@ -95,6 +102,17 @@ public sealed class BackupControllerTests var protectedText = System.Text.Encoding.UTF8.GetString(file.FileContents); var json = Convert.FromBase64String(provider.CreateProtector("JobTrackerApi.Backup.v1").Unprotect(protectedText)); using var document = System.Text.Json.JsonDocument.Parse(json); + var letters = document.RootElement.GetProperty("Data").GetProperty("CoverLetterVersions").EnumerateArray().ToArray(); + Assert.Equal(2, letters.Length); + Assert.Equal("Original letter", letters[0].GetProperty("Text").GetString()); + Assert.Equal(2, letters[1].GetProperty("Version").GetInt32()); + Assert.Equal("Approved letter", letters[1].GetProperty("Text").GetString()); + Assert.Equal(CoverLetterSources.Ai, letters[1].GetProperty("Source").GetString()); + Assert.Equal("improve", letters[1].GetProperty("AiAction").GetString()); + Assert.Equal("{\"senderName\":\"Historical sender\"}", letters[1].GetProperty("DocumentSettingsJson").GetString()); + var frozen = Assert.Single(document.RootElement.GetProperty("Data").GetProperty("SubmittedApplicationPackages").EnumerateArray()); + Assert.Equal("

Exact frozen HTML

", frozen.GetProperty("RenderedCoverLetterHtml").GetString()); + Assert.Equal("{\"subject\":\"Frozen subject\"}", frozen.GetProperty("CoverLetterDocumentSettingsJson").GetString()); var attempt = Assert.Single(document.RootElement.GetProperty("Data").GetProperty("EmailSendAttempts").EnumerateArray()); Assert.Equal("gmail", attempt.GetProperty("Provider").GetString()); Assert.Equal("sent", attempt.GetProperty("Status").GetString()); diff --git a/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs b/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs new file mode 100644 index 0000000..1603257 --- /dev/null +++ b/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs @@ -0,0 +1,63 @@ +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class CoverLetterDocumentContractTests +{ + [Fact] + public void Document_style_reuses_cv_allowlists_clamps_non_finite_values_and_escapes_all_metadata() + { + var settings = CoverLetterDocumentSettingsJson.Normalize(new() + { + ThemeId = "unknown", BodyFont = "Arial; background:url(https://invalid.test)", AccentColor = "#fff;bad", + BaseFontSizePt = 100, LineHeight = double.PositiveInfinity, PageMarginMm = -10, PageSize = "
+

{{E(s.SenderName)}}

{{E(s.SenderTitle)}}
{{contact}}
+
{{E(s.Date)}}
{{E(s.Recipient)}}

{{E(s.Subject)}}

+
{{body}}
+ """; + return new(html, s.ThemeId, "cover-letter.pdf"); + } +} diff --git a/JobTrackerApi/Services/CvAiSuggestionService.cs b/JobTrackerApi/Services/CvAiSuggestionService.cs index 6aef3aa..cfb4fa9 100644 --- a/JobTrackerApi/Services/CvAiSuggestionService.cs +++ b/JobTrackerApi/Services/CvAiSuggestionService.cs @@ -29,7 +29,7 @@ public sealed class CvAiSuggestionService( { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly IDataProtector protector = protection.CreateProtector("JobTracker.CvAiProposal.v1"); private static readonly HashSet Actions = new(StringComparer.Ordinal) - { "improve", "professional", "shorten", "expand", "grammar", "impact", "ats", "bullets", + { "improve", "suggest", "professional", "shorten", "expand", "grammar", "impact", "ats", "bullets", "summary", "rewrite", "tailor", "document-translate", "document-improve", "document-grammar" }; private sealed record Block(string Id, string? Title, string? Subtitle, List Items); diff --git a/JobTrackerApi/Services/CvVariantService.cs b/JobTrackerApi/Services/CvVariantService.cs index 936b439..446580c 100644 --- a/JobTrackerApi/Services/CvVariantService.cs +++ b/JobTrackerApi/Services/CvVariantService.cs @@ -37,6 +37,7 @@ public interface ICvVariantService // Render a saved variant against the current master profile. Task RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct); + Task RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct, int? expectedVersion); // Render arbitrary (unsaved) settings against the current master profile — live preview. Task RenderSettingsAsync(string ownerUserId, CvVariantSettings settings, CvRenderPerson person, CancellationToken ct); // The master profile resolved to sections+entries (with ItemKeys) for the Content tab to edit. @@ -176,10 +177,15 @@ public sealed class CvVariantService : ICvVariantService return await SaveAsync(ownerUserId, id, null, settings, $"restore:v{version}", expectedVersion, ct); } - public async Task RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct) + public Task RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct) => + RenderAsync(ownerUserId, id, person, ct, null); + + public async Task RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct, int? expectedVersion) { var variant = await GetAsync(ownerUserId, id, ct); if (variant is null) return null; + if (expectedVersion is not null && (expectedVersion < 1 || variant.Version != expectedVersion)) + throw new DbUpdateConcurrencyException("The CV changed after it was loaded."); var settings = CvVariantSettingsJson.Deserialize(variant.SettingsJson); var profile = await _career.LoadStructuredAsync(ownerUserId, ct); return RenderInternal(profile, settings, person); diff --git a/JobTrackerApi/Services/SubmittedApplicationPackageService.cs b/JobTrackerApi/Services/SubmittedApplicationPackageService.cs index 1311868..f7ce3c7 100644 --- a/JobTrackerApi/Services/SubmittedApplicationPackageService.cs +++ b/JobTrackerApi/Services/SubmittedApplicationPackageService.cs @@ -22,7 +22,9 @@ public sealed record SubmittedPackageDetailDto( SubmittedPackageDto Summary, string? RenderedCvHtml, string? CoverLetterText, - string ApplicationMaterialJson); + string ApplicationMaterialJson, + string? RenderedCoverLetterHtml = null, + CoverLetterDocumentSettings? CoverLetterDocumentSettings = null); public interface ISubmittedApplicationPackageService { @@ -89,6 +91,9 @@ public sealed class SubmittedApplicationPackageService : ISubmittedApplicationPa CareerProfileJson = profile?.ProfileJson, RenderedCvHtml = render?.Html, CoverLetterText = job.CoverLetterText, + CoverLetterDocumentSettingsJson = CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson)), + RenderedCoverLetterHtml = string.IsNullOrWhiteSpace(job.CoverLetterText) ? null + : new CoverLetterDocumentRenderer().Render(job.CoverLetterText, CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson)).Html, ApplicationMaterialJson = JsonSerializer.Serialize(new { job.TailoredCvText, @@ -149,7 +154,8 @@ public sealed class SubmittedApplicationPackageService : ISubmittedApplicationPa var package = await _db.SubmittedApplicationPackages.AsNoTracking() .Include(x => x.Attachments) .FirstOrDefaultAsync(x => x.Id == packageId && x.JobApplicationId == jobApplicationId && x.OwnerUserId == ownerUserId, ct); - return package is null ? null : new SubmittedPackageDetailDto(ToDto(package), package.RenderedCvHtml, package.CoverLetterText, package.ApplicationMaterialJson); + return package is null ? null : new SubmittedPackageDetailDto(ToDto(package), package.RenderedCvHtml, package.CoverLetterText, package.ApplicationMaterialJson, + package.RenderedCoverLetterHtml, package.CoverLetterDocumentSettingsJson is null ? null : CoverLetterDocumentSettingsJson.Deserialize(package.CoverLetterDocumentSettingsJson)); } public async Task GetAttachmentAsync(string ownerUserId, int jobApplicationId, int packageId, int attachmentId, CancellationToken ct) diff --git a/JobTrackerApi/Services/ThemedCvRenderer.cs b/JobTrackerApi/Services/ThemedCvRenderer.cs index 8c7899d..ee9dc23 100644 --- a/JobTrackerApi/Services/ThemedCvRenderer.cs +++ b/JobTrackerApi/Services/ThemedCvRenderer.cs @@ -208,7 +208,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer private static readonly Regex UnderlineRx = new(@"__(.+?)__", RegexOptions.Compiled); private static readonly Regex ItalicRx = new(@"(? $"{m.Groups[1].Value}"); diff --git a/docs/cv-redesign/AI.md b/docs/cv-redesign/AI.md new file mode 100644 index 0000000..f8074ca --- /dev/null +++ b/docs/cv-redesign/AI.md @@ -0,0 +1,36 @@ +# CV-UX-AI — contextual writing assistance + +Owner-approved addition, 25 October 2026. This checkpoint is part of CV-UX-01 and does not close AI-P5-13 or authorise production activation. + +## Existing owners and contracts + +`AiSectionAssistant` is already mounted beneath custom content, ordinary section content (including professional summary), and individual entry bullets (experience, projects and education). `CvBuilderEditor` supplies its existing save-before-generation and reviewed-application callbacks. The new compact actions reuse these callbacks: Improve writing (`improve`), Suggest content (`suggest`), Grammar check (`grammar`), Shorter (`shorten`). Existing additional actions remain available. + +`CvVariantController` requires local authentication and the existing Pro entitlement on generation/application. `CvAiSuggestionService` reads the owned variant and Career Profile, checks the requested saved revision/source, validates output shape and grounding, and issues a protected proposal capability bound to owner, variant, version and source fingerprint. Acceptance rechecks that state and uses the ordinary versioned CV save. Neither generation nor acceptance updates the canonical profile. User edits to a proposal are re-evaluated. Generation is not acceptance. + +`ISummarizerService` already routes generation to the self-hosted Python sidecar. Factual review uses `/cv/evaluate-rewrite`; unavailable or invalid assessments fail closed. No additional provider/model/dependency or model download is introduced. Suggest content uses the selected saved section's career evidence; it does not infer experience from job requirements. Empty fields request substantiated career facts before generation. Cross-section recommendations are not claimed. + +The existing cover-letter `AiWorkspaceService` uses the owned linked CV/profile and job context, returns a reviewable suggestion, and records an AI interaction. The existing Pro/local authorization remains. Its body generation does not have the CV capability/grounding evaluator contract: prompt guardrails and user review are not equivalent to semantic qualification. The four direct controls reuse its existing improve/generate/grammar/shorten operations. Local source changes, source CV changes and cancelled requests invalidate pending proposals. Acceptance edits only the local draft; the ordinary revision-checked letter save remains explicit. + +## Safety and UX contract + +The source prerequisite now refuses letter inference before model invocation or interaction creation unless an owned linked CV resolves actual career evidence. Identity, section headers, an advert and a prior draft are insufficient. Five source/action refusal combinations were added to the full backend suite. The UI maps `cover_letter_source_required` to actionable EN/Bokmål guidance. The four letter controls are portalled immediately beneath the body field while retaining the existing assistant read lifecycle. + +- Original and editable proposed text remain separate; Reject never saves and Accept is explicit. +- Current document language is supplied to CV generation. Letter language uses its existing explicit selector; grammar/improvement/shortening must preserve source language and meaning. +- Loading, retry and logical cancellation retain source text. Cancellation discards responses; it does not claim inference-server interruption. +- Shared controls use MUI and EN/Bokmål strings. Keyboard/mobile/browser checks remain distinct from component tests. +- Source documents and job descriptions are untrusted data, not authority to change instructions or career facts. + +## Evidence to date (intermediate, not final acceptance) + +`contextual-ai.trx`: 83 backend CV AI tests passed, including four action review/history/canonical-preservation cases. `ai-ui.json`: 61 combined AI/letter UI tests passed. New direct-action and empty-source tests were observed failing before implementation. The new cover-letter concurrent-edit regression also failed before response invalidation was added. These controlled responses do not demonstrate actual model quality. + +| Action | Implementation | Frontend | Backend | Actual model / production | +|---|---|---|---|---| +| Improve writing | Existing CV/letter paths exposed directly | Component pass | CV tests pass; letter regression pending final suite | NOT TESTED / not eligible | +| Suggest content | New CV action; existing letter generation | Component pass, missing source asks for input | CV tests pass; letter grounding limitation above | NOT TESTED / not eligible | +| Grammar check | Existing paths exposed directly | Component pass | CV tests pass; letter regression pending final suite | NOT TESTED / not eligible | +| Shorter | Existing paths exposed directly | Component pass | CV tests pass; letter regression pending final suite | NOT TESTED / not eligible | + +Outstanding: fresh full regression, endpoint authorization/browser coverage, actual approved self-hosted EN/Bokmål inference and adversarial semantic acceptance, source-grounding qualification for letter operations, human accessibility and language review. Production remains NO-GO; no deferred P5-13 operation is activated by this UI work. diff --git a/docs/cv-redesign/CHECKPOINT_IMPLEMENTATION.md b/docs/cv-redesign/CHECKPOINT_IMPLEMENTATION.md new file mode 100644 index 0000000..89fd7a0 --- /dev/null +++ b/docs/cv-redesign/CHECKPOINT_IMPLEMENTATION.md @@ -0,0 +1,33 @@ +# CV-UX implementation checkpoint — 25 October 2026 + +Workspace, section, candidate-template, letter-document and contextual AI implementation is ready for final integration qualification. This is not final CV-UX acceptance. Commands and remaining lanes are in VERIFICATION.md. + +The existing editor retains its save queue, content/settings ownership, preview and undo. Document context/history provenance are explicit; movement announces position and retains boundary focus; previews have page selection. Template candidates use real renderer samples, make no writes until Apply, reject late responses and use reversible visual-only application. + +CV export flushes local saves and checks the requested saved revision before rendering; changed local revisions discard late downloads. Existing callers without the optional CV revision remain compatible. + +All letter writers stage text/design/history with the parent transaction and shared RD21 Version. Generic/draft null/clear rules remain distinct. Previously unrecorded current text is recovered before replacement; restore creates a new version. The additive nullable migration retains old text-only records. Account/backup exports include history/design; submitted packages capture rendered letter bytes/settings without rewriting historical records. + +Letter editing provides adjacent/responsive physical preview, explicit Save, reviewed contact/design snapshot copying and saved-revision export. Copy creates no live synchronization. Rendering reuses CV normalization, inline formatting, pagination and bounded PDF admission. The redundant narrower text preview is hidden when physical preview is available; legacy responses retain their fallback. + +Four contextual AI controls reuse existing self-hosted owners. CV proposals retain capability/source/revision validation. Letter suggestions edit the local draft explicitly; missing career evidence prevents inference and interaction creation. Cancelled or changed-source/language responses are discarded. Letter prompt guardrails do not establish actual-model semantic qualification; see AI.md. + +## Executed intermediate evidence + +Artifacts reside under `.artifacts/cv-ux-01/`. + +| Check | Result | Qualification | +|---|---|---| +| Full backend Debug successor | 1169 passed, 0 failed/skipped | `backend-successor.trx`; post-test wrapper quoting error retained separately | +| Full backend Release | 1169 passed, 0 failed/skipped | `final-01/backend/backend.trx`; subsequent browser attempt superseded by frontend review correction | +| Earlier full frontend | 972 passed / 107 suites | `frontend-full.json`; predates latest review changes | +| AI/letter frontend successor | 73 passed / 3 suites | `ai-ui-successor.json`; controlled responses, not real inference | +| Preview review correction | 54 passed / 2 suites | `preview-review.json`; physical-preview precedence | +| Actual disposable MariaDB design | 56 passed, 0 skipped | `letter-design-mariadb`; pinned12.2.2, down/up preservation and positive cleanup; fresh final provider run pending | +| Windows letter native PDF | 4 documents / 14 pages, zero differences | `letter-windows-01`; unchanged oracle, real API/export; predates final UI review correction | +| Build/type/lint | PASS | Release API0warnings/errors; TypeScript/lint; static build repeated after correction | +| Independent source review | One P2 corrected | Duplicate preview had narrower formatting dialect; no proven new data-loss/security defect | + +Focused results overlap full suites and are never added to their totals. The first final browser attempt is incomplete/superseded and retained. Final regression must bind source/runtime hashes after the review correction. + +Remaining: fresh ordinary/contract/RD21 browsers, responsive/localisation automation, provider regression, original Windows/Linux CV oracles and Linux letter fidelity. Actual self-hosted model semantics and human accessibility/device/language/print remain unqualified. Hosted isolation/MariaDB restrictions and production/Phase10 NO-GO remain. CV-IMPORT-01 is not started. diff --git a/docs/cv-redesign/PLAN.md b/docs/cv-redesign/PLAN.md index 0a4a30d..ccaad32 100644 --- a/docs/cv-redesign/PLAN.md +++ b/docs/cv-redesign/PLAN.md @@ -11,6 +11,8 @@ No main merge/deployment, canonical-profile replacement, new AI providers, Docli ## Tasks +The owner's additional mandatory AI scope is tracked in [AI.md](AI.md): four directly accessible actions, existing self-hosted contracts, review before application, missing-source handling and honest per-action qualification. Complete this checkpoint alongside section editing and revision safety, before final acceptance. + 1. **CV-UX-00 entry/research:** verify baseline, hash all original files, inspect all 37 screenshot/DOM checkpoints, preserve portable research prose and map every recommendation against current owners. Commit/push entry and plan. 2. **CV-UX-01/02 workspace/sections:** extend `views/CvBuilderEditor.tsx` and both locale dictionaries. Preserve controls and layout owners; improve context, history wording, section reorder announcements/focus and section discovery. Add assertions to current editor browser tests before changing behavior; run focused suites and commit/push. 3. **CV-UX-03 candidates/preview:** add `components/cv/CvTemplatePreviewDialog.tsx`; integrate at `CvCustomizeTab` without changing library creation semantics. Reuse `cvBuilderApi.themePreview(themeId): Promise` and `applyCvTheme`. Test no update on open/cancel, retry, late response, lock enforcement, reset confirmation, preservation and one undo step. Physical pages use cvPreview readiness; no new PDF request for browsing. Add bounded page navigation to existing preview. Commit/push after unit/browser proof. diff --git a/docs/cv-redesign/VERIFICATION.md b/docs/cv-redesign/VERIFICATION.md new file mode 100644 index 0000000..551e57a --- /dev/null +++ b/docs/cv-redesign/VERIFICATION.md @@ -0,0 +1,166 @@ +# CV-UX verification execution plan + +Prepared 25 October 2026 from the existing Phase 9 reproduction instructions and runners. This document is a prospective execution plan, not passing evidence. The parent owns the final source freeze, serial execution, reconciliation and publication. Do not execute while workers are changing backend/frontend sources. Preserve every failed or superseded attempt. + +## Fresh identity and lifecycle + +Use a new absolute evidence root, for example `E:/Storage/Projects/Active/Job tracker/.artifacts/cv-ux-01/final-01`; reject it if it already exists. Use `final-02` after an implementation/fixture correction. Do not invoke `.artifacts/p9-sec-02/final-run.py`, `p9-sec-02/reconcile.py`, `p9-sec-02/linux-run.py`, or the historical MariaDB lane wrapper unchanged: they hardcode historical evidence roots, image identities, counts or source lineage and can overwrite/reuse historical evidence. + +Before the first build, record branch/HEAD/status and SHA256 for all application, migration, frontend, fixture, config, lock, Dockerfile and helper inputs, including new untracked source. Build once, then freeze the API Release binaries/deps/font resources and frontend `out` file hashes plus tool/runtime/browser identities. Repeat source and runtime hashes after all producers finish. A Git SHA alone does not identify uncommitted worker changes. Record UTC start/end, actual argv, cwd, selected nonsecret environment, exit code and native report hashes for every lane. Never print connection/session credentials. App health's fixture token is not source provenance. + +Prerequisites: pinned .NET SDK from `global.json` (currently 9.0.317), `C:/Users/Connor/.dotnet/dotnet.exe`; installed locked UI dependencies and Node; installed Playwright Chromium; Python with `pdfplumber`, `pypdfium2`, Pillow; Docker Linux engine; local pinned MariaDB 12.2.2 image. Do not run npm install, change locks, download models, or mutate developer databases as a substitute for these prerequisites. Obtain the bundled PDF Python path with the app dependency tool if the retained path below is absent. + +All commands below are run serially. Root cwd unless explicitly UI cwd. The execution controller must retain stdout/stderr to per-lane logs and check each exit code before proceeding; pipelines must not turn failures into passing exit codes. UI/API servers must be stopped before rebuilding binaries/static assets. Ports 3300/5302 (plus the diagnostic/volume-specific ports) must be free or positively identified as this attempt's owned processes; `PLAYWRIGHT_REUSE_SERVERS=false` prevents accidental reuse. + +```powershell +$cvUxRoot = 'E:/Storage/Projects/Active/Job tracker/.artifacts/cv-ux-01/final-01' +if (Test-Path -LiteralPath $cvUxRoot) { throw 'Select a fresh evidence root' } +New-Item -ItemType Directory -Path $cvUxRoot | Out-Null +$env:DOTNET_ROOT = 'C:/Users/Connor/.dotnet' +$env:DOTNET_HOST_PATH = 'C:/Users/Connor/.dotnet/dotnet.exe' +$env:NEXT_PUBLIC_API_BASE_URL = 'http://localhost:5302/api' +$env:PLAYWRIGHT_STATIC_EXPORT = 'true' +$env:PLAYWRIGHT_PREBUILT_EXPORT = 'true' +$env:PLAYWRIGHT_REUSE_SERVERS = 'false' +$env:CV_FIDELITY_OUTPUT = "$cvUxRoot/windows/fidelity" +$env:CV_SETTINGS_FIDELITY_OUTPUT = "$cvUxRoot/windows/settings-fidelity" +$env:LETTER_FIDELITY_OUTPUT = "$cvUxRoot/windows/letter-fidelity" +$env:SKIP_MODEL_LOAD = '1' +$env:AI_SERVICE_SKIP_MODEL_LOAD = '1' +$env:PYTHONIOENCODING = 'utf-8' +``` + +The model-disable variables retain the established local application regression conditions. Unchanged Python/model security qualification is inherited from the Phase 9 baseline and explicitly labelled historical; this feature does not rerun or establish GPU/model/hosted/provider/production acceptance. + +## Builds and complete local suites + +Root cwd: + +```powershell +rtk C:/Users/Connor/.dotnet/dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --logger 'trx;LogFileName=backend.trx' --results-directory "$cvUxRoot/backend" +rtk C:/Users/Connor/.dotnet/dotnet.exe build JobTrackerApi/JobTrackerApi.csproj --configuration Release --no-restore +``` + +The backend test build also creates the Release PDF process stub required by the separate admission config. If locked restore is actually required, record that separate command/outcome; never treat a restore-only exit as a test result. + +UI cwd `E:/Storage/Projects/Active/Job tracker/job-tracker-ui`: + +```powershell +rtk node node_modules/jest/bin/jest.js --runInBand --json "--outputFile=$cvUxRoot/frontend.json" +rtk node node_modules/typescript/bin/tsc --noEmit --incremental false +rtk node node_modules/eslint/bin/eslint.js . --max-warnings=0 +rtk node --max-old-space-size=4096 node_modules/next/dist/bin/next build +``` + +Record fresh native totals rather than fixing expected counts to the historical backend 1130/frontend 935. Compare discovery/membership with baseline; every added/removed/renamed case needs an explicit source-backed explanation. Do not add earlier focused worker checks to the full-suite totals. + +## Ordinary browsers and independent contracts + +Keep UI cwd and the environment above. First retain ordinary discovery: + +```powershell +$env:PLAYWRIGHT_JSON_OUTPUT_NAME = "$cvUxRoot/discovery.json" +rtk node node_modules/@playwright/test/cli.js test --list --retries=0 --reporter=list,json +``` + +Execute four complete shards **one at a time**, with process metadata/logs and a separate native JSON for each: + +```powershell +foreach ($cvUxShard in 1..4) { + $env:PLAYWRIGHT_JSON_OUTPUT_NAME = "$cvUxRoot/ordinary-$cvUxShard.json" + rtk node node_modules/@playwright/test/cli.js test --retries=0 --reporter=list,json "--output=$cvUxRoot/ordinary-$cvUxShard-results" "--shard=$cvUxShard/4" + if ($LASTEXITCODE -ne 0) { throw "Ordinary shard $cvUxShard failed; preserve this attempt" } +} +``` + +Historical ordinary discovery was 362. The new `cover-letter-document-fidelity.spec.ts` currently adds **one browser test containing four physical letter fixtures**; new CV workflow cases may increase discovery further. Reconcile exact test ID/project membership across the four native reports with current discovery; require each discovered case exactly once, no retries, failures, unexpected skips or missing errors. Four physical letter documents are not four browser cases. Capture `LETTER_FIDELITY_OUTPUT` only once per attempt: its producer refuses an existing `started.json`. + +Execute each configuration below separately using this exact command pattern, setting `$cvUxLane` and `$cvUxConfig` to the corresponding row. Do not combine or sum these with ordinary cases. + +```powershell +$env:PLAYWRIGHT_JSON_OUTPUT_NAME = "$cvUxRoot/$cvUxLane.json" +rtk node node_modules/@playwright/test/cli.js test --retries=0 --reporter=list,json "--output=$cvUxRoot/$cvUxLane-results" "--config=$cvUxConfig" +``` + +| Lane | Configuration relative to UI cwd | Historical expected membership (verify fresh) | +|---|---|---:| +| ai | `playwright.cv-ai.config.ts` | 2 | +| auth | `playwright.auth-recovery.config.ts` | 2 | +| rate | `playwright.rate-limits.config.ts` | 9 | +| ingress | `playwright.ingress.config.ts` | 6 | +| pdf-admission | `playwright.pdf.config.ts` | 1 | +| preferences | `../docs/redesign/phase-5/p5-15/p5-15-01/diagnostics/playwright.config.ts` | 3 | +| rd21 | `../docs/redesign/phase-9/rd21-remediation/diagnostics/playwright.config.ts` | 6 | +| diagnostics | `../docs/redesign/phase-9/p9-05/diagnostics/playwright.config.ts` | 18 | +| volume | `../docs/redesign/phase-9/p9-05/volume/playwright.config.ts` | 3 | +| rd21-volume | `../docs/redesign/phase-9/rd21-remediation/volume/playwright.config.ts` | 3 | + +The six contract lanes historically total 23; RD21/diagnostics/volume lanes remain separate. Run both volume lanes alone without builds, renders, provider pacing or other browser suites. Admission uses inert child stubs and must never be described as real physical PDF fidelity. + +## Actual MariaDB + +The letter-persistence worker owns provider qualification. Its earlier 47-case all-writer gate predates document design/migration; it is not the final additive-schema gate. Its later checkpoint at `.artifacts/cv-ux-01/letter-design-mariadb` reported 56/56 passing with the additive migration and provider downgrade/reupgrade preservation, and positive generated-project cleanup. That is separately reported worker evidence, not a fresh parent frozen-source full regression. The guarded runner already supports fresh output and generated projects/schemas: + +```powershell +rtk python scripts/mariadb-integration.py --output "$cvUxRoot/mariadb" +``` + +Run only after final source freeze and record actual new TRX membership/count, exact pinned image/server 12.2.2, schema ownership and positive cleanup. The runner's default mode creates a generated `jobtracker-it-` project, loopback random port and tmpfs test data, then removes only its owned test service. Do not substitute SQLite or developer `mariadb-dev`; do not use broad prune/down -v. An earlier worker checkpoint may support development sequencing but cannot be added to this fresh full provider total. + +Existing MariaDB browser/HTTP-observation/15-minute workload helpers have fixed roots/project assumptions. If those are in the final parent-required matrix, adapt only fresh-root/project parameters and retain their native independent counts and exact paced-duration evidence. Run the pacing workload alone. The parent must identify which helper executes that lane before marking it complete; ordinary browsers and backend MariaDB tests do not supply workload proof. + +## Windows original 24-document CV oracle + +Ordinary shards produce the existing 16-fixture fidelity corpus and four settings-browser cases containing eight settings states in the fresh Windows roots above. Stop all producers before proof. Root cwd, use the bundled Python (verify available; retained installation path shown): + +```powershell +rtk C:/Users/Connor/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe -B docs/redesign/phase-9/p9-requal-01/pdf-proof.py --platform windows --fidelity-root "$cvUxRoot/windows/fidelity" --settings-root "$cvUxRoot/windows/settings-fidelity" --output "$cvUxRoot/windows/proof" --record "$cvUxRoot/windows/native-pdf.json" +``` + +`pdf-proof.py` accepts fresh roots unchanged and checks exact 16/36-page plus 8/24-page membership using `scripts/verify-cv-fidelity.py`. Retain original oracle SHA256 `64f82e622cc11483c33307d987e969f6cf27f82ab6bdbd72c2aeb1bfbceade87`; never edit tolerances or fixture expectations to make this feature pass. A real regression stays FAIL with artifacts. + +## Current-source Linux API and original 24-document oracle + +**Do not run the historical image as if it contains current letter schema/rendering.** Prefer the current `JobTrackerApi/Dockerfile` rebuilt from the frozen current source into a new local-only feature tag. Record Dockerfile/context hashes, build log, local base IDs, resulting immutable image ID, runtime/Chromium/font/package identity and API Release DLL hash. `--pull=false` preserves installed inputs but does not make floating local tags immutable. Record resolved inputs; if unavailable, leave the lane blocked rather than silently downloading/reusing a different qualified runtime. + +```powershell +rtk proxy docker build --pull=false -f JobTrackerApi/Dockerfile -t jobtracker-cv-ux-api:final-01 . +rtk proxy docker image inspect jobtracker-cv-ux-api:final-01 +``` + +A narrower alternative when existing SDK/apt inputs cannot rebuild offline is a fresh image **over the verified historical Linux runtime ID with every current portable API publish output copied over `/app`**, built from a fresh hashed publish directory with `/p:UseAppHost=false`. That reuses native runtime/fonts, not historical application-source evidence; record current DLL/deps/migration/resource hashes and a new immutable image ID. Do not claim hermetic OS rebuilding. The old source image ID alone is insufficient. + +The reusable lifecycle is `p9-requal-01/linux-run.py`: generated guarded `jt_it_<32 hex>` schema, isolated network, tmpfs API `/data`, loopback API port 5302, actual ready/migration checks, logs and positive API/network/schema cleanup. It needs a **small new feature adapter** before execution because its DB project/port checks are hardcoded to `jobtracker-it-p9-requal`/33308 and its config includes only historical CV tests. Required parameters: fresh output, immutable newly built image ID, frozen source identity, validated dedicated tmpfs DB container/project/port and a current Linux Playwright config. Guard DB image pin, tmpfs/no persistent mounts, loopback-only port, nonprivileged/nonhost networking and exact server 12.2.2 before any SQL. Verify `/health` version and source commit against recorded identity; do not infer source freshness from the unchanged legacy fixture-ready token. + +The feature Linux Playwright config must retain the original base frontend server only (Linux runner owns API), workers 1/retries 0, and select: + +- `cv-readiness.spec.ts` / `native preview/PDF fidelity corpus` (1 browser case, 16 documents). +- `cv-settings-presentation.spec.ts` / `document controls preserve settings` (4 browser cases, 8 states). +- `cover-letter-document-fidelity.spec.ts` / `cover letter native preview/PDF fidelity` (1 browser case, 4 documents). + +Set fresh `CV_FIDELITY_OUTPUT`, `CV_SETTINGS_FIDELITY_OUTPUT`, `LETTER_FIDELITY_OUTPUT` under `$cvUxRoot/linux`, `PLAYWRIGHT_JSON_OUTPUT_NAME=$cvUxRoot/linux-renderer.json`, the immutable image and full current source identity. The adapter should retain the existing guarded cleanup and add offline assert checks for wrong image/project/port/schema, reused output, API startup failure and cleanup failures. No adapter was implemented in this documentation-only ownership; the parent must implement/review it or supply an equivalent owned lifecycle before running Linux. + +After successful Linux producers and positive cleanup, root cwd: + +```powershell +rtk C:/Users/Connor/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe -B docs/redesign/phase-9/p9-requal-01/pdf-proof.py --platform linux --fidelity-root "$cvUxRoot/linux/fidelity" --settings-root "$cvUxRoot/linux/settings-fidelity" --output "$cvUxRoot/linux/proof" --record "$cvUxRoot/linux/linux-pdf.json" +``` + +## Separate letter physical fidelity + +The new `e2e/cover-letter-document-fidelity.spec.ts` is a real authenticated producer, with one browser case/four independent multipage A4/Letter × EN/NB × light/dark documents. It creates a synthetic owned job per fixture, saves text+design with the shared expectedVersion, previews through the real server and application iframe, waits for font/image pagination readiness, walks every next-page control, proves final marker reachability, checks sender/recipient and inline formatting, downloads the saved-revision PDF and records bytes/HTML hashes, settings, all unique text-marker geometry/style, actual browser version and per-page screenshots. It asserts preview/export do not mutate saved letter or canonical source. It refuses reusing the attempt and writes root `measurements.json` only after all four succeed. No browser execution has occurred at preparation time. + +Its measurements use the existing oracle's schema. The unchanged lower-level oracle supports arbitrary corpus size, so **no new PDF oracle or widened tolerances are needed**. Run separately after each platform's producers stop: + +```powershell +rtk C:/Users/Connor/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe -B scripts/verify-cv-fidelity.py "$cvUxRoot/windows/letter-fidelity" +rtk C:/Users/Connor/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe -B scripts/verify-cv-fidelity.py "$cvUxRoot/linux/letter-fidelity" +``` + +Require exactly the four fixture names `light-en-a4`, `dark-nb-a4`, `light-nb-letter`, `dark-en-letter`, each native result passing without failures, no duplicate/missing marker, matching physical page count/geometry/fonts/colors/safe areas. Retain `fidelity-results.json`, actual PDF page images/comparison images and producer inputs/hashes. Do not add these four documents to either original CV24 count. Review extracted PDF text for full sender email/phone/location, multiline recipient, subject, bold/italic content and final diacritics; marker checks alone do not establish every unmarked string. No physical printer/human fidelity claim follows from this oracle. + +## Reconciliation and stop rules + +The historical collector is not parameterized for this feature and hardcodes old totals/roots. A minimal new parent-owned collector must read only this fresh attempt, reconcile current discovery exactly against four ordinary reports, enumerate frontend assertion names and backend/provider TRX test IDs, validate all independent lane reports and process exits, verify no retries/unexpected skips, check CV24 and letter4 oracle membership, bind inputs/runtime/image hashes, and require positive cleanup. Its offline self-check must reject a historical-root report, absent/duplicate cases, bad process exit, source/runtime drift, wrong image and missing cleanup. `--collect-only` must not mean acceptance. Historical Phase 9 evidence stays immutable. + +Stop/re-freeze on source or runtime drift, stale apphost/static output, migration/schema failure, failed oracle, frozen history loss or unqualified revision behavior. Keep failed attempts separate; never replace a full failed shard with an overlapping focused test total. Report focused/full/backend/frontend/browser/provider/PDF/human outcomes independently. Human screen-reader/touch/device/zoom/print, hosted isolation/MariaDB, provider, deployment and production remain NOT TESTED/BLOCKED as applicable. Production and Phase 10 remain NO-GO. diff --git a/job-tracker-ui/AGENTS.md b/job-tracker-ui/AGENTS.md new file mode 100644 index 0000000..af3b7c8 --- /dev/null +++ b/job-tracker-ui/AGENTS.md @@ -0,0 +1,9 @@ + + +## This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/job-tracker-ui/e2e/cover-letter-document-fidelity.spec.ts b/job-tracker-ui/e2e/cover-letter-document-fidelity.spec.ts new file mode 100644 index 0000000..61ea2ad --- /dev/null +++ b/job-tracker-ui/e2e/cover-letter-document-fidelity.spec.ts @@ -0,0 +1,148 @@ +import { expect, test, type APIRequestContext } from '@playwright/test'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { previewDocumentHtml } from '../src/cvPreview'; +import { translations } from '../src/i18n/translations'; + +const apiUrl = 'http://localhost:5302/api'; +const output = path.resolve(process.env.LETTER_FIDELITY_OUTPUT || '../.artifacts/cv-ux-01/letter-fidelity'); +const password = 'E2ePassword123!'; + +async function login(api: APIRequestContext, email: string) { + expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); + const me = await (await api.get(`${apiUrl}/auth/me`)).json(); + return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, + 'X-Session-Context': me.sessionContext as string }; +} + +test('cover letter native preview/PDF fidelity records four independent physical documents', async ({ page, context, browser, playwright }, info) => { + test.setTimeout(360_000); + // Existing attempts remain reviewable, including a partially produced failed run. + await fs.mkdir(output, { recursive: true }); + await fs.writeFile(path.join(output, 'started.json'), JSON.stringify({ startedUtc: new Date().toISOString(), browserVersion: browser.version() }), { flag: 'wx' }); + await context.route('**/*', route => { + const url = new URL(route.request().url()); + return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort(); + }); + await context.addInitScript(() => { + const query = new URLSearchParams(location.search); + if (query.has('fidelityMode')) localStorage.setItem('jobtracker.themeMode', query.get('fidelityMode')!); + if (query.has('fidelityLanguage')) localStorage.setItem('uiLanguage', query.get('fidelityLanguage')!); + }); + const admin = await playwright.request.newContext(); + const adminHeaders = await login(admin, 'e2e@example.test'); + const email = `letter-fidelity-${Date.now()}@example.test`; + try { + expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200); + } finally { await admin.dispose(); } + const headers = await login(page.request, email); + const sourceBefore = await (await page.request.get(`${apiUrl}/career/profile`)).json(); + const companyResponse = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys Synthetic AS', source: 'User' } }); + expect(companyResponse.status()).toBe(201); + const company = await companyResponse.json(); + const fixtures = []; + for (const [mode, language, pageSize] of [['light', 'en', 'a4'], ['dark', 'nb', 'a4'], ['light', 'nb', 'letter'], ['dark', 'en', 'letter']] as const) { + const fixture = `${mode}-${language}-${pageSize}`; + const directory = path.join(output, fixture); + await fs.mkdir(directory); + const t = translations[language]; + const sentence = language === 'nb' ? 'Dokumenterte pålitelige arbeidsflyter og tilgjengelig navigasjon for norske arbeidssøkere. ' + : 'Documented reliable workflows and accessible navigation using reviewed source evidence. '; + const text = Array.from({ length: 24 }, (_, index) => `B${String(index + 1).padStart(2, '0')} ${sentence.repeat(4)}`) + .concat(['- S01 **Reviewed achievement** — æøå.', '- S02 *Verified collaboration* — ÆØÅ.', 'R99 Final reviewed letter content — Åse Østby.']).join('\n\n'); + const documentSettings = { schemaVersion: 1, senderName: 'R01 Åse Østby', senderTitle: 'P01 Platform engineer', + senderEmail: 'ase@example.test', senderPhone: '+47 12345678', senderLocation: 'Oslo, Norge', + recipient: 'R03 Hiring team\nNordlys Synthetic AS\nOslo, Norge', subject: language === 'nb' ? 'R02 Søknad om plattformutvikler' : 'R02 Application for platform engineer', + date: '2026-10-25', themeId: 'minimal', accentColor: '#126b55', bodyFont: 'Arial, Helvetica, sans-serif', + baseFontSizePt: 11, lineHeight: 1.4, pageMarginMm: 20, pageSize }; + const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: fixture, status: 'Saved', description: 'Synthetic letter fidelity fixture.' } }); + expect(created.status()).toBe(201); + const job = await created.json(); + const letterUrl = `${apiUrl}/jobapplications/${job.id}/cover-letter`; + const original = await (await page.request.get(letterUrl)).json(); + const save = await page.request.put(letterUrl, { headers, data: { text, documentSettings, source: 'manual', expectedVersion: original.revision } }); + expect(save.status()).toBe(200); + const acknowledgement = await save.json(); + // MariaDB stores datetime(6); compare subsequent reads with durable history timestamps. + const saved = await (await page.request.get(letterUrl)).json(); + expect(saved.revision).toBe(acknowledgement.revision); + expect(saved.text).toBe(text); + const preview = await page.request.post(`${letterUrl}/preview`, { headers, data: { text: saved.text, documentSettings: saved.documentSettings } }); + expect(preview.status()).toBe(200); + const rendered = await preview.json(); + expect(rendered.html).toContain('class="page letter-paper"'); + const pdf = await page.request.post(`${letterUrl}/export-pdf?expectedVersion=${saved.revision}`, { headers }); + expect(pdf.status()).toBe(200); + expect(pdf.headers()['content-type']).toContain('application/pdf'); + const bytes = await pdf.body(); + expect(bytes.subarray(0, 5).toString()).toBe('%PDF-'); + await fs.writeFile(path.join(directory, 'export.pdf'), bytes); + await fs.writeFile(path.join(directory, 'render.html'), rendered.html); + expect(await (await page.request.get(letterUrl)).json()).toEqual(saved); + + expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto(`/jobs/${job.id}?section=application&sub=letter&fidelityMode=${mode}&fidelityLanguage=${language}#materials`); + await expect(page.locator('html')).toHaveAttribute('data-color-scheme', mode); + const frame = page.getByTitle(t.letterDocumentPreview, { exact: true }); + const previewWorkspace = frame.locator('..').locator('..').locator('..'); + await expect(frame).toBeVisible(); + await expect.poll(() => frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true'); + const measured = await frame.evaluate(async element => { + const doc = (element as HTMLIFrameElement).contentDocument!; + const win = (element as HTMLIFrameElement).contentWindow!; + await doc.fonts.ready; + const geometry = doc.documentElement.dataset; + const width = Number(geometry.cvPreviewWidth); const height = Number(geometry.cvPreviewHeight); + const tokens = []; + const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT); let node; + while ((node = walker.nextNode())) { + if (['SCRIPT', 'STYLE'].includes(node.parentElement?.tagName || '')) continue; + for (const match of (node.textContent || '').matchAll(/\b[SEBPR]\d{2}\b/g)) { + const range = doc.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length); + const rect = range.getClientRects()[0]; const style = win.getComputedStyle(node.parentElement!); const index = Math.floor(rect.x / width); + tokens.push({ token: match[0], x: rect.x - index * width, y: rect.y + index * height, page: index + 1, fontSize: style.fontSize, fontFamily: style.fontFamily, color: style.color }); + } + } + return { width, height, contentHeight: height * Number(geometry.cvPreviewPages), pages: Number(geometry.cvPreviewPages), + effectiveMargin: win.getComputedStyle(doc.documentElement).getPropertyValue('--cv-page-margin'), text: doc.body.innerText, + bold: doc.querySelectorAll('strong').length, italic: doc.querySelectorAll('em').length, tokens }; + }); + expect(measured.pages).toBeGreaterThan(1); + expect(measured.pages).toBeLessThan(15); + expect(measured.tokens.map(token => token.token).sort()).toEqual(['R01', 'R02', 'R03', 'R99', 'P01', 'S01', 'S02', ...Array.from({ length: 24 }, (_, index) => `B${String(index + 1).padStart(2, '0')}`)].sort()); + expect(measured.text).toContain('ase@example.test'); + expect(measured.text).toContain('Nordlys Synthetic AS'); + expect(measured.bold).toBeGreaterThan(0); expect(measured.italic).toBeGreaterThan(0); + expect(measured.tokens.find(token => token.token === 'R99')!.page).toBe(measured.pages); + for (let index = 0; index < measured.pages; index++) { + if (index > 0) { + await previewWorkspace.getByRole('button', { name: t.cvEditorNext, exact: true }).click(); + await expect(previewWorkspace.getByText(t.letterPage.replace('{page}', String(index + 1)).replace('{count}', String(measured.pages)), { exact: true })).toBeVisible(); + await expect.poll(() => frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true'); + } + const capture = await browser.newPage({ viewport: { width: Math.ceil(measured.width), height: Math.ceil(measured.height) } }); + try { + await capture.setContent(previewDocumentHtml(rendered.html, measured.height, index)); + await expect.poll(() => capture.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe('true'); + await capture.screenshot({ path: path.join(directory, `preview-${index + 1}.png`) }); + } finally { await capture.close(); } + } + const reachable = await frame.evaluate(element => { + const doc = (element as HTMLIFrameElement).contentDocument!; + const node = Array.from(doc.querySelectorAll('p')).find(item => item.textContent?.includes('R99'))!; + const rect = node.getBoundingClientRect(); + return rect.right > 0 && rect.left < Number(doc.documentElement.dataset.cvPreviewWidth) && rect.top >= 0 && rect.bottom <= Number(doc.documentElement.dataset.cvPreviewHeight); + }); + expect(reachable).toBe(true); + const record = { fixture, previewPages: measured.pages, settings: saved.documentSettings, measured, + savedRevision: saved.revision, pdfSha256: createHash('sha256').update(bytes).digest('hex'), htmlSha256: createHash('sha256').update(rendered.html).digest('hex'), browserVersion: browser.version(), language, mode }; + await fs.writeFile(path.join(directory, 'measurements.json'), JSON.stringify(record, null, 2)); + await page.screenshot({ path: path.join(directory, 'last-page-workspace.png') }); + await info.attach(fixture, { body: JSON.stringify(record), contentType: 'application/json' }); + fixtures.push(record); + } + expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(sourceBefore); + await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), fixtures }, null, 2), { flag: 'wx' }); +}); diff --git a/job-tracker-ui/e2e/cv-editor-frame.spec.ts b/job-tracker-ui/e2e/cv-editor-frame.spec.ts index 4d9f662..722422b 100644 --- a/job-tracker-ui/e2e/cv-editor-frame.spec.ts +++ b/job-tracker-ui/e2e/cv-editor-frame.spec.ts @@ -81,6 +81,7 @@ for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb'] const move = page.getByRole('button', { name: label('cvEditorMoveSectionDown', t.cvSectionExperience), exact: true }); await move.focus(); await page.keyboard.press('Enter'); await expect(move).toBeFocused(); await saved(); + await expect(page.getByRole('status').filter({ hasText: t.cvEditorReordered.replace('{item}', t.cvSectionExperience).replace('{position}', '4').replace('{total}', '13') })).toBeVisible(); expect((await read()).settings.sections.slice(0, 4).map((row: { key: string }) => row.key)).toEqual(['summary', 'projects', 'education', 'experience']); await capture(page, info, 'reordered-section-focus'); @@ -137,7 +138,7 @@ for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb'] await page.getByRole('menuitem', { name: t.cvEditorStopSharing, exact: true }).click(); await expect(page.getByText(t.cvEditorShared, { exact: true })).toHaveCount(0); expect((await anonymous.get(`${apiUrl}/public-cv/${publicCv.publicSlug}`)).status()).toBe(404); await anonymous.dispose(); - const pdfResponse = page.waitForResponse(response => response.url() === `${cvUrl}/export-pdf` && response.request().method() === 'POST'); + const pdfResponse = page.waitForResponse(response => new URL(response.url()).pathname === new URL(`${cvUrl}/export-pdf`).pathname && new URL(response.url()).searchParams.has('expectedVersion') && response.request().method() === 'POST'); const download = page.waitForEvent('download'); await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click(); expect((await pdfResponse).status()).toBe(200); expect((await download).suggestedFilename()).toMatch(/\.pdf$/i); @@ -145,6 +146,7 @@ for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb'] await page.setViewportSize({ width: 1440, height: 900 }); await page.getByRole('tab', { name: t.cvEditorHistory, exact: true }).click(); const history = page.getByRole('tabpanel', { name: t.cvEditorHistory, exact: true }); + await expect(history.getByText(t.cvEditorHistoryScope, { exact: true })).toBeVisible(); await expect(history.getByText(t.cvEditorVersion.replace('{version}', '1'), { exact: true })).toBeVisible(); await capture(page, info, 'history'); await scan(page, info, 'history'); await page.getByRole('tab', { name: t.cvEditorAiTools, exact: true }).click(); diff --git a/job-tracker-ui/e2e/cv-settings-presentation.spec.ts b/job-tracker-ui/e2e/cv-settings-presentation.spec.ts index 7cda018..b4c4d43 100644 --- a/job-tracker-ui/e2e/cv-settings-presentation.spec.ts +++ b/job-tracker-ui/e2e/cv-settings-presentation.spec.ts @@ -54,7 +54,7 @@ async function measure(page: Page, browser: Browser, info: TestInfo, cvUrl: stri }); const variant = await (await page.request.get(cvUrl)).json(); const directory = path.join(output, fixture); await fs.mkdir(directory, { recursive: true }); - const response = page.waitForResponse(r => r.url() === `${cvUrl}/export-pdf` && r.request().method() === 'POST'); + const response = page.waitForResponse(r => new URL(r.url()).pathname === new URL(`${cvUrl}/export-pdf`).pathname && new URL(r.url()).searchParams.has('expectedVersion') && r.request().method() === 'POST'); const downloading = page.waitForEvent('download'); await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click(); const pdf = await response; expect(pdf.status()).toBe(200); expect((await downloading).suggestedFilename()).toMatch(/\.pdf$/i); @@ -163,9 +163,15 @@ for (const [mode, language, premium] of [['light', 'en', false], ['dark', 'nb', await capture(page, info, 'templates'); await scan(page, info, 'templates'); const target = premium ? technical : page.getByRole('button', { name: t.cvTemplateCodeName, exact: true }); await target.focus(); await page.keyboard.press('Enter'); - const dialog = page.getByRole('dialog', { name: t.cvEditorChangeTemplateTitle }); await expect(dialog).toBeVisible(); await capture(page, info, 'reset-confirmation'); + const candidate = page.getByRole('dialog', { name: new RegExp(t.cvEditorPreviewDesign) }); + await expect(candidate).toBeVisible(); + await expect(candidate.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true })).toBeEnabled(); + expect((await read()).settings).toEqual(changed.settings); + await capture(page, info, 'candidate-preview'); await scan(page, info, 'candidate-preview'); await page.keyboard.press('Escape'); await expect(target).toBeFocused(); expect((await read()).settings.themeId).toBe('minimal'); - await page.keyboard.press('Space'); await dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click(); await saved(); + await page.keyboard.press('Space'); await candidate.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click(); + const dialog = page.getByRole('dialog', { name: t.cvEditorChangeTemplateTitle }); await expect(dialog).toBeVisible(); await capture(page, info, 'reset-confirmation'); + await dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click(); await saved(); await expect.poll(async () => (await read()).settings.themeId).toBe(premium ? 'technical' : 'code'); const selected = await read(); expect(selected.settings.themeId).toBe(premium ? 'technical' : 'code'); for (const key of ['accentColor', 'bodyFont', 'baseFontSizePt', 'pageMarginMm', 'entryGapMm', 'sectionGapMm', 'lineHeight']) expect(selected.settings[key]).toBeNull(); diff --git a/job-tracker-ui/src/ai-section-assistant.test.tsx b/job-tracker-ui/src/ai-section-assistant.test.tsx index cf3ab72..5377292 100644 --- a/job-tracker-ui/src/ai-section-assistant.test.tsx +++ b/job-tracker-ui/src/ai-section-assistant.test.tsx @@ -61,6 +61,23 @@ beforeEach(() => { afterEach(() => window.localStorage.removeItem("uiLanguage")); +test.each([['Improve writing', 'improve'], ['Suggest content', 'suggest'], ['Grammar check', 'grammar'], ['Shorter', 'shorten']])( + 'direct %s action retains explicit review', async (label, action) => { + const { onGenerate, onAccept } = renderAssistant(); + fireEvent.click(screen.getByRole('button', { name: label })); + await waitFor(() => expect(onGenerate).toHaveBeenCalledWith(expect.objectContaining({ action, language: 'nb-NO' }))); + expect(await screen.findByDisplayValue('Proposed wording')).toBeInTheDocument(); + expect(onAccept).not.toHaveBeenCalled(); + }, +); + +test('empty source requests career facts without making an AI request', () => { + const { onGenerate } = renderAssistant({ text: '' }); + fireEvent.click(screen.getByRole('button', { name: 'Suggest content' })); + expect(screen.getByText(/Add verified career facts/)).toBeInTheDocument(); + expect(onGenerate).not.toHaveBeenCalled(); +}); + test("section AI uses the CV document language and requires explicit server acceptance", async () => { const generated = proposal("Utviklet driftssikre API-er"); const { onGenerate, onAccept } = renderAssistant({ onGenerate: jest.fn().mockResolvedValue(generated) }); diff --git a/job-tracker-ui/src/application-assets.test.tsx b/job-tracker-ui/src/application-assets.test.tsx index 6e7cd41..e47ba33 100644 --- a/job-tracker-ui/src/application-assets.test.tsx +++ b/job-tracker-ui/src/application-assets.test.tsx @@ -62,6 +62,12 @@ const coverLetter = { aiSuggestionCount: 1, }; +const letterSettings = { + schemaVersion: 1, senderName: "Recorded sender", senderTitle: "Engineer", senderEmail: "sender@example.test", senderPhone: "", senderLocation: "Oslo", + recipient: "Hiring team", subject: "Original subject", date: "2026-10-25", themeId: "modern", accentColor: "#2563eb", bodyFont: "Arial, Helvetica, sans-serif", + baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: "a4", +}; + function routeGet(overrides: Record = {}) { mockedApi.get.mockImplementation((url: string) => { if (url.endsWith("/tailoring")) return Promise.resolve({ data: overrides.tailoring ?? tailoring } as any); @@ -283,6 +289,43 @@ test("the empty cover-letter template and rich-text controls follow Bokmål UI l expect(screen.getByRole("button", { name: "Lenke" })).toBeInTheDocument(); }); +test("AI letter results are discarded when the source changes during generation", async () => { + routeGet(); + let complete!: (value: unknown) => void; + mockedApi.post.mockImplementation(() => new Promise(resolve => { complete = resolve; }) as never); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Generate" })); + fireEvent.change(screen.getByRole("textbox", { name: "Cover letter" }), { target: { value: "Newer local facts" } }); + await act(async () => complete({ data: { result: { text: "Outdated proposal" } } })); + expect(screen.queryByRole("button", { name: /Apply to editor/i })).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Newer local facts"); +}); + +test.each([['Improve writing', 'improve'], ['Suggest content', 'generate'], ['Grammar check', 'grammar'], ['Shorter', 'shorten']])( + 'letter %s uses the existing reviewed action', async (label, action) => { + routeGet(); + mockedApi.post.mockResolvedValue({ data: { result: { text: 'Reviewed candidate wording' } } } as never); + render(); + fireEvent.click(await screen.findByRole('button', { name: label })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/7/ai/generate', expect.objectContaining({ action }))); + expect(await screen.findByDisplayValue('Reviewed candidate wording')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Dear team'); + expect(mockedApi.put).not.toHaveBeenCalled(); + }, +); + +test('cancelling letter generation ignores its late response', async () => { + routeGet(); + const pending = deferred(); + mockedApi.post.mockImplementation(() => pending.promise); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Suggest content' })); + fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + await act(async () => pending.resolve({ data: { result: { text: 'Cancelled response' } } })); + expect(screen.queryByDisplayValue('Cancelled response')).not.toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Dear team'); +}); + test("AI cover letter suggestions use the linked CV and require explicit apply", async () => { routeGet(); mockedApi.post.mockResolvedValue({ @@ -535,7 +578,7 @@ test("held letter Save submits one snapshot and prevents an AI Apply from replac act(() => { fireEvent.click(save); fireEvent.click(save); }); expect(mockedApi.put).toHaveBeenCalledTimes(1); expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Explicit manual save snapshot", source: "manual", aiAction: undefined, expectedVersion: 7 }); - expect(apply).toBeDisabled(); + expect(apply).not.toBeInTheDocument(); fireEvent.click(apply); expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Explicit manual save snapshot"); await act(async () => { write.resolve({ data: { ...coverLetter, text: "Explicit manual save snapshot" } }); }); @@ -779,3 +822,166 @@ test.each([409, 0])("cover-letter rejection (%s) retains draft and source revisi expect(mockedApi.get).toHaveBeenCalledTimes(reads); if (status === 409) expect(section("workspaceCoverLetter").getByRole("alert")).toHaveTextContent(copy("revisionConflict")); }); + +test("letter text preview shows unsaved formatting, hides the inactive narrow pane and never saves on switching", async () => { + routeGet(); + render(); + const editor = await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }); + fireEvent.change(editor, { target: { value: "Dear **team**, *hello*\n- First achievement\n[Portfolio](https://example.com)" } }); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") })); + const preview = screen.getByRole("region", { name: copy("assetsLetterTextPreview") }); + expect(within(preview).getByText("team").tagName).toBe("STRONG"); + expect(within(preview).getByText("hello").tagName).toBe("EM"); + expect(within(preview).getByRole("listitem")).toHaveTextContent("First achievement"); + expect(within(preview).getByRole("link", { name: "Portfolio" })).toHaveAttribute("rel", "noopener noreferrer"); + expect(screen.queryByRole("textbox", { name: copy("jobDetailsCoverLetter") })).not.toBeInTheDocument(); + expect(editor).not.toBeVisible(); + expect(screen.getByText(copy("assetsLetterTextPreviewHelp"))).toBeVisible(); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") })); + expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toBe(editor); + expect(screen.queryByRole("region", { name: copy("assetsLetterTextPreview") })).not.toBeInTheDocument(); + expect(mockedApi.put).not.toHaveBeenCalled(); + expect(mockedApi.post).not.toHaveBeenCalled(); +}); + +test("letter text preview renders unsafe Markdown as inert text", async () => { + routeGet(); + render(); + fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: '\n\n[Run](javascript:alert(1))\n[Data](data:text/html,bad)' } }); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") })); + const preview = screen.getByRole("region", { name: copy("assetsLetterTextPreview") }); + expect(preview.querySelector("script, img, iframe, a")).toBeNull(); + expect(preview).toHaveTextContent(""); + expect(preview).toHaveTextContent("[Run](javascript:alert(1))"); +}); + +test("letter preview retains the draft after cancelled restore and failed save", async () => { + routeGet(); + mockedApi.put.mockRejectedValue({ response: { status: 409 } }); + render(); + fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Retained **draft** body" } }); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") })); + fireEvent.click(screen.getByRole("button", { name: /Restore version 1/i })); + fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: copy("cancel") })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(screen.getByRole("region", { name: copy("assetsLetterTextPreview") })).toHaveTextContent("Retained draft body"); + expect(mockedApi.post).not.toHaveBeenCalled(); + fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") })); + await section("workspaceCoverLetter").findByRole("alert"); + expect(screen.getByRole("region", { name: copy("assetsLetterTextPreview") })).toHaveTextContent("Retained draft body"); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") })); + expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("Retained **draft** body"); + expect(mockedApi.put).toHaveBeenCalledTimes(1); +}); + +test("keyed letter job changes discard the old preview and ignore its late save", async () => { + routeGet(); + const saveA = deferred(); + mockedApi.put.mockReturnValueOnce(saveA.promise); + const mounted = render(); + fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Private job A draft" } }); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") })); + fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") })); + routeGet({ coverLetter: { ...coverLetter, text: "Job B saved letter" } }); + mounted.rerender(withProviders()); + fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Private job B draft" } }); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") })); + await act(async () => { saveA.resolve({ data: { ...coverLetter, text: "Private job A draft" } }); await saveA.promise; }); + const preview = screen.getByRole("region", { name: copy("assetsLetterTextPreview") }); + expect(preview).toHaveTextContent("Private job B draft"); + expect(preview).not.toHaveTextContent("Private job A draft"); + expect(mockedApi.put).toHaveBeenCalledTimes(1); +}); + +test("wide letter workspace keeps one editor beside its live preview", async () => { + const previous = Object.getOwnPropertyDescriptor(window, "matchMedia"); + Object.defineProperty(window, "matchMedia", { configurable: true, value: (query: string) => ({ + matches: query.includes("min-width:900px"), media: query, onchange: null, + addListener: jest.fn(), removeListener: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(), + }) }); + try { + routeGet(); + render(); + const editor = await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }); + fireEvent.change(editor, { target: { value: "Live desktop **draft**" } }); + expect(screen.getByRole("region", { name: copy("assetsLetterTextPreview") })).toHaveTextContent("Live desktop draft"); + expect(editor).toBeVisible(); + expect(screen.getAllByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveLength(1); + expect(screen.queryByRole("tablist", { name: copy("assetsLetterWorkspace") })).not.toBeInTheDocument(); + expect(mockedApi.put).not.toHaveBeenCalled(); + } finally { + if (previous) Object.defineProperty(window, "matchMedia", previous); else Reflect.deleteProperty(window, "matchMedia"); + } +}); + +test("letter preview controls follow Bokmål without translating or saving the local draft", async () => { + routeGet(); + render(); + fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Keep this **original** prose" } }); + changeLanguage("nb"); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") })); + expect(screen.getByRole("region", { name: copy("assetsLetterTextPreview") })).toHaveTextContent("Keep this original prose"); + expect(screen.getByText(copy("assetsLetterTextPreviewHelp"))).toBeVisible(); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") })); + expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("Keep this **original** prose"); + expect(mockedApi.put).not.toHaveBeenCalled(); + expect(mockedApi.post).not.toHaveBeenCalled(); +}); + +test("document settings are dirty local drafts until explicit Save, and Discard restores saved details", async () => { + routeGet({ coverLetter: { ...coverLetter, documentSettings: letterSettings } }); + mockedApi.put.mockResolvedValue({ data: { ...coverLetter, revision: 8, documentSettings: { ...letterSettings, subject: "New subject" } } } as any); + render(); + fireEvent.change(await screen.findByRole("textbox", { name: copy("letterSubject") }), { target: { value: "New subject" } }); + expect(screen.getByText(copy("assetsUnsavedChanges"))).toBeInTheDocument(); + expect(mockedApi.put).not.toHaveBeenCalled(); + fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") })); + await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", expect.objectContaining({ text: "Dear team", expectedVersion: 7, documentSettings: { ...letterSettings, subject: "New subject" } }))); + await waitFor(() => expect(screen.queryByText(copy("assetsUnsavedChanges"))).not.toBeInTheDocument()); + fireEvent.change(screen.getByRole("textbox", { name: copy("letterSubject") }), { target: { value: "Discard me" } }); + fireEvent.click(screen.getByRole("button", { name: copy("assetsDiscardChanges") })); + expect(screen.getByRole("textbox", { name: copy("letterSubject") })).toHaveValue("New subject"); +}); + +test("letter PDF awaits durable settings and blocks duplicate exports; failed save never exports", async () => { + routeGet({ coverLetter: { ...coverLetter, documentSettings: letterSettings } }); + const write = deferred(); + mockedApi.put.mockReturnValueOnce(write.promise); + render(); + fireEvent.change(await screen.findByRole("textbox", { name: copy("letterSubject") }), { target: { value: "Keep draft" } }); + const exportButton = screen.getByRole("button", { name: copy("letterExportPdf") }); + act(() => { fireEvent.click(exportButton); fireEvent.click(exportButton); }); + expect(mockedApi.put).toHaveBeenCalledTimes(1); + expect(mockedApi.post).not.toHaveBeenCalled(); + await act(async () => { write.reject({ response: { status: 409 } }); }); + expect(await section("workspaceCoverLetter").findByRole("alert")).toHaveTextContent(copy("revisionConflict")); + expect(screen.getByRole("textbox", { name: copy("letterSubject") })).toHaveValue("Keep draft"); + expect(mockedApi.post).not.toHaveBeenCalled(); +}); + +test("letter document preview ignores stale responses, keeps last good HTML on failure and retries without saving", async () => { + routeGet({ coverLetter: { ...coverLetter, documentSettings: letterSettings } }); + const oldPreview = deferred(); + mockedApi.post.mockReturnValueOnce(oldPreview.promise) + .mockResolvedValueOnce({ data: { html: "Current document", themeId: "modern", suggestedFileName: "letter.pdf" } } as any) + .mockRejectedValueOnce({ response: { status: 503 } }) + .mockResolvedValueOnce({ data: { html: "Retried document", themeId: "modern", suggestedFileName: "letter.pdf" } } as any); + render(); + await screen.findByRole("textbox", { name: copy("letterSubject") }); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") })); + fireEvent.change(screen.getByRole("textbox", { name: copy("letterSubject") }), { target: { value: "Current subject" } }); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") })); + await waitFor(() => expect(screen.getByTitle(copy("letterDocumentPreview"))).toHaveAttribute("srcdoc", expect.stringContaining("Current document"))); + await act(async () => { oldPreview.resolve({ data: { html: "Stale document" } }); await oldPreview.promise; }); + expect(screen.getByTitle(copy("letterDocumentPreview"))).not.toHaveAttribute("srcdoc", expect.stringContaining("Stale document")); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") })); + fireEvent.change(screen.getByRole("textbox", { name: copy("letterSubject") }), { target: { value: "Next subject" } }); + fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") })); + await screen.findByText(copy("letterPreviewFailed")); + expect(screen.getByTitle(copy("letterDocumentPreview"))).toHaveAttribute("srcdoc", expect.stringContaining("Current document")); + fireEvent.click(screen.getByRole("button", { name: copy("retry") })); + await waitFor(() => expect(screen.getByTitle(copy("letterDocumentPreview"))).toHaveAttribute("srcdoc", expect.stringContaining("Retried document"))); + expect(mockedApi.put).not.toHaveBeenCalled(); +}); diff --git a/job-tracker-ui/src/applicationWorkspace.ts b/job-tracker-ui/src/applicationWorkspace.ts index 5e7d4e7..4bc4ae8 100644 --- a/job-tracker-ui/src/applicationWorkspace.ts +++ b/job-tracker-ui/src/applicationWorkspace.ts @@ -258,6 +258,16 @@ export type CoverLetter = { currentVersion: number; versions: CoverLetterVersion[]; aiSuggestionCount: number; + documentSettings?: CoverLetterDocumentSettings; +}; + +export type CoverLetterDocumentSettings = { + schemaVersion: 1; + senderName: string; senderTitle: string; senderEmail: string; senderPhone: string; senderLocation: string; + recipient: string; subject: string; date: string; + themeId: string; accentColor: string; bodyFont: string; + baseFontSizePt: number; lineHeight: number; pageMarginMm: number; pageSize: string; + sourceCvId?: number | null; }; export type SubmittedPackageAttachment = { @@ -297,8 +307,12 @@ export const applicationAssetsApi = { api.get(`/jobapplications/${jobId}/tailoring`).then((r) => r.data), coverLetter: (jobId: number) => api.get(`/jobapplications/${jobId}/cover-letter`).then((r) => r.data), - saveCoverLetter: (jobId: number, text: string, expectedVersion: number, source = "manual", aiAction?: string) => - api.put(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction, expectedVersion }).then((r) => r.data), + saveCoverLetter: (jobId: number, text: string, expectedVersion: number, source = "manual", aiAction?: string, documentSettings?: CoverLetterDocumentSettings) => + api.put(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction, expectedVersion, ...(documentSettings ? { documentSettings } : {}) }).then((r) => r.data), + previewCoverLetter: (jobId: number, text: string, documentSettings: CoverLetterDocumentSettings) => + api.post<{ html: string; themeId: string; suggestedFileName: string }>(`/jobapplications/${jobId}/cover-letter/preview`, { text, documentSettings }).then(r => r.data), + exportCoverLetter: (jobId: number, expectedVersion: number) => + api.post(`/jobapplications/${jobId}/cover-letter/export-pdf?expectedVersion=${expectedVersion}`, undefined, { responseType: "blob" }).then(r => r.data), restoreCoverLetter: (jobId: number, version: number, expectedVersion: number) => api.post(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`, { expectedVersion }).then((r) => r.data), submittedPackages: (jobId: number) => diff --git a/job-tracker-ui/src/components/ApplicationAssets.tsx b/job-tracker-ui/src/components/ApplicationAssets.tsx index ac2faf0..63878c9 100644 --- a/job-tracker-ui/src/components/ApplicationAssets.tsx +++ b/job-tracker-ui/src/components/ApplicationAssets.tsx @@ -1,10 +1,13 @@ import React, { useCallback, useEffect, useId, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, IconButton, InputLabel, MenuItem, Paper, Select, - Stack, TextField, Tooltip, Typography, + Stack, Tab, Tabs, TextField, Tooltip, Typography, useMediaQuery, useTheme, } from "@mui/material"; import RichTextField from "./RichTextField"; +import Markdown from "./Markdown"; +import { CoverLetterDocumentFields, CoverLetterDocumentPreview } from "./CoverLetterDocument"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import RestoreIcon from "@mui/icons-material/Restore"; import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; @@ -13,7 +16,7 @@ import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined"; import { getApiErrorMessage } from "../api"; import { - ApplicationCv, CoverLetter, SubmittedPackage, SubmittedPackageDetail, TailoringPlan, applicationAssetsApi, + ApplicationCv, CoverLetter, CoverLetterDocumentSettings, SubmittedPackage, SubmittedPackageDetail, TailoringPlan, applicationAssetsApi, } from "../applicationWorkspace"; import { cvBuilderApi } from "../cvBuilder"; import { aiWorkspaceApi } from "../aiWorkspace"; @@ -470,6 +473,7 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) { // ---------- Cover letter ---------- const historySources = new Map([ + ["legacy", "historySourceLegacy"], ["manual", "historySourceManual"], ["ai", "historySourceAi"], ["template", "historySourceTemplate"], ["restore", "historySourceRestore"], ]); @@ -509,13 +513,18 @@ Med vennlig hilsen } as const; export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) { + const [aiToolbarHost, setAiToolbarHost] = useState(null); const { language, t } = useI18n(); + const workspaceId = useId(); + const wide = useMediaQuery(useTheme().breakpoints.up("md")); + const [pane, setPane] = useState("write"); const { confirm } = useConfirm(); const { data, error, loading, setData, setError, reload, isCurrent } = useAsset( () => applicationAssetsApi.coverLetter(jobId), [jobId], ); const [draft, setDraft] = useState(null); + const [settingsDraft, setSettingsDraft] = useState(null); const [draftAiAction, setDraftAiAction] = useState(null); const [busy, setBusy] = useState(false); const [confirming, setConfirming] = useState(false); @@ -525,27 +534,38 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: // The textarea is only seeded from the server until the user starts typing, so a reload never // clobbers unsaved edits. const text = draft ?? data?.text ?? ""; - const dirty = draft !== null && draft !== (data?.text ?? ""); + const settings = settingsDraft ?? data?.documentSettings; + const settingsDirty = settingsDraft !== null && JSON.stringify(settingsDraft) !== JSON.stringify(data?.documentSettings); + const dirty = (draft !== null && draft !== (data?.text ?? "")) || settingsDirty; useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false); }, [dirty, onDirtyChange]); - const save = async (value: string, source = "manual", aiAction?: string) => { + const save = async (value: string, source = "manual", aiAction?: string, exportPdf = false) => { if (busyRef.current || loading || !data) return; busyRef.current = true; contentRef.current?.focus(); setBusy(true); try { - const next = await applicationAssetsApi.saveCoverLetter(jobId, value, data.revision, source, aiAction); + const next = !exportPdf || dirty ? await applicationAssetsApi.saveCoverLetter(jobId, value, data.revision, source, aiAction, settingsDirty ? settings : undefined) : data; if (!isCurrent()) return; setData(next); setDraft(null); + setSettingsDraft(null); setDraftAiAction(null); setError(null); + if (exportPdf) { + if (!Number.isInteger(next.revision) || next.revision <= 0) throw new Error("Missing committed job revision"); + const blob = await applicationAssetsApi.exportCoverLetter(jobId, next.revision); + if (!isCurrent()) return; + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; anchor.download = "cover-letter.pdf"; anchor.click(); URL.revokeObjectURL(url); + } } catch (err) { - if (isCurrent()) setError(assetError(err, "assetsCoverSaveFailed")); + if (isCurrent()) setError(assetError(err, exportPdf ? "letterExportFailed" : "assetsCoverSaveFailed")); } finally { busyRef.current = false; setBusy(false); @@ -572,6 +592,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: if (!isCurrent()) return; setData(next); setDraft(null); + setSettingsDraft(null); setDraftAiAction(null); setError(null); } catch (err) { @@ -595,22 +616,41 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: contentRef={contentRef} > - + {!wide && setPane(value)} aria-label={t("assetsLetterWorkspace")} variant="fullWidth"> + + + } + +