fix(cv): bind letter proposals to current sources and preserve document choices

This commit is contained in:
cesnimda
2026-10-26 03:44:37 +01:00
parent 8948c988e2
commit 8979244576
21 changed files with 816 additions and 32 deletions
@@ -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));
}
}
+4 -1
View File
@@ -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)));
}
}