fc132273f7
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>
145 lines
5.9 KiB
C#
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);
|
|
}
|
|
}
|