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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user