Files
jobtrackingapp/JobTrackerApi.Tests/AiWorkspaceTests.cs
T
cesnimda fc132273f7
CI and Deploy / test (push) Failing after 2m21s
CI and Deploy / deploy (push) Has been skipped
feat(ai): include application intelligence in interview preparation
Interview generation saw only the profile and the advert, so it produced generic
questions. It now also receives what the workspace already computed: seniority,
employment type, key requirements and advert technologies from the job analysis,
plus the match score, the skills the candidate demonstrably has, the most
relevant experience and projects — and above all the gaps, which is exactly what
an interviewer probes.

No second pipeline. The context comes from ApplicationIntelligenceService, which
is deterministic and read-only, so this adds no AI call and cannot alter user
data. Generation still runs through AiWorkspaceService and is still appended to
AiInteraction.

The dependency is optional, so existing constructions keep working and a missing
intelligence service degrades to the previous prompt instead of failing. Only the
interview module is affected; job-analysis, career-match, cover-letter and
application-review assemble exactly as before.

Suggestion-only is unchanged and now pinned by tests: generation adds an
AiInteraction and nothing else, creates no InterviewPrepItem, leaves existing
prep items and the CareerProfile untouched, and refuses another user's
application. Context is scoped to the requesting user, so another user's profile
is never scored in.

379 backend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:07:00 +02:00

145 lines
5.9 KiB
C#

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;
// 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 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);
[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.Single(await db.AiInteractions.IgnoreQueryFilters().Where(x => x.JobApplicationId == jobId).ToListAsync());
}
[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<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 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);
}
}