Files
jobtrackingapp/JobTrackerApi.MariaDb.Tests/CoverLetterAiSourceTests.cs
T

147 lines
7.2 KiB
C#

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);
}
}