diff --git a/JobTrackerApi.MariaDb.Tests/CoverLetterDocumentPersistenceTests.cs b/JobTrackerApi.MariaDb.Tests/CoverLetterDocumentPersistenceTests.cs index aa0415a..2962c84 100644 --- a/JobTrackerApi.MariaDb.Tests/CoverLetterDocumentPersistenceTests.cs +++ b/JobTrackerApi.MariaDb.Tests/CoverLetterDocumentPersistenceTests.cs @@ -4,6 +4,7 @@ using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; @@ -14,6 +15,42 @@ namespace JobTrackerApi.MariaDb.Tests; public sealed partial class CoverLetterWriterTests { + [Fact] + public async Task Premium_letter_entitlement_uses_actual_roles_and_preserves_downgraded_saved_design() + { + await using var connection = await MariaDbDatabase.CreateAsync(); + await using var db = Context(connection); + db.Users.Add(new() { Id = Owner, UserName = "synthetic-letter-owner" }); + var job = await Seed(db, "Original letter"); + var design = new CoverLetterDocumentSettings { DesignVersion = 2, ThemeId = "technical" }; + await Assert.ThrowsAsync(() => Assets(db).SaveCoverLetterAsync( + Owner, job.Id, "Rejected", CoverLetterSources.Manual, null, 1, default, design)); + Assert.Empty(await db.CoverLetterVersions.ToListAsync()); + Assert.Equal("Original letter", (await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText); + + var role = new IdentityRole("Premium") { NormalizedName = "PREMIUM" }; + db.Roles.Add(role); + db.UserRoles.Add(new IdentityUserRole { UserId = Owner, RoleId = role.Id }); + await db.SaveChangesAsync(); + var saved = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, "Approved premium letter", CoverLetterSources.Manual, null, 1, default, design); + Assert.Equal(2, saved!.Revision); + Assert.Equal("technical", saved.DocumentSettings!.ThemeId); + + await db.UserRoles.Where(row => row.UserId == Owner).ExecuteDeleteAsync(); + var retained = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, "Approved after downgrade", CoverLetterSources.Manual, null, saved.Revision, default, design); + Assert.Equal(3, retained!.Revision); + var historyCount = await db.CoverLetterVersions.CountAsync(); + await Assert.ThrowsAsync(() => Assets(db).SaveCoverLetterAsync(Owner, job.Id, + "Rejected switch", CoverLetterSources.Manual, null, retained.Revision, default, new() { DesignVersion = 2, ThemeId = "executive" })); + await using var verify = Context(connection); + var actual = await verify.JobApplications.AsNoTracking().SingleAsync(); + Assert.Equal("Approved after downgrade", actual.CoverLetterText); + Assert.Equal(3, actual.Version); + Assert.Equal("technical", CoverLetterDocumentSettingsJson.Deserialize(actual.CoverLetterDocumentSettingsJson).ThemeId); + Assert.Equal(historyCount, await verify.CoverLetterVersions.CountAsync()); + Assert.Contains("Approved after downgrade", (await Assets(verify).RenderSavedCoverLetterAsync(Owner, job.Id, 3, default))!.Html); + } + [Fact] public async Task Document_schema_upgrade_preserves_legacy_letter_history_and_frozen_rows_on_mariadb() { diff --git a/JobTrackerApi.MariaDb.Tests/CvImportLifecycleConcurrencyTests.cs b/JobTrackerApi.MariaDb.Tests/CvImportLifecycleConcurrencyTests.cs index e19d24a..77d55f8 100644 --- a/JobTrackerApi.MariaDb.Tests/CvImportLifecycleConcurrencyTests.cs +++ b/JobTrackerApi.MariaDb.Tests/CvImportLifecycleConcurrencyTests.cs @@ -20,6 +20,63 @@ public class CvImportLifecycleConcurrencyTests private const string Owner = "rd21-owner"; private static readonly ProfileCvController.ApplyReviewedRequest Review = new(0, new[] { new CvImportDecision("skills.0", "use") }); + [Fact] + public async Task Document_only_claim_is_owner_bound_and_translates_on_MariaDb_without_restricting_general_claims() + { + await using var database = await MariaDbDatabase.CreateAsync(); + Guid documentId, legacyId, strategyId, mismatchedId, wrongTriggerId; + await using (var seed = database.Context()) + { + seed.Users.AddRange(new ApplicationUser { Id = Owner, UserName = "claim-owner" }, + new ApplicationUser { Id = "other-owner", UserName = "claim-other" }); + var document = new CvExtractionRun { OwnerUserId = Owner, ParserVersion = ProfileCvController.DocumentParserVersion, Trigger = "document_import", Status = "queued" }; + var foreign = new CvExtractionRun { OwnerUserId = "other-owner", ParserVersion = ProfileCvController.DocumentParserVersion, Trigger = "document_import", Status = "queued" }; + var legacy = new CvExtractionRun { OwnerUserId = Owner, ParserVersion = "legacy-text", Trigger = "document_import", Status = "queued" }; + var wrongTrigger = new CvExtractionRun { OwnerUserId = Owner, ParserVersion = ProfileCvController.DocumentParserVersion, Trigger = "text_extraction", Status = "queued" }; + seed.CvExtractionRuns.AddRange(document, foreign, legacy, wrongTrigger); + await seed.SaveChangesAsync(); + UserOperation Queued(CvExtractionRun run, int priority) + { + var operation = Operation(run.Id, OperationStatuses.Queued); + operation.IdempotencyKey = operation.Id.ToString("N"); + operation.LeaseToken = null; + operation.Priority = priority; + return operation; + } + var eligible = Queued(document, 100); + var legacyOperation = Queued(legacy, 1000); + var mismatched = Queued(foreign, 900); + var wrongTriggerOperation = Queued(wrongTrigger, 800); + var strategy = Queued(document, 1100); + strategy.TaskType = StrategySnapshotService.TaskType; + strategy.SubjectType = "job_application"; + seed.UserOperations.AddRange(eligible, legacyOperation, mismatched, wrongTriggerOperation, strategy); + await seed.SaveChangesAsync(); + (documentId, legacyId, strategyId, mismatchedId, wrongTriggerId) = + (eligible.Id, legacyOperation.Id, strategy.Id, mismatched.Id, wrongTriggerOperation.Id); + } + + await using var neutral = database.Context(null!); + var store = new UserOperationStore(neutral, TimeProvider.System); + var claimed = Assert.IsType(await store.ClaimNextAsync(TimeSpan.FromMinutes(1), default, documentImportsOnly: true)); + Assert.Equal(documentId, claimed.OperationId); + Assert.Equal(Owner, claimed.OwnerUserId); + Assert.Null(await store.ClaimNextAsync(TimeSpan.FromMinutes(1), default, documentImportsOnly: true)); + var generalCv = Assert.IsType(await store.ClaimNextAsync(TimeSpan.FromMinutes(1), default, new[] { CvProcessingQueue.TaskType })); + Assert.Equal(legacyId, generalCv.OperationId); + var generalOther = Assert.IsType(await store.ClaimNextAsync(TimeSpan.FromMinutes(1), default, new[] { StrategySnapshotService.TaskType })); + Assert.Equal(strategyId, generalOther.OperationId); + var excluded = await neutral.UserOperations.IgnoreQueryFilters().AsNoTracking() + .Where(operation => operation.Id == mismatchedId || operation.Id == wrongTriggerId).ToListAsync(); + Assert.Equal(2, excluded.Count); + Assert.All(excluded, operation => + { + Assert.Equal(OperationStatuses.Queued, operation.Status); + Assert.Equal(0, operation.AttemptCount); + Assert.Null(operation.LeaseToken); + }); + } + [Fact] public async Task Acknowledged_discard_prevents_an_apply_that_already_read_pending() { diff --git a/JobTrackerApi.MariaDb.Tests/packages.lock.json b/JobTrackerApi.MariaDb.Tests/packages.lock.json index c1fbb54..a53f657 100644 --- a/JobTrackerApi.MariaDb.Tests/packages.lock.json +++ b/JobTrackerApi.MariaDb.Tests/packages.lock.json @@ -392,6 +392,30 @@ "System.Drawing.Common": "6.0.0" } }, + "SkiaSharp": { + "type": "Transitive", + "resolved": "4.153.1", + "contentHash": "TghYLOUaP3q6rTjQw7U9uQaaXh8lEs9rfA1U7/VtmyAptVDtj/SmjtcWzFWmcC/VMWxe3QgbV9uyvqDL7ySFtQ==", + "dependencies": { + "SkiaSharp.NativeAssets.Win32": "4.153.1", + "SkiaSharp.NativeAssets.macOS": "4.153.1" + } + }, + "SkiaSharp.NativeAssets.Linux.NoDependencies": { + "type": "Transitive", + "resolved": "4.153.1", + "contentHash": "NrnPjcxjcRau4GMOY0BTpUdMey+MnmLU3jC5AuExEFHPS2NkX1tdkXW0n/c5nQMMHBTAMpHnt7qEOpXs2FNfIQ==" + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "4.153.1", + "contentHash": "YTojWeWIhnpF7uC8vpMxB5wRONKkDB2t3r/nNPA3DL2pgj9fqMXRevA/rIanYazWnco4jo1ufgp3HkXuh1Tz8A==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "4.153.1", + "contentHash": "Yqg6rChTVh0BUE8QQxtOA3uUBnihfgID4yex/AvDa0+os2bVcBjmI3Qj9tnOB+R1hd/Jry1Gwl8qw5JI9HZG8Q==" + }, "SQLitePCLRaw.bundle_e_sqlite3": { "type": "Transitive", "resolved": "2.1.10", @@ -545,6 +569,8 @@ "Pomelo.EntityFrameworkCore.MySql": "[9.0.0, )", "QRCoder": "[1.8.0, )", "SQLitePCLRaw.lib.e_sqlite3": "[2.1.12, )", + "SkiaSharp": "[4.153.1, )", + "SkiaSharp.NativeAssets.Linux.NoDependencies": "[4.153.1, )", "Stripe.net": "[52.2.0, )", "System.IdentityModel.Tokens.Jwt": "[8.14.0, )", "System.Security.Cryptography.Pkcs": "[8.0.2, )" diff --git a/JobTrackerApi.Tests/AvatarRetentionTests.cs b/JobTrackerApi.Tests/AvatarRetentionTests.cs new file mode 100644 index 0000000..f7ee1f7 --- /dev/null +++ b/JobTrackerApi.Tests/AvatarRetentionTests.cs @@ -0,0 +1,93 @@ +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class AvatarRetentionTests +{ + [Fact] + public async Task Hourly_retention_removes_old_orphans_but_preserves_current_recent_and_unrelated_files() + { + var root = Path.Combine(Path.GetTempPath(), "avatar-retention-" + Guid.NewGuid().ToString("N")); + try + { + var paths = Paths(root); + await using var db = TestHostFactory.CreateInMemoryDb(); + var current = await AvatarStorage.StageAsync(root, "owner", new byte[] { 1 }, default); + var orphan = await AvatarStorage.StageAsync(root, "owner", new byte[] { 2 }, default); + var recent = await AvatarStorage.StageAsync(root, "owner", new byte[] { 3 }, default); + var missingOwner = await AvatarStorage.StageAsync(root, "deleted-owner", new byte[] { 4 }, default); + var legacyOrphan = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 5 }, "image/webp", default); + var legacyCurrent = await AvatarStorage.StoreAsync(root, "legacy-owner", new byte[] { 6 }, "image/jpeg", default); + var unrelated = Path.Combine(Path.GetDirectoryName(orphan[5..])!, "unrelated.png"); + await File.WriteAllTextAsync(unrelated, "keep"); + var now = DateTimeOffset.UtcNow; + foreach (var file in new[] { current, orphan, missingOwner, legacyOrphan, legacyCurrent }) File.SetLastWriteTimeUtc(file[5..], now.AddDays(-2).UtcDateTime); + db.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner", AvatarImageDataUrl = current }); + db.Users.Add(new ApplicationUser { Id = "legacy-owner", UserName = "legacy-owner", AvatarImageDataUrl = legacyCurrent }); + await db.SaveChangesAsync(); + + await CvImportRetentionHostedService.SweepAsync(db, paths, now, default); + + Assert.True(File.Exists(current[5..])); + Assert.True(File.Exists(recent[5..])); + Assert.True(File.Exists(unrelated)); + Assert.False(File.Exists(orphan[5..])); + Assert.False(File.Exists(missingOwner[5..])); + Assert.False(File.Exists(legacyOrphan[5..])); + Assert.True(File.Exists(legacyCurrent[5..])); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public async Task Account_inventory_includes_only_its_own_staged_portraits_even_without_a_current_avatar() + { + var root = Path.Combine(Path.GetTempPath(), "avatar-inventory-" + Guid.NewGuid().ToString("N")); + try + { + var paths = Paths(root); + await using var db = TestHostFactory.CreateInMemoryDb(); + var own = await AvatarStorage.StageAsync(root, "owner", new byte[] { 1 }, default); + var other = await AvatarStorage.StageAsync(root, "other", new byte[] { 2 }, default); + db.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner" }); + await db.SaveChangesAsync(); + var inventory = await new AccountOwnedFileInventory(db, paths, Mock.Of()).BuildAsync("owner", default); + var avatar = Assert.Single(inventory.Files, file => file.Category == "avatar"); + Assert.Equal(own[5..], avatar.SourcePath); + Assert.DoesNotContain(inventory.Files, file => file.SourcePath == other[5..]); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + private static AppPaths Paths(string root) => new( + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Data:Root"] = root }).Build(), + Mock.Of(environment => environment.ContentRootPath == root)); + + [Theory] + [InlineData("image/png")] + [InlineData("image/jpeg")] + [InlineData("image/webp")] + public async Task Account_inventory_covers_exact_legacy_leftovers_after_portrait_replacement(string contentType) + { + var root = Path.Combine(Path.GetTempPath(), "avatar-legacy-inventory-" + Guid.NewGuid().ToString("N")); + try + { + var paths = Paths(root); + await using var db = TestHostFactory.CreateInMemoryDb(); + var legacy = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1 }, contentType, default); + var current = await AvatarStorage.StageAsync(root, "owner", new byte[] { 2 }, default); + db.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner", AvatarImageDataUrl = current }); + await db.SaveChangesAsync(); + var inventory = await new AccountOwnedFileInventory(db, paths, Mock.Of()).BuildAsync("owner", default); + Assert.Contains(inventory.Files, file => file.SourcePath == legacy[5..]); + Assert.Single(inventory.Files, file => file.SourcePath == current[5..]); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } +} diff --git a/JobTrackerApi.Tests/AvatarUploadTests.cs b/JobTrackerApi.Tests/AvatarUploadTests.cs new file mode 100644 index 0000000..ca4a82a --- /dev/null +++ b/JobTrackerApi.Tests/AvatarUploadTests.cs @@ -0,0 +1,270 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using SkiaSharp; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class AvatarUploadTests +{ + [Theory] + [InlineData(SKEncodedImageFormat.Png)] + [InlineData(SKEncodedImageFormat.Jpeg)] + [InlineData(SKEncodedImageFormat.Webp)] + public void Actual_supported_images_are_decoded_and_normalized_to_metadata_free_png(SKEncodedImageFormat format) + { + var source = Image(format, 12, 8).Concat(System.Text.Encoding.UTF8.GetBytes("private-camera-metadata")).ToArray(); + var normalized = AvatarImageProcessor.Normalize(source); + Assert.NotNull(normalized); + Assert.True(normalized.Length <= 1_000_000); + Assert.DoesNotContain("private-camera-metadata", System.Text.Encoding.UTF8.GetString(normalized)); + using var codec = SKCodec.Create(new MemoryStream(normalized)); + Assert.Equal(SKEncodedImageFormat.Png, codec.EncodedFormat); + Assert.Equal(12, codec.Info.Width); + Assert.Equal(8, codec.Info.Height); + using var decoded = SKBitmap.Decode(normalized); + Assert.NotNull(decoded); + } + + [Theory] + [InlineData(2049, 1)] + [InlineData(1, 2049)] + [InlineData(2001, 2000)] + public void Oversized_dimensions_are_rejected(int width, int height) => + Assert.Null(AvatarImageProcessor.Normalize(Image(SKEncodedImageFormat.Png, width, height))); + + [Fact] + public void Truncated_pixels_and_unsupported_formats_are_rejected() + { + var png = Image(SKEncodedImageFormat.Png, 12, 8); + Assert.Null(AvatarImageProcessor.Normalize(png[..40])); + Assert.Null(AvatarImageProcessor.Normalize(Convert.FromBase64String("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"))); + } + + [Fact] + public void Animated_webp_is_rejected_instead_of_silently_selecting_a_frame() + { + using var first = new SKBitmap(12, 8); + using var second = new SKBitmap(12, 8); + first.Erase(SKColors.Red); + second.Erase(SKColors.Blue); + using var animated = SKWebpEncoder.EncodeAnimated(new SKWebpEncoderFrame[] + { + new(first, TimeSpan.FromMilliseconds(100)), new(second, TimeSpan.FromMilliseconds(100)), + }, SKWebpEncoderOptions.Default); + Assert.NotNull(animated); + Assert.Null(AvatarImageProcessor.Normalize(animated.ToArray())); + } + + [Theory] + [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] + [InlineData(5)] [InlineData(6)] [InlineData(7)] [InlineData(8)] + public void Exif_orientation_is_applied_before_metadata_is_removed(int orientation) + { + var jpeg = Image(SKEncodedImageFormat.Jpeg, 12, 8); + using var original = SKBitmap.Decode(jpeg); + var normalized = AvatarImageProcessor.Normalize(WithOrientation(jpeg, orientation)); + Assert.NotNull(normalized); + using var result = SKBitmap.Decode(normalized); + Assert.Equal(orientation >= 5 ? original.Height : original.Width, result.Width); + Assert.Equal(orientation >= 5 ? original.Width : original.Height, result.Height); + for (var y = 0; y < result.Height; y++) + for (var x = 0; x < result.Width; x++) + { + var source = orientation switch + { + 2 => (original.Width - 1 - x, y), + 3 => (original.Width - 1 - x, original.Height - 1 - y), + 4 => (x, original.Height - 1 - y), + 5 => (y, x), + 6 => (y, original.Height - 1 - x), + 7 => (original.Width - 1 - y, original.Height - 1 - x), + 8 => (original.Width - 1 - y, x), + _ => (x, y), + }; + Assert.Equal(original.GetPixel(source.Item1, source.Item2), result.GetPixel(x, y)); + } + using var codec = SKCodec.Create(new MemoryStream(normalized)); + Assert.Equal(SKEncodedOrigin.TopLeft, codec.EncodedOrigin); + } + + [Fact] + public async Task Identity_failure_preserves_previous_file_and_removes_staged_image() + { + var root = Path.Combine(Path.GetTempPath(), "avatar-identity-" + Guid.NewGuid().ToString("N")); + try + { + var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default); + var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored }; + var users = TestHostFactory.CreateUserManager(); + users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync(user); + users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "private failure" })); + await using var db = TestHostFactory.CreateInMemoryDb(); + var controller = Controller(root, user, users.Object, db); + await using var stream = new MemoryStream(Image(SKEncodedImageFormat.Png, 12, 8)); + var result = Assert.IsType(await controller.UploadAvatar(new FormFile(stream, 0, stream.Length, "file", "portrait.png"))); + Assert.DoesNotContain("private failure", result.Value?.ToString() ?? string.Empty); + Assert.Equal(stored, user.AvatarImageDataUrl); + Assert.Equal("data:image/jpeg;base64,AQID", AvatarStorage.Resolve(stored)); + Assert.Single(Directory.GetFiles(root, "*", SearchOption.AllDirectories)); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public async Task Actual_stream_bytes_are_bounded_even_when_declared_length_is_small() + { + var user = new ApplicationUser { Id = "owner" }; + var users = TestHostFactory.CreateUserManager(); + users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync(user); + var file = new Mock(); + file.SetupGet(value => value.Length).Returns(8); + file.SetupGet(value => value.FileName).Returns("portrait.png"); + file.Setup(value => value.OpenReadStream()).Returns(new MemoryStream(new byte[1_000_001])); + await using var db = TestHostFactory.CreateInMemoryDb(); + var result = Assert.IsType(await Controller(Path.GetTempPath(), user, users.Object, db).UploadAvatar(file.Object)); + Assert.Equal("Avatar image is too large.", result.Value); + users.Verify(manager => manager.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Successful_identity_ack_is_not_reported_as_failed_when_old_file_cleanup_is_blocked() + { + var root = Path.Combine(Path.GetTempPath(), "avatar-cleanup-" + Guid.NewGuid().ToString("N")); + try + { + var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default); + var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored }; + var users = TestHostFactory.CreateUserManager(); + users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync(user); + users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + await using var db = TestHostFactory.CreateInMemoryDb(); + await using var locked = new FileStream(stored[5..], FileMode.Open, FileAccess.Read, FileShare.None); + await using var stream = new MemoryStream(Image(SKEncodedImageFormat.Png, 12, 8)); + var response = await Controller(root, user, users.Object, db).UploadAvatar(new FormFile(stream, 0, stream.Length, "file", "portrait.png")); + Assert.IsType(response); + Assert.NotEqual(stored, user.AvatarImageDataUrl); + Assert.NotNull(AvatarStorage.Resolve(user.AvatarImageDataUrl)); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public async Task Ambiguous_identity_exception_retains_staged_target_for_reference_aware_reconciliation() + { + var root = Path.Combine(Path.GetTempPath(), "avatar-ambiguous-" + Guid.NewGuid().ToString("N")); + try + { + var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default); + var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored }; + var users = TestHostFactory.CreateUserManager(); + users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync(user); + string? committedTarget = null; + users.Setup(manager => manager.UpdateAsync(user)).Callback(() => committedTarget = user.AvatarImageDataUrl) + .ThrowsAsync(new IOException("Synthetic acknowledgement loss after commit")); + await using var db = TestHostFactory.CreateInMemoryDb(); + await using var stream = new MemoryStream(Image(SKEncodedImageFormat.Png, 12, 8)); + await Assert.ThrowsAsync(() => Controller(root, user, users.Object, db) + .UploadAvatar(new FormFile(stream, 0, stream.Length, "file", "portrait.png"))); + Assert.NotNull(committedTarget); + Assert.NotEqual(stored, committedTarget); + Assert.NotNull(AvatarStorage.Resolve(committedTarget)); + Assert.Equal(stored, user.AvatarImageDataUrl); + Assert.Equal("data:image/jpeg;base64,AQID", AvatarStorage.Resolve(stored)); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public async Task Removal_is_acknowledged_when_identity_succeeded_but_file_cleanup_is_blocked() + { + var root = Path.Combine(Path.GetTempPath(), "avatar-remove-" + Guid.NewGuid().ToString("N")); + try + { + var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default); + var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored }; + var users = TestHostFactory.CreateUserManager(); + users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync(user); + users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + await using var db = TestHostFactory.CreateInMemoryDb(); + await using var locked = new FileStream(stored[5..], FileMode.Open, FileAccess.Read, FileShare.None); + Assert.IsType(await Controller(root, user, users.Object, db).DeleteAvatar()); + Assert.Null(user.AvatarImageDataUrl); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + private static byte[] Image(SKEncodedImageFormat format, int width, int height) + { + using var bitmap = new SKBitmap(width, height); + using (var canvas = new SKCanvas(bitmap)) canvas.Clear(SKColors.Navy); + bitmap.SetPixel(0, 0, SKColors.Red); + bitmap.SetPixel(width - 1, 0, SKColors.Green); + bitmap.SetPixel(0, height - 1, SKColors.Yellow); + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(format, 100); + return data.ToArray(); + } + + private static byte[] WithOrientation(byte[] jpeg, int orientation) + { + using var payload = new MemoryStream(); + using (var writer = new BinaryWriter(payload, System.Text.Encoding.UTF8, true)) + { + writer.Write(new byte[] { 69, 120, 105, 102, 0, 0, 73, 73 }); + writer.Write((ushort)42); writer.Write(8); writer.Write((ushort)1); + writer.Write((ushort)0x112); writer.Write((ushort)3); writer.Write(1); + writer.Write((ushort)orientation); writer.Write((ushort)0); writer.Write(0); + } + var bytes = payload.ToArray(); + return jpeg[..2].Concat(new byte[] { 0xff, 0xe1, (byte)((bytes.Length + 2) >> 8), (byte)(bytes.Length + 2) }) + .Concat(bytes).Concat(jpeg[2..]).ToArray(); + } + + [Fact] + public async Task Signature_only_png_is_rejected_without_replacing_the_previous_portrait() + { + var root = Path.Combine(Path.GetTempPath(), "avatar-validation-" + Guid.NewGuid().ToString("N")); + try + { + var originalBytes = new byte[] { 1, 2, 3 }; + var stored = await AvatarStorage.StoreAsync(root, "owner", originalBytes, "image/png", default); + var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored }; + var users = TestHostFactory.CreateUserManager(); + users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync(user); + users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + await using var db = TestHostFactory.CreateInMemoryDb(); + var controller = Controller(root, user, users.Object, db); + await using var stream = new MemoryStream(new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a }); + var file = new FormFile(stream, 0, stream.Length, "file", "portrait.png"); + + var response = await controller.UploadAvatar(file); + + Assert.IsType(response); + Assert.Equal(stored, user.AvatarImageDataUrl); + Assert.Equal("data:image/png;base64,AQID", AvatarStorage.Resolve(stored)); + users.Verify(manager => manager.UpdateAsync(It.IsAny()), Times.Never); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + private static AuthController Controller(string root, ApplicationUser user, UserManager users, JobTrackerApi.Data.JobTrackerContext db) => + new(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Data:Root"] = root }).Build(), + users, Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of>(), Mock.Of(), db) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "local")) }, + }, + }; +} diff --git a/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs b/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs index 5c891dd..83ca05c 100644 --- a/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs +++ b/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs @@ -7,6 +7,25 @@ namespace JobTrackerApi.Tests; public sealed class CoverLetterDocumentContractTests { + [Fact] + public void Matching_letter_themes_use_the_CV_design_without_rewriting_legacy_output_or_body() + { + var renderer = new CoverLetterDocumentRenderer(); + foreach (var theme in CvThemeCatalog.Themes) + { + var legacy = new CoverLetterDocumentSettings { ThemeId = theme.Id, SenderName = "Reviewed sender" }; + var original = renderer.Render("Reviewed body", legacy).Html; + var revised = CoverLetterDocumentSettingsJson.Deserialize("{\"themeId\":\"" + theme.Id + "\",\"designVersion\":2,\"senderName\":\"Reviewed sender\"}"); + var html = renderer.Render("Reviewed body", revised).Html; + Assert.Contains("letter-theme-" + theme.Id, html); + Assert.Contains("Reviewed sender", html); + Assert.Contains("Reviewed body", html); + Assert.Contains("\"designVersion\":2", CoverLetterDocumentSettingsJson.Serialize(revised)); + Assert.Contains(theme.Accent, html); + Assert.Equal(original, renderer.Render("Reviewed body", CoverLetterDocumentSettingsJson.Deserialize(CoverLetterDocumentSettingsJson.Serialize(legacy))).Html); + Assert.DoesNotContain("designVersion", CoverLetterDocumentSettingsJson.Serialize(legacy)); + } + } [Theory] [InlineData("en", "Cover letter", "Cover letter text")] [InlineData("nb-NO", "Søknadsbrev", "Brevtekst")] diff --git a/JobTrackerApi.Tests/CoverLetterThemeEntitlementTests.cs b/JobTrackerApi.Tests/CoverLetterThemeEntitlementTests.cs new file mode 100644 index 0000000..b5ba16b --- /dev/null +++ b/JobTrackerApi.Tests/CoverLetterThemeEntitlementTests.cs @@ -0,0 +1,127 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class CoverLetterThemeEntitlementTests +{ + [Theory] + [InlineData("save")] + [InlineData("preview")] + [InlineData("restore")] + public async Task Free_user_cannot_activate_a_premium_version_two_letter_through_any_design_writer(string action) + { + var (db, job, controller, _) = await Fixture(); + await using var dispose = db; + var premium = new CoverLetterDocumentSettings { ThemeId = "executive", DesignVersion = 2 }; + db.CoverLetterVersions.Add(new CoverLetterVersion { OwnerUserId = "owner", JobApplicationId = job.Id, Version = 1, + Text = "Historical premium", Source = CoverLetterSources.Manual, DocumentSettingsJson = CoverLetterDocumentSettingsJson.Serialize(premium) }); + await db.SaveChangesAsync(); + var response = action switch + { + "preview" => (await controller.PreviewCoverLetter(job.Id, new("Reviewed text", premium), default)).Result, + "restore" => (await controller.RestoreCoverLetter(job.Id, 1, new(job.Version), default)).Result, + _ => (await controller.SaveCoverLetter(job.Id, new("Reviewed text", "manual", null, job.Version, premium), default)).Result, + }; + Assert.Equal(403, Assert.IsAssignableFrom(response).StatusCode); + Assert.Null(job.CoverLetterDocumentSettingsJson); + Assert.Null(job.CoverLetterText); + } + + [Fact] + public async Task Shared_service_rejects_premium_design_before_changing_letter_or_history() + { + var (db, job, _, service) = await Fixture(); + await using var dispose = db; + await Assert.ThrowsAsync(() => service.SaveCoverLetterAsync("owner", job.Id, "Unsaved", "manual", null, + job.Version, default, new() { DesignVersion = 2, ThemeId = "technical" })); + Assert.Null(job.CoverLetterText); + Assert.Null(job.CoverLetterDocumentSettingsJson); + Assert.Empty(db.CoverLetterVersions); + } + + [Theory] + [InlineData(null)] + [InlineData(1)] + public async Task Legacy_letter_metadata_remains_editable_and_previewable_for_free_users(int? designVersion) + { + var (db, job, controller, _) = await Fixture(); + await using var dispose = db; + var legacy = new CoverLetterDocumentSettings { ThemeId = "executive", DesignVersion = designVersion }; + Assert.IsType((await controller.PreviewCoverLetter(job.Id, new("Legacy text", legacy), default)).Result); + Assert.IsType((await controller.SaveCoverLetter(job.Id, new("Legacy text", "manual", null, job.Version, legacy), default)).Result); + } + + [Fact] + public async Task Premium_account_can_save_and_preview_a_new_premium_letter_design() + { + var (db, job, controller, service) = await Fixture(premium: true); + await using var dispose = db; + var design = new CoverLetterDocumentSettings { ThemeId = "technical", DesignVersion = 2 }; + Assert.IsType((await controller.PreviewCoverLetter(job.Id, new("Reviewed text", design), default)).Result); + Assert.IsType((await controller.SaveCoverLetter(job.Id, new("Reviewed text", "manual", null, job.Version, design), default)).Result); + Assert.NotNull(await service.RenderSavedCoverLetterAsync("owner", job.Id, job.Version, default)); + } + + [Fact] + public async Task Downgraded_account_keeps_current_premium_design_but_cannot_switch_to_another_premium_design() + { + var (db, job, controller, service) = await Fixture(); + await using var dispose = db; + var stored = new CoverLetterDocumentSettings { ThemeId = "executive", DesignVersion = 2 }; + job.CoverLetterDocumentSettingsJson = CoverLetterDocumentSettingsJson.Serialize(stored); + job.CoverLetterText = "Stored premium text"; + await db.SaveChangesAsync(); + Assert.NotNull(await service.GetCoverLetterAsync("owner", job.Id, default)); + Assert.NotNull(await service.RenderSavedCoverLetterAsync("owner", job.Id, job.Version, default)); + Assert.IsType((await controller.PreviewCoverLetter(job.Id, new("Text edit", stored), default)).Result); + Assert.IsType((await controller.SaveCoverLetter(job.Id, new("Text edit", "manual", null, job.Version, stored), default)).Result); + var different = new CoverLetterDocumentSettings { ThemeId = "technical", DesignVersion = 2 }; + Assert.Equal(403, Assert.IsAssignableFrom((await controller.PreviewCoverLetter(job.Id, new("Text edit", different), default)).Result).StatusCode); + Assert.Equal(403, Assert.IsAssignableFrom((await controller.SaveCoverLetter(job.Id, new("Text edit", "manual", null, job.Version, different), default)).Result).StatusCode); + Assert.Equal("executive", CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson).ThemeId); + } + + [Fact] + public async Task Foreign_application_remains_not_found_before_entitlement_checks() + { + var (db, job, controller, _) = await Fixture(); + await using var dispose = db; + job.OwnerUserId = "other"; + await db.SaveChangesAsync(); + var design = new CoverLetterDocumentSettings { ThemeId = "executive", DesignVersion = 2 }; + Assert.IsType((await controller.PreviewCoverLetter(job.Id, new("Foreign", design), default)).Result); + Assert.IsType((await controller.SaveCoverLetter(job.Id, new("Foreign", "manual", null, job.Version, design), default)).Result); + } + + private static async Task<(JobTrackerContext db, JobApplication job, ApplicationAssetsController controller, ApplicationAssetsService service)> Fixture(bool premium = false) + { + var db = TestHostFactory.CreateInMemoryDb("owner"); + var user = new ApplicationUser { Id = "owner", UserName = "owner" }; + db.Users.Add(user); + if (premium) + { + db.Roles.Add(new IdentityRole("Premium") { Id = "premium-role" }); + db.UserRoles.Add(new IdentityUserRole { UserId = user.Id, RoleId = "premium-role" }); + } + var company = new Company { OwnerUserId = user.Id, Name = "Synthetic" }; + var job = new JobApplication { OwnerUserId = user.Id, Company = company, JobTitle = "Synthetic role" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + var service = new ApplicationAssetsService(db, Mock.Of(), Mock.Of()); + var users = TestHostFactory.CreateUserManager(); + users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync(user); + var controller = new ApplicationAssetsController(users.Object, service, Mock.Of()) + { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; + return (db, job, controller, service); + } +} diff --git a/JobTrackerApi.Tests/CvBuilderTests.cs b/JobTrackerApi.Tests/CvBuilderTests.cs index 96a55b1..9bfeb36 100644 --- a/JobTrackerApi.Tests/CvBuilderTests.cs +++ b/JobTrackerApi.Tests/CvBuilderTests.cs @@ -28,6 +28,94 @@ public sealed class CvBuilderTests // ---- Resolver (the lens over the master profile) ---- + [Theory] + [InlineData("modern")] + [InlineData("ats-classic")] + [InlineData("code")] + public void Revised_design_has_space_below_the_main_title_and_header(string themeId) + { + var settings = new CvVariantSettings { ThemeId = themeId, DesignVersion = 2 }; + var html = new ThemedCvRenderer().Render(CvVariantResolver.Build(Rich(), settings, "", null), CvThemeCatalog.Resolve(themeId), settings).Html; + Assert.Contains(".header .name{margin-bottom:3mm;}", html); + Assert.Contains(".header + .main{padding-top:5mm;}", html); + } + + [Theory] + [InlineData("technical", "left")] + [InlineData("creative", "left")] + [InlineData("nordic", "right")] + public void Revised_sidebar_background_fills_each_physical_page_including_its_margins(string themeId, string edge) + { + var settings = new CvVariantSettings { ThemeId = themeId, DesignVersion = 2 }; + var html = new ThemedCvRenderer().Render(CvVariantResolver.Build(Rich(), settings, "", null), CvThemeCatalog.Resolve(themeId), settings).Html; + Assert.Contains("@top-" + edge + "{content:", html); + Assert.Contains("@bottom-" + edge + "{content:", html); + Assert.Contains("body::before{content:", html); + } + + [Fact] + public void Design_revision_missing_and_explicit_one_preserve_identical_legacy_html_for_every_theme() + { + foreach (var theme in CvThemeCatalog.Themes) + { + var legacy = new CvVariantSettings { ThemeId = theme.Id }; + var explicitOne = CvVariantSettingsJson.Deserialize("{\"designVersion\":1,\"themeId\":\"" + theme.Id + "\"}"); + var renderer = new ThemedCvRenderer(); + var model = CvVariantResolver.Build(Rich(), legacy, "Fallback", null); + Assert.Equal(renderer.Render(model, theme, legacy).Html, renderer.Render(model, theme, explicitOne).Html); + Assert.DoesNotContain("designVersion", CvVariantSettingsJson.Serialize(legacy)); + } + } + + [Fact] + public void Design_revision_two_is_persisted_and_changes_presentation_without_changing_content() + { + var settings = CvVariantSettingsJson.Deserialize("{\"designVersion\":2,\"themeId\":\"modern\"}"); + Assert.Contains("\"designVersion\":2", CvVariantSettingsJson.Serialize(settings)); + var model = CvVariantResolver.Build(Rich(), settings, "Fallback", null); + var theme = CvThemeCatalog.Resolve("modern"); + var renderer = new ThemedCvRenderer(); + var revised = renderer.Render(model, theme, settings).Html; + var legacy = renderer.Render(model, theme, new() { ThemeId = "modern" }).Html; + Assert.NotEqual(legacy, revised); + Assert.Contains("Ada Lovelace", revised); + Assert.Contains("Builder of engines.", revised); + Assert.Contains("Built X", revised); + Assert.Contains("AZ-204", revised); + } + + [Theory] + [InlineData("technical", "#0f4c5c", "#ffffff")] + [InlineData("nordic", "#eef3f8", "#1f2937")] + [InlineData("creative", "#be185d", "#ffffff")] + public void Design_revision_two_sidebar_uses_its_own_palette_and_room_for_identity(string themeId, string background, string ink) + { + var settings = CvVariantSettingsJson.Deserialize("{\"designVersion\":2,\"themeId\":\"" + themeId + "\"}"); + var model = CvVariantResolver.Build(Rich(), settings, "Fallback", null); + var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve(themeId), settings).Html; + Assert.Contains("--cv-sidebar-bg:" + background, html); + Assert.Contains("--cv-sidebar-ink:" + ink, html); + Assert.Contains("background:var(--cv-sidebar-bg)", html); + Assert.Contains("color:var(--cv-sidebar-ink)", html); + Assert.Contains("padding:8mm", html); + Assert.Contains("68mm", html); + } + + [Fact] + public async Task Design_revision_duplicate_and_restore_keep_their_stored_opt_in() + { + var (db, service) = NewService("user-1"); + await using var dispose = db; + var created = await service.CreateAsync("user-1", "Legacy design", null, new(), default); + var revisionTwo = CvVariantSettingsJson.Deserialize("{\"designVersion\":2,\"themeId\":\"technical\"}"); + await service.SaveAsync("user-1", created.Id, null, revisionTwo, "manual", 1, default); + var copy = (await service.DuplicateAsync("user-1", created.Id, null, default))!; + Assert.Contains("\"designVersion\":2", copy.SettingsJson); + var restored = (await service.RestoreVersionAsync("user-1", created.Id, 1, 2, default))!; + Assert.DoesNotContain("designVersion", restored.SettingsJson); + Assert.Contains("\"designVersion\":2", (await db.CvVariantVersions.SingleAsync(row => row.CvVariantId == created.Id && row.Version == 2)).SettingsJson); + } + [Fact] public void Explicit_blank_roundtrips_without_inheriting_profile_content_or_header() { diff --git a/JobTrackerApi.Tests/CvProcessingOperationTests.cs b/JobTrackerApi.Tests/CvProcessingOperationTests.cs index f367d50..f68ae93 100644 --- a/JobTrackerApi.Tests/CvProcessingOperationTests.cs +++ b/JobTrackerApi.Tests/CvProcessingOperationTests.cs @@ -19,6 +19,60 @@ namespace JobTrackerApi.Tests; public sealed class CvProcessingOperationTests { + [Fact] + public async Task Document_worker_processes_previously_queued_imports_while_generative_jobs_remain_disabled() + { + await using var fixture = await Fixture.CreateAsync(); + await fixture.SeedUserAsync(); + await using (var scope = fixture.Provider.CreateAsyncScope()) + { + using var owner = scope.ServiceProvider.GetRequiredService().UseBackgroundUser("user-1"); + Assert.IsType(await CreateController(scope.ServiceProvider).Upload(File())); + var bytes = Encoding.UTF8.GetBytes("%PDF-1.4\nSynthetic document transport"); + Assert.IsType(await CreateController(scope.ServiceProvider).Import(new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "synthetic.pdf") { Headers = new HeaderDictionary(), ContentType = "application/pdf" })); + } + var configuration = fixture.Provider.GetRequiredService(); + configuration["Workers:AiOperationsEnabled"] = "false"; + configuration["Workers:CvDocumentImportsEnabled"] = "true"; + using var hosted = new AiOperationHostedService(fixture.Provider.GetRequiredService(), configuration); + await hosted.StartAsync(default); + try + { + var ready = false; + for (var attempt = 0; attempt < 50 && !ready; attempt++) + { + await Task.Delay(100); + await using var scope = fixture.Provider.CreateAsyncScope(); + var processingDb = scope.ServiceProvider.GetRequiredService(); + ready = await processingDb.CvExtractionRuns.IgnoreQueryFilters() + .AnyAsync(run => run.Trigger == "document_import" && run.Status == "pending_review") && + await processingDb.UserOperations.IgnoreQueryFilters().AnyAsync(operation => operation.Status == OperationStatuses.Succeeded); + } + Assert.True(ready, "Document-only processing must run even while generative AI workers are disabled."); + } + finally { await hosted.StopAsync(default); } + await using var verify = fixture.Provider.CreateAsyncScope(); + var db = verify.ServiceProvider.GetRequiredService(); + Assert.Equal("queued", (await db.CvExtractionRuns.IgnoreQueryFilters().SingleAsync(run => run.Trigger != "document_import")).Status); + Assert.Equal(1, await db.UserOperations.IgnoreQueryFilters().CountAsync(operation => operation.Status == OperationStatuses.Succeeded)); + Assert.Equal(1, await db.UserOperations.IgnoreQueryFilters().CountAsync(operation => operation.Status == OperationStatuses.Queued)); + } + + [Fact] + public async Task Disabled_document_and_AI_workers_reject_import_with_an_actionable_service_error() + { + await using var fixture = await Fixture.CreateAsync(); + await fixture.SeedUserAsync(); + fixture.Provider.GetRequiredService()["Workers:AiOperationsEnabled"] = "false"; + await using var scope = fixture.Provider.CreateAsyncScope(); + using var owner = scope.ServiceProvider.GetRequiredService().UseBackgroundUser("user-1"); + var bytes = Encoding.UTF8.GetBytes("%PDF-1.4\nSynthetic document transport"); + var response = await CreateController(scope.ServiceProvider).Import(new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "synthetic.pdf") { Headers = new HeaderDictionary(), ContentType = "application/pdf" }); + Assert.Equal(503, Assert.IsType(response).StatusCode); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Empty(await db.UserOperations.ToListAsync()); + Assert.Equal("failed", (await db.CvExtractionRuns.SingleAsync()).Status); + } [Fact] public async Task Worker_that_lost_its_lease_cannot_overwrite_a_recovered_successful_review() { @@ -317,6 +371,8 @@ public sealed class CvProcessingOperationTests ["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts"), ["AiQueue:HeartbeatSeconds"] = "5", ["Ai:ExternalProcessingEnabled"] = "false", + ["Workers:AiOperationsEnabled"] = "true", + ["Workers:CvDocumentImportsEnabled"] = "false", }).Build(); var environment = new Mock(); environment.SetupGet(item => item.ContentRootPath).Returns(tempRoot); diff --git a/JobTrackerApi.Tests/packages.lock.json b/JobTrackerApi.Tests/packages.lock.json index 5ac43a9..452b3b4 100644 --- a/JobTrackerApi.Tests/packages.lock.json +++ b/JobTrackerApi.Tests/packages.lock.json @@ -418,6 +418,30 @@ "System.Drawing.Common": "6.0.0" } }, + "SkiaSharp": { + "type": "Transitive", + "resolved": "4.153.1", + "contentHash": "TghYLOUaP3q6rTjQw7U9uQaaXh8lEs9rfA1U7/VtmyAptVDtj/SmjtcWzFWmcC/VMWxe3QgbV9uyvqDL7ySFtQ==", + "dependencies": { + "SkiaSharp.NativeAssets.Win32": "4.153.1", + "SkiaSharp.NativeAssets.macOS": "4.153.1" + } + }, + "SkiaSharp.NativeAssets.Linux.NoDependencies": { + "type": "Transitive", + "resolved": "4.153.1", + "contentHash": "NrnPjcxjcRau4GMOY0BTpUdMey+MnmLU3jC5AuExEFHPS2NkX1tdkXW0n/c5nQMMHBTAMpHnt7qEOpXs2FNfIQ==" + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "4.153.1", + "contentHash": "YTojWeWIhnpF7uC8vpMxB5wRONKkDB2t3r/nNPA3DL2pgj9fqMXRevA/rIanYazWnco4jo1ufgp3HkXuh1Tz8A==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "4.153.1", + "contentHash": "Yqg6rChTVh0BUE8QQxtOA3uUBnihfgID4yex/AvDa0+os2bVcBjmI3Qj9tnOB+R1hd/Jry1Gwl8qw5JI9HZG8Q==" + }, "SQLitePCLRaw.bundle_e_sqlite3": { "type": "Transitive", "resolved": "2.1.10", @@ -566,6 +590,8 @@ "Pomelo.EntityFrameworkCore.MySql": "[9.0.0, )", "QRCoder": "[1.8.0, )", "SQLitePCLRaw.lib.e_sqlite3": "[2.1.12, )", + "SkiaSharp": "[4.153.1, )", + "SkiaSharp.NativeAssets.Linux.NoDependencies": "[4.153.1, )", "Stripe.net": "[52.2.0, )", "System.IdentityModel.Tokens.Jwt": "[8.14.0, )", "System.Security.Cryptography.Pkcs": "[8.0.2, )" diff --git a/JobTrackerApi/Controllers/ApplicationAssetsController.cs b/JobTrackerApi/Controllers/ApplicationAssetsController.cs index 9a35170..bb1aacc 100644 --- a/JobTrackerApi/Controllers/ApplicationAssetsController.cs +++ b/JobTrackerApi/Controllers/ApplicationAssetsController.cs @@ -79,9 +79,13 @@ public sealed class ApplicationAssetsController : ControllerBase var userId = await CurrentUserIdAsync(); if (userId is null) return Unauthorized(); if (request.DocumentSettings is { SchemaVersion: not 1 }) return BadRequest("Unsupported letter settings schema."); - var result = await _assets.SaveCoverLetterAsync( - userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, request?.ExpectedVersion, ct, request?.DocumentSettings); - return result is null ? NotFound() : Ok(result); + try + { + var result = await _assets.SaveCoverLetterAsync( + userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, request?.ExpectedVersion, ct, request?.DocumentSettings); + return result is null ? NotFound() : Ok(result); + } + catch (CoverLetterThemeAccessException) { return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); } } [HttpPost("cover-letter/preview")] @@ -90,8 +94,12 @@ public sealed class ApplicationAssetsController : ControllerBase var userId = await CurrentUserIdAsync(); if (userId is null) return Unauthorized(); if (request.DocumentSettings is { SchemaVersion: not 1 }) return BadRequest("Unsupported letter settings schema."); - var result = await _assets.PreviewCoverLetterAsync(userId, jobId, request.Text, request.DocumentSettings, ct); - return result is null ? NotFound() : Ok(result); + try + { + var result = await _assets.PreviewCoverLetterAsync(userId, jobId, request.Text, request.DocumentSettings, ct); + return result is null ? NotFound() : Ok(result); + } + catch (CoverLetterThemeAccessException) { return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); } } [HttpPost("cover-letter/export-pdf")] @@ -118,8 +126,12 @@ public sealed class ApplicationAssetsController : ControllerBase { var userId = await CurrentUserIdAsync(); if (userId is null) return Unauthorized(); - var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, request?.ExpectedVersion, ct); - return result is null ? NotFound() : Ok(result); + try + { + var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, request?.ExpectedVersion, ct); + return result is null ? NotFound() : Ok(result); + } + catch (CoverLetterThemeAccessException) { return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); } } [HttpGet("submitted-packages")] diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index a22b804..87efcd3 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -16,6 +16,7 @@ namespace JobTrackerApi.Controllers; [Route("api/auth")] public sealed class AuthController : ControllerBase { + private static readonly SemaphoreSlim AvatarProcessingSlots = new(2, 2); private readonly IConfiguration _cfg; private readonly UserManager _users; private readonly ITokenService _tokens; @@ -933,9 +934,16 @@ public sealed class AuthController : ControllerBase } await using var stream = file.OpenReadStream(); - using var memory = new MemoryStream(); - await stream.CopyToAsync(memory); - var bytes = memory.ToArray(); + var bytes = new byte[MaxAvatarBytes + 1]; + var length = 0; + while (length < bytes.Length) + { + var read = await stream.ReadAsync(bytes.AsMemory(length), HttpContext.RequestAborted); + if (read == 0) break; + length += read; + } + if (length > MaxAvatarBytes) return BadRequest("Avatar image is too large."); + Array.Resize(ref bytes, length); var detectedContentType = DetectAvatarContentType(bytes); if (detectedContentType is null) @@ -943,15 +951,40 @@ public sealed class AuthController : ControllerBase return BadRequest("Only PNG, JPEG, or WebP images are supported."); } - user.AvatarImageDataUrl = await AvatarStorage.StoreAsync(_avatarDataRoot, user.Id, bytes, detectedContentType, HttpContext.RequestAborted); - - var result = await _users.UpdateAsync(user); - if (!result.Succeeded) + if (!await AvatarProcessingSlots.WaitAsync(0, HttpContext.RequestAborted)) + return StatusCode(503, new { code = "avatar_busy", message = "Image processing is busy. Try again shortly." }); + try { - return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); + byte[]? normalized; + try { normalized = AvatarImageProcessor.Normalize(bytes); } + catch (Exception exception) when (exception is DllNotFoundException or TypeInitializationException or EntryPointNotFoundException or BadImageFormatException) + { + return StatusCode(503, new { code = "avatar_decoder_unavailable", message = "Image processing is unavailable. Your previous photo has been preserved." }); + } + if (normalized is null) return BadRequest("Select a valid, still PNG, JPEG or WebP image up to 2048 pixels per side and 4 megapixels. Crop or resize larger images."); + var previous = user.AvatarImageDataUrl; + var staged = await AvatarStorage.StageAsync(_avatarDataRoot, user.Id, normalized, HttpContext.RequestAborted); + try + { + user.AvatarImageDataUrl = staged; + var result = await _users.UpdateAsync(user); + if (!result.Succeeded) + { + user.AvatarImageDataUrl = previous; + AvatarStorage.TryDelete(staged); + return BadRequest("The photo could not be saved. Your previous photo has been preserved."); + } + } + catch + { + user.AvatarImageDataUrl = previous; + // An acknowledgement failure may follow a committed identity write; retention checks references. + throw; + } + AvatarStorage.TryDelete(previous); + return Ok(new { avatarImageDataUrl = $"data:image/png;base64,{Convert.ToBase64String(normalized)}" }); } - - return Ok(new { avatarImageDataUrl = AvatarStorage.Resolve(user.AvatarImageDataUrl) }); + finally { AvatarProcessingSlots.Release(); } } [HttpDelete("avatar")] @@ -972,7 +1005,7 @@ public sealed class AuthController : ControllerBase return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); } - AvatarStorage.Delete(storedAvatar); + AvatarStorage.TryDelete(storedAvatar); return NoContent(); } diff --git a/JobTrackerApi/Controllers/CvVariantController.cs b/JobTrackerApi/Controllers/CvVariantController.cs index 32a529d..bbcb032 100644 --- a/JobTrackerApi/Controllers/CvVariantController.cs +++ b/JobTrackerApi/Controllers/CvVariantController.cs @@ -50,7 +50,7 @@ public sealed class CvVariantController : ControllerBase var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var proThemes = AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes; - var themes = CvThemeCatalog.Themes.Select(t => new + var themes = CvThemeCatalog.RevisedThemes.Select(t => new { id = t.Id, name = t.Name, @@ -67,6 +67,7 @@ public sealed class CvVariantController : ControllerBase supportedSettings = t.SupportedSettings, pageMarginMm = t.PageMarginMm, bodyFont = t.BodyFont, + headingFont = t.HeadingFont, baseFontSizePt = t.BodySizePt, lineHeight = t.LineHeight, }); @@ -82,7 +83,7 @@ public sealed class CvVariantController : ControllerBase if (user is null) return Unauthorized(); if (!CvThemeCatalog.Exists(themeId)) return NotFound(); var theme = CvThemeCatalog.Resolve(themeId); - var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings { ThemeId = theme.Id, ShowIcons = true }); + var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings { ThemeId = theme.Id, DesignVersion = 2, ShowIcons = true }); var render = _renderer.Render(TemplatePreviewModel(), theme, settings); return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName)); } diff --git a/JobTrackerApi/JobTrackerApi.csproj b/JobTrackerApi/JobTrackerApi.csproj index 716077d..f6e755d 100644 --- a/JobTrackerApi/JobTrackerApi.csproj +++ b/JobTrackerApi/JobTrackerApi.csproj @@ -28,6 +28,8 @@ + + diff --git a/JobTrackerApi/Models/CoverLetterDocumentSettings.cs b/JobTrackerApi/Models/CoverLetterDocumentSettings.cs index b423999..88122d3 100644 --- a/JobTrackerApi/Models/CoverLetterDocumentSettings.cs +++ b/JobTrackerApi/Models/CoverLetterDocumentSettings.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; namespace JobTrackerApi.Models; @@ -6,6 +7,10 @@ namespace JobTrackerApi.Models; public sealed class CoverLetterDocumentSettings { public int SchemaVersion { get; set; } = 1; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? DesignVersion { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? HeadingFont { get; set; } public string Language { get; set; } = "en"; public string SenderName { get; set; } = ""; public string SenderTitle { get; set; } = ""; @@ -38,16 +43,18 @@ public static class CoverLetterDocumentSettingsJson public static CoverLetterDocumentSettings Normalize(CoverLetterDocumentSettings? value) { value ??= new(); - var theme = CvThemeCatalog.Resolve(value.ThemeId); + var theme = CvThemeCatalog.Resolve(value.ThemeId, value.DesignVersion); var style = CvVariantSettingsJson.Normalize(new() { - AccentColor = value.AccentColor, BodyFont = value.BodyFont, + AccentColor = value.AccentColor, BodyFont = value.BodyFont, HeadingFont = value.HeadingFont, BaseFontSizePt = value.BaseFontSizePt, LineHeight = value.LineHeight, PageMarginMm = value.PageMarginMm, }); static string Text(string? text, int limit) => (text ?? "")[..Math.Min(text?.Length ?? 0, limit)]; return new() { SchemaVersion = 1, ThemeId = theme.Id, + DesignVersion = value.DesignVersion is 1 or 2 ? value.DesignVersion : null, + HeadingFont = value.DesignVersion == 2 ? style.HeadingFont ?? theme.HeadingFont : null, Language = value.Language?.Trim().ToLowerInvariant() is "nb" or "nb-no" ? "nb-NO" : "en", SenderName = Text(value.SenderName, 300), SenderTitle = Text(value.SenderTitle, 300), SenderEmail = Text(value.SenderEmail, 300), SenderPhone = Text(value.SenderPhone, 100), SenderLocation = Text(value.SenderLocation, 300), diff --git a/JobTrackerApi/Models/CvTheme.cs b/JobTrackerApi/Models/CvTheme.cs index acdf132..02d6613 100644 --- a/JobTrackerApi/Models/CvTheme.cs +++ b/JobTrackerApi/Models/CvTheme.cs @@ -38,6 +38,8 @@ public sealed class CvTheme public double PageMarginMm { get; init; } = 16; public double SectionGapMm { get; init; } = 6; public double EntryGapMm { get; init; } = 4.5; + public double NameGapMm { get; init; } + public double HeaderContentGapMm { get; init; } // plain | band | centered | kicker public string HeaderStyle { get; init; } = "plain"; @@ -159,6 +161,45 @@ public static class CvThemeCatalog }, }; + // Versioned data keeps existing saved lenses and frozen output on their original design. + public static readonly IReadOnlyList RevisedThemes = Themes.Select(Revised).ToArray(); + + private static CvTheme Revised(CvTheme source) + { + var (nameSize, sectionGap, entryGap) = source.Id switch + { + "modern" => (25d, 5.8, 4.0), + "minimal" => (25d, 7.5, 4.8), + "executive" => (28d, 6.5, 4.6), + "technical" => (24d, 4.8, 3.2), + "ats-classic" => (23d, 5.0, 3.4), + "nordic" => (23d, 6.0, 4.2), + "elegant" => (27d, 7.5, 5.0), + "creative" => (20d, 5.5, 3.8), + _ => (25.5, 5.2, 3.6), + }; + return new CvTheme + { + Id = source.Id, Name = source.Name, Category = source.Category, Description = source.Description, + Layout = source.Layout, SidebarWidthMm = 68, + Accent = source.Accent, Ink = source.Ink, Muted = source.Muted, Line = source.Line, + Paper = source.Paper, SidebarBg = source.SidebarBg, SidebarInk = source.SidebarInk, HeadingColor = source.HeadingColor, + HeadingFont = source.HeadingFont, BodyFont = source.BodyFont, UtilityFont = source.UtilityFont, + NameSizePt = nameSize, HeadingSizePt = source.HeadingSizePt, BodySizePt = source.BodySizePt, LineHeight = source.LineHeight, + PageMarginMm = source.PageMarginMm, SectionGapMm = sectionGap, EntryGapMm = entryGap, + NameGapMm = source.Id is "modern" or "ats-classic" or "code" ? 3 : 0, + HeaderContentGapMm = source.Id is "modern" or "ats-classic" or "code" ? 5 : 0, + HeaderStyle = source.HeaderStyle, HeadingStyle = source.HeadingStyle, PhotoShape = source.PhotoShape, + DefaultIcons = source.DefaultIcons, AtsFriendly = source.AtsFriendly, Premium = source.Premium, + NumberSections = source.NumberSections, DefaultSkillsStyle = source.DefaultSkillsStyle, + SupportedSettings = new(source.SupportedSettings), SidebarSections = new(source.SidebarSections), + }; + } + + public static CvTheme Resolve(string? id, int? designVersion) => designVersion == 2 + ? RevisedThemes.FirstOrDefault(theme => theme.Id == (id ?? string.Empty).Trim().ToLowerInvariant()) ?? RevisedThemes[0] + : Resolve(id); + public static CvTheme Resolve(string? id) { var key = (id ?? string.Empty).Trim().ToLowerInvariant(); diff --git a/JobTrackerApi/Models/CvVariantSettings.cs b/JobTrackerApi/Models/CvVariantSettings.cs index 8363160..364ef65 100644 --- a/JobTrackerApi/Models/CvVariantSettings.cs +++ b/JobTrackerApi/Models/CvVariantSettings.cs @@ -9,6 +9,8 @@ namespace JobTrackerApi.Models; public sealed class CvVariantSettings { public string ThemeId { get; set; } = "modern"; + // Missing or version 1 retains the original template output. + public int? DesignVersion { get; set; } // Theme overrides (null => use the theme's own value). public string? AccentColor { get; set; } @@ -132,6 +134,7 @@ public static class CvVariantSettingsJson { s ??= new CvVariantSettings(); s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant(); + s.DesignVersion = s.DesignVersion is 1 or 2 ? s.DesignVersion : null; s.AccentColor = NormalizeColor(s.AccentColor); s.TextColor = NormalizeColor(s.TextColor); s.MutedColor = NormalizeColor(s.MutedColor); diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 83dd54a..f2f9bb6 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -187,7 +187,7 @@ builder.Services.AddHttpClient("ai-service", client => ?? builder.Configuration["Summarizer:BaseUrl"] ?? "http://127.0.0.1:8001"; client.BaseAddress = new Uri(baseUrl); - client.Timeout = TimeSpan.FromSeconds(30); + client.Timeout = TimeSpan.FromSeconds(Math.Clamp(builder.Configuration.GetValue("Ai:RequestTimeoutSeconds", 30), 5, 300)); var serviceToken = builder.Configuration["Ai:ServiceToken"]; if (!string.IsNullOrWhiteSpace(serviceToken)) diff --git a/JobTrackerApi/Services/AccountOwnedFileInventory.cs b/JobTrackerApi/Services/AccountOwnedFileInventory.cs index e6009c9..5acb0c1 100644 --- a/JobTrackerApi/Services/AccountOwnedFileInventory.cs +++ b/JobTrackerApi/Services/AccountOwnedFileInventory.cs @@ -48,6 +48,11 @@ public sealed class AccountOwnedFileInventory(JobTrackerContext db, AppPaths pat var avatar = await db.Users.AsNoTracking().Where(item => item.Id == ownerUserId).Select(item => item.AvatarImageDataUrl).FirstOrDefaultAsync(cancellationToken); AddAvatar(files, warnings, ownerUserId, avatar); + foreach (var portrait in AvatarStorage.PortraitFiles(paths.DataRoot, AppPaths.GetOwnerStorageKey(ownerUserId))) + { + if (files.Any(file => string.Equals(file.SourcePath, Path.GetFullPath(portrait), OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))) continue; + AddPath(files, warnings, portrait, $"files/avatar/{SafeSegment(Path.GetFileName(portrait))}", "avatar", candidate => IsManagedPath(paths.DataRoot, candidate)); + } AddDirectory(files, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv"); AddDirectory(files, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export"); if (includeAccountExports) diff --git a/JobTrackerApi/Services/AiOperationQueue.cs b/JobTrackerApi/Services/AiOperationQueue.cs index de2393f..6770b50 100644 --- a/JobTrackerApi/Services/AiOperationQueue.cs +++ b/JobTrackerApi/Services/AiOperationQueue.cs @@ -155,13 +155,13 @@ public sealed class AiOperationWorker( private readonly IReadOnlyDictionary _handlers = registeredHandlers .ToDictionary(handler => handler.TaskType, StringComparer.Ordinal); - public async Task RunOnceAsync(CancellationToken stoppingToken) + public async Task RunOnceAsync(CancellationToken stoppingToken, bool documentImportsOnly = false) { if (_handlers.Count == 0) return false; var leaseSeconds = Math.Clamp(configuration.GetValue("AiQueue:LeaseSeconds", 120), 30, 1800); await using var claimScope = scopes.CreateAsyncScope(); var lease = await claimScope.ServiceProvider.GetRequiredService() - .ClaimNextAsync(TimeSpan.FromSeconds(leaseSeconds), stoppingToken, _handlers.Keys.ToArray()); + .ClaimNextAsync(TimeSpan.FromSeconds(leaseSeconds), stoppingToken, _handlers.Keys.ToArray(), documentImportsOnly); if (lease is null) return false; await using var ownerScope = scopes.CreateAsyncScope(); @@ -255,17 +255,18 @@ public sealed class AiOperationHostedService(AiOperationWorker worker, IConfigur { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - if (!configuration.GetValue("Workers:AiOperationsEnabled", false)) return; + var aiEnabled = configuration.GetValue("Workers:AiOperationsEnabled", false); + if (!aiEnabled && !configuration.GetValue("Workers:CvDocumentImportsEnabled", false)) return; var concurrency = Math.Clamp(configuration.GetValue("AiQueue:WorkerConcurrency", 1), 1, 4); - await Task.WhenAll(Enumerable.Range(0, concurrency).Select(_ => RunWorkerAsync(stoppingToken))); + await Task.WhenAll(Enumerable.Range(0, concurrency).Select(_ => RunWorkerAsync(stoppingToken, !aiEnabled))); } - private async Task RunWorkerAsync(CancellationToken stoppingToken) + private async Task RunWorkerAsync(CancellationToken stoppingToken, bool documentImportsOnly) { var idleDelayMs = Math.Clamp(configuration.GetValue("AiQueue:IdleDelayMs", 1000), 100, 10_000); while (!stoppingToken.IsCancellationRequested) { - if (!await worker.RunOnceAsync(stoppingToken)) + if (!await worker.RunOnceAsync(stoppingToken, documentImportsOnly)) await Task.Delay(idleDelayMs, stoppingToken); } } diff --git a/JobTrackerApi/Services/ApplicationAssetsService.cs b/JobTrackerApi/Services/ApplicationAssetsService.cs index f309e7a..6d3a1af 100644 --- a/JobTrackerApi/Services/ApplicationAssetsService.cs +++ b/JobTrackerApi/Services/ApplicationAssetsService.cs @@ -45,6 +45,8 @@ public sealed record CoverLetterDto( int Revision = 1, CoverLetterDocumentSettings? DocumentSettings = null); +public sealed class CoverLetterThemeAccessException() : Exception("This theme requires Pro."); + public interface IApplicationAssetsService { Task GetCvAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); @@ -293,7 +295,9 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService public async Task PreviewCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, CoverLetterDocumentSettings? settings, CancellationToken ct) { var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); - return job is null ? null : _letters.Render(text, settings ?? CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson)); + if (job is null) return null; + await RequireLetterThemeAsync(_db, job, settings, ct); + return _letters.Render(text, settings ?? CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson)); } public async Task RenderSavedCoverLetterAsync(string ownerUserId, int jobApplicationId, int? expectedVersion, CancellationToken ct) @@ -331,6 +335,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService internal static async Task StageCoverLetterAsync(JobTrackerContext db, JobApplication job, string? text, string source, string? aiAction, CancellationToken ct, CoverLetterDocumentSettings? settings = null) { + await RequireLetterThemeAsync(db, job, settings, ct); var currentSettings = CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson)); var nextSettings = settings is null ? currentSettings : CoverLetterDocumentSettingsJson.Serialize(settings); if (string.Equals(job.CoverLetterText, text, StringComparison.Ordinal) && nextSettings == currentSettings) return; @@ -362,6 +367,21 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService if (settings is not null) job.CoverLetterDocumentSettingsJson = nextSettings; } + private static async Task RequireLetterThemeAsync(JobTrackerContext db, JobApplication job, CoverLetterDocumentSettings? settings, CancellationToken ct) + { + if (settings is null) return; + var next = CoverLetterDocumentSettingsJson.Normalize(settings); + if (next.DesignVersion != 2 || CvThemeCatalog.CanUse(next.ThemeId, false)) return; + var current = CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson); + if (current.DesignVersion == 2 && current.ThemeId == next.ThemeId) return; + var roles = await (from userRole in db.UserRoles.AsNoTracking() + join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id + where userRole.UserId == job.OwnerUserId && role.Name != null + select role.Name!).ToListAsync(ct); + if (!CvThemeCatalog.CanUse(next.ThemeId, AccountPlans.ForRoles(roles).ProThemes)) + throw new CoverLetterThemeAccessException(); + } + private Task LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) => _db.JobApplications.AsNoTracking() .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); diff --git a/JobTrackerApi/Services/AvatarImageProcessor.cs b/JobTrackerApi/Services/AvatarImageProcessor.cs new file mode 100644 index 0000000..a33405e --- /dev/null +++ b/JobTrackerApi/Services/AvatarImageProcessor.cs @@ -0,0 +1,38 @@ +using SkiaSharp; + +namespace JobTrackerApi.Services; + +public static class AvatarImageProcessor +{ + public static byte[]? Normalize(byte[] source) + { + if (source.Length is 0 or > 1_000_000) return null; + using var data = SKData.CreateCopy(source); + using var codec = SKCodec.Create(data); + if (codec is null || codec.EncodedFormat is not (SKEncodedImageFormat.Png or SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Webp)) return null; + var info = codec.Info; + if (info.Width is < 1 or > 2048 || info.Height is < 1 or > 2048 || (long)info.Width * info.Height > 4_000_000 || codec.FrameCount > 1) return null; + using var pixels = new SKBitmap(info.Width, info.Height, SKColorType.Rgba8888, SKAlphaType.Premul); + if (codec.GetPixels(pixels.Info, pixels.GetPixels()) != SKCodecResult.Success) return null; + var rotated = codec.EncodedOrigin is SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightTop or SKEncodedOrigin.RightBottom or SKEncodedOrigin.LeftBottom; + using var oriented = new SKBitmap(rotated ? info.Height : info.Width, rotated ? info.Width : info.Height, SKColorType.Rgba8888, SKAlphaType.Premul); + using (var canvas = new SKCanvas(oriented)) + { + switch (codec.EncodedOrigin) + { + case SKEncodedOrigin.TopRight: canvas.Translate(info.Width, 0); canvas.Scale(-1, 1); break; + case SKEncodedOrigin.BottomRight: canvas.Translate(info.Width, info.Height); canvas.RotateDegrees(180); break; + case SKEncodedOrigin.BottomLeft: canvas.Translate(0, info.Height); canvas.Scale(1, -1); break; + case SKEncodedOrigin.LeftTop: canvas.RotateDegrees(90); canvas.Scale(1, -1); break; + case SKEncodedOrigin.RightTop: canvas.Translate(info.Height, 0); canvas.RotateDegrees(90); break; + case SKEncodedOrigin.RightBottom: canvas.Translate(info.Height, info.Width); canvas.RotateDegrees(90); canvas.Scale(-1, 1); break; + case SKEncodedOrigin.LeftBottom: canvas.Translate(0, info.Width); canvas.RotateDegrees(270); break; + } + canvas.DrawBitmap(pixels, 0, 0, new SKSamplingOptions(SKFilterMode.Nearest), null); + } + // Re-encoding decoded pixels drops source metadata and trailing payloads. + using var image = SKImage.FromBitmap(oriented); + using var encoded = image.Encode(SKEncodedImageFormat.Png, 100); + return encoded is null || encoded.Size > 1_000_000 ? null : encoded.ToArray(); + } +} diff --git a/JobTrackerApi/Services/AvatarStorage.cs b/JobTrackerApi/Services/AvatarStorage.cs index 8d999a8..839a463 100644 --- a/JobTrackerApi/Services/AvatarStorage.cs +++ b/JobTrackerApi/Services/AvatarStorage.cs @@ -1,5 +1,7 @@ using System.Security.Cryptography; using System.Text; +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; @@ -7,6 +9,64 @@ public static class AvatarStorage { private const string FilePrefix = "file:"; + public static async Task StageAsync(string dataRoot, string userId, byte[] bytes, CancellationToken cancellationToken) + { + var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant(); + var folder = Path.Combine(dataRoot, "Avatars", key); + Directory.CreateDirectory(folder); + var path = Path.Combine(folder, $"avatar-{Guid.NewGuid():N}.png"); + try { await File.WriteAllBytesAsync(path, bytes, cancellationToken); } + catch + { + TryDelete(FilePrefix + path); + throw; + } + return FilePrefix + path; + } + + public static IEnumerable PortraitFiles(string dataRoot, string ownerKey) + { + var root = Path.Combine(dataRoot, "Avatars"); + var folder = Path.Combine(root, ownerKey); + if (ownerKey.Length != 64 || !ownerKey.All(Uri.IsHexDigit) + || !Directory.Exists(folder) || (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0 + || (File.GetAttributes(folder) & FileAttributes.ReparsePoint) != 0) yield break; + foreach (var path in Directory.EnumerateFiles(folder, "avatar*", new EnumerationOptions { AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true })) + { + var name = Path.GetFileName(path); + if (name is "avatar.png" or "avatar.jpg" or "avatar.webp" + || (name.StartsWith("avatar-", StringComparison.Ordinal) && name.EndsWith(".png", StringComparison.Ordinal) + && name.Length == 43 && Guid.TryParseExact(name[7..^4], "N", out _))) yield return path; + } + } + + public static async Task SweepOrphansAsync(JobTrackerContext db, string dataRoot, DateTimeOffset now, CancellationToken token) + { + var root = Path.Combine(dataRoot, "Avatars"); + if (!Directory.Exists(root) || (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) return 0; + var candidates = Directory.EnumerateDirectories(root, "*", new EnumerationOptions { AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true }) + .Take(1000).SelectMany(folder => PortraitFiles(dataRoot, Path.GetFileName(folder)).Take(1000)) + .Where(path => File.GetLastWriteTimeUtc(path) <= now.AddDays(-1).UtcDateTime) + .Take(1000).Select(path => FilePrefix + Path.GetFullPath(path)).ToList(); + var referenced = (await db.Users.AsNoTracking().Where(user => candidates.Contains(user.AvatarImageDataUrl!)) + .Select(user => user.AvatarImageDataUrl!).ToListAsync(token)) + .ToHashSet(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + var removed = 0; + foreach (var candidate in candidates) + { + token.ThrowIfCancellationRequested(); + if (!referenced.Contains(candidate) && TryDelete(candidate)) removed++; + } + return removed; + } + + public static bool TryDelete(string? value) + { + try { Delete(value); return true; } + catch (IOException) { return false; } + catch (UnauthorizedAccessException) { return false; } + } + public static async Task StoreAsync(string dataRoot, string userId, byte[] bytes, string contentType, CancellationToken cancellationToken) { var userKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant(); diff --git a/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs b/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs index 13a2228..2ba4ff4 100644 --- a/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs +++ b/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs @@ -15,6 +15,8 @@ public sealed class CoverLetterDocumentRenderer static string E(string? value) => WebUtility.HtmlEncode(value ?? ""); static string F(double? value) => value!.Value.ToString("0.##", CultureInfo.InvariantCulture); var font = s.BodyFont!; + var theme = CvThemeCatalog.Resolve(s.ThemeId, s.DesignVersion); + var fonts = s.DesignVersion == 2 ? CvDocumentFonts.Embed(font, s.HeadingFont!, theme.UtilityFont) : CvDocumentFonts.Embed(font); var size = s.PageSize == "letter" ? "215.9mm 279.4mm" : "210mm 297mm"; var width = s.PageSize == "letter" ? "215.9mm" : "210mm"; var height = s.PageSize == "letter" ? "279.4mm" : "297mm"; @@ -40,7 +42,7 @@ public sealed class CoverLetterDocumentRenderer {{E(string.IsNullOrEmpty(title) ? letterLabel : title)}}", css + "", StringComparison.Ordinal) + .Replace("class=\"page letter-paper\"", $"class=\"page letter-paper letter-theme-{theme.Id}\"", StringComparison.Ordinal); + } return new(html, s.ThemeId, "cover-letter.pdf"); } } diff --git a/JobTrackerApi/Services/CvImportRetentionHostedService.cs b/JobTrackerApi/Services/CvImportRetentionHostedService.cs index c21b614..5673268 100644 --- a/JobTrackerApi/Services/CvImportRetentionHostedService.cs +++ b/JobTrackerApi/Services/CvImportRetentionHostedService.cs @@ -90,6 +90,6 @@ public sealed class CvImportRetentionHostedService(IServiceScopeFactory scopes, catch (UnauthorizedAccessException) { } } } - return removed; + return removed + await AvatarStorage.SweepOrphansAsync(db, paths.DataRoot, now, token); } } diff --git a/JobTrackerApi/Services/CvProcessingQueue.cs b/JobTrackerApi/Services/CvProcessingQueue.cs index 094c49f..f5482d6 100644 --- a/JobTrackerApi/Services/CvProcessingQueue.cs +++ b/JobTrackerApi/Services/CvProcessingQueue.cs @@ -23,19 +23,26 @@ public interface ICvProcessingQueue /// Compatibility name for the CV producer bridge. Durable scheduling and execution are owned by /// the shared AI operation queue; this type does not keep an in-memory CV queue. /// -public sealed class CvProcessingQueue(AiOperationAdmission admission) : ICvProcessingQueue +public sealed class CvProcessingQueue(AiOperationAdmission admission, JobTrackerContext db, IConfiguration configuration) : ICvProcessingQueue { public const string TaskType = "cv.process"; public const string SubjectType = "cv_extraction_run"; public async Task EnqueueAsync(int runId, CancellationToken cancellationToken = default) - => await admission.EnqueueAsync( + { + var document = await db.CvExtractionRuns.AnyAsync(run => run.Id == runId && + run.ParserVersion == ProfileCvController.DocumentParserVersion && run.Trigger == "document_import", cancellationToken); + if (document && !configuration.GetValue("Workers:AiOperationsEnabled", false) && + !configuration.GetValue("Workers:CvDocumentImportsEnabled", false)) + throw new AiOperationAdmissionException("cv_worker_disabled", "CV processing is disabled on this server. Ask the administrator to enable document processing, then retry the upload.", StatusCodes.Status503ServiceUnavailable); + return await admission.EnqueueAsync( TaskType, $"run:{runId}", SubjectType, runId.ToString(System.Globalization.CultureInfo.InvariantCulture), AiOperationPriorities.UserVisible, cancellationToken); + } } public sealed class NoOpCvProcessingQueue : ICvProcessingQueue diff --git a/JobTrackerApi/Services/ThemedCvRenderer.cs b/JobTrackerApi/Services/ThemedCvRenderer.cs index f612428..409d78a 100644 --- a/JobTrackerApi/Services/ThemedCvRenderer.cs +++ b/JobTrackerApi/Services/ThemedCvRenderer.cs @@ -24,6 +24,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer public ThemedCvRenderResult Render(CvRenderModel model, CvTheme theme, CvVariantSettings settings) { settings = CvVariantSettingsJson.Normalize(settings); + if (settings.DesignVersion == 2) theme = CvThemeCatalog.Resolve(theme.Id, settings.DesignVersion); var accent = Override(settings.AccentColor, theme.Accent); var headerInk = ContrastInk(accent); var ink = Override(settings.TextColor, theme.Ink); @@ -238,6 +239,12 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer var headingSize = settings.HeadingSizePt ?? t.HeadingSizePt; var lineHeight = settings.LineHeight ?? t.LineHeight; var sidebarWidth = settings.SidebarWidthMm ?? t.SidebarWidthMm; + var sidebarPadding = settings.DesignVersion == 2 ? "8" : margin; + var sidebarBackground = settings.DesignVersion == 2 ? "var(--cv-sidebar-bg)" : "var(--cv-accent-color)"; + var sidebarInk = settings.DesignVersion == 2 ? "var(--cv-sidebar-ink)" : "var(--cv-accent-ink)"; + var sidebarVariables = settings.DesignVersion == 2 ? $"--cv-sidebar-bg:{t.SidebarBg};--cv-sidebar-ink:{t.SidebarInk};" : string.Empty; + var sidebarDetailCss = settings.DesignVersion == 2 + ? ".sidebar .bullets li::marker,.sidebar .skill-group-name{color:var(--cv-sidebar-ink);}" : string.Empty; var headingCss = headingStyle switch { "underline" => ".section-title{border-bottom:1.5px solid var(--cv-accent-soft);padding-bottom:1.5mm;}", @@ -250,13 +257,13 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer : $"{F(sidebarWidth)}mm minmax(0,1fr)"; var layoutCss = twoColumn ? $@".cols{{display:grid;grid-template-columns:{columnTemplate};min-height:{page.h};}} -.sidebar{{background:var(--cv-accent-color);color:var(--cv-accent-ink);padding:{margin}mm;}} -.sidebar .section-title{{color:var(--cv-accent-ink);border-color:color-mix(in srgb,var(--cv-accent-ink) 38%,transparent);}} -.sidebar .tag{{border-color:color-mix(in srgb,var(--cv-accent-ink) 45%,transparent);}} -.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:var(--cv-accent-ink);}} +.sidebar{{background:{sidebarBackground};color:{sidebarInk};padding:{sidebarPadding}mm;}} +.sidebar .section-title{{color:{sidebarInk};border-color:color-mix(in srgb,{sidebarInk} 38%,transparent);}} +.sidebar .tag{{border-color:color-mix(in srgb,{sidebarInk} 45%,transparent);}} +.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{sidebarInk};}} .sidebar a{{color:inherit;}} .main{{padding:{margin}mm;}} -.hero .name{{color:var(--cv-accent-ink);}}" +.hero .name{{color:{sidebarInk};}}" : $@".main{{padding:0 {margin}mm {margin}mm {margin}mm;}} .header{{padding:{margin}mm {margin}mm {F(t.SectionGapMm * density)}mm {margin}mm;display:flex;gap:6mm;align-items:center;}} .header-band{{background:{accent};color:{headerInk};}} @@ -269,9 +276,20 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer ? $@".main{{counter-reset:cv-section;}} .main>.section>.section-title{{counter-increment:cv-section;font-family:{CvDocumentFonts.Stack(t.UtilityFont)};font-weight:600;}} .main>.section>.section-title::before{{content:counter(cv-section,decimal-leading-zero) ' — ';color:var(--cv-accent-color);}}" : string.Empty; + var revisedCss = settings.DesignVersion == 2 + ? $".header .name{{margin-bottom:{F(t.NameGapMm * density)}mm;}}.header + .main{{padding-top:{F(t.HeaderContentGapMm * density)}mm;}}" : string.Empty; + if (settings.DesignVersion == 2 && twoColumn) + { + var edge = layout == "sidebar-right" ? "right" : "left"; + // Margin boxes paint the sidebar bleed without changing the repeated text safe area. + revisedCss += $"@page{{@top-{edge}{{content:\"\";background:{t.SidebarBg};width:{F(sidebarWidth)}mm;}}@bottom-{edge}{{content:\"\";background:{t.SidebarBg};width:{F(sidebarWidth)}mm;}}}}" + + $"body::before{{content:\"\";position:fixed;{edge}:0;top:0;bottom:0;width:{F(sidebarWidth)}mm;background:{t.SidebarBg};z-index:0;}}" + + ".page{position:relative;background:transparent;}.cols{position:relative;z-index:1;}.main{background:var(--cv-paper);}.sidebar{background:transparent;}"; + } + return $@" *{{box-sizing:border-box;}} -:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};--cv-page-margin:{margin}mm;}} +:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};--cv-page-margin:{margin}mm;{sidebarVariables}}} html,body{{min-width:0;}} body{{margin:0;background:#e9edf2;color:var(--cv-ink);font-family:{bodyFont};font-size:{F(bodySize)}pt;line-height:{F(lineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}} .page{{width:{page.w};min-height:{page.h};margin:0 auto;background:var(--cv-paper);overflow:visible;overflow-wrap:anywhere;word-break:normal;}} @@ -325,7 +343,7 @@ h1,h2{{font-family:{headingFont};}} .entry-meta{{color:{muted};font-size:{F(bodySize - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}} .entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}} .entry-tags{{margin-top:1.4mm;}} -{layoutCss} +{layoutCss}{sidebarDetailCss} {numberedSections} /* Print quality: keep normal entries whole, but allow intentionally classified long entries and long list items to flow. An unsplittable block taller than a page is otherwise clipped. */ @@ -349,7 +367,7 @@ body{{background:transparent;}} .main,.sidebar{{padding-top:0;padding-bottom:0;}} }} @page{{size:{page.w} {page.h};margin:{margin}mm 0;}} -"; +" + revisedCss; } private static double DensityScale(string? density) => (density ?? "balanced").Trim().ToLowerInvariant() switch @@ -374,7 +392,7 @@ body{{background:transparent;}} private static string Override(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); - private static string ContrastInk(string background) + internal static string ContrastInk(string background) { if (background.Length != 7 || background[0] != '#' || !background.Skip(1).All(Uri.IsHexDigit)) return "#000"; diff --git a/JobTrackerApi/Services/UserOperationStore.cs b/JobTrackerApi/Services/UserOperationStore.cs index 9e56b99..2ff4d21 100644 --- a/JobTrackerApi/Services/UserOperationStore.cs +++ b/JobTrackerApi/Services/UserOperationStore.cs @@ -107,7 +107,8 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr public async Task ClaimNextAsync( TimeSpan leaseDuration, CancellationToken cancellationToken, - IReadOnlyCollection? allowedTaskTypes = null) + IReadOnlyCollection? allowedTaskTypes = null, + bool documentImportsOnly = false) { if (db.CurrentUserId is not null) throw new InvalidOperationException("Worker claims require a neutral background scope."); ValidateLeaseDuration(leaseDuration); @@ -123,6 +124,12 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr (operation.DeadlineAtUtc == null || operation.DeadlineAtUtc > now)); if (allowedTaskTypes is { Count: > 0 }) candidates = candidates.Where(operation => allowedTaskTypes.Contains(operation.TaskType)); + if (documentImportsOnly) + candidates = candidates.Where(operation => operation.TaskType == CvProcessingQueue.TaskType && + operation.SubjectType == CvProcessingQueue.SubjectType && + db.CvExtractionRuns.IgnoreQueryFilters().Any(run => run.OwnerUserId == operation.OwnerUserId && + run.Id.ToString() == operation.SubjectId && run.ParserVersion == Controllers.ProfileCvController.DocumentParserVersion && + run.Trigger == "document_import")); var candidateIds = await candidates .OrderByDescending(operation => operation.Priority) .ThenBy(operation => operation.CreatedAtUtc) diff --git a/JobTrackerApi/appsettings.Development.json b/JobTrackerApi/appsettings.Development.json index 3866960..db295d5 100644 --- a/JobTrackerApi/appsettings.Development.json +++ b/JobTrackerApi/appsettings.Development.json @@ -1,4 +1,7 @@ { + "Workers": { + "CvDocumentImportsEnabled": true + }, "Database": { "Provider": "mariadb", "ServerVersion": "12.2.2" diff --git a/JobTrackerApi/appsettings.json b/JobTrackerApi/appsettings.json index 32a27fd..69ca7fb 100644 --- a/JobTrackerApi/appsettings.json +++ b/JobTrackerApi/appsettings.json @@ -20,7 +20,8 @@ "SavedJobSearchAlertsEnabled": true, "DailyExportEnabled": false, "JobEnrichmentEnabled": false, - "AiOperationsEnabled": false + "AiOperationsEnabled": false, + "CvDocumentImportsEnabled": false }, "JobDiscovery": { "SavedSearchIntervalHours": 6 diff --git a/JobTrackerApi/packages.lock.json b/JobTrackerApi/packages.lock.json index aa78c13..e698037 100644 --- a/JobTrackerApi/packages.lock.json +++ b/JobTrackerApi/packages.lock.json @@ -112,6 +112,22 @@ "System.Drawing.Common": "6.0.0" } }, + "SkiaSharp": { + "type": "Direct", + "requested": "[4.153.1, )", + "resolved": "4.153.1", + "contentHash": "TghYLOUaP3q6rTjQw7U9uQaaXh8lEs9rfA1U7/VtmyAptVDtj/SmjtcWzFWmcC/VMWxe3QgbV9uyvqDL7ySFtQ==", + "dependencies": { + "SkiaSharp.NativeAssets.Win32": "4.153.1", + "SkiaSharp.NativeAssets.macOS": "4.153.1" + } + }, + "SkiaSharp.NativeAssets.Linux.NoDependencies": { + "type": "Direct", + "requested": "[4.153.1, )", + "resolved": "4.153.1", + "contentHash": "NrnPjcxjcRau4GMOY0BTpUdMey+MnmLU3jC5AuExEFHPS2NkX1tdkXW0n/c5nQMMHBTAMpHnt7qEOpXs2FNfIQ==" + }, "SQLitePCLRaw.lib.e_sqlite3": { "type": "Direct", "requested": "[2.1.12, )", @@ -473,6 +489,16 @@ "resolved": "13.0.3", "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "4.153.1", + "contentHash": "YTojWeWIhnpF7uC8vpMxB5wRONKkDB2t3r/nNPA3DL2pgj9fqMXRevA/rIanYazWnco4jo1ufgp3HkXuh1Tz8A==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "4.153.1", + "contentHash": "Yqg6rChTVh0BUE8QQxtOA3uUBnihfgID4yex/AvDa0+os2bVcBjmI3Qj9tnOB+R1hd/Jry1Gwl8qw5JI9HZG8Q==" + }, "SQLitePCLRaw.bundle_e_sqlite3": { "type": "Transitive", "resolved": "2.1.10", diff --git a/dev/README.md b/dev/README.md index 43e0312..b2b14ba 100644 --- a/dev/README.md +++ b/dev/README.md @@ -35,3 +35,11 @@ OAuth/mail synchronisation, billing and external data providers need their own a The local setup and worker apply passed. Independent model-blob verification passed. A real constrained JSON CPU response passed in 20.546 seconds including model loading. Authenticated sidecar checks passed: trained local job summarisation in 2.518 seconds and an Ollama-backed CV rewrite in 40.997 seconds. These checks assert actual nonempty content, not semantic correctness. No production digest parity or CV extraction accuracy is claimed. Official sources: [Ollama model](https://ollama.com/library/qwen2.5:7b), [frozen registry source](https://registry.ollama.ai/v2/library/qwen2.5/manifests/7b), [Ollama Docker guidance](https://docs.ollama.com/docker), [local/cloud configuration](https://docs.ollama.com/faq). + +## Local engine recovery, 28 October 2026 + +The resumed session found Docker Desktop4.86.0 stopped. Startup failed with Windows error1920 on orphaned AF_UNIX entries in `Docker/run/dockerInference` and `docker-secrets-engine/engine.sock`. Exact socket rename/delete attempts failed. Directory inspection confirmed only zero-byte reparse-point runtime entries; the Docker WSL distribution was stopped. + +After stopping only the failed local Desktop/backend processes, the runtime directories were moved aside and recreated. Backups remain in `C:/Users/Connor/AppData/Local/Docker/run.pre-p10-stale-20261028`, `run.pre-p10-stale-20261028-02` and `C:/Users/Connor/AppData/Local/docker-secrets-engine.pre-p10-stale-20261028`. They contain only the inspected stale IPC entries. The Linux engine then responded successfully as `docker-desktop`. Model files, database volumes and Docker settings were not reset. This was local development recovery; production was not accessed. + +The same failure pattern is reported in [Docker's Windows issue tracker](https://github.com/docker/for-win/issues/15064). The recovery above is supported by current local inspection and a successful engine response; it is not a general instruction to move Docker data directories or reset an installation. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 88ffc10..d359a80 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,6 +5,8 @@ services: - ASPNETCORE_ENVIRONMENT=Development - App__PublicBaseUrl=http://localhost:3000 - Proxy__TrustForwardedHeaders=false + # Deterministic document extraction only; generative jobs stay disabled. + - Workers__CvDocumentImportsEnabled=true ports: - "5202:8080" diff --git a/docs/cv-redesign/linux-run.py b/docs/cv-redesign/linux-run.py index 4588681..0b6317d 100644 --- a/docs/cv-redesign/linux-run.py +++ b/docs/cv-redesign/linux-run.py @@ -91,7 +91,7 @@ def sources(commands): 'scripts/verify-cv-fidelity.py', 'docs/redesign/phase-9/p9-requal-01/pdf-proof.py'} selected = [name for name in paths if name.startswith(prefixes) or name in exact or name.startswith(('Directory.', 'job-tracker-ui/playwright', 'job-tracker-ui/scripts/')) - or name.startswith('docs/cv-redesign/') and name.endswith(('.py', '.ts', '.json'))] + or name.startswith(('docs/cv-redesign/', 'docs/pre-phase10-ux/')) and name.endswith(('.py', '.ts', '.json'))] return {name: digest((ROOT / name).read_bytes()) for name in sorted(set(selected)) if (ROOT / name).is_file()} @@ -240,6 +240,8 @@ def run(args): record['frontend'] = tree(static) if not record['frontend']: raise ValueError('Prebuilt frontend output is required.') + if not any(b'http://localhost:5302/api' in file.read_bytes() for file in static.rglob('*.js')): + raise ValueError('Build the static frontend with NEXT_PUBLIC_API_BASE_URL=http://localhost:5302/api; serving-time environment cannot change compiled client routing.') record.update({'buildRecord': str(Path(args.build_record).resolve()), 'buildRecordSha256': digest(Path(args.build_record).read_bytes()), 'sourceSha256': build_record['sourceSha256'], 'imageId': args.image, 'commit': build_record['commit'], 'schema': schema, 'dbContainer': db, 'dbProject': project, 'apiContainer': api, 'network': network}) @@ -316,12 +318,18 @@ def run(args): 'NEXT_PUBLIC_API_BASE_URL': 'http://localhost:5302/api', 'CV_FIDELITY_OUTPUT': str(out / 'fidelity'), 'CV_SETTINGS_FIDELITY_OUTPUT': str(out / 'settings-fidelity'), 'LETTER_FIDELITY_OUTPUT': str(out / 'letter-fidelity'), 'PLAYWRIGHT_JSON_OUTPUT_NAME': str(out / 'linux-renderer.json')} - commands.run(['node', 'node_modules/@playwright/test/cli.js', 'test', '--config=' + CONFIG, '--retries=0', + config = CONFIG + if args.include_template_library: + config = '../docs/pre-phase10-ux/linux-template.config.ts' + env.update({'PRE_P10_DESIGN_VERSION': '2', 'CV_TEMPLATE_OUTPUT': str(out / 'template-library'), + 'LETTER_LIBRARY_OUTPUT': str(out / 'letter-library')}) + commands.run(['node', 'node_modules/@playwright/test/cli.js', 'test', '--config=' + config, '--retries=0', '--reporter=list,json', '--output=' + str(out / 'test-results')], cwd=ROOT / 'job-tracker-ui', env=env, timeout=1800) native = json.loads((out / 'linux-renderer.json').read_text(encoding='utf-8')) stats = native.get('stats', {}) - if stats.get('expected') != 6 or any(stats.get(key) != 0 for key in ('unexpected', 'flaky', 'skipped')) or native.get('errors'): - raise ValueError('Expected six passing browser cases with no skips, retries or errors.') + expected_cases = 8 if args.include_template_library else 6 + if stats.get('expected') != expected_cases or any(stats.get(key) != 0 for key in ('unexpected', 'flaky', 'skipped')) or native.get('errors'): + raise ValueError(f'Expected {expected_cases} passing browser cases with no skips, retries or errors.') record['browserStats'] = stats if sql(migration_sql).splitlines() != record['migrations'] or sources(commands) != current_sources or tree(static) != record['frontend']: raise ValueError('Source/runtime/migration drift during rendering.') @@ -335,6 +343,10 @@ def run(args): raise ValueError('The original CV16 + settings8 document membership is incomplete.') if {fixture.get('fixture') for fixture in letter.get('fixtures', [])} != {'light-en-a4', 'dark-nb-a4', 'light-nb-letter', 'dark-en-letter'}: raise ValueError('The independent letter4 document membership is incomplete.') + if args.include_template_library: + for folder, count in (('template-library', 36), ('letter-library', 18)): + if len(json.loads((out / folder / 'measurements.json').read_text(encoding='utf-8')).get('fixtures', [])) != count: + raise ValueError('Incomplete revised design corpus: ' + folder) record['exitCode'] = 0 except Exception as error: record['error'] = scrub(error) @@ -373,6 +385,7 @@ def main(): renderer.add_argument('--output', required=True) renderer.add_argument('--build-record', required=True) renderer.add_argument('--image', required=True) + renderer.add_argument('--include-template-library', action='store_true', help='Also render the separate PRE-P10 revised CV and matching-letter corpora.') args = parser.parse_args() return build(args) if args.mode == 'build' else run(args) diff --git a/docs/pre-phase10-ux/B_ACCEPTANCE.md b/docs/pre-phase10-ux/B_ACCEPTANCE.md new file mode 100644 index 0000000..463637c --- /dev/null +++ b/docs/pre-phase10-ux/B_ACCEPTANCE.md @@ -0,0 +1,71 @@ +# B and owner additions: development acceptance + +28 October 2026. Branch `feature/cv-template-library-review`, cumulatively based on accepted A commit `663023a` and local-runtime checkpoint `73ea888`. The source-bearing commit immediately following this report is the qualified checkpoint; source/image hashes are retained in the build records. No main merge or production deployment is authorised. + +**Decision: CONDITIONAL GO for continued development and workstream C. Production: NO-GO.** The application changes and finite supported-document checks pass. The unchanged large-name coordinate oracle remains partial, human/device/print qualifications remain open, and the consolidated PRE-P10 regression is still required after C/D. + +## Delivered + +- Version-two designs for the existing nine compatible CV IDs: title/header separation in Modern/Classic/Code, full-height physical sidebar backgrounds, actual sidebar palettes, wider identities and measured name typography. Existing absent/version-one designs retain their original rendering. +- Actual server-rendered synthetic template cards, lazy loading and sandboxing; explicit candidate Apply preserves CV content and canonical profile data. +- Nine matching cover-letter designs and chooser, current-authored-text preview, explicit snapshot application and existing shared revision/history persistence. Server-side premium entitlement enforcement covers save/preview/restore, while preserving legacy and existing same-theme designs after downgrade. +- Optional CV portrait controls using the existing shared profile/crop/storage owner; keyboard zoom/pan, bounded decoded-image validation, metadata stripping, durable save acknowledgements, staged-file reconciliation and owner-only retention/account inventory. +- Document-only durable queue execution while generative work remains disabled. Disabled workers reject new PDF/DOCX imports with actionable503 rather than acknowledging an unprocessable queue. English/Bokmål recovery guidance is covered. +- Isolated verified local Ollama, approved read-only trained summariser checkpoint and parser-capable sidecar. Development workers are enabled, external AI is off and email is routed to Mailpit. The configurable backend timeout supports slower local CPU inference. Source image activation is a separate operational step, recorded in the local-runtime documentation. + +## Exact evidence + +Counts below are separate lanes. Focused reruns are not added to full-suite totals. + +| Lane | Result | Raw evidence under `.artifacts/` | +|---|---|---| +| Full .NET backend | 1,394 passed,0 failed,0 skipped | `pre-phase10-ux/b-backend-04/b-backend.trx` | +| Full frontend | 1,014 passed across110 suites,0 failed | `pre-phase10-ux/b-frontend-full-02.json` | +| Real MariaDB12.2.2 | 84 passed,0 skipped; generated schemas cleaned | `pre-phase10-ux/b-mariadb-03/` | +| B interactive browser | 6 passed: actual gallery, actual letter preview, keyboard crop/optional photo; EN1440/NB390 | `pre-phase10-ux/b-selection-browser-03.json` | +| Real Docling import | 2 passed: upload→actual extraction→refresh→review/correct/apply→profile→CV→PDF; EN/light and NB/dark | `pre-phase10-ux/queue-real-docling-02.json` | +| Windows revised CV producer | 36 documents/122 pages; marker/page/bounds36/36; whole-name36/36; sidebar43/43 pages | `pre-phase10-ux/templates-revised-02/` | +| Windows matching letters | 18 documents/38 pages; full source retention/count/bounds18/18 | `pre-phase10-ux/matching-letters-02/` | +| Current v1 HTML preservation | 36/36 exact CSS/fonts, body/order and image-normalized HTML; raw12/36 exact | `pre-phase10-ux/templates-revised-02/legacy-preservation.json` | +| Linux producer/browser | 8 passed,0 failed/skipped/flaky; all owned resources cleaned | `cv-ux-01/pre-p10-b-linux-run-02/process.json` | +| Linux original CV fidelity | 24 documents/60 pages; unchanged oracle PASS | `cv-ux-01/pre-p10-b-linux-run-02/linux-pdf.json` | +| Linux original letter fidelity | 4 documents/14 pages; unchanged oracle4/4 PASS | `cv-ux-01/pre-p10-b-linux-run-02/letter-fidelity/fidelity-results.json` | +| Linux revised CVs | 36 documents/122 pages; content/bounds36/36, whole-name36/36, sidebar43/43 pages | `cv-ux-01/pre-p10-b-linux-run-02/template-library/` | +| Linux matching letters | 18 documents/38 pages; full source retention/count/bounds18/18 | `cv-ux-01/pre-p10-b-linux-run-02/letter-library/pdf-proof.json` | +| TypeScript/lint/static build | Native exits0; corrected static API target explicitly verified | `pre-phase10-ux/b-{typecheck-final,lint-final,build-02}.log` | +| NuGet vulnerability query | Exit0; no vulnerable packages reported, including transitives | `pre-phase10-ux/b-dependency-audit.json` | +| Local model integrity/runtime | All pinned blobs reverified28 October; earlier actual JSON/summary/rewrite smokes pass | `pre-phase10-ux/local-ai/` and `dev/README.md` | + +The original Windows24-CV/four-letter corpus and the complete ordinary/contract/security/Python/preservation lanes must be rerun in consolidated integration. Prior accepted results are historical and are not relabelled as fresh B checks. Backend/frontend totals include their existing regression cases; they are not a substitute for browser or provider lanes. + +## Retained failures and limits + +The revised-name unchanged oracle remains **Windows8/36 PASS with28 S01-only failures; Linux16/36 PASS with20 S01-only failures**. No other marker/font/colour/membership failures occur. Separate captured-ink diagnostics show a maximum3px top difference on each final platform. The original2mm character-cell/DOM Range tolerance remains unchanged; the diagnostic is not a replacement acceptance criterion. Source completeness, physical bounds and full-height sidebar pixel checks independently pass. See TEMPLATE_AUDIT.md. + +Earlier failures remain available: signature-only avatar RED;27-case legacy/removal RED;9-case entitlement RED;8 surname-fragment benchmark failures; a worker test stopped before the durable operation acknowledgement (fixed to await both review and completed operation); an initial static build pointed at the wrong test API (six UI failures, corrected by rebuilding with the explicit test endpoint and adding a preflight guard). Docker being stopped prevented one new MariaDB run from starting; no tests ran in that attempt. The subsequent84-test run passed after local IPC recovery. No failed attempt is counted as a skip or a pass. + +The checked browser journeys emit pre-existing disabled-tooltip warnings and a handled checklist seed uniqueness race in the isolated SQLite host; their assertions pass. No general checklist rewrite is included. The MariaDB lane validates real persistence separately. + +Automated JPEG/WebP/EXIF decoder cases ran on Windows; the actual Linux import/gallery corpus exercises the normal cropped-PNG upload path. Human assistive technology, physical devices, native language review and manual printing remain unperformed. Sparse final pages can remain for sparse source content; arbitrary names and custom font sizes cannot be guaranteed to fit without user adjustment. + +Local `qwen2.5:7b` matches the repository's recorded production model name. Exact deployed artifact parity is unverified. Runtime smoke success does not qualify factual CV extraction accuracy or all queued generative workflows. Generative CV-import assistance remains disabled pending its separate grounded-output/model-quality qualification. No production cache, service, database or filesystem concern is treated as resolved. + +## Independent decisions + +| Area | Decision | +|---|---| +| Template implementation, finite content retention and v1 compatibility | PASS | +| Original Linux CV/letter fidelity | PASS | +| Revised full-height sidebars and benchmark name wrapping | PASS | +| Revised large-name coordinate oracle | PARTIAL; original failures retained | +| Actual gallery and explicit matching-letter selection | PASS, finite automation | +| Optional photo workflow/security | CONDITIONAL GO; stated platform/human limits | +| Upload queue recovery / actual deterministic Docling journey | PASS, finite supported fixtures | +| MariaDB / RD21 preservation | PASS in84-test provider lane | +| Local AI runtime / worker configuration | PASS for integrity, connectivity and stated smokes; source activation separately recorded | +| Real generative CV extraction accuracy / deployed model parity | NOT QUALIFIED | +| Workstream C entry | CONDITIONAL GO; continue safe development | +| Consolidated PRE-P10 acceptance / Phase10 cleanup | NOT YET READY | +| Production | NO-GO | + +Next: activate qualified source images only in the existing local development stack; then create `feature/dashboard-interactive-grid` from this accepted cumulative checkpoint. Keep all C/D and consolidated integration requirements in PLAN.md. No additional owner approval is needed for these already-authorised safe development steps. diff --git a/docs/pre-phase10-ux/OWNER_ADDENDUM.md b/docs/pre-phase10-ux/OWNER_ADDENDUM.md index cfb265b..18275ea 100644 --- a/docs/pre-phase10-ux/OWNER_ADDENDUM.md +++ b/docs/pre-phase10-ux/OWNER_ADDENDUM.md @@ -24,8 +24,8 @@ Local runtime target: official Ollama0.40.11 linux/amd64 image digest `sha256:45 ## Checkpoint additions -- [ ] B-owner: qualify title spacing/full-page sidebar backgrounds and real template thumbnails; match letter designs through versioned settings and existing revision owners. -- [ ] Import-recovery: document-only worker can consume prior document imports while model jobs remain disabled; disabled workers return an actionable503; real Docling journey verifies completion. +- [x] B-owner: qualified title spacing/full-page sidebar backgrounds and real template thumbnails; matching letter designs use versioned settings and existing revision owners. Finite evidence and partial oracle/human qualifications are explicit in B_ACCEPTANCE.md. +- [x] Import-recovery: document-only worker consumes prior document imports while model jobs remain disabled; disabled workers return an actionable503; two real Docling journeys verify completion. - [x] Local-options configuration: guarded standalone dev stack identity/loopback/network/storage boundaries; authorised workers enabled; email forced to Mailpit. Account entitlements, schedules and missing provider credentials remain applicable. This is configuration activation, not full feature acceptance. - [x] Local-Ollama: independent pinned container/cache; all official manifest blobs verified; actual constrained JSON CPU inference and authenticated sidecar summary/rewrite checks passed. Deployed digest parity and real CV extraction accuracy remain unqualified. diff --git a/docs/pre-phase10-ux/PLAN.md b/docs/pre-phase10-ux/PLAN.md index 200dddf..f96f2e1 100644 --- a/docs/pre-phase10-ux/PLAN.md +++ b/docs/pre-phase10-ux/PLAN.md @@ -27,8 +27,8 @@ No main merge, production service/database/storage/model/cache/deployment operat - [x] P10-UX-00: read owner specification, verify latest CV-QA ancestry/remote/entry, isolate managed worktree, audit four owners and choose compatibility-first design. Create feedback register and branch plan. - [x] A-01: reproduce blank hydration/header/export and Strict Mode history failures in focused tests plus actual local development browser. Save RED evidence; no application fix before reproduction. - [x] A-02: nullable explicit inclusion flags across resolver/editor/AI/matcher, deliberate creation/profile selection, empty export validation; pure immutable grouped session history/shortcuts/epoch guards; focused unit/provider/browser and RD21 checks, review, docs, commit/push/verify. Evidence: A_ACCEPTANCE.md, backend108/frontend98/browser4/MariaDB82, TypeScript/lint0; fresh review fixes have RED/GREEN tests. Commit is the source-bearing checkpoint immediately following5d4b8f2. -- [ ] B-00: branch from accepted A; inventory9 IDs, render equivalent short/medium/long/dense samples, establish measured spacing/pagination defects and legacy source/PDF proof. -- [ ] B-01: opt-in revised design semantics and template-specific spacing/palette/identity fixes; portrait controls reuse secure existing storage/crop with keyboard alternatives; tests and per-template Windows/Linux visual evidence; commit/push/verify. +- [x] B-00: branch from accepted A; inventory9 IDs, render equivalent short/medium/long/dense samples, establish measured spacing/pagination defects and legacy source/PDF proof. See TEMPLATE_AUDIT.md. +- [x] B-01 implementation/qualification: versioned designs, actual samples, matching letters, secure optional photographs, queue repair and local runtime. Backend1394/frontend1014/MariaDB84/interactive browser6/real Docling2; revised CV36+letter18 per platform and original Linux24+4. Conditional development acceptance and retained large-name oracle failures: B_ACCEPTANCE.md. Commit/push/remote verification closes this checkpoint. - [ ] C-00: branch from accepted B; verify official grid API/licence/compatibility/bundle, define v2 JSON and v1 migration tests. - [ ] C-01: interactive real widget grid/edit/catalogue with geometry, min sizes, keyboard/touch alternatives, conflict-safe persistence; unit/provider/drag/resize/reflow/browser coverage; commit/push/verify. - [ ] D-00: branch from accepted C; define exact safe diagnostic/event schema and admin identity/association policies; tests first. diff --git a/docs/pre-phase10-ux/TEMPLATE_AUDIT.md b/docs/pre-phase10-ux/TEMPLATE_AUDIT.md new file mode 100644 index 0000000..d0c548e --- /dev/null +++ b/docs/pre-phase10-ux/TEMPLATE_AUDIT.md @@ -0,0 +1,63 @@ +# Template audit and versioned design library + +27 October 2026. Source baseline: accepted A checkpoint `663023a`; local runtime checkpoint `73ea888` is additive. The nine existing theme IDs are retained for saved-document compatibility. New documents and explicit template Apply opt into `designVersion: 2`. Missing/version-one settings retain the original catalogue and rendering. Duplicate/restore preserve their stored design version. + +## Inventory and changes + +Body typography, line height, paper size and default page margins remain theme-specific and unchanged. Existing font embedding and licensing remain authoritative. The revised scale below is in points for the name and millimetres for section/entry gaps. + +| Theme | Family | Name / section / entry | Revised treatment | +|---|---|---|---| +| Modern | Single column, blue header band | 25 / 5.8 / 4 | 3mm name gap and 5mm header-to-main separation | +| Minimal | Plain single column | 25 / 7.5 / 4.8 | Open section/entry rhythm, restrained rules | +| Executive | Centred editorial serif | 28 / 6.5 / 4.6 | Centred identity and measured entry spacing | +| Technical | Left technical sidebar | 24 / 4.8 / 3.2 | 68mm sidebar, 8mm internal padding, readable teal/white palette | +| ATS Classic | Plain text-oriented column | 23 / 5 / 3.4 | 3mm name gap and 5mm header-to-main separation | +| Nordic | Right pale sidebar | 23 / 6 / 4.2 | Actual pale catalogue palette; wider identity column | +| Elegant | Serif heading, editorial kicker | 27 / 7.5 / 5 | Distinct editorial hierarchy and entry rhythm | +| Creative | Left magenta sidebar | 20 / 5.5 / 3.8 | Wider identity column, actual magenta/white palette | +| Code | Numbered developer sections | 25.5 / 5.2 / 3.6 | 3mm name gap and 5mm header-to-main separation; mono details | + +Original sidebar usable width was approximately30mm; revised usable width is52mm. Version-two sidebars use fixed page backgrounds plus native print margin boxes, covering every physical page without consuming text-safe margins. Existing pagination/content rules are preserved. No source facts or filler are inserted to fill sparse pages. + +## Real visual specimens + +The same synthetic short English, medium Bokmål, long English and dense Bokmål histories were rendered across all nine designs. They include long names, Norwegian characters, missing optional fields, long bullets, many skills and a deliberately nonhuman rectangle image. The reference profile is identical across themes; all outputs retain source markers and profile/document revision checks. + +| Corpus | Documents | Physical pages | Content/count/bounds | +|---|---:|---:|---| +| Version-one baseline | 36 | 122 | 36/36 passed | +| Revised version two | 36 | 122 | 36/36 passed | +| Matching letters, short EN / long NB | 18 | 38 | 18/18 passed | + +Final Windows roots: `.artifacts/pre-phase10-ux/templates-legacy`, `templates-revised-02`, `matching-letters-02`. Each includes source measurements and native PDF bytes. CV contact sheets cover first and last pages for all four histories; letter sheets cover both histories. CV Poppler review includes63 first/last images; letters render all38 pages. Earlier `*-01` roots remain immutable intermediate evidence. Linux qualification is recorded separately when executed. + +Physical page changes: short Creative2→1; long Technical4→5; dense Technical5→6; dense Creative7→6. Other cases retain their page counts. More readable spacing can require additional pages. The total remains122; no content was dropped to reduce page count. + +All43 revised CV sidebar pages pass an independent raster check at two physical edge columns, every pixel row including top/bottom, tolerance±2RGB. Initial version-two specimens still split an unhyphenated surname in Nordic/Creative: the added benchmark reported28/36 passes and8 failures. Their name sizes were adjusted to the measured52mm column; fresh specimens pass36/36 with no unhyphenated name fragments. Name wrapping now improves Technical7→4 lines, Nordic7→4 and Creative8→4. Natural hyphen breaks remain allowed. Arbitrary longer names/custom font sizes cannot be guaranteed; existing typography controls remain available. Minimal can retain a sparse final page. + +## Fidelity and preservation accounting + +The unchanged marker oracle reports8/36 passes and28 S01-only failures in **both** baseline and revised CV sets. The large name marker's DOM Range top differs from the PDF character-cell top by2.05–3.16mm, exceeding the original2mm limit. Other marker position, font, colour and membership checks pass. The tolerance was not relaxed and these failures remain in `fidelity-results.json`. + +Separate actual-ink diagnostics compare the captured preview/PDF foreground pixels: maximum top difference2px baseline and3px revised. Viewed crops support a coordinate metric mismatch for this large marker, rather than demonstrated visible displacement. This diagnostic does not replace the original oracle or human print acceptance. + +Current-source version-one comparison passes36/36 exact CSS (including embedded fonts), normalized HTML, body and marker-order checks. Raw HTML is byte-identical12/36; the other24 differ only in the synthetic avatar data-URI bytes after decoded-pixel normalization. Only image payload bytes are excluded in that explicitly labelled comparison. Original goldens and historical acceptance records are preserved. + +The first letter proof used whole-page line extraction, interleaving sidebar and body text in six documents. Its12/18 result is retained in `matching-letters-01/pdf-proof-linear-reading.json`. Physical column-aware extraction then verifies all18 complete body/recipient/sender/subject texts without changing PDF bytes, marker counts, page dimensions or word-bound checks. Fresh final letter specimens also pass18/18. This is a reading-order correction in the independent proof, not a content tolerance exception. + +## Actual previews and matching letters + +CV selection cards now lazily load the existing server-rendered synthetic sample endpoint and display a scaled sandboxed, noninteractive document. Only synthetic samples are cached; user documents are not placed in the thumbnail cache. The full candidate dialog remains read-only until explicit Apply. Skeleton/failure states are translated. The former test forbidding any thumbnail iframe was intentionally replaced because the owner explicitly requested actual rendered previews; isolation and distinct-theme checks remain covered. + +The letter chooser uses the same nine available/locked themes. Explicit Apply changes the local style snapshot and existing actual letter preview, preserving current text, sender, recipient, date, language and page size. Match CV also copies the reviewed heading font and existing paper-size behaviour. Existing letter revision/save/history owners remain unchanged. Version-two rendering matches palette, fonts and appropriate header/sidebar details; old letter settings remain version one and serialize without added null properties. Shared server-side persistence/preview checks reject new premium-theme activation without the existing ProThemes entitlement, including restore. Legacy settings and currently saved same-theme premium designs remain usable after downgrade. The review-discovered API bypass has5 actual RED failures followed by the full1394-test backend GREEN. + +## Optional portrait safety + +The existing shared profile portrait remains the only image owner. CV controls link to that editor and retain per-CV Show Photo. Crop zoom and both axes have keyboard sliders, labels and existing bounded512px output. + +Uploads now validate actual stream size, decode still PNG/JPEG/WebP, enforce2048px axes/4million pixels, orient EXIF and re-encode PNG without source metadata. Decode concurrency is bounded. Corrupt, truncated, unsupported and animated inputs are rejected. Identity failure retains the old image; ambiguous acknowledgement loss preserves the staged target for reconciliation. Cleanup failure after durable success does not report a false failed save/removal. + +The existing hourly retention hook deletes unreferenced staged/exact legacy portrait files older than24hours, preserving current references and excluding reparse paths. Account export/erasure inventory includes owner-only staged and legacy leftovers. No second image store or master-profile owner was introduced. Decoder dependency: pinned SkiaSharp4.153.1 plus Linux native assets, locked transitive hashes; MIT upstream licence. + +Human accessibility, physical devices, native language review and manual printing remain open. Neither synthetic images nor browser automation establish those qualifications. diff --git a/docs/pre-phase10-ux/letter-library-proof.py b/docs/pre-phase10-ux/letter-library-proof.py new file mode 100644 index 0000000..b7b6862 --- /dev/null +++ b/docs/pre-phase10-ux/letter-library-proof.py @@ -0,0 +1,90 @@ +"""Independent PDF retention/bounds and Poppler contact-sheet proof for 18 letters. + +Keeps the original four-letter corpus and all fidelity tolerances untouched. +""" +import argparse +import hashlib +import json +import re +import subprocess +import unicodedata +from collections import Counter +from pathlib import Path + +import pdfplumber +from PIL import Image, ImageDraw + + +def normalize(text): + return re.sub(r"\s+", "", unicodedata.normalize("NFKC", text)).casefold() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("directory", type=Path) + parser.add_argument("--poppler", type=Path, required=True) + args = parser.parse_args() + source = json.loads((args.directory / "measurements.json").read_text(encoding="utf-8")) + fixtures = [] + for fixture in source["fixtures"]: + directory = args.directory / fixture["fixture"] + filename = directory / "export.pdf" + failures, pages, markers, texts = [], [], [], [] + columns = [[], []] + sidebar = fixture["measured"]["pageDisplay"] == "grid" + with pdfplumber.open(filename) as document: + for number, page in enumerate(document.pages, 1): + text = page.extract_text() or "" + texts.append(text) + if sidebar: + # PDF line extraction otherwise interleaves separate sidebar/body columns. + boundary = (210 - 68 if fixture["settings"]["themeId"] == "nordic" else 68) * 72 / 25.4 + for index, bounds in enumerate(((0, 0, boundary, page.height), (boundary, 0, page.width, page.height))): + columns[index].append(page.crop(bounds).extract_text() or "") + markers.extend(re.findall(r"\b[SEBPR]\d{2}\b", text)) + words = page.extract_words() + outside = [word for word in words if word["x0"] < -1.42 or word["top"] < -1.42 + or word["x1"] > page.width + 1.42 or word["bottom"] > page.height + 1.42] + if outside or not words: + failures.append(f"page {number}: {len(outside)} outside words; {len(words)} total words") + if abs(page.width * 25.4 / 72 - 210) > .5 or abs(page.height * 25.4 / 72 - 297) > .5: + failures.append(f"page {number}: unexpected A4 dimensions") + pages.append({"page": number, "widthPt": page.width, "heightPt": page.height, "words": len(words), "outsideWords": outside}) + combined = normalize("\n".join("\n".join(column) for column in columns) if sidebar else "\n".join(texts)) + for text in fixture["expectedParagraphs"] + [fixture["expectedRecipient"], fixture["expectedSender"], fixture["expectedSubject"]]: + if normalize(text) not in combined: + failures.append("Missing or changed source text: " + text[:90]) + if Counter(markers) != Counter(fixture["expectedMarkers"]): + failures.append("PDF/source marker multisets differ") + if len(pages) != fixture["previewPages"]: + failures.append("PDF/preview page count differs") + for page in range(1, len(pages) + 1): + subprocess.run([str(args.poppler), "-f", str(page), "-l", str(page), "-singlefile", "-scale-to", "1400", "-png", str(filename), str(directory / f"poppler-{page}")], check=True, capture_output=True) + fixtures.append({"fixture": fixture["fixture"], "pass": not failures, "failures": failures, + "pdfSha256": hashlib.sha256(filename.read_bytes()).hexdigest(), "pages": pages, + "expectedMarkers": len(fixture["expectedMarkers"]), "actualMarkers": len(markers)}) + report = {"total": len(fixtures), "passed": sum(item["pass"] for item in fixtures), + "pages": sum(len(item["pages"]) for item in fixtures), "fixtures": fixtures, + "qualification": "Finite local synthetic retention, page dimensions/count and physical word bounds; sidebar text extracted by physical columns to avoid line interleaving. Not human print acceptance."} + (args.directory / "pdf-proof.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + for language in ("short-en", "long-nb"): + for edge in ("first", "last"): + sheet = Image.new("RGB", (900, 1365), "#e5e7eb") + draw = ImageDraw.Draw(sheet) + for index, fixture in enumerate([item for item in fixtures if item["fixture"].startswith(language + "-")]): + number = 1 if edge == "first" else len(fixture["pages"]) + with Image.open(args.directory / fixture["fixture"] / f"poppler-{number}.png") as image: + image.thumbnail((286, 414)) + x, y = (index % 3) * 300 + 7, (index // 3) * 455 + 32 + sheet.paste(image, (x, y)) + draw.text((x, y - 25), f"{fixture['fixture']} p{number}/{len(fixture['pages'])}", fill="#111827") + sheet.save(args.directory / f"contact-{language}-{edge}.png") + print(f"Matching letter physical proof: {report['passed']}/{report['total']} documents, {report['pages']} pages") + for item in fixtures: + if not item["pass"]: + print(item["fixture"], item["failures"]) + raise SystemExit(0 if report["passed"] == report["total"] else 1) + + +if __name__ == "__main__": + main() diff --git a/docs/pre-phase10-ux/linux-template.config.ts b/docs/pre-phase10-ux/linux-template.config.ts new file mode 100644 index 0000000..f6fa469 --- /dev/null +++ b/docs/pre-phase10-ux/linux-template.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from '../../job-tracker-ui/node_modules/@playwright/test'; +import base from '../cv-redesign/linux/playwright.config'; + +// Keep the original 24 CVs/four letters and their assertions in this same native lane. +export default defineConfig({ ...base, + testMatch: ['cv-readiness.spec.ts', 'cv-settings-presentation.spec.ts', 'cover-letter-document-fidelity.spec.ts', + 'pre-p10-template-library.spec.ts', 'pre-p10-letter-library.spec.ts'], + grep: /native preview\/PDF fidelity corpus|document controls preserve settings|cover letter native preview\/PDF fidelity|\[PRE-P10 B\]/, + metadata: { ...base.metadata, revisedCvDocuments: 36, matchingLetterDocuments: 18 }, +}); diff --git a/docs/pre-phase10-ux/template-name-wrap-proof.py b/docs/pre-phase10-ux/template-name-wrap-proof.py new file mode 100644 index 0000000..c7b7d14 --- /dev/null +++ b/docs/pre-phase10-ux/template-name-wrap-proof.py @@ -0,0 +1,22 @@ +"""Check that the benchmark's unhyphenated name words are not broken into fragments.""" +import argparse +import json +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory", type=Path) + args = parser.parse_args() + source = json.loads((args.directory / "measurements.json").read_text(encoding="utf-8")) + fixtures = [{"fixture": row["fixture"], "brokenWords": [word for word in row["measured"]["splitNameWords"] if "-" not in word]} + for row in source["fixtures"]] + result = {"total": len(fixtures), "passed": sum(not row["brokenWords"] for row in fixtures), "fixtures": fixtures, + "limits": "Finite synthetic name fixture only; natural hyphen breaks permitted, arbitrary names/custom font sizes not guaranteed."} + (args.directory / "name-wrap-proof.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=True)) + raise SystemExit(0 if result["passed"] == result["total"] else 1) + + +if __name__ == "__main__": + main() diff --git a/docs/pre-phase10-ux/template-namebox-proof.py b/docs/pre-phase10-ux/template-namebox-proof.py new file mode 100644 index 0000000..4e3979e --- /dev/null +++ b/docs/pre-phase10-ux/template-namebox-proof.py @@ -0,0 +1,51 @@ +"""Separate DOM Range/PDF character-cell metrics from visible raster ink alignment. + +This leaves the original 2mm fidelity oracle and all its failures unchanged. +""" +import argparse +import json +import math +import re + +from pathlib import Path +from PIL import Image + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("directory", type=Path) + args = parser.parse_args() + source = json.loads((args.directory / "measurements.json").read_text(encoding="utf-8")) + oracle = json.loads((args.directory / "fidelity-results.json").read_text(encoding="utf-8")) + results = [] + for fixture in source["fixtures"]: + directory = args.directory / fixture["fixture"] + marker = next(item for item in fixture["measured"]["tokens"] if item["token"] == "S01") + rect = next(item for item in fixture["measured"]["nameLines"] if item["text"] == "S01") + foreground = tuple(map(int, re.findall(r"\d+", marker["color"])[:3])) + bounds = (math.floor(rect["x"]), math.floor(rect["y"]), math.ceil(rect["x"] + rect["width"]), math.ceil(rect["y"] + rect["height"] + 12)) + boxes = {} + for kind, filename in (("preview", "preview-1.png"), ("pdf", "pdf-1.png")): + with Image.open(directory / filename) as image: + crop = image.convert("RGB").crop(bounds) + pixels = [(x, y) for y in range(crop.height) for x in range(crop.width) + if max(abs(a - b) for a, b in zip(crop.getpixel((x, y)), foreground)) <= 50] + if not pixels: + raise ValueError(f"No foreground ink for {fixture['fixture']} {kind}") + boxes[kind] = [min(x for x, y in pixels) + bounds[0], min(y for x, y in pixels) + bounds[1], + max(x for x, y in pixels) + bounds[0], max(y for x, y in pixels) + bounds[1]] + crop.resize((crop.width * 4, crop.height * 4)).save(directory / f"S01-{kind}-ink.png") + original = next(item for item in oracle["fixtures"] if item["fixture"] == fixture["fixture"]) + comparison = next(item for item in original["comparisons"] if item["token"] == "S01") + results.append({"fixture": fixture["fixture"], "domRange": rect, "foregroundRgb": foreground, + "originalOracleRangeVsPdfCharacterCellDeltaMm": comparison["deltaYmm"], + "visibleInkBoundsPx": boxes, "visibleInkTopDeltaPx": abs(boxes["preview"][1] - boxes["pdf"][1]), + "visibleInkTopDeltaMm": abs(boxes["preview"][1] - boxes["pdf"][1]) * 25.4 / 96}) + report = {"qualification": "Separate diagnostic measurements only. Pixels within 50 per RGB channel of the recorded foreground identify visible ink in the S01 ROI. Original fidelity tolerances/failures are unchanged.", + "fixtures": results, "maxVisibleInkTopDeltaPx": max(item["visibleInkTopDeltaPx"] for item in results)} + (args.directory / "name-box-metrics.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"S01 raster-ink diagnostic: {len(results)} documents, max top delta {report['maxVisibleInkTopDeltaPx']}px; original oracle unchanged") + + +if __name__ == "__main__": + main() diff --git a/docs/pre-phase10-ux/template-pdf-proof.py b/docs/pre-phase10-ux/template-pdf-proof.py new file mode 100644 index 0000000..49aca7a --- /dev/null +++ b/docs/pre-phase10-ux/template-pdf-proof.py @@ -0,0 +1,68 @@ +"""Independent byte-bound completeness/bounds report; no presentation-quality goldens. + +Run after the producer and unchanged scripts/verify-cv-fidelity.py. All reported +whitespace/name-wrap observations remain measurements for human review. +""" +import argparse +import hashlib +import json +import re +from collections import Counter +from pathlib import Path + +import pdfplumber + + +def inspect(root, fixture): + filename = root / fixture["fixture"] / "export.pdf" + failures, pages, markers = [], [], [] + with pdfplumber.open(filename) as document: + for index, page in enumerate(document.pages): + words = page.extract_words() + text = page.extract_text() or "" + markers.extend(re.findall(r"\b[SEBPR]\d{2}\b", text)) + outside = [word for word in words if word["x0"] < -1.42 or word["top"] < -1.42 + or word["x1"] > page.width + 1.42 or word["bottom"] > page.height + 1.42] + if outside: + failures.append(f"page {index + 1}: {len(outside)} words outside physical page by more than 0.5mm") + if not words: + failures.append(f"page {index + 1}: blank output") + top = min((word["top"] for word in words), default=0) + bottom = max((word["bottom"] for word in words), default=0) + pages.append({"page": index + 1, "wordCount": len(words), "widthPt": page.width, + "heightPt": page.height, "textTopPt": top, "textBottomPt": bottom, + "occupiedVerticalFraction": (bottom - top) / page.height, + "trailingWhitespaceFraction": (page.height - bottom) / page.height, + "outsideWords": outside, "text": text, + "embeddedImages": len(page.images)}) + if Counter(markers) != Counter(fixture["expectedMarkers"]): + failures.append("PDF marker multiset differs from independent source fixture") + if len(pages) != fixture["previewPages"]: + failures.append("PDF/preview page count differs") + return {"fixture": fixture["fixture"], "pass": not failures, "failures": failures, + "pdfSha256": hashlib.sha256(filename.read_bytes()).hexdigest(), "expectedMarkerCount": len(fixture["expectedMarkers"]), + "actualMarkerCount": len(markers), "nameLineCount": fixture["measured"]["nameLineCount"], + "splitNameWords": fixture["measured"]["splitNameWords"], "pages": pages} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("directory", type=Path) + args = parser.parse_args() + measurements = json.loads((args.directory / "measurements.json").read_text(encoding="utf-8")) + fixtures = [inspect(args.directory, item) for item in measurements["fixtures"]] + result = {"measurementSha256": hashlib.sha256((args.directory / "measurements.json").read_bytes()).hexdigest(), + "designVersion": measurements["designVersion"], "total": len(fixtures), + "passed": sum(item["pass"] for item in fixtures), "pages": sum(len(item["pages"]) for item in fixtures), + "qualification": "Finite synthetic completeness and physical bounds only; whitespace and name wrapping require visual review.", + "fixtures": fixtures} + (args.directory / "pdf-proof.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"Source-bound PDF completeness/bounds: {result['passed']}/{result['total']} documents, {result['pages']} pages") + for item in fixtures: + if not item["pass"]: + print(item["fixture"], item["failures"]) + raise SystemExit(0 if result["passed"] == result["total"] else 1) + + +if __name__ == "__main__": + main() diff --git a/docs/pre-phase10-ux/template-sidebar-proof.py b/docs/pre-phase10-ux/template-sidebar-proof.py new file mode 100644 index 0000000..097b6f4 --- /dev/null +++ b/docs/pre-phase10-ux/template-sidebar-proof.py @@ -0,0 +1,63 @@ +"""Inspect actual PDF raster pixels at both sidebar edges on every exported page.""" +import argparse +import json +import re +from pathlib import Path + +from PIL import Image, ImageDraw + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("directory", type=Path) + args = parser.parse_args() + source = json.loads((args.directory / "measurements.json").read_text(encoding="utf-8")) + results = [] + for fixture in source["fixtures"]: + if not fixture["fixture"].endswith(("-technical", "-creative", "-nordic")): + continue + html = (args.directory / fixture["fixture"] / "render.html").read_text(encoding="utf-8") + color = re.search(r"--cv-sidebar-bg:(#[0-9a-fA-F]{6})", html) + if not color: + continue + expected = tuple(int(color[1][index:index + 2], 16) for index in (1, 3, 5)) + for number in range(1, fixture["previewPages"] + 1): + with Image.open(args.directory / fixture["fixture"] / f"pdf-{number}.png") as image: + image = image.convert("RGB") + # 2px and 8px lie inside the bleed and outside the 8mm text padding. + columns = [image.width - 3, image.width - 9] if fixture["fixture"].endswith("-nordic") else [2, 8] + samples = [] + for x in columns: + mismatched = [y for y in range(image.height) if max(abs(a - b) for a, b in zip(image.getpixel((x, y)), expected)) > 2] + samples.append({"x": x, "expectedRgb": expected, "height": image.height, + "mismatchedRows": len(mismatched), "firstMismatchRows": mismatched[:8], + "topPixel": image.getpixel((x, 0)), "bottomPixel": image.getpixel((x, image.height - 1))}) + results.append({"fixture": fixture["fixture"], "page": number, "pass": all(sample["mismatchedRows"] == 0 for sample in samples), "samples": samples}) + report = {"rasterizer": "pypdfium2 via unchanged verify-cv-fidelity.py, actual exported PDF bytes", + "colorTolerancePerChannel": 2, "passedPages": sum(item["pass"] for item in results), "pages": len(results), "results": results} + (args.directory / "sidebar-pixels.json").write_text(json.dumps(report, indent=2), encoding="utf-8") + print(f"Full-height sidebar raster coverage: {report['passedPages']}/{report['pages']} pages") + + # Contact sheets use independent Poppler first/last rasterizations. + for sample in ("short-en", "medium-nb", "long-en", "dense-nb"): + fixtures = [item for item in source["fixtures"] if item["fixture"].startswith(sample + "-")] + for edge in ("first", "last"): + sheet = Image.new("RGB", (900, 1365), "#e5e7eb") + draw = ImageDraw.Draw(sheet) + for index, fixture in enumerate(fixtures): + page = 1 if edge == "first" else fixture["previewPages"] + filename = args.directory / fixture["fixture"] / f"poppler-{page}.png" + if not filename.exists(): + raise FileNotFoundError(filename) + with Image.open(filename) as image: + image = image.convert("RGB") + image.thumbnail((286, 414)) + x, y = (index % 3) * 300 + 7, (index // 3) * 455 + 32 + sheet.paste(image, (x, y)) + draw.text((x, y - 25), f"{fixture['fixture']} p{page}/{fixture['previewPages']}", fill="#111827") + sheet.save(args.directory / f"contact-{sample}-{edge}.png") + raise SystemExit(0 if report["passedPages"] == report["pages"] and report["pages"] > 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/job-tracker-ui/e2e/pre-p10-letter-library.spec.ts b/job-tracker-ui/e2e/pre-p10-letter-library.spec.ts new file mode 100644 index 0000000..6243371 --- /dev/null +++ b/job-tracker-ui/e2e/pre-p10-letter-library.spec.ts @@ -0,0 +1,124 @@ +import { expect, test } from '@playwright/test'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { previewDocumentHtml } from '../src/cvPreview'; + +const api = 'http://localhost:5302/api'; +const output = path.resolve(process.env.LETTER_LIBRARY_OUTPUT || '../.artifacts/pre-phase10-ux/matching-letters-01'); +const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex'); + +test('[PRE-P10 B] nine matching letter designs retain short EN and long NB physical documents', async ({ page, context, browser }, info) => { + test.setTimeout(600_000); + expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); + 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(); + }); + expect((await page.request.post(`${api}/auth/login`, { data: { email: 'e2e@example.test', password: 'E2ePassword123!' } })).status()).toBe(200); + const me = await (await page.request.get(`${api}/auth/me`)).json(); + const headers = { 'X-CSRF-TOKEN': (await page.request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string }; + const sourceBefore = await (await page.request.get(`${api}/career/profile`)).json(); + const themes = await (await page.request.get(`${api}/cv/themes`)).json(); + expect(themes).toHaveLength(9); + const companyResponse = await page.request.post(`${api}/companies`, { headers, data: { name: 'Fictional Fjord Systems AS', source: 'User' } }); + expect(companyResponse.status()).toBe(201); + const company = await companyResponse.json(); + await fs.mkdir(output, { recursive: true }); + const fixtures = []; + for (const language of ['en', 'nb'] as const) { + const nb = language === 'nb'; + const paragraphs = Array.from({ length: nb ? 20 : 3 }, (_, index) => { + const sentence = nb ? 'Jeg dokumenterte pålitelige arbeidsflyter, tilgjengelige grensesnitt og tydelig ansvar i et oppdiktet prosjekt. ' + : 'I documented reliable workflows, accessible interfaces and clear ownership in a fictional project. '; + return `B${String(index + 1).padStart(2, '0')} ${sentence.repeat(nb ? 4 : 1)}`.trim(); + }); + paragraphs.push(nb ? 'R99 Med vennlig hilsen, Åse Ødegård-Sørensen. Dette er kun syntetiske eksempler.' + : 'R99 Kind regards, Åse Ødegård-Sørensen. These are synthetic examples only.'); + const text = paragraphs.join('\n\n'); + for (const theme of themes) { + const fixture = `${nb ? 'long-nb' : 'short-en'}-${theme.id}`; + const directory = path.join(output, fixture); + await fs.mkdir(directory, { recursive: true }); + const documentSettings = { schemaVersion: 1, designVersion: 2, language: nb ? 'nb-NO' : 'en', themeId: theme.id, + senderName: 'R01 Åse Ødegård-Sørensen', senderTitle: nb ? 'P01 Plattformingeniør' : 'P01 Platform engineer', + senderEmail: 'fictional.ase@example.test', senderLocation: 'Tromsø, Norge', + recipient: 'R03 Fictional hiring team\nFictional Fjord Systems AS\nTromsø, Norge', + subject: nb ? 'R02 Søknad om plattformingeniør' : 'R02 Application for platform engineer', date: '2026-10-27', pageSize: 'a4' }; + const created = await page.request.post(`${api}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: fixture, status: 'Saved', description: 'Synthetic matching-letter physical proof.' } }); + expect(created.status(), fixture).toBe(201); + const job = await created.json(); + const letterUrl = `${api}/jobapplications/${job.id}/cover-letter`; + const original = await (await page.request.get(letterUrl)).json(); + const savedResponse = await page.request.put(letterUrl, { headers, data: { text, documentSettings, source: 'manual', expectedVersion: original.revision } }); + expect(savedResponse.status(), fixture).toBe(200); + const saved = await (await page.request.get(letterUrl)).json(); + expect(saved.text).toBe(text); + expect(saved.documentSettings).toMatchObject({ designVersion: 2, themeId: theme.id, accentColor: theme.accent, bodyFont: theme.bodyFont, headingFont: theme.headingFont }); + const previewResponse = await page.request.post(`${letterUrl}/preview`, { headers, data: { text: saved.text, documentSettings: saved.documentSettings } }); + expect(previewResponse.status(), fixture).toBe(200); + const rendered = await previewResponse.json(); + expect(rendered.html).toContain(`letter-theme-${theme.id}`); + expect(rendered.html).toContain('data:font/ttf;base64,'); + expect(rendered.html).toContain(theme.accent); + await fs.writeFile(path.join(directory, 'render.html'), rendered.html); + const expectedMarkers = [documentSettings.senderName, documentSettings.senderTitle, documentSettings.recipient, documentSettings.subject, text].join('\n').match(/\b[SEBPR]\d{2}\b/g)!; + await page.setViewportSize({ width: 794, height: 1123 }); + await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4)); + await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady), { message: fixture }).toBe('true'); + const measured = await page.evaluate(() => { + const data = document.documentElement.dataset; + const width = Number(data.cvPreviewWidth), height = Number(data.cvPreviewHeight); + const tokens = []; + const walker = document.createTreeWalker(document.querySelector('.page')!, 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 = document.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length); + const rect = range.getClientRects()[0], style = 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, color: style.color }); + } + } + const header = getComputedStyle(document.querySelector('header')!); + const heading = getComputedStyle(document.querySelector('h1')!); + const contact = getComputedStyle(document.querySelector('.contact')!); + return { width, height, contentHeight: Number(data.cvPreviewPages) * height, pages: Number(data.cvPreviewPages), + effectiveMargin: getComputedStyle(document.documentElement).getPropertyValue('--cv-page-margin'), layoutDurationMs: Number(data.cvPreviewDurationMs), tokens, + text: document.querySelector('.page')!.textContent, bodyFont: getComputedStyle(document.body).fontFamily, + headingFont: heading.fontFamily, headingColor: heading.color, headingSize: heading.fontSize, + headerBackground: header.backgroundColor, headerAlign: header.textAlign, headerBorder: header.borderBottomWidth, + headerGridColumn: header.gridColumn, headerBefore: getComputedStyle(document.querySelector('header')!, '::before').content, + pageDisplay: getComputedStyle(document.querySelector('.page')!).display, contactFont: contact.fontFamily }; + }); + expect(measured.tokens.map(token => token.token).sort(), `${fixture} complete preview markers`).toEqual([...expectedMarkers].sort()); + for (const paragraph of paragraphs) expect(measured.text, `${fixture} full body paragraph`).toContain(paragraph); + expect(measured.text).toContain(documentSettings.recipient); + if (theme.id === 'modern') expect(measured.headerBackground).not.toBe('rgba(0, 0, 0, 0)'); + if (theme.id === 'minimal') expect(measured.headerBorder).toBe('0px'); + if (theme.id === 'executive') expect(measured.headerAlign).toBe('center'); + if (theme.id === 'elegant') expect(measured.headerBefore).toContain(nb ? 'Søknadsbrev' : 'Cover letter'); + if (theme.id === 'code') expect(measured.contactFont).toContain('Roboto Mono'); + if (['technical', 'nordic', 'creative'].includes(theme.id)) expect(measured.pageDisplay).toBe('grid'); + const exported = await page.request.post(`${letterUrl}/export-pdf?expectedVersion=${saved.revision}`, { headers }); + expect(exported.status(), fixture).toBe(200); + const bytes = await exported.body(); + expect(bytes.subarray(0, 5).toString()).toBe('%PDF-'); + await fs.writeFile(path.join(directory, 'export.pdf'), bytes); + for (let index = 0; index < measured.pages; index++) { + await page.setContent(previewDocumentHtml(rendered.html, measured.height, index)); + await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe('true'); + await page.screenshot({ path: path.join(directory, `preview-${index + 1}.png`) }); + } + expect(await (await page.request.get(letterUrl)).json()).toEqual(saved); + fixtures.push({ fixture, settings: saved.documentSettings, expectedMarkers, expectedParagraphs: paragraphs, + expectedRecipient: documentSettings.recipient, expectedSender: documentSettings.senderName, expectedSubject: documentSettings.subject, + previewPages: measured.pages, measured, savedRevision: saved.revision, htmlSha256: sha256(rendered.html), pdfSha256: sha256(bytes) }); + await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), fixtures }, null, 2)); + } + } + expect(fixtures).toHaveLength(18); + expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(sourceBefore); + await info.attach('matching-letter-library-measurements', { path: path.join(output, 'measurements.json'), contentType: 'application/json' }); +}); diff --git a/job-tracker-ui/e2e/pre-p10-template-fixtures.ts b/job-tracker-ui/e2e/pre-p10-template-fixtures.ts new file mode 100644 index 0000000..c46110d --- /dev/null +++ b/job-tracker-ui/e2e/pre-p10-template-fixtures.ts @@ -0,0 +1,40 @@ +import { emptyStructuredCv } from '../src/profileCv'; + +// Synthetic comparison corpus: each theme receives identical facts and marker membership. +export const templateFixtures = [ + { key: 'short-en', language: 'en', jobs: 1, bullets: 2, skills: 5 }, + { key: 'medium-nb', language: 'nb', jobs: 4, bullets: 3, skills: 12 }, + { key: 'long-en', language: 'en', jobs: 10, bullets: 4, skills: 25 }, + { key: 'dense-nb', language: 'nb', jobs: 12, bullets: 5, skills: 60 }, +] as const; + +export function templateProfile(sample: typeof templateFixtures[number]) { + const nb = sample.language === 'nb'; + const profile = { + ...emptyStructuredCv(), + contact: { ...emptyStructuredCv().contact, + fullName: 'S01 Åse Ødegård-Sørensen LangtEtternavn', + headline: nb ? 'S02 Senior plattformingeniør og fagansvarlig' : 'S02 Senior platform engineer and technical lead', + email: 'ase.odegard-sorensen.long-account-identifier@example.test', location: nb ? 'Tromsø, Norge' : 'Tromsø, Norway' }, + summary: [nb ? 'S03 Bygger pålitelige tjenester med tydelig dokumentasjon, tilgjengelighet og målbare resultater.' : 'S03 Builds reliable services with clear documentation, accessibility and measurable results.'], + jobs: Array.from({ length: sample.jobs }, (_, index) => ({ + id: `comparison-job-${index + 1}`, + title: `E${String(index + 1).padStart(2, '0')} ${nb ? 'Senior utvikler og teknisk rådgiver' : 'Senior software engineer and technical adviser'}`, + company: `${nb ? 'Eksempel Teknologi' : 'Example Technology'} ${index + 1}`, + startDate: `${2025 - index}-01`, endDate: index ? `${2026 - index}-01` : undefined, isCurrent: index === 0, + bullets: Array.from({ length: sample.bullets }, (_, detail) => { + const marker = `B${String(index * sample.bullets + detail + 1).padStart(2, '0')}`; + return nb ? `${marker} Forbedret stabile arbeidsflyter gjennom samarbeid, grundig testing og tilgjengelige grensesnitt for norske brukere. Dokumenterte ansvar, beslutninger og målbare resultater uten å miste historiske opplysninger.` + : `${marker} Improved reliable workflows through collaboration, thorough testing and accessible interfaces. Documented ownership, engineering decisions and measurable outcomes while preserving historical information.`; + }), + })), + skills: Array.from({ length: sample.skills }, (_, index) => `P${String(index + 1).padStart(2, '0')} ${['C# og .NET', 'React og TypeScript', 'Tilgjengelighet', 'Distribuerte systemer', 'PostgreSQL', 'Docker'][index % 6]}`), + // Short intentionally omits education; all fixtures omit optional phone/website/projects. + education: sample.key === 'short-en' ? [] : [{ id: 'comparison-education', qualification: 'R01 Master i informatikk', institution: 'Universitetet i Tromsø', startDate: '2014', endDate: '2019' }], + languages: [{ name: 'R02 Norsk bokmål', level: 'Morsmål' }, { name: 'R03 English', level: 'Fluent' }], + interests: ['R04 Friluftsliv og åpen kildekode'], + }; + const markers = JSON.stringify(profile).match(/\b[SEBPR]\d{2}\b/g)!; + if (new Set(markers).size !== markers.length) throw new Error(`Duplicate fixture markers: ${sample.key}`); + return { profile, markers }; +} diff --git a/job-tracker-ui/e2e/pre-p10-template-legacy.spec.ts b/job-tracker-ui/e2e/pre-p10-template-legacy.spec.ts new file mode 100644 index 0000000..9a54264 --- /dev/null +++ b/job-tracker-ui/e2e/pre-p10-template-legacy.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { templateFixtures, templateProfile } from './pre-p10-template-fixtures'; + +const api = 'http://localhost:5302/api'; +const baseline = path.resolve('../.artifacts/pre-phase10-ux/templates-legacy'); +const output = path.resolve(process.env.CV_TEMPLATE_OUTPUT || '../.artifacts/pre-phase10-ux/templates-revised-01'); +const hash = (text: string) => createHash('sha256').update(text).digest('hex'); +// The existing avatar endpoint now normalizes image pixels with Skia. Retain font data +// verbatim; exclude only embedded image payload bytes when comparing document markup. +const normalizeAvatar = (html: string) => html.replace(/data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+/g, 'data:image/normalized-avatar'); + +test('[PRE-P10 B] existing design-one markup, CSS and source markers survive design-two additions', async ({ request }, info) => { + test.setTimeout(180_000); + expect((await request.post(`${api}/auth/login`, { data: { email: 'e2e@example.test', password: 'E2ePassword123!' } })).status()).toBe(200); + const me = await (await request.get(`${api}/auth/me`)).json(); + const headers = { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string }; + const avatar = await fs.readFile(path.join(baseline, 'synthetic-nonhuman-color-sample.png')); + expect((await request.post(`${api}/auth/avatar`, { headers, multipart: { file: { name: 'synthetic-nonhuman.png', mimeType: 'image/png', buffer: avatar } } })).status()).toBe(200); + const themes = await (await request.get(`${api}/cv/themes`)).json(); + const results = []; + await fs.mkdir(path.join(output, 'legacy-current'), { recursive: true }); + for (const sample of templateFixtures) { + const { profile } = templateProfile(sample); + const source = await (await request.get(`${api}/career/profile`)).json(); + expect((await request.put(`${api}/career/profile`, { headers, data: { expectedVersion: source.revision, profile } })).status()).toBe(200); + for (const theme of themes) { + const fixture = `${sample.key}-${theme.id}`; + const created = await request.post(`${api}/cv/variants`, { headers, data: { name: fixture, settings: { themeId: theme.id, language: sample.language, showPhoto: true, showIcons: true } } }); + expect(created.status(), fixture).toBe(200); + const variant = await created.json(); + const response = await request.get(`${api}/cv/variants/${variant.id}/preview`); + expect(response.status(), fixture).toBe(200); + const current = (await response.json()).html as string; + const original = await fs.readFile(path.join(baseline, fixture, 'render.html'), 'utf8'); + const style = (html: string) => html.match(/