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().UseSqlite(connection).Options, Mock.Of(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())).ReturnsAsync(user); users.Setup(manager => manager.GetRolesAsync(It.IsAny())).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(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()!.AuthenticationSchemes); Assert.Equal(ProEntitlement.Policy, typeof(AiWorkspaceController).GetMethod(nameof(AiWorkspaceController.ValidateSource))!.GetCustomAttribute()!.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())).ReturnsAsync(user); var controller = new AiWorkspaceController(users.Object, service, new ConfigurationBuilder().Build(), db); Assert.IsType(await controller.ValidateSource(jobId, generated!.Id, default)); Assert.IsType(await controller.ValidateSource(jobId + 1, generated.Id, default)); Assert.IsType(await controller.ValidateSource(jobId, int.MaxValue, default)); await using (var changed = Independent(db)) await ChangeSource(changed, jobId, "cv-settings"); var stale = Assert.IsType(await controller.ValidateSource(jobId, generated.Id, default)); Assert.Contains("cover_letter_source_changed", JsonSerializer.Serialize(stale.Value)); users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync(new ApplicationUser { Id = "foreign" }); Assert.IsType(await controller.ValidateSource(jobId, generated.Id, default)); users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); Assert.IsType(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)db.GetService(), Mock.Of(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(() => 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(); intelligence.Setup(value => value.AnalyzeAsync("user-1", jobId, It.IsAny())) .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())).ReturnsAsync((CareerMatchDto?)null); var service = new AiWorkspaceService(db, ai, intelligence.Object); await Assert.ThrowsAsync(() => 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)); } }