|
|
|
@@ -0,0 +1,246 @@
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using System.Security.Claims;
|
|
|
|
|
using System.Reflection;
|
|
|
|
|
using JobTrackerApi.Controllers;
|
|
|
|
|
using JobTrackerApi.Data;
|
|
|
|
|
using JobTrackerApi.Models;
|
|
|
|
|
using JobTrackerApi.Services;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
|
|
|
using Microsoft.AspNetCore.Authorization;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
|
using JobTrackerApi.Tests.TestSupport;
|
|
|
|
|
using Moq;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
|
|
|
|
|
|
public sealed partial class AiWorkspaceTests
|
|
|
|
|
{
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData("[]")]
|
|
|
|
|
[InlineData("\"legacy\"")]
|
|
|
|
|
[InlineData("null")]
|
|
|
|
|
public async Task Non_object_legacy_AI_result_fails_closed_without_throwing(string json)
|
|
|
|
|
{
|
|
|
|
|
var (db, service, _) = New("user-1");
|
|
|
|
|
await using var dispose = db;
|
|
|
|
|
var jobId = await SeedJobAsync(db, "user-1");
|
|
|
|
|
var row = new AiInteraction { OwnerUserId = "user-1", JobApplicationId = jobId, Module = "cover-letter", ResultJson = json };
|
|
|
|
|
db.Add(row);
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
Assert.False(await service.ValidateCoverLetterSourceAsync("user-1", jobId, row.Id, default));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Stale_generation_HTTP_conflict_finalizes_actual_consumed_usage_without_recording_stale_history()
|
|
|
|
|
{
|
|
|
|
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
|
|
|
|
await connection.OpenAsync();
|
|
|
|
|
await using var db = new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options,
|
|
|
|
|
Mock.Of<ICurrentUserService>(user => user.UserId == "user-1"));
|
|
|
|
|
await db.Database.EnsureCreatedAsync();
|
|
|
|
|
var user = new ApplicationUser { Id = "user-1", DisplayName = "Ada" };
|
|
|
|
|
db.Users.Add(user);
|
|
|
|
|
var jobId = await SeedJobAsync(db, user.Id);
|
|
|
|
|
await SeedLinkedProfileAsync(db, jobId);
|
|
|
|
|
var users = TestHostFactory.CreateUserManager(user);
|
|
|
|
|
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
|
|
|
|
users.Setup(manager => manager.GetRolesAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(new[] { "Premium" });
|
|
|
|
|
var ai = new FakeAi
|
|
|
|
|
{
|
|
|
|
|
BeforeReturn = async () =>
|
|
|
|
|
{
|
|
|
|
|
await using var changed = Independent(db);
|
|
|
|
|
await ChangeSource(changed, jobId, "profile-version");
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
var controller = new AiWorkspaceController(users.Object, new AiWorkspaceService(db, ai), new ConfigurationBuilder().Build(), db);
|
|
|
|
|
var response = await controller.Generate(jobId, new("cover-letter", null, null), default);
|
|
|
|
|
var conflict = Assert.IsType<ConflictObjectResult>(response.Result);
|
|
|
|
|
Assert.Contains("cover_letter_source_changed", JsonSerializer.Serialize(conflict.Value));
|
|
|
|
|
var usage = await db.AiUsageRecords.AsNoTracking().SingleAsync();
|
|
|
|
|
Assert.Equal(1, usage.CallCount);
|
|
|
|
|
Assert.Equal(ai.Next!.Trim().Length, usage.OutputCharacterCount);
|
|
|
|
|
Assert.NotEqual(16_000, usage.InputCharacterCount);
|
|
|
|
|
Assert.True(usage.InputCharacterCount > 0);
|
|
|
|
|
Assert.Equal(AiWorkspaceService.EstimateTokens(usage.InputCharacterCount + usage.OutputCharacterCount), usage.EstimatedTokenCount);
|
|
|
|
|
Assert.Empty(await db.AiInteractions.AsNoTracking().ToListAsync());
|
|
|
|
|
Assert.Empty(await db.CoverLetterVersions.AsNoTracking().ToListAsync());
|
|
|
|
|
Assert.Null((await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText);
|
|
|
|
|
Assert.Equal(1, (await db.CareerProfiles.AsNoTracking().SingleAsync()).Version);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Validator_HTTP_contract_is_read_only_Pro_authenticated_and_scoped_to_owner_job_module()
|
|
|
|
|
{
|
|
|
|
|
Assert.Equal("local", typeof(AiWorkspaceController).GetCustomAttribute<AuthorizeAttribute>()!.AuthenticationSchemes);
|
|
|
|
|
Assert.Equal(ProEntitlement.Policy, typeof(AiWorkspaceController).GetMethod(nameof(AiWorkspaceController.ValidateSource))!.GetCustomAttribute<AuthorizeAttribute>()!.Policy);
|
|
|
|
|
var (db, service, _) = New("user-1");
|
|
|
|
|
await using var dispose = db;
|
|
|
|
|
var jobId = await SeedJobAsync(db, "user-1");
|
|
|
|
|
await SeedLinkedProfileAsync(db, jobId);
|
|
|
|
|
var generated = await service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default);
|
|
|
|
|
var user = new ApplicationUser { Id = "user-1" };
|
|
|
|
|
var users = TestHostFactory.CreateUserManager(user);
|
|
|
|
|
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
|
|
|
|
var controller = new AiWorkspaceController(users.Object, service, new ConfigurationBuilder().Build(), db);
|
|
|
|
|
Assert.IsType<NoContentResult>(await controller.ValidateSource(jobId, generated!.Id, default));
|
|
|
|
|
Assert.IsType<NotFoundResult>(await controller.ValidateSource(jobId + 1, generated.Id, default));
|
|
|
|
|
Assert.IsType<NotFoundResult>(await controller.ValidateSource(jobId, int.MaxValue, default));
|
|
|
|
|
await using (var changed = Independent(db)) await ChangeSource(changed, jobId, "cv-settings");
|
|
|
|
|
var stale = Assert.IsType<ConflictObjectResult>(await controller.ValidateSource(jobId, generated.Id, default));
|
|
|
|
|
Assert.Contains("cover_letter_source_changed", JsonSerializer.Serialize(stale.Value));
|
|
|
|
|
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(new ApplicationUser { Id = "foreign" });
|
|
|
|
|
Assert.IsType<NotFoundResult>(await controller.ValidateSource(jobId, generated.Id, default));
|
|
|
|
|
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync((ApplicationUser?)null);
|
|
|
|
|
Assert.IsType<UnauthorizedResult>(await controller.ValidateSource(jobId, generated.Id, default));
|
|
|
|
|
Assert.Empty(await db.AiUsageRecords.AsNoTracking().ToListAsync());
|
|
|
|
|
Assert.Single(await db.AiInteractions.AsNoTracking().ToListAsync());
|
|
|
|
|
Assert.False(db.ChangeTracker.HasChanges());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static JobTrackerContext Independent(JobTrackerContext db) => new(
|
|
|
|
|
(DbContextOptions<JobTrackerContext>)db.GetService<IDbContextOptions>(), Mock.Of<ICurrentUserService>(user => user.UserId == "user-1"));
|
|
|
|
|
|
|
|
|
|
private static async Task ChangeSource(JobTrackerContext db, int jobId, string change)
|
|
|
|
|
{
|
|
|
|
|
switch (change)
|
|
|
|
|
{
|
|
|
|
|
case "profile-version": (await db.CareerProfiles.SingleAsync()).Version++; break;
|
|
|
|
|
case "profile-content": (await db.CareerProfiles.SingleAsync()).ProfileJson = StructuredCvProfileJson.Serialize(new() { Skills = { "A different qualification" } }); break;
|
|
|
|
|
case "profile-long-tail": (await db.CareerProfiles.SingleAsync()).LongTailJson = "{\"summary\":[\"Changed source\"]}"; break;
|
|
|
|
|
case "cv-version": (await db.CvVariants.SingleAsync()).Version++; break;
|
|
|
|
|
case "cv-settings": (await db.CvVariants.SingleAsync()).SettingsJson = CvVariantSettingsJson.Serialize(new() { Headline = "Changed CV lens" }); break;
|
|
|
|
|
case "cv-detach": (await db.CvVariants.SingleAsync()).JobApplicationId = null; break;
|
|
|
|
|
case "cv-attach":
|
|
|
|
|
db.CvVariants.Add(new() { OwnerUserId = "user-1", JobApplicationId = jobId, Name = "New CV", PublicSlug = Guid.NewGuid().ToString("N"), UpdatedAtUtc = DateTimeOffset.UtcNow.AddMinutes(1), SettingsJson = "{}" }); break;
|
|
|
|
|
case "attachment-add":
|
|
|
|
|
db.Attachments.Add(new() { JobApplicationId = jobId, FileName = "new.txt", FilePath = "new-managed-path", FileSize = 10 }); break;
|
|
|
|
|
case "attachment-change": (await db.Attachments.SingleAsync()).UseForAi = false; break;
|
|
|
|
|
case "attachment-remove": db.Attachments.Remove(await db.Attachments.SingleAsync()); break;
|
|
|
|
|
case "job": (await db.JobApplications.SingleAsync()).Description = "Different role requirements"; break;
|
|
|
|
|
case "company": (await db.Companies.SingleAsync()).Name = "Different employer"; break;
|
|
|
|
|
case "candidate-name": (await db.Users.SingleAsync()).DisplayName = "Babbage"; break;
|
|
|
|
|
case "letter": (await db.JobApplications.SingleAsync()).CoverLetterText = "New saved draft"; break;
|
|
|
|
|
default: throw new ArgumentException(change);
|
|
|
|
|
}
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData("profile-version")]
|
|
|
|
|
[InlineData("profile-content")]
|
|
|
|
|
[InlineData("cv-settings")]
|
|
|
|
|
[InlineData("cv-detach")]
|
|
|
|
|
[InlineData("attachment-add")]
|
|
|
|
|
[InlineData("job")]
|
|
|
|
|
[InlineData("candidate-name")]
|
|
|
|
|
[InlineData("letter")]
|
|
|
|
|
public async Task Held_letter_generation_refuses_changed_source_without_history_or_letter_writes(string change)
|
|
|
|
|
{
|
|
|
|
|
var (db, service, ai) = New("user-1");
|
|
|
|
|
await using var dispose = db;
|
|
|
|
|
var jobId = await SeedJobAsync(db, "user-1");
|
|
|
|
|
await SeedLinkedProfileAsync(db, jobId);
|
|
|
|
|
if (change == "candidate-name") { db.Users.Add(new() { Id = "user-1", DisplayName = "Ada" }); await db.SaveChangesAsync(); }
|
|
|
|
|
var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
|
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
|
ai.BeforeReturn = () => { reached.TrySetResult(); return release.Task; };
|
|
|
|
|
var pending = service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default);
|
|
|
|
|
await reached.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await using var writer = Independent(db);
|
|
|
|
|
await ChangeSource(writer, jobId, change);
|
|
|
|
|
}
|
|
|
|
|
finally { release.TrySetResult(); }
|
|
|
|
|
var error = await Assert.ThrowsAsync<CoverLetterSourceChangedException>(() => pending);
|
|
|
|
|
Assert.Equal("cover_letter_source_changed", error.Message);
|
|
|
|
|
Assert.Empty(await db.AiInteractions.AsNoTracking().ToListAsync());
|
|
|
|
|
Assert.Empty(await db.CoverLetterVersions.AsNoTracking().ToListAsync());
|
|
|
|
|
Assert.Equal(change == "letter" ? "New saved draft" : null, (await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText);
|
|
|
|
|
Assert.Equal(1, ai.Calls);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData("profile-version")]
|
|
|
|
|
[InlineData("profile-content")]
|
|
|
|
|
[InlineData("profile-long-tail")]
|
|
|
|
|
[InlineData("cv-version")]
|
|
|
|
|
[InlineData("cv-settings")]
|
|
|
|
|
[InlineData("cv-detach")]
|
|
|
|
|
[InlineData("cv-attach")]
|
|
|
|
|
[InlineData("attachment-add")]
|
|
|
|
|
[InlineData("attachment-change")]
|
|
|
|
|
[InlineData("attachment-remove")]
|
|
|
|
|
[InlineData("job")]
|
|
|
|
|
[InlineData("company")]
|
|
|
|
|
[InlineData("candidate-name")]
|
|
|
|
|
[InlineData("letter")]
|
|
|
|
|
public async Task Pre_apply_validation_reloads_source_and_rejects_post_generation_changes(string change)
|
|
|
|
|
{
|
|
|
|
|
var (db, service, _) = New("user-1");
|
|
|
|
|
await using var dispose = db;
|
|
|
|
|
var jobId = await SeedJobAsync(db, "user-1");
|
|
|
|
|
await SeedLinkedProfileAsync(db, jobId);
|
|
|
|
|
if (change == "candidate-name") { db.Users.Add(new() { Id = "user-1", DisplayName = "Ada" }); await db.SaveChangesAsync(); }
|
|
|
|
|
db.Attachments.Add(new() { JobApplicationId = jobId, FileName = "original.txt", FilePath = "original-managed-path", FileSize = 1 });
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
var generated = await service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default);
|
|
|
|
|
Assert.True(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated!.Id, default));
|
|
|
|
|
await using (var writer = Independent(db)) await ChangeSource(writer, jobId, change);
|
|
|
|
|
Assert.False(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated.Id, default));
|
|
|
|
|
Assert.Single(await db.AiInteractions.AsNoTracking().ToListAsync());
|
|
|
|
|
Assert.Empty(await db.CoverLetterVersions.AsNoTracking().ToListAsync());
|
|
|
|
|
Assert.Equal(change == "letter" ? "New saved draft" : null, (await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Source_changed_during_prompt_assembly_is_not_rebound_to_the_newer_profile_stamp()
|
|
|
|
|
{
|
|
|
|
|
var (db, _, ai) = New("user-1");
|
|
|
|
|
await using var dispose = db;
|
|
|
|
|
var jobId = await SeedJobAsync(db, "user-1");
|
|
|
|
|
await SeedLinkedProfileAsync(db, jobId);
|
|
|
|
|
var intelligence = new Mock<IApplicationIntelligenceService>();
|
|
|
|
|
intelligence.Setup(value => value.AnalyzeAsync("user-1", jobId, It.IsAny<CancellationToken>()))
|
|
|
|
|
.Returns(async () =>
|
|
|
|
|
{
|
|
|
|
|
await using var changed = Independent(db);
|
|
|
|
|
await ChangeSource(changed, jobId, "profile-content");
|
|
|
|
|
return (JobAnalysisDto?)null;
|
|
|
|
|
});
|
|
|
|
|
intelligence.Setup(value => value.MatchAsync("user-1", jobId, It.IsAny<CancellationToken>())).ReturnsAsync((CareerMatchDto?)null);
|
|
|
|
|
var service = new AiWorkspaceService(db, ai, intelligence.Object);
|
|
|
|
|
await Assert.ThrowsAsync<CoverLetterSourceChangedException>(() => service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default));
|
|
|
|
|
Assert.Contains("C#", ai.LastText);
|
|
|
|
|
Assert.DoesNotContain("A different qualification", ai.LastText);
|
|
|
|
|
Assert.Empty(await db.AiInteractions.AsNoTracking().ToListAsync());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Source_validator_fails_closed_for_legacy_and_damaged_binding_and_scopes_owner_job_and_module()
|
|
|
|
|
{
|
|
|
|
|
var (db, service, _) = New("user-1");
|
|
|
|
|
await using var dispose = db;
|
|
|
|
|
var jobId = await SeedJobAsync(db, "user-1");
|
|
|
|
|
await SeedLinkedProfileAsync(db, jobId);
|
|
|
|
|
var generated = await service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default);
|
|
|
|
|
Assert.True(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated!.Id, default));
|
|
|
|
|
Assert.Null(await service.ValidateCoverLetterSourceAsync("foreign", jobId, generated.Id, default));
|
|
|
|
|
Assert.Null(await service.ValidateCoverLetterSourceAsync("user-1", jobId + 1, generated.Id, default));
|
|
|
|
|
Assert.Null(await service.ValidateCoverLetterSourceAsync("user-1", jobId, int.MaxValue, default));
|
|
|
|
|
generated.ResultJson = JsonSerializer.Serialize(new { text = "Legacy output", meta = new { model = "old" } });
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
Assert.False(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated.Id, default));
|
|
|
|
|
generated.ResultJson = "{invalid json";
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
Assert.False(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated.Id, default));
|
|
|
|
|
generated.Module = "interview";
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
Assert.Null(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated.Id, default));
|
|
|
|
|
}
|
|
|
|
|
}
|