using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed 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; // 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); 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); [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"); 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 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 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); } }