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 partial class AiWorkspaceTests { private sealed class FakeAi : ISummarizerService { public string? Next = "## Result\nGenerated suggestion."; public int Calls; public string? LastInstruction; public string? ActualProvider; public string? FallbackReason; public Func? BeforeReturn; // The source text the module assembled — what the prompt actually saw. public string? LastText; public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) { Calls++; LastInstruction = instruction; LastText = text; return Task.FromResult(Next); } public Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next); public async Task GenerateSectionWithMetadataAsync( string instruction, string text, int maxLength = 180, int minLength = 40, 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"); } public Task ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task RunProbeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task GetMetricsAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); } private static (JobTrackerContext db, AiWorkspaceService svc, FakeAi ai) New(string userId) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; var currentUser = new Mock(); currentUser.SetupGet(s => s.UserId).Returns(userId); var db = new JobTrackerContext(options, currentUser.Object); var ai = new FakeAi(); return (db, new AiWorkspaceService(db, ai), ai); } private static async Task SeedJobAsync(JobTrackerContext db, string owner) { var company = new Company { OwnerUserId = owner, Name = "Acme" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { OwnerUserId = owner, CompanyId = company.Id, JobTitle = "Senior Engineer", Status = "Applied", Description = "Build things with C#." }; db.JobApplications.Add(job); await db.SaveChangesAsync(); return job.Id; } private static AiGenerateRequest Req(string module, string? mode = null) => new(module, mode, null); private static async Task SeedLinkedProfileAsync(JobTrackerContext db, int jobId, StructuredCvProfile? profile = null, CvVariantSettings? settings = null, string profileOwner = "user-1") { db.CareerProfiles.Add(new CareerProfile { OwnerUserId = profileOwner, ProfileJson = StructuredCvProfileJson.Serialize(profile ?? new StructuredCvProfile { Skills = { "C#" } }) }); db.CvVariants.Add(new CvVariant { OwnerUserId = "user-1", JobApplicationId = jobId, Name = "Source CV", PublicSlug = Guid.NewGuid().ToString("N"), SettingsJson = CvVariantSettingsJson.Serialize(settings ?? new()), Version = 1, CreatedAtUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(); } [Theory] [InlineData("generate", "no-linked-cv")] [InlineData("improve", "no-linked-cv")] [InlineData("grammar", "identity-only")] [InlineData("shorten", "hidden-facts")] [InlineData("generate", "foreign-profile")] public async Task Letter_requires_owned_visible_career_facts_before_inference_even_with_draft_and_advert(string action, string source) { var (db, svc, ai) = New("user-1"); await using var _ = db; var jobId = await SeedJobAsync(db, "user-1"); if (source != "no-linked-cv") { var profile = new StructuredCvProfile { Contact = new StructuredCvContact { FullName = "Ada Example", Headline = "Engineer" } }; var settings = new CvVariantSettings(); if (source == "hidden-facts") { profile.Skills.Add("C#"); settings.Sections.Add(new() { Key = "skills", Hidden = true }); } if (source == "foreign-profile") profile.Skills.Add("Foreign profile skill"); await SeedLinkedProfileAsync(db, jobId, profile, settings, source == "foreign-profile" ? "user-2" : "user-1"); } var error = await Assert.ThrowsAsync(() => svc.GenerateAsync("user-1", jobId, "Unverified legacy fallback claiming 99 years experience", "Ada", new("cover-letter", "professional", "Invent impressive results", "en", "An existing unverified letter draft", action), "p", default)); Assert.Equal("cover_letter_source_required", error.Message); Assert.Equal(0, ai.Calls); Assert.Empty(await db.AiInteractions.IgnoreQueryFilters().ToListAsync()); } [Fact] public async Task Generate_stores_an_interaction_and_returns_it() { var (db, svc, _) = New("user-1"); await using var _ = db; var jobId = await SeedJobAsync(db, "user-1"); var res = await svc.GenerateAsync("user-1", jobId, "My CV text", "Ada", Req("job-analysis"), "gemini", default); Assert.NotNull(res); Assert.Equal("job-analysis", res!.Module); Assert.Equal("gemini", res.Provider); Assert.Contains("Generated suggestion", res.ResultJson); Assert.True(res.InputCharacterCount > 0); Assert.Equal("## Result\nGenerated suggestion.".Length, res.OutputCharacterCount); Assert.Equal((res.InputCharacterCount + res.OutputCharacterCount + 3) / 4, res.EstimatedTokenCount); Assert.Single(await db.AiInteractions.IgnoreQueryFilters().Where(x => x.JobApplicationId == jobId).ToListAsync()); } [Fact] public async Task Generate_records_the_actual_provider_and_fallback_metadata() { var (db, svc, ai) = New("user-1"); await using var _ = db; ai.ActualProvider = "groq"; ai.FallbackReason = "local_circuit_open"; var jobId = await SeedJobAsync(db, "user-1"); var result = await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("job-analysis"), "configured-label", default); Assert.Equal("groq", result!.Provider); Assert.Contains("local_circuit_open", result.ResultJson); Assert.Contains("test-model", result.ResultJson); } [Fact] public async Task Cover_letter_normalizes_an_unknown_mode_and_labels_the_title() { var (db, svc, _) = New("user-1"); await using var _ = db; var jobId = await SeedJobAsync(db, "user-1"); await SeedLinkedProfileAsync(db, jobId); var res = await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("cover-letter", "banana"), "p", default); Assert.Equal("professional", res!.Mode); Assert.Contains("Professional", res.Title); } [Fact] public async Task Cover_letter_uses_the_linked_cv_current_draft_and_requested_language() { var (db, svc, ai) = New("user-1"); await using var _ = db; var jobId = await SeedJobAsync(db, "user-1"); var profile = new StructuredCvProfile { Contact = new StructuredCvContact { FullName = "Ada Example" }, Jobs = { new StructuredCvJob { Id = "visible-role", Title = "Backend Developer", Company = "Initech", Bullets = { "Built truthful .NET services" } }, new StructuredCvJob { Id = "hidden-role", Title = "Unrelated role", Company = "Hidden Corp", Bullets = { "Unrelated work" } }, }, Skills = { "C#", ".NET" }, }; db.CareerProfiles.Add(new CareerProfile { OwnerUserId = "user-1", ProfileJson = StructuredCvProfileJson.Serialize(profile), }); db.CvVariants.Add(new CvVariant { OwnerUserId = "user-1", JobApplicationId = jobId, Name = "Backend CV", PublicSlug = Guid.NewGuid().ToString("N"), SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings { Overrides = { ["hidden-role"] = new CvItemOverride { Hidden = true } }, }), Version = 1, CreatedAtUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(); await svc.GenerateAsync("user-1", jobId, "master profile fallback", "Ada", new AiGenerateRequest("cover-letter", "professional", null, "nb-NO", "Existing manual draft", "shorten"), "p", default); Assert.Contains("CV BEING USED: Backend CV", ai.LastText); Assert.Contains("Built truthful .NET services", ai.LastText); Assert.DoesNotContain("Hidden Corp", ai.LastText); Assert.Contains("CURRENT DOCUMENT TO REVISE", ai.LastText); Assert.Contains("Existing manual draft", ai.LastText); Assert.Contains("Preserve the source language and meaning; do not translate.", ai.LastInstruction); Assert.Contains("Shorten the current cover letter", ai.LastInstruction); } [Theory] [InlineData("improve", "en", true)] [InlineData("improve", "nb-NO", true)] [InlineData("grammar", "en", true)] [InlineData("grammar", "nb-NO", true)] [InlineData("shorten", "en", true)] [InlineData("shorten", "nb-NO", true)] [InlineData("generate", "en", false)] [InlineData("generate", "nb-NO", false)] [InlineData("regenerate", "en", false)] [InlineData("regenerate", "nb-NO", false)] public async Task Letter_prompt_preserves_source_language_for_rewrites_only(string action, string language, bool preserve) { var (db, svc, ai) = New("user-1"); await using var _ = db; var jobId = await SeedJobAsync(db, "user-1"); await SeedLinkedProfileAsync(db, jobId); await svc.GenerateAsync("user-1", jobId, "unused fallback", "Ada", new("cover-letter", "professional", null, language, "Existing factual letter", action), "p", default); if (preserve) { Assert.Contains("Preserve the source language and meaning; do not translate.", ai.LastInstruction); Assert.DoesNotContain("Write in natural, professional", ai.LastInstruction); } else Assert.Contains(language == "nb-NO" ? "Write in natural, professional Norwegian" : "Write in natural, professional English.", ai.LastInstruction); } [Fact] public async Task History_is_newest_first_and_filters_by_module() { var (db, svc, _) = New("user-1"); await using var _ = db; var jobId = await SeedJobAsync(db, "user-1"); await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("job-analysis"), "p", default); await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("career-match"), "p", default); await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("career-match"), "p", default); var all = await svc.HistoryAsync("user-1", jobId, null, default); Assert.Equal(3, all.Count); var match = await svc.HistoryAsync("user-1", jobId, "career-match", default); Assert.Equal(2, match.Count); Assert.All(match, m => Assert.Equal("career-match", m.Module)); } [Fact] public async Task Delete_removes_only_the_owner_row() { var (db, svc, _) = New("user-1"); await using var _ = db; var jobId = await SeedJobAsync(db, "user-1"); var res = await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("interview"), "p", default); Assert.True(await svc.DeleteAsync("user-1", res!.Id, default)); Assert.Null(await svc.GetAsync("user-1", res.Id, default)); Assert.False(await svc.DeleteAsync("user-1", res.Id, default)); } [Fact] public async Task Unknown_module_is_rejected() { var (db, svc, _) = New("user-1"); await using var _ = db; var jobId = await SeedJobAsync(db, "user-1"); await Assert.ThrowsAsync(() => svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("write-my-life-story"), "p", default)); } [Fact] public async Task Empty_ai_output_raises_unavailable() { var (db, svc, ai) = New("user-1"); await using var _ = db; ai.Next = " "; var jobId = await SeedJobAsync(db, "user-1"); await SeedLinkedProfileAsync(db, jobId); await Assert.ThrowsAsync(() => svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("cover-letter"), "p", default)); } [Fact] public async Task Another_users_job_is_not_found() { var (db, svc, _) = New("user-1"); await using var _ = db; var otherJob = await SeedJobAsync(db, "user-2"); var res = await svc.GenerateAsync("user-1", otherJob, "cv", "Ada", Req("job-analysis"), "p", default); Assert.Null(res); } }