Files
jobtrackingapp/JobTrackerApi.Tests/AiWorkspaceTests.cs
T

294 lines
14 KiB
C#

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<Task>? BeforeReturn;
// The source text the module assembled — what the prompt actually saw.
public string? LastText;
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
{
Calls++;
LastInstruction = instruction;
LastText = text;
return Task.FromResult(Next);
}
public Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next);
public async Task<AiGenerationResult?> 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<AiTextExtractionResult?> ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task RunProbeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<AiServiceMetrics> GetMetricsAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException();
}
private static (JobTrackerContext db, AiWorkspaceService svc, FakeAi ai) New(string userId)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
var currentUser = new Mock<ICurrentUserService>();
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<int> 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<ArgumentException>(() => 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<ArgumentException>(() => 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<AiUnavailableException>(() => 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);
}
}