fix(cv): bind letter proposals to current sources and preserve document choices
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.MariaDb.Tests;
|
||||
|
||||
public sealed class CoverLetterAiSourceTests
|
||||
{
|
||||
private const string Owner = "rd21-owner";
|
||||
|
||||
private static async Task<int> SeedAsync(JobTrackerContext db)
|
||||
{
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = Owner, Company = new Company { OwnerUserId = Owner, Name = "Source-bound employer" },
|
||||
JobTitle = "Engineer", Description = "Build C# services", Status = "Prospect", CoverLetterText = "Approved manual letter",
|
||||
};
|
||||
db.Users.Add(new() { Id = Owner, DisplayName = "Ada" });
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
db.CareerProfiles.Add(new()
|
||||
{
|
||||
OwnerUserId = Owner, Version = 1,
|
||||
ProfileJson = StructuredCvProfileJson.Serialize(new() { Skills = { "C#" } }),
|
||||
});
|
||||
db.CvVariants.Add(new()
|
||||
{
|
||||
OwnerUserId = Owner, JobApplicationId = job.Id, Name = "Owned linked CV", Version = 1,
|
||||
PublicSlug = Guid.NewGuid().ToString("N"), SettingsJson = CvVariantSettingsJson.Serialize(new()),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return job.Id;
|
||||
}
|
||||
|
||||
private static AiWorkspaceService Service(JobTrackerContext db, Func<Task>? beforeResult = null)
|
||||
{
|
||||
var ai = new Mock<ISummarizerService>();
|
||||
ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async () =>
|
||||
{
|
||||
// Provider waits must not hold the later serializable validation/history transaction.
|
||||
Assert.Null(db.Database.CurrentTransaction);
|
||||
if (beforeResult is not null) await beforeResult();
|
||||
return new AiGenerationResult("Verified source-bound suggestion.", "local-test", "stub", null, "local_primary");
|
||||
});
|
||||
return new(db, ai.Object);
|
||||
}
|
||||
|
||||
private static Task<AiInteraction?> Generate(AiWorkspaceService service, int jobId) =>
|
||||
service.GenerateAsync(Owner, jobId, "unused fallback", "Ada", new("cover-letter", "professional", null), "local-test", default);
|
||||
|
||||
private static async Task AssertLetterUntouched(JobTrackerContext db, int jobId)
|
||||
{
|
||||
var job = await db.JobApplications.AsNoTracking().SingleAsync(value => value.Id == jobId);
|
||||
Assert.Equal("Approved manual letter", job.CoverLetterText);
|
||||
Assert.Equal(1, job.Version);
|
||||
Assert.Empty(await db.CoverLetterVersions.AsNoTracking().ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bound_generation_commits_on_mariadb_and_read_only_validation_accepts_current_source()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = database.Context();
|
||||
var jobId = await SeedAsync(db);
|
||||
var service = Service(db);
|
||||
var result = await Generate(service, jobId);
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(db.Database.CurrentTransaction);
|
||||
Assert.True(await service.ValidateCoverLetterSourceAsync(Owner, jobId, result.Id, default));
|
||||
await using var fresh = database.Context();
|
||||
Assert.True(await Service(fresh).ValidateCoverLetterSourceAsync(Owner, jobId, result.Id, default));
|
||||
Assert.Single(await fresh.AiInteractions.AsNoTracking().ToListAsync());
|
||||
Assert.Equal(1, (await fresh.CareerProfiles.AsNoTracking().SingleAsync()).Version);
|
||||
Assert.Equal(1, (await fresh.CvVariants.AsNoTracking().SingleAsync()).Version);
|
||||
await AssertLetterUntouched(fresh, jobId);
|
||||
Assert.False(db.ChangeTracker.HasChanges());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Separate_context_profile_edit_during_inference_is_rejected_without_stale_history_on_mariadb()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = database.Context();
|
||||
var jobId = await SeedAsync(db);
|
||||
var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var service = Service(db, () => { reached.TrySetResult(); return release.Task; });
|
||||
var pending = Generate(service, jobId);
|
||||
await reached.Task.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
try
|
||||
{
|
||||
await using var writer = database.Context();
|
||||
var profile = await writer.CareerProfiles.SingleAsync();
|
||||
profile.Version++;
|
||||
profile.ProfileJson = StructuredCvProfileJson.Serialize(new() { Skills = { "Changed qualification" } });
|
||||
await writer.SaveChangesAsync();
|
||||
}
|
||||
finally { release.TrySetResult(); }
|
||||
var stale = await Assert.ThrowsAsync<CoverLetterSourceChangedException>(() => pending);
|
||||
Assert.Equal("cover_letter_source_changed", stale.Message);
|
||||
Assert.True(stale.InputCharacterCount > 0);
|
||||
Assert.True(stale.OutputCharacterCount > 0);
|
||||
Assert.Null(db.Database.CurrentTransaction);
|
||||
await using var fresh = database.Context();
|
||||
Assert.Empty(await fresh.AiInteractions.AsNoTracking().ToListAsync());
|
||||
Assert.Equal(2, (await fresh.CareerProfiles.AsNoTracking().SingleAsync()).Version);
|
||||
await AssertLetterUntouched(fresh, jobId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Separate_context_cv_and_attachment_changes_invalidate_pre_apply_source_on_mariadb()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = database.Context();
|
||||
var jobId = await SeedAsync(db);
|
||||
var service = Service(db);
|
||||
var beforeCv = await Generate(service, jobId);
|
||||
Assert.True(await service.ValidateCoverLetterSourceAsync(Owner, jobId, beforeCv!.Id, default));
|
||||
await using (var writer = database.Context())
|
||||
{
|
||||
var cv = await writer.CvVariants.SingleAsync();
|
||||
cv.Version++;
|
||||
cv.SettingsJson = CvVariantSettingsJson.Serialize(new() { Headline = "Reviewed CV lens changed" });
|
||||
await writer.SaveChangesAsync();
|
||||
}
|
||||
Assert.False(await service.ValidateCoverLetterSourceAsync(Owner, jobId, beforeCv.Id, default));
|
||||
var beforeAttachment = await Generate(service, jobId);
|
||||
Assert.True(await service.ValidateCoverLetterSourceAsync(Owner, jobId, beforeAttachment!.Id, default));
|
||||
await using (var writer = database.Context())
|
||||
{
|
||||
writer.Attachments.Add(new() { JobApplicationId = jobId, FileName = "new-source.txt", FilePath = "synthetic-managed-path", FileSize = 17 });
|
||||
await writer.SaveChangesAsync();
|
||||
}
|
||||
Assert.False(await service.ValidateCoverLetterSourceAsync(Owner, jobId, beforeAttachment.Id, default));
|
||||
await using var fresh = database.Context();
|
||||
Assert.Equal(2, await fresh.AiInteractions.CountAsync());
|
||||
Assert.Equal(1, (await fresh.CareerProfiles.AsNoTracking().SingleAsync()).Version);
|
||||
await AssertLetterUntouched(fresh, jobId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Reflection;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed partial class AiWorkspaceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("[]")]
|
||||
[InlineData("\"legacy\"")]
|
||||
[InlineData("null")]
|
||||
public async Task Non_object_legacy_AI_result_fails_closed_without_throwing(string json)
|
||||
{
|
||||
var (db, service, _) = New("user-1");
|
||||
await using var dispose = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
var row = new AiInteraction { OwnerUserId = "user-1", JobApplicationId = jobId, Module = "cover-letter", ResultJson = json };
|
||||
db.Add(row);
|
||||
await db.SaveChangesAsync();
|
||||
Assert.False(await service.ValidateCoverLetterSourceAsync("user-1", jobId, row.Id, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_generation_HTTP_conflict_finalizes_actual_consumed_usage_without_recording_stale_history()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options,
|
||||
Mock.Of<ICurrentUserService>(user => user.UserId == "user-1"));
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var user = new ApplicationUser { Id = "user-1", DisplayName = "Ada" };
|
||||
db.Users.Add(user);
|
||||
var jobId = await SeedJobAsync(db, user.Id);
|
||||
await SeedLinkedProfileAsync(db, jobId);
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
users.Setup(manager => manager.GetRolesAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(new[] { "Premium" });
|
||||
var ai = new FakeAi
|
||||
{
|
||||
BeforeReturn = async () =>
|
||||
{
|
||||
await using var changed = Independent(db);
|
||||
await ChangeSource(changed, jobId, "profile-version");
|
||||
},
|
||||
};
|
||||
var controller = new AiWorkspaceController(users.Object, new AiWorkspaceService(db, ai), new ConfigurationBuilder().Build(), db);
|
||||
var response = await controller.Generate(jobId, new("cover-letter", null, null), default);
|
||||
var conflict = Assert.IsType<ConflictObjectResult>(response.Result);
|
||||
Assert.Contains("cover_letter_source_changed", JsonSerializer.Serialize(conflict.Value));
|
||||
var usage = await db.AiUsageRecords.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(1, usage.CallCount);
|
||||
Assert.Equal(ai.Next!.Trim().Length, usage.OutputCharacterCount);
|
||||
Assert.NotEqual(16_000, usage.InputCharacterCount);
|
||||
Assert.True(usage.InputCharacterCount > 0);
|
||||
Assert.Equal(AiWorkspaceService.EstimateTokens(usage.InputCharacterCount + usage.OutputCharacterCount), usage.EstimatedTokenCount);
|
||||
Assert.Empty(await db.AiInteractions.AsNoTracking().ToListAsync());
|
||||
Assert.Empty(await db.CoverLetterVersions.AsNoTracking().ToListAsync());
|
||||
Assert.Null((await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText);
|
||||
Assert.Equal(1, (await db.CareerProfiles.AsNoTracking().SingleAsync()).Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validator_HTTP_contract_is_read_only_Pro_authenticated_and_scoped_to_owner_job_module()
|
||||
{
|
||||
Assert.Equal("local", typeof(AiWorkspaceController).GetCustomAttribute<AuthorizeAttribute>()!.AuthenticationSchemes);
|
||||
Assert.Equal(ProEntitlement.Policy, typeof(AiWorkspaceController).GetMethod(nameof(AiWorkspaceController.ValidateSource))!.GetCustomAttribute<AuthorizeAttribute>()!.Policy);
|
||||
var (db, service, _) = New("user-1");
|
||||
await using var dispose = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
await SeedLinkedProfileAsync(db, jobId);
|
||||
var generated = await service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default);
|
||||
var user = new ApplicationUser { Id = "user-1" };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var controller = new AiWorkspaceController(users.Object, service, new ConfigurationBuilder().Build(), db);
|
||||
Assert.IsType<NoContentResult>(await controller.ValidateSource(jobId, generated!.Id, default));
|
||||
Assert.IsType<NotFoundResult>(await controller.ValidateSource(jobId + 1, generated.Id, default));
|
||||
Assert.IsType<NotFoundResult>(await controller.ValidateSource(jobId, int.MaxValue, default));
|
||||
await using (var changed = Independent(db)) await ChangeSource(changed, jobId, "cv-settings");
|
||||
var stale = Assert.IsType<ConflictObjectResult>(await controller.ValidateSource(jobId, generated.Id, default));
|
||||
Assert.Contains("cover_letter_source_changed", JsonSerializer.Serialize(stale.Value));
|
||||
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(new ApplicationUser { Id = "foreign" });
|
||||
Assert.IsType<NotFoundResult>(await controller.ValidateSource(jobId, generated.Id, default));
|
||||
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync((ApplicationUser?)null);
|
||||
Assert.IsType<UnauthorizedResult>(await controller.ValidateSource(jobId, generated.Id, default));
|
||||
Assert.Empty(await db.AiUsageRecords.AsNoTracking().ToListAsync());
|
||||
Assert.Single(await db.AiInteractions.AsNoTracking().ToListAsync());
|
||||
Assert.False(db.ChangeTracker.HasChanges());
|
||||
}
|
||||
|
||||
private static JobTrackerContext Independent(JobTrackerContext db) => new(
|
||||
(DbContextOptions<JobTrackerContext>)db.GetService<IDbContextOptions>(), Mock.Of<ICurrentUserService>(user => user.UserId == "user-1"));
|
||||
|
||||
private static async Task ChangeSource(JobTrackerContext db, int jobId, string change)
|
||||
{
|
||||
switch (change)
|
||||
{
|
||||
case "profile-version": (await db.CareerProfiles.SingleAsync()).Version++; break;
|
||||
case "profile-content": (await db.CareerProfiles.SingleAsync()).ProfileJson = StructuredCvProfileJson.Serialize(new() { Skills = { "A different qualification" } }); break;
|
||||
case "profile-long-tail": (await db.CareerProfiles.SingleAsync()).LongTailJson = "{\"summary\":[\"Changed source\"]}"; break;
|
||||
case "cv-version": (await db.CvVariants.SingleAsync()).Version++; break;
|
||||
case "cv-settings": (await db.CvVariants.SingleAsync()).SettingsJson = CvVariantSettingsJson.Serialize(new() { Headline = "Changed CV lens" }); break;
|
||||
case "cv-detach": (await db.CvVariants.SingleAsync()).JobApplicationId = null; break;
|
||||
case "cv-attach":
|
||||
db.CvVariants.Add(new() { OwnerUserId = "user-1", JobApplicationId = jobId, Name = "New CV", PublicSlug = Guid.NewGuid().ToString("N"), UpdatedAtUtc = DateTimeOffset.UtcNow.AddMinutes(1), SettingsJson = "{}" }); break;
|
||||
case "attachment-add":
|
||||
db.Attachments.Add(new() { JobApplicationId = jobId, FileName = "new.txt", FilePath = "new-managed-path", FileSize = 10 }); break;
|
||||
case "attachment-change": (await db.Attachments.SingleAsync()).UseForAi = false; break;
|
||||
case "attachment-remove": db.Attachments.Remove(await db.Attachments.SingleAsync()); break;
|
||||
case "job": (await db.JobApplications.SingleAsync()).Description = "Different role requirements"; break;
|
||||
case "company": (await db.Companies.SingleAsync()).Name = "Different employer"; break;
|
||||
case "candidate-name": (await db.Users.SingleAsync()).DisplayName = "Babbage"; break;
|
||||
case "letter": (await db.JobApplications.SingleAsync()).CoverLetterText = "New saved draft"; break;
|
||||
default: throw new ArgumentException(change);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("profile-version")]
|
||||
[InlineData("profile-content")]
|
||||
[InlineData("cv-settings")]
|
||||
[InlineData("cv-detach")]
|
||||
[InlineData("attachment-add")]
|
||||
[InlineData("job")]
|
||||
[InlineData("candidate-name")]
|
||||
[InlineData("letter")]
|
||||
public async Task Held_letter_generation_refuses_changed_source_without_history_or_letter_writes(string change)
|
||||
{
|
||||
var (db, service, ai) = New("user-1");
|
||||
await using var dispose = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
await SeedLinkedProfileAsync(db, jobId);
|
||||
if (change == "candidate-name") { db.Users.Add(new() { Id = "user-1", DisplayName = "Ada" }); await db.SaveChangesAsync(); }
|
||||
var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
ai.BeforeReturn = () => { reached.TrySetResult(); return release.Task; };
|
||||
var pending = service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default);
|
||||
await reached.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
await using var writer = Independent(db);
|
||||
await ChangeSource(writer, jobId, change);
|
||||
}
|
||||
finally { release.TrySetResult(); }
|
||||
var error = await Assert.ThrowsAsync<CoverLetterSourceChangedException>(() => pending);
|
||||
Assert.Equal("cover_letter_source_changed", error.Message);
|
||||
Assert.Empty(await db.AiInteractions.AsNoTracking().ToListAsync());
|
||||
Assert.Empty(await db.CoverLetterVersions.AsNoTracking().ToListAsync());
|
||||
Assert.Equal(change == "letter" ? "New saved draft" : null, (await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText);
|
||||
Assert.Equal(1, ai.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("profile-version")]
|
||||
[InlineData("profile-content")]
|
||||
[InlineData("profile-long-tail")]
|
||||
[InlineData("cv-version")]
|
||||
[InlineData("cv-settings")]
|
||||
[InlineData("cv-detach")]
|
||||
[InlineData("cv-attach")]
|
||||
[InlineData("attachment-add")]
|
||||
[InlineData("attachment-change")]
|
||||
[InlineData("attachment-remove")]
|
||||
[InlineData("job")]
|
||||
[InlineData("company")]
|
||||
[InlineData("candidate-name")]
|
||||
[InlineData("letter")]
|
||||
public async Task Pre_apply_validation_reloads_source_and_rejects_post_generation_changes(string change)
|
||||
{
|
||||
var (db, service, _) = New("user-1");
|
||||
await using var dispose = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
await SeedLinkedProfileAsync(db, jobId);
|
||||
if (change == "candidate-name") { db.Users.Add(new() { Id = "user-1", DisplayName = "Ada" }); await db.SaveChangesAsync(); }
|
||||
db.Attachments.Add(new() { JobApplicationId = jobId, FileName = "original.txt", FilePath = "original-managed-path", FileSize = 1 });
|
||||
await db.SaveChangesAsync();
|
||||
var generated = await service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default);
|
||||
Assert.True(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated!.Id, default));
|
||||
await using (var writer = Independent(db)) await ChangeSource(writer, jobId, change);
|
||||
Assert.False(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated.Id, default));
|
||||
Assert.Single(await db.AiInteractions.AsNoTracking().ToListAsync());
|
||||
Assert.Empty(await db.CoverLetterVersions.AsNoTracking().ToListAsync());
|
||||
Assert.Equal(change == "letter" ? "New saved draft" : null, (await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Source_changed_during_prompt_assembly_is_not_rebound_to_the_newer_profile_stamp()
|
||||
{
|
||||
var (db, _, ai) = New("user-1");
|
||||
await using var dispose = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
await SeedLinkedProfileAsync(db, jobId);
|
||||
var intelligence = new Mock<IApplicationIntelligenceService>();
|
||||
intelligence.Setup(value => value.AnalyzeAsync("user-1", jobId, It.IsAny<CancellationToken>()))
|
||||
.Returns(async () =>
|
||||
{
|
||||
await using var changed = Independent(db);
|
||||
await ChangeSource(changed, jobId, "profile-content");
|
||||
return (JobAnalysisDto?)null;
|
||||
});
|
||||
intelligence.Setup(value => value.MatchAsync("user-1", jobId, It.IsAny<CancellationToken>())).ReturnsAsync((CareerMatchDto?)null);
|
||||
var service = new AiWorkspaceService(db, ai, intelligence.Object);
|
||||
await Assert.ThrowsAsync<CoverLetterSourceChangedException>(() => service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default));
|
||||
Assert.Contains("C#", ai.LastText);
|
||||
Assert.DoesNotContain("A different qualification", ai.LastText);
|
||||
Assert.Empty(await db.AiInteractions.AsNoTracking().ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Source_validator_fails_closed_for_legacy_and_damaged_binding_and_scopes_owner_job_and_module()
|
||||
{
|
||||
var (db, service, _) = New("user-1");
|
||||
await using var dispose = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
await SeedLinkedProfileAsync(db, jobId);
|
||||
var generated = await service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default);
|
||||
Assert.True(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated!.Id, default));
|
||||
Assert.Null(await service.ValidateCoverLetterSourceAsync("foreign", jobId, generated.Id, default));
|
||||
Assert.Null(await service.ValidateCoverLetterSourceAsync("user-1", jobId + 1, generated.Id, default));
|
||||
Assert.Null(await service.ValidateCoverLetterSourceAsync("user-1", jobId, int.MaxValue, default));
|
||||
generated.ResultJson = JsonSerializer.Serialize(new { text = "Legacy output", meta = new { model = "old" } });
|
||||
await db.SaveChangesAsync();
|
||||
Assert.False(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated.Id, default));
|
||||
generated.ResultJson = "{invalid json";
|
||||
await db.SaveChangesAsync();
|
||||
Assert.False(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated.Id, default));
|
||||
generated.Module = "interview";
|
||||
await db.SaveChangesAsync();
|
||||
Assert.Null(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated.Id, default));
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,13 @@ using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class AiWorkspaceTests
|
||||
public sealed partial class AiWorkspaceTests
|
||||
{
|
||||
private sealed class FakeAi : ISummarizerService
|
||||
{
|
||||
@@ -16,6 +17,7 @@ public sealed class AiWorkspaceTests
|
||||
public string? LastInstruction;
|
||||
public string? ActualProvider;
|
||||
public string? FallbackReason;
|
||||
public Func<Task>? BeforeReturn;
|
||||
// The source text the module assembled — what the prompt actually saw.
|
||||
public string? LastText;
|
||||
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
|
||||
@@ -34,6 +36,7 @@ public sealed class AiWorkspaceTests
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var generated = await SummarizeSectionAsync(instruction, text, maxLength, minLength);
|
||||
if (BeforeReturn is not null) await BeforeReturn();
|
||||
return generated is null ? null : new AiGenerationResult(generated, ActualProvider, "test-model", FallbackReason,
|
||||
FallbackReason is null ? "local_primary" : "external_fallback");
|
||||
}
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CoverLetterDocumentContractTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, "en")]
|
||||
[InlineData("{}", "en")]
|
||||
[InlineData("{\"senderName\":\"Legacy sender\"}", "en")]
|
||||
[InlineData("{\"language\":\"en\"}", "en")]
|
||||
[InlineData("{\"language\":\"nb\"}", "nb-NO")]
|
||||
[InlineData("{\"language\":\"nb-NO\"}", "nb-NO")]
|
||||
[InlineData("{\"language\":\" NB-no \"}", "nb-NO")]
|
||||
[InlineData("{\"language\":null}", "en")]
|
||||
[InlineData("{\"language\":\"fr\"}", "en")]
|
||||
[InlineData("{\"language\":\"en\\\" onload=alert(1)\"}", "en")]
|
||||
public void Document_language_defaults_normalizes_and_roundtrips_only_supported_values(string? json, string expected)
|
||||
{
|
||||
var settings = CoverLetterDocumentSettingsJson.Deserialize(json);
|
||||
var normalized = CoverLetterDocumentSettingsJson.Serialize(settings);
|
||||
Assert.Equal(expected, JsonDocument.Parse(normalized).RootElement.GetProperty("language").GetString());
|
||||
Assert.Equal(normalized, CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(normalized)));
|
||||
var html = new CoverLetterDocumentRenderer().Render("Reviewed letter — æøå", settings).Html;
|
||||
Assert.Contains($"<html lang=\"{expected}\">", html);
|
||||
Assert.DoesNotContain("onload=", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Document_style_reuses_cv_allowlists_clamps_non_finite_values_and_escapes_all_metadata()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed partial class CoverLetterWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Letter_language_is_a_reviewed_snapshot_in_history_and_frozen_bytes()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
var job = await Seed(db);
|
||||
var service = Assets(db);
|
||||
var norwegian = CoverLetterDocumentSettingsJson.Deserialize("{\"language\":\"nb\",\"senderName\":\"Åse\"}");
|
||||
await service.SaveCoverLetterAsync(Owner, job.Id, "Gjennomgått søknad — æøå", CoverLetterSources.Manual, null, job.Version, default, norwegian);
|
||||
var settingsBytes = Encoding.UTF8.GetBytes(job.CoverLetterDocumentSettingsJson!);
|
||||
var packages = new SubmittedApplicationPackageService(db, Mock.Of<ICvVariantService>(), Mock.Of<IAttachmentStorage>());
|
||||
var captured = await packages.CaptureAsync(Owner, job.Id, new("Fallback", null), default);
|
||||
var frozen = await db.SubmittedApplicationPackages.AsNoTracking().SingleAsync(x => x.Id == captured!.Id);
|
||||
Assert.Contains("<html lang=\"nb-NO\">", frozen.RenderedCoverLetterHtml);
|
||||
Assert.Equal("nb-NO", JsonDocument.Parse(frozen.CoverLetterDocumentSettingsJson!).RootElement.GetProperty("language").GetString());
|
||||
var htmlBytes = Encoding.UTF8.GetBytes(frozen.RenderedCoverLetterHtml!);
|
||||
|
||||
await service.SaveCoverLetterAsync(Owner, job.Id, "English revision", CoverLetterSources.Manual, null, job.Version, default, new());
|
||||
Assert.Contains("<html lang=\"en\">", (await service.RenderSavedCoverLetterAsync(Owner, job.Id, job.Version, default))!.Html);
|
||||
await service.RestoreCoverLetterAsync(Owner, job.Id, 1, job.Version, default);
|
||||
Assert.Equal(settingsBytes, Encoding.UTF8.GetBytes(job.CoverLetterDocumentSettingsJson!));
|
||||
var history = await db.CoverLetterVersions.Where(x => x.JobApplicationId == job.Id).OrderBy(x => x.Version).ToListAsync();
|
||||
Assert.Equal(new[] { "nb-NO", "en", "nb-NO" }, history.Select(x => JsonDocument.Parse(x.DocumentSettingsJson!).RootElement.GetProperty("language").GetString()));
|
||||
var detail = await packages.GetAsync(Owner, job.Id, captured!.Id, default);
|
||||
Assert.Equal(htmlBytes, Encoding.UTF8.GetBytes(detail!.RenderedCoverLetterHtml!));
|
||||
Assert.Equal(settingsBytes, Encoding.UTF8.GetBytes(CoverLetterDocumentSettingsJson.Serialize(detail.CoverLetterDocumentSettings)));
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
if (_db is not null && string.Equals(request?.Module?.Trim(), "cover-letter", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == user.Id, ct);
|
||||
if (user is null) return Unauthorized();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(request?.Module)) return BadRequest("Choose an AI module.");
|
||||
|
||||
AiUsageReservation? reservation = null;
|
||||
@@ -93,6 +98,13 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
reservation.Record.Id, interaction.InputCharacterCount, interaction.OutputCharacterCount, ct);
|
||||
return Ok(ToDto(interaction));
|
||||
}
|
||||
catch (CoverLetterSourceChangedException ex)
|
||||
{
|
||||
if (reservation is not null)
|
||||
await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).FinalizeAsync(
|
||||
reservation.Record.Id, ex.InputCharacterCount, ex.OutputCharacterCount, ct);
|
||||
return Conflict(new { code = "cover_letter_source_changed" });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
if (reservation is not null) await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).ReleaseAsync(reservation.Record.Id, ct);
|
||||
@@ -113,6 +125,17 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
return Ok(history.Select(ToDto));
|
||||
}
|
||||
|
||||
[HttpPost("history/{id:int}/validate-source")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<IActionResult> ValidateSource(int jobId, int id, CancellationToken ct)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
var current = await _workspace.ValidateCoverLetterSourceAsync(user.Id, jobId, id, ct);
|
||||
return current is null ? NotFound() : current.Value ? NoContent()
|
||||
: Conflict(new { code = "cover_letter_source_changed" });
|
||||
}
|
||||
|
||||
[HttpDelete("history/{id:int}")]
|
||||
public async Task<IActionResult> Delete(int jobId, int id, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace JobTrackerApi.Models;
|
||||
public sealed class CoverLetterDocumentSettings
|
||||
{
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
public string Language { get; set; } = "en";
|
||||
public string SenderName { get; set; } = "";
|
||||
public string SenderTitle { get; set; } = "";
|
||||
public string SenderEmail { get; set; } = "";
|
||||
@@ -47,6 +48,7 @@ public static class CoverLetterDocumentSettingsJson
|
||||
return new()
|
||||
{
|
||||
SchemaVersion = 1, ThemeId = theme.Id,
|
||||
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),
|
||||
Recipient = Text(value.Recipient, 2000), Subject = Text(value.Subject, 500), Date = Text(value.Date, 100),
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using System.Security.Cryptography;
|
||||
using System.Data;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -19,6 +22,13 @@ public sealed class AiUnavailableException : Exception
|
||||
public AiUnavailableException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public sealed class CoverLetterSourceChangedException(int inputCharacterCount, int outputCharacterCount)
|
||||
: InvalidOperationException("cover_letter_source_changed")
|
||||
{
|
||||
public int InputCharacterCount { get; } = inputCharacterCount;
|
||||
public int OutputCharacterCount { get; } = outputCharacterCount;
|
||||
}
|
||||
|
||||
public interface IAiWorkspaceService
|
||||
{
|
||||
// Runs one module, stores the result as an append-only AiInteraction, and returns it. Never
|
||||
@@ -27,6 +37,7 @@ public interface IAiWorkspaceService
|
||||
Task<IReadOnlyList<AiInteraction>> HistoryAsync(string ownerUserId, int jobApplicationId, string? module, CancellationToken ct);
|
||||
Task<AiInteraction?> GetAsync(string ownerUserId, int id, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct);
|
||||
Task<bool?> ValidateCoverLetterSourceAsync(string ownerUserId, int jobApplicationId, int interactionId, CancellationToken ct);
|
||||
|
||||
// The module keys this service supports (for the controller/UI to enumerate).
|
||||
IReadOnlyList<string> Modules { get; }
|
||||
@@ -35,6 +46,9 @@ public interface IAiWorkspaceService
|
||||
public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
private sealed record LetterSourceBinding(int Version, string Fingerprint, string CandidateNameSha256);
|
||||
private sealed record LinkedCvContext(string Name, string Text, bool HasCareerEvidence,
|
||||
int VariantId, int VariantVersion, string SettingsJson, int ProfileId, int ProfileVersion, string ProfileJson, string LongTailJson);
|
||||
|
||||
public IReadOnlyList<string> Modules { get; } = new[]
|
||||
{
|
||||
@@ -114,7 +128,7 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
var module = (req.Module ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (!Modules.Contains(module)) throw new ArgumentException($"Unknown AI module '{module}'.");
|
||||
|
||||
var job = await _db.JobApplications.Include(j => j.Company)
|
||||
var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company)
|
||||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||||
if (job is null) return null;
|
||||
|
||||
@@ -153,6 +167,10 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
};
|
||||
|
||||
var prompt = $"{instruction} {Guardrail}";
|
||||
var candidateNameHash = Hash(candidateName);
|
||||
var binding = module == "cover-letter"
|
||||
? new LetterSourceBinding(1, await FingerprintAsync(ownerUserId, job, linkedCv, candidateNameHash, ct), candidateNameHash)
|
||||
: null;
|
||||
AiGenerationResult? generation;
|
||||
try
|
||||
{
|
||||
@@ -170,6 +188,12 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
|
||||
var actualProvider = string.IsNullOrWhiteSpace(generation?.Provider) ? provider : generation.Provider;
|
||||
|
||||
// Hold only the post-inference validation/history commit, never a provider call, in this transaction.
|
||||
await using var transaction = module == "cover-letter" && _db.Database.IsRelational()
|
||||
? await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct) : null;
|
||||
if (binding is not null && binding.Fingerprint != await CurrentFingerprintAsync(ownerUserId, jobApplicationId, candidateNameHash, ct))
|
||||
throw new CoverLetterSourceChangedException(prompt.Length + source.Length, result.Trim().Length);
|
||||
|
||||
var interaction = new AiInteraction
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
@@ -186,6 +210,7 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
model = generation?.Model,
|
||||
fallbackReason = generation?.FallbackReason,
|
||||
routeReason = generation?.RouteReason,
|
||||
coverLetterSource = binding,
|
||||
},
|
||||
}, Json),
|
||||
InputCharacterCount = prompt.Length + source.Length,
|
||||
@@ -195,6 +220,7 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
};
|
||||
_db.AiInteractions.Add(interaction);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
if (transaction is not null) await transaction.CommitAsync(ct);
|
||||
return interaction;
|
||||
}
|
||||
|
||||
@@ -210,6 +236,63 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
public Task<AiInteraction?> GetAsync(string ownerUserId, int id, CancellationToken ct) =>
|
||||
_db.AiInteractions.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct);
|
||||
|
||||
public async Task<bool?> ValidateCoverLetterSourceAsync(string ownerUserId, int jobApplicationId, int interactionId, CancellationToken ct)
|
||||
{
|
||||
var row = await _db.AiInteractions.AsNoTracking().FirstOrDefaultAsync(item => item.Id == interactionId
|
||||
&& item.OwnerUserId == ownerUserId && item.JobApplicationId == jobApplicationId && item.Module == "cover-letter", ct);
|
||||
if (row is null) return null;
|
||||
if (!await _db.JobApplications.AsNoTracking().AnyAsync(job => job.Id == jobApplicationId && job.OwnerUserId == ownerUserId, ct)) return null;
|
||||
LetterSourceBinding? binding;
|
||||
try
|
||||
{
|
||||
using var json = JsonDocument.Parse(row.ResultJson);
|
||||
if (json.RootElement.ValueKind != JsonValueKind.Object || !json.RootElement.TryGetProperty("meta", out var meta) || meta.ValueKind != JsonValueKind.Object
|
||||
|| !meta.TryGetProperty("coverLetterSource", out var source) || source.ValueKind != JsonValueKind.Object) return false;
|
||||
binding = source.Deserialize<LetterSourceBinding>(Json);
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
if (binding is not { Version: 1 } || !IsHash(binding.Fingerprint) || !IsHash(binding.CandidateNameSha256)) return false;
|
||||
return binding.Fingerprint == await CurrentFingerprintAsync(ownerUserId, jobApplicationId, binding.CandidateNameSha256, ct);
|
||||
}
|
||||
|
||||
private static bool IsHash(string? value) => value is { Length: 64 } && value.All(Uri.IsHexDigit);
|
||||
private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
|
||||
private async Task<string?> CurrentFingerprintAsync(string ownerUserId, int jobApplicationId, string candidateNameHash, CancellationToken ct)
|
||||
{
|
||||
var job = await _db.JobApplications.AsNoTracking().Include(item => item.Company)
|
||||
.FirstOrDefaultAsync(item => item.Id == jobApplicationId && item.OwnerUserId == ownerUserId, ct);
|
||||
if (job is null) return null;
|
||||
var linked = await BuildLinkedCvContextAsync(ownerUserId, jobApplicationId, string.Empty, ct);
|
||||
return await FingerprintAsync(ownerUserId, job, linked, candidateNameHash, ct, refreshCandidateName: true);
|
||||
}
|
||||
|
||||
private async Task<string> FingerprintAsync(string ownerUserId, JobApplication job, LinkedCvContext? linked,
|
||||
string candidateNameHash, CancellationToken ct, bool refreshCandidateName = false)
|
||||
{
|
||||
var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, ct);
|
||||
if (refreshCandidateName && user is not null)
|
||||
{
|
||||
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim();
|
||||
candidateNameHash = Hash(name ?? string.Empty);
|
||||
}
|
||||
var attachments = await _db.Attachments.AsNoTracking().Where(item => item.JobApplicationId == job.Id)
|
||||
.OrderBy(item => item.Id).Select(item => new
|
||||
{
|
||||
item.Id, item.FileName, item.FilePath, item.FileSize, item.FileType, item.UploadDate, item.Purpose, item.UseForAi,
|
||||
}).ToListAsync(ct);
|
||||
// Only the digest is stored; raw profile, document paths and identity stay out of AI metadata.
|
||||
return Hash(JsonSerializer.Serialize(new
|
||||
{
|
||||
schemaVersion = 1, ownerUserId, job.Id, job.Version, job.CompanyId, jobContext = BuildJobContext(job),
|
||||
job.CoverLetterText, job.CoverLetterDocumentSettingsJson, candidateNameHash, linked,
|
||||
identity = user is null ? null : new { user.FirstName, user.LastName, user.DisplayName, user.UserName, user.ProfileCvText, user.ProfileCvStructureJson, user.CurrentCvProfileVersion },
|
||||
attachments,
|
||||
}, Json));
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct)
|
||||
{
|
||||
var row = await GetAsync(ownerUserId, id, ct);
|
||||
@@ -242,22 +325,24 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
: "generate";
|
||||
}
|
||||
|
||||
private async Task<(string Name, string Text, bool HasCareerEvidence)?> BuildLinkedCvContextAsync(string ownerUserId, int jobApplicationId, string candidateName, CancellationToken ct)
|
||||
private async Task<LinkedCvContext?> BuildLinkedCvContextAsync(string ownerUserId, int jobApplicationId, string candidateName, CancellationToken ct)
|
||||
{
|
||||
var query = _db.CvVariants.AsNoTracking()
|
||||
.Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId);
|
||||
var variant = _db.Database.IsSqlite()
|
||||
? (await query.ToListAsync(ct)).MaxBy(v => v.UpdatedAtUtc)
|
||||
: await query.OrderByDescending(v => v.UpdatedAtUtc).FirstOrDefaultAsync(ct);
|
||||
? (await query.ToListAsync(ct)).OrderByDescending(v => v.UpdatedAtUtc).ThenByDescending(v => v.Id).FirstOrDefault()
|
||||
: await query.OrderByDescending(v => v.UpdatedAtUtc).ThenByDescending(v => v.Id).FirstOrDefaultAsync(ct);
|
||||
if (variant is null) return null;
|
||||
|
||||
var profileJson = await _db.CareerProfiles.AsNoTracking()
|
||||
var career = await _db.CareerProfiles.AsNoTracking()
|
||||
.Where(profile => profile.OwnerUserId == ownerUserId)
|
||||
.Select(profile => profile.ProfileJson)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(profileJson)) return (variant.Name, "(the linked CV has no content yet)", false);
|
||||
LinkedCvContext Snapshot(string text, bool hasEvidence) => new(variant.Name, text, hasEvidence,
|
||||
variant.Id, variant.Version, variant.SettingsJson, career?.Id ?? 0, career?.Version ?? 0,
|
||||
career?.ProfileJson ?? string.Empty, career?.LongTailJson ?? string.Empty);
|
||||
if (string.IsNullOrWhiteSpace(career?.ProfileJson)) return Snapshot("(the linked CV has no content yet)", false);
|
||||
|
||||
var profile = StructuredCvProfileJson.DeserializePersisted(profileJson);
|
||||
var profile = StructuredCvProfileJson.DeserializePersisted(career.ProfileJson);
|
||||
var model = CvVariantResolver.Build(profile, CvVariantSettingsJson.Deserialize(variant.SettingsJson), candidateName, null);
|
||||
// Headers, identity, the advert and a prior draft do not establish candidate experience.
|
||||
var hasCareerEvidence = model.Sections.Any(section => section.Bullets.Any(HasText)
|
||||
@@ -275,7 +360,7 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
string.Join(" | ", new[] { entry.Title, entry.Subtitle, entry.Meta, string.Join("; ", entry.Bullets), string.Join(", ", entry.Tags) }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)))));
|
||||
}
|
||||
return (variant.Name, string.Join("\n", lines.Where(line => !string.IsNullOrWhiteSpace(line))), hasCareerEvidence);
|
||||
return Snapshot(string.Join("\n", lines.Where(line => !string.IsNullOrWhiteSpace(line))), hasCareerEvidence);
|
||||
}
|
||||
|
||||
private static bool HasText(string? value) => !string.IsNullOrWhiteSpace(value);
|
||||
|
||||
@@ -32,7 +32,7 @@ public sealed class CoverLetterDocumentRenderer
|
||||
if (list) body.Append("</ul>");
|
||||
var contact = string.Join(" · ", new[] { s.SenderEmail, s.SenderPhone, s.SenderLocation }.Where(v => !string.IsNullOrWhiteSpace(v)).Select(E));
|
||||
var html = $$"""
|
||||
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||
<!DOCTYPE html><html lang="{{E(s.Language)}}"><head><meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; font-src data:; base-uri 'none'">
|
||||
<title>{{E(s.Subject)}} — {{E(s.SenderName)}}</title><style>
|
||||
{{CvDocumentFonts.Embed(font)}}
|
||||
|
||||
+10
-4
@@ -14,6 +14,10 @@ The existing cover-letter `AiWorkspaceService` uses the owned linked CV/profile
|
||||
|
||||
## Safety and UX contract
|
||||
|
||||
Recorded letter suggestions now bind a SHA256 source identity in existing interaction metadata. The fingerprint represents the job/CV/profile snapshot actually used for generation, relevant identity/attachment state and current letter revision. A fresh post-inference check rejects changed input before history is committed; inference is outside the short relational transaction. Stale inference still finalizes its actual usage reservation. Before local Apply, the client calls authenticated Pro, owner/job/module-scoped `POST /api/jobapplications/{jobId}/ai/history/{id}/validate-source`;204 permits local acceptance,409 requests regeneration,404 hides unavailable ownership. Legacy/malformed unbound results fail closed. Local edits, cancellation, job changes, unmount and an overlapping Save discard late validation responses. This proves source freshness at validation, not factual correctness of generated prose.
|
||||
|
||||
Persistent letter language and AI generation language share the local settings draft. Rendering declares allowlisted English or Bokmål metadata; legacy settings default to English. Rewrite/grammar/shorten retain source-language instructions exclusively. The independently reviewed contact copy defaults name/title/email/phone on and optional location off, preserving unselected destination fields.
|
||||
|
||||
The source prerequisite now refuses letter inference before model invocation or interaction creation unless an owned linked CV resolves actual career evidence. Identity, section headers, an advert and a prior draft are insufficient. Five source/action refusal combinations were added to the full backend suite. The UI maps `cover_letter_source_required` to actionable EN/Bokmål guidance. The four letter controls are portalled immediately beneath the body field while retaining the existing assistant read lifecycle.
|
||||
|
||||
- Original and editable proposed text remain separate; Reject never saves and Accept is explicit.
|
||||
@@ -32,9 +36,11 @@ The successor focused backend run passed65/65 (`prompt-language-green.trx`). Act
|
||||
|
||||
| Action | Implementation | Frontend | Backend | Actual model / production |
|
||||
|---|---|---|---|---|
|
||||
| Improve writing | Existing CV/letter paths exposed directly | Component pass | CV tests pass; letter regression pending final suite | NOT TESTED / not eligible |
|
||||
| Suggest content | New CV action; existing letter generation | Component pass, missing source asks for input | CV tests pass; letter grounding limitation above | NOT TESTED / not eligible |
|
||||
| Grammar check | Existing paths exposed directly | Component pass | CV tests pass; letter regression pending final suite | NOT TESTED / not eligible |
|
||||
| Shorter | Existing paths exposed directly | Component pass | CV tests pass; letter regression pending final suite | NOT TESTED / not eligible |
|
||||
| Improve writing | Existing CV/letter paths exposed directly | Full frontend PASS | Full backend PASS; recorded source validation | BLOCKED / not eligible |
|
||||
| Suggest content | New CV action; existing letter generation | Full frontend PASS; missing facts require input | Full backend PASS; letter semantic limitation above | BLOCKED / not eligible |
|
||||
| Grammar check | Existing paths exposed directly | Full frontend PASS | Full backend PASS; exclusive source-language instruction | BLOCKED / not eligible |
|
||||
| Shorter | Existing paths exposed directly | Full frontend PASS | Full backend PASS; exclusive source-language instruction | BLOCKED / not eligible |
|
||||
|
||||
Outstanding: fresh full regression, endpoint authorization/browser coverage, actual approved self-hosted EN/Bokmål inference and adversarial semantic acceptance, source-grounding qualification for letter operations, human accessibility and language review. Production remains NO-GO; no deferred P5-13 operation is activated by this UI work.
|
||||
|
||||
Reviewed successor evidence: source-bound letter guard103 focused backend cases pass; full Release backend1227/1227; full frontend981/981 across107 suites with before/after source hashes equal; application-assets53/53 and contact/language10/10 focused cases overlap the full frontend total. Source guard tests first produced19 failures; malformed legacy hardening produced3 failures before correction. Updated physical language assertions and seven responsive scenarios passed in two real browser cases; Windows letter oracle4/4 across14pages has zero differences. Independent re-review found no remaining material frontend/backend issue. Fresh aggregate browser/provider/Linux gates remain pending final reconciliation; actual inference remains unavailable and unqualified.
|
||||
|
||||
@@ -40,3 +40,11 @@ Remaining: fresh ordinary/contract/RD21 browsers, responsive/localisation automa
|
||||
- Letter responsive browser case passes seven widths320–1440, EN/Bokmål, light/dark, keyboard pane/focus/draft preservation, root/main overflow and primary control hit testing (`letter-responsive-03`). The two predecessors expose fixture errors, not product passes: iframe init storage reset and wrong assumed900px breakpoint. The tested application theme breakpoint is1024px.
|
||||
- Offline Linux adapter guard/lifecycle checks10/10. Current application publish is overlaid in a new app directory on the exact verified cached native runtime. The first build's unnecessary audit override was removed; its image is explicitly superseded. No advisories are suppressed in the successor helper. Native OS rebuilding and actual Linux rendering are distinct gates.
|
||||
- Handoff requirements prepared in CV_IMPORT_HANDOFF.md; import implementation remains unstarted.
|
||||
|
||||
## Independent-review corrections
|
||||
|
||||
The first independent full review found three P2 acceptance gaps: location was copied by default, all letters declared English HTML language, and letter AI invalidation watched cached source metadata. All three are corrected. Contact copy now has reviewed default/optional field selection; language is an additive normalized settings property retained by history/frozen snapshots; AI suggestions bind actual input source and revalidate after inference and before local Apply. Legacy unbound suggestions require regeneration. No new schema/provider was introduced by these corrections.
|
||||
|
||||
Fresh full Release backend1227/1227 (40 added cases, zero removed from the preceding1187 report) and frontend981/981 across107 suites passed. Source-bound frontend process before/after hashes match. Two letter browser cases passed after corrections; the unchanged native oracle passed all four letter fixtures/14pages with zero differences. Actual MariaDB source-bound transaction cases and final aggregate Windows/Linux runs remain pending. Re-review of the corrections found no remaining material defect; review did not substitute for execution.
|
||||
|
||||
Actual MariaDB source-boundary successor59/59 passed (`provider-final-03`), including three real relational transaction/held-source cases with a stub inference service. Source hashes match and owned cleanup is confirmed. The intermediate `provider-final-02` preparation failed before starting a service or executing tests and remains preserved. Real API authorization checks also passed (missing owned interaction404; free account403). The seven-scenario responsive browser case additionally passed five targeted WCAG A/AA scans with zero violations; incomplete/manual qualifications remain open and final native reports retain their scan details. Its preceding exact-name locator timeout is a fixture failure retained separately: MUI exposes the select's label together with its selected value.
|
||||
|
||||
@@ -10,4 +10,6 @@ Contact/design copies are explicit reviewed snapshots. Imported facts or later C
|
||||
|
||||
CV AI proposals are bound to owned source/revision capabilities and reviewed acceptance. Letter AI requires an owned linked CV with actual visible career evidence. Advert requirements, identity/header text and a previous letter do not establish experience. Import text is untrusted data, never model instructions. Reuse the existing approved self-hosted infrastructure; do not infer semantic, GPU or production readiness from CPU compatibility or mocked UI tests.
|
||||
|
||||
Letter suggestions now bind source fingerprints to their recorded interaction. Profile/CV/job/attachment changes invalidate generated output and pre-Apply validation; legacy unbound results must be regenerated. Import must use existing revisioned profile owners so these source boundaries continue to work. Letter document language is an allowlisted additive JSON setting (`en` or `nb-NO`, legacy default `en`); copying contact/design preserves it. Optional location is unchecked by default and unselected destination contact fields are retained.
|
||||
|
||||
Before CV-IMPORT entry: read CV-UX final acceptance and its open gates, verify the accepted branch/commit and current architecture, re-establish supported-provider migration/rollback evidence and preserve frozen fixtures. Keep the legacy parser until the separately authorised replacement is qualified. Production, hosted isolation and security/recovery decisions remain separate.
|
||||
|
||||
@@ -18,4 +18,8 @@ Implementation/focused checks are complete for CV-UX-01–05 and CV-UX-AI; their
|
||||
|
||||
Reviewed successor:65/65 prompt contract tests,972/972 full frontend,56/56 fresh actual MariaDB and one seven-scenario responsive letter browser case passed. Linux lifecycle self-checks10/10. Full ordinary discovery increases from362 historical to364 (one physical letter producer and one responsive case). Final independent contract/PDF/browser lanes remain open. Actual local inference is blocked by absent approved local services; human/hosted/production gates remain open.
|
||||
|
||||
Independent review corrections complete: optional contact selection, persistent physical/AI language, and recorded-source validation after inference/before Apply. Full Release backend1227/1227 and full frontend981/981/107suites passed; source hashes stable. Two focused real letter browser cases and unchanged Windows letter oracle4documents/14pages passed. Final aggregate and actual MariaDB source-transaction lanes remain open. Superseded final-01/02/03 attempts are retained and cannot establish full browser acceptance.
|
||||
|
||||
Actual provider source-boundary suite59/59 passed with stable sources and positive cleanup; five targeted letter axe scans and real403/404 authorization checks passed. Final aggregate browser/contract/volume and Windows/Linux original CV+letter PDF gates remain open.
|
||||
|
||||
Historical evidence: Phase 9 conditional acceptance is inherited, not fresh CV-UX test execution. Hosted isolation/production/manual/provider/AI semantic limitations remain open. Research checksum verification: 83/83; screenshot pairs: 37/37; contact sheets inspected locally. Raw third-party imagery stays outside Git/product assets.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -35,6 +36,7 @@ test('cover letter native preview/PDF fidelity records four independent physical
|
||||
const email = `letter-fidelity-${Date.now()}@example.test`;
|
||||
try {
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
expect((await admin.post(`${apiUrl}/jobapplications/2147483647/ai/history/2147483647/validate-source`, { headers: adminHeaders })).status()).toBe(404);
|
||||
} finally { await admin.dispose(); }
|
||||
const headers = await login(page.request, email);
|
||||
const sourceBefore = await (await page.request.get(`${apiUrl}/career/profile`)).json();
|
||||
@@ -51,7 +53,7 @@ test('cover letter native preview/PDF fidelity records four independent physical
|
||||
: 'Documented reliable workflows and accessible navigation using reviewed source evidence. ';
|
||||
const text = Array.from({ length: 24 }, (_, index) => `B${String(index + 1).padStart(2, '0')} ${sentence.repeat(4)}`)
|
||||
.concat(['- S01 **Reviewed achievement** — æøå.', '- S02 *Verified collaboration* — ÆØÅ.', 'R99 Final reviewed letter content — Åse Østby.']).join('\n\n');
|
||||
const documentSettings = { schemaVersion: 1, senderName: 'R01 Åse Østby', senderTitle: 'P01 Platform engineer',
|
||||
const documentSettings = { schemaVersion: 1, language: language === 'nb' ? 'nb-NO' : 'en', senderName: 'R01 Åse Østby', senderTitle: 'P01 Platform engineer',
|
||||
senderEmail: 'ase@example.test', senderPhone: '+47 12345678', senderLocation: 'Oslo, Norge',
|
||||
recipient: 'R03 Hiring team\nNordlys Synthetic AS\nOslo, Norge', subject: language === 'nb' ? 'R02 Søknad om plattformutvikler' : 'R02 Application for platform engineer',
|
||||
date: '2026-10-25', themeId: 'minimal', accentColor: '#126b55', bodyFont: 'Arial, Helvetica, sans-serif',
|
||||
@@ -71,6 +73,7 @@ test('cover letter native preview/PDF fidelity records four independent physical
|
||||
const preview = await page.request.post(`${letterUrl}/preview`, { headers, data: { text: saved.text, documentSettings: saved.documentSettings } });
|
||||
expect(preview.status()).toBe(200);
|
||||
const rendered = await preview.json();
|
||||
expect(rendered.html).toContain(`<html lang="${documentSettings.language}">`);
|
||||
expect(rendered.html).toContain('class="page letter-paper"');
|
||||
const pdf = await page.request.post(`${letterUrl}/export-pdf?expectedVersion=${saved.revision}`, { headers });
|
||||
expect(pdf.status()).toBe(200);
|
||||
@@ -89,6 +92,7 @@ test('cover letter native preview/PDF fidelity records four independent physical
|
||||
const previewWorkspace = frame.locator('..').locator('..').locator('..');
|
||||
await expect(frame).toBeVisible();
|
||||
await expect.poll(() => frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true');
|
||||
expect(await frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.lang)).toBe(documentSettings.language);
|
||||
const measured = await frame.evaluate(async element => {
|
||||
const doc = (element as HTMLIFrameElement).contentDocument!;
|
||||
const win = (element as HTMLIFrameElement).contentWindow!;
|
||||
@@ -171,6 +175,7 @@ test('cover letter workspace preserves keyboard drafts and localised controls ac
|
||||
const created = await context.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: 'Responsive reviewed letter', status: 'Saved' } });
|
||||
expect(created.status()).toBe(201);
|
||||
const job = await created.json();
|
||||
expect((await context.request.post(`${apiUrl}/jobapplications/${job.id}/ai/history/2147483647/validate-source`, { headers })).status()).toBe(403);
|
||||
const letterUrl = `${apiUrl}/jobapplications/${job.id}/cover-letter`;
|
||||
const original = await (await context.request.get(letterUrl)).json();
|
||||
const savedResponse = await context.request.put(letterUrl, { headers, data: { text: 'Original reviewed letter — æøå.', source: 'manual', expectedVersion: original.revision } });
|
||||
@@ -199,6 +204,10 @@ test('cover letter workspace preserves keyboard drafts and localised controls ac
|
||||
const workspace = page.getByRole('group', { name: t.workspaceCoverLetter, exact: true });
|
||||
const body = workspace.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true });
|
||||
await expect(body).toHaveValue(saved.text);
|
||||
const documentLanguage = workspace.getByRole('combobox', { name: t.letterDocumentLanguage });
|
||||
await expect(documentLanguage).toBeVisible();
|
||||
await documentLanguage.click();
|
||||
await page.getByRole('option', { name: language === 'nb' ? t.languageNorwegianBokmal : t.languageEnglish, exact: true }).click();
|
||||
const draft = `${width} ${language === 'nb' ? 'Ulagret søknadsbrev med gjennomgåtte fakta' : 'Unsaved letter with reviewed facts'} — æøå.`;
|
||||
await body.focus(); await body.press('ControlOrMeta+A'); await body.pressSequentially(draft);
|
||||
await expect(body).toHaveValue(draft);
|
||||
@@ -220,6 +229,15 @@ test('cover letter workspace preserves keyboard drafts and localised controls ac
|
||||
expect(geometry.root).toBeLessThanOrEqual(1); expect(geometry.app).toBeLessThanOrEqual(1);
|
||||
const writePane = workspace.locator('[id$="-write-panel"]');
|
||||
const previewPane = workspace.locator('[id$="-preview-panel"]');
|
||||
const audit = async (state: string) => {
|
||||
await workspace.evaluate(node => node.setAttribute('data-cv-ux-axe', 'letter'));
|
||||
const result = await new AxeBuilder({ page }).include('[data-cv-ux-axe="letter"]').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
const reportPath = info.outputPath(`letter-${width}-${state}-axe.json`);
|
||||
await fs.writeFile(reportPath, JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2));
|
||||
await info.attach(`letter-${width}-${state}-axe`, { path: reportPath, contentType: 'application/json' });
|
||||
expect(result.violations).toEqual([]);
|
||||
};
|
||||
if (width === 320 || width === 390 || width === 1440) await audit('write');
|
||||
if (width < 1024) {
|
||||
const tabs = workspace.getByRole('tablist', { name: t.assetsLetterWorkspace, exact: true });
|
||||
const write = tabs.getByRole('tab', { name: t.assetsLetterWrite, exact: true });
|
||||
@@ -228,6 +246,7 @@ test('cover letter workspace preserves keyboard drafts and localised controls ac
|
||||
await write.focus(); await page.keyboard.press('ArrowRight'); await expect(preview).toBeFocused(); await page.keyboard.press('Enter');
|
||||
await expect(preview).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(writePane).not.toBeVisible(); await expect(previewPane).toBeVisible();
|
||||
if (width === 320 || width === 390) await audit('preview');
|
||||
await expect(workspace.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true })).toHaveCount(0);
|
||||
for (let step = 0; step < 8; step++) {
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
@@ -32,5 +32,6 @@ export const aiWorkspaceApi = {
|
||||
api.post<AiInteraction>(`/jobapplications/${jobId}/ai/generate`, body).then((r) => r.data),
|
||||
history: (jobId: number, module?: string) =>
|
||||
api.get<AiInteraction[]>(`/jobapplications/${jobId}/ai/history`, { params: { module } }).then((r) => r.data),
|
||||
validateSource: (jobId: number, id: number) => api.post(`/jobapplications/${jobId}/ai/history/${id}/validate-source`).then(() => undefined),
|
||||
remove: (jobId: number, id: number) => api.delete(`/jobapplications/${jobId}/ai/history/${id}`),
|
||||
};
|
||||
|
||||
@@ -359,7 +359,7 @@ test("AI cover letter suggestions use the linked CV and require explicit apply",
|
||||
));
|
||||
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Dear team");
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Apply to editor/i }));
|
||||
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("A tailored, truthful suggestion.");
|
||||
await waitFor(() => expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("A tailored, truthful suggestion."));
|
||||
|
||||
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "A tailored, truthful suggestion." } } as any);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
@@ -369,6 +369,77 @@ test("AI cover letter suggestions use the linked CV and require explicit apply",
|
||||
));
|
||||
});
|
||||
|
||||
test("letter AI revalidates its recorded source before applying and refuses a cross-tab source conflict", async () => {
|
||||
routeGet();
|
||||
mockedApi.post.mockImplementation(((url: string) => url.endsWith('/generate')
|
||||
? Promise.resolve({ data: { id: 91, result: { text: 'Old source proposal' } } })
|
||||
: Promise.reject({ response: { status: 409, data: { code: 'cover_letter_source_changed' } } })) as any);
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Apply to editor/i }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/7/ai/history/91/validate-source'));
|
||||
expect(await screen.findByText(copy('coverAiSourceChanged'))).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Dear team');
|
||||
expect(screen.queryByRole('button', { name: /Apply to editor/i })).not.toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("letter AI language follows the saved document and updates the same local language draft", async () => {
|
||||
routeGet({ coverLetter: { ...coverLetter, documentSettings: { ...letterSettings, language: 'nb-NO' } } });
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
expect(await screen.findByRole('combobox', { name: 'Letter language' })).toHaveTextContent('Norsk bokmål');
|
||||
await waitFor(() => expect(screen.getByRole('combobox', { name: 'Document language' })).toHaveTextContent('Norsk bokmål'));
|
||||
fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Document language' }));
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'English' }));
|
||||
expect(screen.getByRole('combobox', { name: 'Letter language' })).toHaveTextContent('English');
|
||||
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Dear team');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, revision: 8, documentSettings: { ...letterSettings, language: 'en' } } } as any);
|
||||
fireEvent.click(section('workspaceCoverLetter').getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/7/cover-letter', expect.objectContaining({ documentSettings: { ...letterSettings, language: 'en' }, expectedVersion: 7 })));
|
||||
});
|
||||
|
||||
test.each(['edit', 'cancel', 'job change'])("letter AI ignores a late source validation after local %s", async action => {
|
||||
routeGet();
|
||||
const validation = deferred<any>();
|
||||
mockedApi.post.mockImplementation(((url: string) => url.endsWith('/generate')
|
||||
? Promise.resolve({ data: { id: 92, result: { text: 'Previously reviewed proposal' } } })
|
||||
: validation.promise) as any);
|
||||
const view = render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Apply to editor/i }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/7/ai/history/92/validate-source'));
|
||||
if (action === 'edit') fireEvent.change(screen.getByRole('textbox', { name: 'Cover letter' }), { target: { value: 'New local draft' } });
|
||||
else if (action === 'cancel') fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
else {
|
||||
routeGet({ coverLetter: { ...coverLetter, text: 'Different job draft' } });
|
||||
view.rerender(withProviders(<ApplicationCoverLetterSection jobId={8} />));
|
||||
await screen.findByDisplayValue('Different job draft');
|
||||
}
|
||||
await act(async () => { validation.resolve({ status: 204 }); await validation.promise; });
|
||||
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue(action === 'edit' ? 'New local draft' : action === 'cancel' ? 'Dear team' : 'Different job draft');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a held letter Save prevents an in-flight AI validation from replacing the submitted snapshot", async () => {
|
||||
routeGet();
|
||||
const validation = deferred<any>(); const save = deferred<any>();
|
||||
mockedApi.post.mockImplementation(((url: string) => url.endsWith('/generate')
|
||||
? Promise.resolve({ data: { id: 94, result: { text: 'Validated proposal' } } }) : validation.promise) as any);
|
||||
mockedApi.put.mockReturnValue(save.promise);
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.change(await screen.findByRole('textbox', { name: 'Cover letter' }), { target: { value: 'Snapshot being saved' } });
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Apply to editor/i }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/7/ai/history/94/validate-source'));
|
||||
fireEvent.click(section('workspaceCoverLetter').getByRole('button', { name: 'Save' }));
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/7/cover-letter', expect.objectContaining({ text: 'Snapshot being saved', expectedVersion: 7 }));
|
||||
await act(async () => { validation.resolve({ status: 204 }); await validation.promise; });
|
||||
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Snapshot being saved');
|
||||
await act(async () => { save.resolve({ data: { ...coverLetter, text: 'Snapshot being saved', revision: 8 } }); await save.promise; });
|
||||
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Snapshot being saved');
|
||||
});
|
||||
|
||||
test("[PRES-W06] rejecting an AI cover letter preserves the saved draft without a write", async () => {
|
||||
routeGet();
|
||||
mockedApi.post.mockResolvedValue({ data: { result: { text: "Unaccepted letter" } } } as never);
|
||||
@@ -601,14 +672,15 @@ test("held AI generation locks its controls and preserves captured action attrib
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "cover-letter", action: "generate", mode: "professional", targetLanguage: "nb-NO", currentText: "Dear team", extraContext: "Use only recorded context" }));
|
||||
for (const name of ["Action", "Tone", "Document language"]) expect(screen.getByRole("combobox", { name })).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByRole("textbox", { name: "Additional instructions" })).toBeDisabled();
|
||||
await act(async () => { generation.resolve({ data: { result: { text: "Captured generated suggestion" } } }); });
|
||||
await act(async () => { generation.resolve({ data: { id: 93, result: { text: "Captured generated suggestion" } } }); });
|
||||
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Action" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Improve" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Apply to editor/i }));
|
||||
await waitFor(() => expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Captured generated suggestion"));
|
||||
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Captured generated suggestion" } } as any);
|
||||
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Captured generated suggestion", source: "ai", aiAction: "generate", expectedVersion: 7 }));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("capture, detail and download failures keep owned frozen history and recover only through their original explicit actions", async () => {
|
||||
|
||||
@@ -263,6 +263,7 @@ export type CoverLetter = {
|
||||
|
||||
export type CoverLetterDocumentSettings = {
|
||||
schemaVersion: 1;
|
||||
language?: "en" | "nb-NO";
|
||||
senderName: string; senderTitle: string; senderEmail: string; senderPhone: string; senderLocation: string;
|
||||
recipient: string; subject: string; date: string;
|
||||
themeId: string; accentColor: string; bodyFont: string;
|
||||
|
||||
@@ -666,6 +666,8 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
|
||||
<CoverLetterAiAssistant
|
||||
jobId={jobId}
|
||||
documentLanguage={settings?.language}
|
||||
onDocumentLanguageChange={settings ? next => setSettingsDraft({ ...settings, language: next }) : undefined}
|
||||
toolbarHost={aiToolbarHost}
|
||||
currentText={text}
|
||||
disabled={busy || confirming || loading || !data}
|
||||
@@ -737,7 +739,7 @@ const COVER_LETTER_ACTIONS = [
|
||||
{ key: "tailor", labelKey: "coverActionTailor" },
|
||||
] as const;
|
||||
|
||||
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbarHost }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void; toolbarHost: HTMLDivElement | null }) {
|
||||
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbarHost, documentLanguage, onDocumentLanguageChange }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void; toolbarHost: HTMLDivElement | null; documentLanguage?: "en" | "nb-NO"; onDocumentLanguageChange?: (language: "en" | "nb-NO") => void }) {
|
||||
const labelId = useId();
|
||||
const { canUseAi } = useAccountPlan();
|
||||
const { language: uiLanguage, t } = useI18n();
|
||||
@@ -746,11 +748,14 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
|
||||
const [mode, setMode] = useState("professional");
|
||||
const [language, setLanguage] = useState<"en" | "nb-NO">(() => uiLanguage === "nb" ? "nb-NO" : "en");
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [suggestion, setSuggestion] = useState<{ text: string; action: string } | null>(null);
|
||||
const [suggestion, setSuggestion] = useState<{ id: number; text: string; action: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<AssetError | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
useEffect(() => { if (documentLanguage) setLanguage(documentLanguage); }, [documentLanguage]);
|
||||
|
||||
const requestSequence = useRef(0);
|
||||
useEffect(() => {
|
||||
@@ -779,11 +784,39 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
|
||||
});
|
||||
if (!isCurrent() || request !== requestSequence.current) return;
|
||||
const text = result.result.text?.trim() ?? "";
|
||||
setSuggestion(text ? { text, action: requestedAction } : null);
|
||||
setSuggestion(text ? { id: result.id, text, action: requestedAction } : null);
|
||||
} catch (err) {
|
||||
if (isCurrent() && request === requestSequence.current) {
|
||||
const sourceRequired = (err as { response?: { data?: unknown } })?.response?.data === "cover_letter_source_required";
|
||||
setError(sourceRequired ? { kind: "error", message: "", messageKey: "coverAiSourceRequired", retryable: false } : assetError(err, "coverAiGenerateFailed"));
|
||||
const sourceChanged = (err as { response?: { data?: { code?: string } } })?.response?.data?.code === "cover_letter_source_changed";
|
||||
setError(sourceRequired || sourceChanged ? { kind: "error", message: "", messageKey: sourceChanged ? "coverAiSourceChanged" : "coverAiSourceRequired", retryable: false } : assetError(err, "coverAiGenerateFailed"));
|
||||
}
|
||||
} finally {
|
||||
if (request === requestSequence.current) { busyRef.current = false; setBusy(false); }
|
||||
}
|
||||
};
|
||||
|
||||
const applySuggestion = async () => {
|
||||
if (!suggestion || busyRef.current || disabledRef.current || loadingCv) return;
|
||||
if (!Number.isSafeInteger(suggestion.id) || suggestion.id <= 0) {
|
||||
setSuggestion(null);
|
||||
setError({ kind: "error", message: "", messageKey: "coverAiSourceChanged", retryable: false });
|
||||
return;
|
||||
}
|
||||
const request = ++requestSequence.current;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await aiWorkspaceApi.validateSource(jobId, suggestion.id);
|
||||
if (!isCurrent() || request !== requestSequence.current || disabledRef.current) return;
|
||||
onApply(suggestion.text, suggestion.action);
|
||||
setSuggestion(null);
|
||||
} catch (err) {
|
||||
if (isCurrent() && request === requestSequence.current) {
|
||||
setSuggestion(null);
|
||||
const sourceChanged = (err as { response?: { data?: { code?: string } } })?.response?.data?.code === "cover_letter_source_changed";
|
||||
setError(sourceChanged ? { kind: "error", message: "", messageKey: "coverAiSourceChanged", retryable: false } : assetError(err, "coverAiGenerateFailed"));
|
||||
}
|
||||
} finally {
|
||||
if (request === requestSequence.current) { busyRef.current = false; setBusy(false); }
|
||||
@@ -835,7 +868,7 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 190 }}>
|
||||
<InputLabel id={`${labelId}-coverAiDocumentLanguage`}>{t("coverAiDocumentLanguage")}</InputLabel>
|
||||
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
|
||||
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => { const next = event.target.value as "en" | "nb-NO"; setLanguage(next); onDocumentLanguageChange?.(next); }}>
|
||||
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
||||
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||
</Select>
|
||||
@@ -875,7 +908,7 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
|
||||
<TextField label={t("coverAiSuggestion")} fullWidth multiline minRows={3} value={suggestion.text} disabled={busy || disabled}
|
||||
onChange={event => setSuggestion({ ...suggestion, text: event.target.value })} />
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
|
||||
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv || !suggestion.text.trim()} onClick={() => { if (!busyRef.current && !disabled && !loadingCv) { onApply(suggestion.text, suggestion.action); setSuggestion(null); } }}>{t("coverAiApply")}</Button>
|
||||
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv || !suggestion.text.trim()} onClick={() => void applySuggestion()}>{t("coverAiApply")}</Button>
|
||||
<Button size="small" disabled={busy || disabled || loadingCv} onClick={() => setSuggestion(null)}>{t("coverAiReject")}</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useId, useRef, useState } from "react";
|
||||
import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
||||
import { api } from "../api";
|
||||
import { applicationAssetsApi, CoverLetterDocumentSettings } from "../applicationWorkspace";
|
||||
import { cvBuilderApi, CvTheme, CvVariantSummary, getCvPageMetrics, getCvPreviewFitZoom } from "../cvBuilder";
|
||||
@@ -25,6 +25,11 @@ export function CoverLetterDocumentFields({ settings, disabled, onChange }: Prop
|
||||
<Button disabled={disabled} onClick={() => setCopy("contact")}>{t("letterCopyContact")}</Button>
|
||||
<Button disabled={disabled} onClick={() => setCopy("design")}>{t("letterMatchDesign")}</Button>
|
||||
</Stack>
|
||||
<TextField select size="small" label={t("letterDocumentLanguage")} value={settings.language ?? "en"} disabled={disabled}
|
||||
onChange={event => onChange({ ...settings, language: event.target.value as "en" | "nb-NO" })}>
|
||||
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
||||
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||
</TextField>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))" }, gap: 1.5 }}>
|
||||
{detailFields.map(key => <TextField key={key} label={t(fieldLabels[key])} size="small" fullWidth multiline={key === "recipient"} value={settings[key]} disabled={disabled}
|
||||
slotProps={{ htmlInput: { maxLength: key === "recipient" ? 2000 : key === "subject" ? 500 : key === "date" || key === "senderPhone" ? 100 : 300 } }}
|
||||
@@ -44,6 +49,7 @@ function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "conta
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedContact, setSelectedContact] = useState<readonly typeof contactFields[number][]>(contactFields.filter(key => key !== "senderLocation"));
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setLoading(true); setFailed(false);
|
||||
@@ -78,25 +84,37 @@ function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "conta
|
||||
// The dialog captures the original local draft until the user explicitly applies or cancels.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected, kind, attempt]);
|
||||
const apply = () => {
|
||||
if (!snapshot) return;
|
||||
if (kind === "design") { onApply(snapshot); return; }
|
||||
const next = { ...settings };
|
||||
const chosen = selectedContact.filter(key => snapshot[key].trim());
|
||||
for (const key of chosen) next[key] = snapshot[key];
|
||||
if (chosen.length) next.sourceCvId = snapshot.sourceCvId;
|
||||
onApply(next);
|
||||
};
|
||||
return <Dialog open onClose={onClose} fullWidth maxWidth="sm" aria-labelledby={`${id}-title`} aria-describedby={`${id}-help`}>
|
||||
<DialogTitle id={`${id}-title`}>{t(kind === "contact" ? "letterCopyContact" : "letterMatchDesign")}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack spacing={2}>
|
||||
<Typography id={`${id}-help`} variant="body2">{t("letterCopyHelp")}</Typography>
|
||||
{kind === "contact" && <Typography variant="body2">{t("letterCopyContactHelp")}</Typography>}
|
||||
<TextField select fullWidth size="small" label={t("letterSourceCv")} value={selected} disabled={loading} onChange={event => setSelected(Number(event.target.value))}>
|
||||
{variants.map(variant => <MenuItem key={variant.id} value={variant.id}>{variant.name}</MenuItem>)}
|
||||
</TextField>
|
||||
{!loading && !failed && variants.length === 0 && <Typography variant="body2">{t("assetsNoCvVariants")}</Typography>}
|
||||
{failed && <Alert severity="error" action={<Button onClick={() => setAttempt(value => value + 1)}>{t("retry")}</Button>}>{t("letterCopyFailed")}</Alert>}
|
||||
{snapshot && <Box role="region" aria-label={t(kind === "contact" ? "letterContactSnapshot" : "letterDesignSnapshot")} sx={{ overflowWrap: "anywhere" }}>
|
||||
{kind === "contact" ? contactFields.map(key => <Typography key={key} variant="body2">{t(fieldLabels[key])}: {snapshot[key]}</Typography>) : <>
|
||||
{kind === "contact" ? <Stack>{contactFields.map(key => <FormControlLabel key={key} label={`${t(fieldLabels[key])}: ${snapshot[key]}`}
|
||||
control={<Checkbox size="small" disabled={!snapshot[key].trim()} checked={selectedContact.includes(key) && !!snapshot[key].trim()}
|
||||
onChange={(_, checked) => setSelectedContact(current => checked ? [...current, key] : current.filter(field => field !== key))} />} />)}</Stack> : <>
|
||||
<Typography variant="body2">{snapshot.themeId} · {snapshot.accentColor} · {snapshot.bodyFont}</Typography>
|
||||
<Typography variant="body2">{snapshot.baseFontSizePt} pt · {snapshot.lineHeight} · {snapshot.pageMarginMm} mm · {snapshot.pageSize}</Typography>
|
||||
</>}
|
||||
</Box>}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={loading || failed || !snapshot} onClick={() => snapshot && onApply(snapshot)}>{t("letterCopyApply")}</Button></DialogActions>
|
||||
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={loading || failed || !snapshot} onClick={apply}>{t("letterCopyApply")}</Button></DialogActions>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const settings: CoverLetterDocumentSettings = {
|
||||
schemaVersion: 1, senderName: "Saved sender", senderTitle: "Saved title", senderEmail: "saved@example.test", senderPhone: "123", senderLocation: "Oslo",
|
||||
recipient: "Preserve recipient", subject: "Preserve subject", date: "2026-10-25", themeId: "modern", accentColor: "#2563eb", bodyFont: "Arial, Helvetica, sans-serif",
|
||||
baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: "a4",
|
||||
baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: "a4", language: "nb-NO",
|
||||
};
|
||||
const letter = { revision: 7, text: "Preserve letter body", currentVersion: 2, aiSuggestionCount: 0, documentSettings: settings,
|
||||
versions: [{ version: 1, source: "manual", aiAction: null, length: 20, createdAtUtc: "2026-10-25T10:00:00Z", isCurrent: false }] };
|
||||
const variant = { id: 3, name: "Chosen CV", settings: { themeId: "modern", headline: null, accentColor: null, bodyFont: null, baseFontSizePt: null, lineHeight: null, pageMarginMm: null, pageSize: null, density: "compact" } };
|
||||
const variant = { id: 3, name: "Chosen CV", settings: { themeId: "modern", headline: null, accentColor: null, bodyFont: null, baseFontSizePt: null, lineHeight: null, pageMarginMm: null, pageSize: null, density: "compact", language: "en" } };
|
||||
const wrap = (ui: React.ReactNode) => <I18nProvider><ConfirmProvider>{ui}</ConfirmProvider></I18nProvider>;
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
@@ -52,6 +52,8 @@ test("contact copy is a reviewed canonical snapshot; cancel is inert and apply p
|
||||
expect(snapshot).toHaveTextContent("Canonical name");
|
||||
expect(snapshot).toHaveTextContent("Canonical title");
|
||||
expect(snapshot).not.toHaveTextContent("private");
|
||||
for (const name of [/Sender name/, /Professional title/, /Email/, /Phone/]) expect(within(snapshot).getByRole("checkbox", { name })).toBeChecked();
|
||||
expect(within(snapshot).getByRole("checkbox", { name: /Location/ })).not.toBeChecked();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
@@ -60,11 +62,52 @@ test("contact copy is a reviewed canonical snapshot; cancel is inert and apply p
|
||||
await selectSource();
|
||||
await screen.findByRole("region", { name: "Contact snapshot" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
|
||||
expect(onChange).toHaveBeenCalledWith({ ...settings, senderName: "Canonical name", senderTitle: "Canonical title", senderEmail: "canonical@example.test", senderPhone: "+47 555", senderLocation: "Bergen", sourceCvId: 3 });
|
||||
expect(onChange).toHaveBeenCalledWith({ ...settings, senderName: "Canonical name", senderTitle: "Canonical title", senderEmail: "canonical@example.test", senderPhone: "+47 555", sourceCvId: 3 });
|
||||
expect(mockedApi.get).not.toHaveBeenCalledWith("/auth/me");
|
||||
expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("contact location copies only after selection and unselected contact values preserve destination fields", async () => {
|
||||
const onChange = jest.fn();
|
||||
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy contact from CV" }));
|
||||
await selectSource();
|
||||
const snapshot = within(await screen.findByRole("region", { name: "Contact snapshot" }));
|
||||
for (const name of [/Sender name/, /Professional title/, /Email/, /Phone/]) fireEvent.click(snapshot.getByRole("checkbox", { name }));
|
||||
fireEvent.click(snapshot.getByRole("checkbox", { name: /Location/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
|
||||
expect(onChange).toHaveBeenCalledWith({ ...settings, senderLocation: "Bergen", sourceCvId: 3 });
|
||||
expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("blank source contacts and choosing no fields never clear destination contacts", async () => {
|
||||
const originalGet = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation(((url: string) => url === "/career/profile" ? Promise.resolve({ data: { profile: { contact: { fullName: "Canonical name", email: "", phone: " " } } } }) : originalGet(url)) as any);
|
||||
const onChange = jest.fn();
|
||||
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy contact from CV" }));
|
||||
await selectSource();
|
||||
const snapshot = within(await screen.findByRole("region", { name: "Contact snapshot" }));
|
||||
expect(snapshot.getByRole("checkbox", { name: /Email/ })).toBeDisabled();
|
||||
expect(snapshot.getByRole("checkbox", { name: /Phone/ })).toBeDisabled();
|
||||
fireEvent.click(snapshot.getByRole("checkbox", { name: /Sender name/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
|
||||
expect(onChange).toHaveBeenCalledWith(settings);
|
||||
});
|
||||
|
||||
test("letter language is an explicit document draft and saves independently of interface and CV language", async () => {
|
||||
mockedApi.put.mockResolvedValue({ data: { ...letter, revision: 8, documentSettings: { ...settings, language: "en" } } } as any);
|
||||
render(wrap(<ApplicationCoverLetterSection jobId={7} />));
|
||||
const language = await screen.findByRole("combobox", { name: "Letter language" });
|
||||
expect(language).toHaveTextContent(/Norsk bokmål/i);
|
||||
fireEvent.mouseDown(language);
|
||||
fireEvent.click(await screen.findByRole("option", { name: "English" }));
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", expect.objectContaining({ documentSettings: { ...settings, language: "en" }, expectedVersion: 7 })));
|
||||
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue(letter.text);
|
||||
});
|
||||
|
||||
test("design copy resolves null overrides through actual theme defaults and effective CV density", async () => {
|
||||
const onChange = jest.fn();
|
||||
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
|
||||
@@ -109,7 +152,7 @@ test("failed copy reads are retryable and cannot apply or save a partial snapsho
|
||||
});
|
||||
|
||||
test("restoring a historical letter restores saved details with text after explicit dirty confirmation", async () => {
|
||||
mockedApi.post.mockResolvedValue({ data: { ...letter, revision: 8, text: "Historical body", documentSettings: { ...settings, subject: "Historical subject", senderName: "Historical sender" } } } as any);
|
||||
mockedApi.post.mockResolvedValue({ data: { ...letter, revision: 8, text: "Historical body", documentSettings: { ...settings, subject: "Historical subject", senderName: "Historical sender", language: "en" } } } as any);
|
||||
render(wrap(<ApplicationCoverLetterSection jobId={7} />));
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: "Subject" }), { target: { value: "Local unsaved subject" } });
|
||||
expect(screen.queryByRole("region", { name: "Letter text preview" })).not.toBeInTheDocument();
|
||||
@@ -118,6 +161,7 @@ test("restoring a historical letter restores saved details with text after expli
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Discard and restore" }));
|
||||
await waitFor(() => expect(screen.getByRole("textbox", { name: "Subject" })).toHaveValue("Historical subject"));
|
||||
expect(screen.getByRole("textbox", { name: "Sender name" })).toHaveValue("Historical sender");
|
||||
expect(screen.getByRole("combobox", { name: "Letter language" })).toHaveTextContent("English");
|
||||
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Historical body");
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/versions/1/restore", { expectedVersion: 7 });
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
|
||||
@@ -716,6 +716,9 @@ export const translations = {
|
||||
letterRecipient: "Recipient",
|
||||
letterSubject: "Subject",
|
||||
letterDate: "Date",
|
||||
letterDocumentLanguage: "Letter language",
|
||||
coverAiSourceChanged: "The linked CV, Career Profile or job changed. Generate a new suggestion before applying.",
|
||||
letterCopyContactHelp: "Choose the contact fields to copy. Location is optional and starts unselected. Blank or unselected fields keep your existing letter details.",
|
||||
letterCopyContact: "Copy contact from CV",
|
||||
letterMatchDesign: "Match CV design",
|
||||
letterCopyHelp: "Review this snapshot before applying. Your letter text and recipient stay unchanged; future profile or CV edits do not update this copy.",
|
||||
@@ -3638,6 +3641,9 @@ export const translations = {
|
||||
letterRecipient: "Mottaker",
|
||||
letterSubject: "Emne",
|
||||
letterDate: "Dato",
|
||||
letterDocumentLanguage: "Språk i brevet",
|
||||
coverAiSourceChanged: "Den tilknyttede CV-en, karriereprofilen eller stillingen er endret. Generer et nytt forslag før du bruker det.",
|
||||
letterCopyContactHelp: "Velg kontaktfeltene du vil kopiere. Sted er valgfritt og er ikke valgt som standard. Tomme eller ikke valgte felt beholder de eksisterende opplysningene i brevet.",
|
||||
letterCopyContact: "Kopier kontakt fra CV",
|
||||
letterMatchDesign: "Bruk CV-design",
|
||||
letterCopyHelp: "Se gjennom kopien før du bruker den. Brevteksten og mottakeren beholdes; senere profil- eller CV-endringer oppdaterer ikke denne kopien.",
|
||||
|
||||
Reference in New Issue
Block a user