feat(ai): queue strategy snapshots
This commit is contained in:
@@ -13,9 +13,9 @@ using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Candidate fit and focus plan share AiWorkspaceNote persistence with the same rules as
|
||||
// InterviewPrepNote: reuse across calls, regenerate on refresh, regenerate when the attachment
|
||||
// selection changes (career-workspace-implementation-roadmap.md Phase F5).
|
||||
// Candidate fit and Strategy Snapshot share AiWorkspaceNote persistence. Strategy generation is
|
||||
// durable now, so this file verifies its service-level result cache rather than invoking a model
|
||||
// from the GET endpoint.
|
||||
public sealed class AiWorkspaceNotePersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
@@ -66,24 +66,25 @@ public sealed class AiWorkspaceNotePersistenceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFocusPlan_persists_and_reuses_the_generated_note()
|
||||
public async Task StrategySnapshot_generation_persists_a_stable_cached_result()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var job = await SeedJobWithCvAsync(db);
|
||||
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
var callCount = 0;
|
||||
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(() => { callCount++; return $"Text {callCount}"; });
|
||||
summarizer.Setup(x => x.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AiGenerationResult("""{"strategicSummary":"Lead with backend delivery.","cvBulletIdeas":["Built APIs"],"proofPointsToLeadWith":["Led delivery"],"coverLetterAngles":["Relevant platform work"]}""", "ollama", "test-model"));
|
||||
|
||||
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||
var service = new StrategySnapshotService(db, summarizer.Object);
|
||||
var generated = await service.GenerateAsync(job.Id, [], CancellationToken.None);
|
||||
var cached = await service.GetCachedAsync(job.Id, string.Empty, CancellationToken.None);
|
||||
|
||||
await controller.GetFocusPlan(job.Id, null, false, CancellationToken.None);
|
||||
var callsAfterFirst = callCount;
|
||||
|
||||
await controller.GetFocusPlan(job.Id, null, false, CancellationToken.None);
|
||||
|
||||
Assert.Equal(callsAfterFirst, callCount);
|
||||
Assert.NotNull(cached);
|
||||
Assert.Equal(generated.Result.StrategicSummary, cached.StrategicSummary);
|
||||
Assert.Equal(generated.Result.CvBulletIdeas, cached.CvBulletIdeas);
|
||||
Assert.Equal(generated.Result.ProofPointsToLeadWith, cached.ProofPointsToLeadWith);
|
||||
Assert.Equal(generated.Result.CoverLetterAngles, cached.CoverLetterAngles);
|
||||
Assert.Equal("ollama", generated.Provider);
|
||||
|
||||
var stored = Assert.Single(db.AiWorkspaceNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id && x.NoteType == "focus-plan"));
|
||||
Assert.NotEmpty(stored.ResultJson);
|
||||
|
||||
@@ -85,7 +85,7 @@ public sealed class ProEntitlementAuthorizationTests
|
||||
[InlineData(typeof(ProfileCvController), nameof(ProfileCvController.Improve))]
|
||||
[InlineData(typeof(JobApplicationsController), nameof(JobApplicationsController.RefreshAi))]
|
||||
[InlineData(typeof(JobApplicationsController), nameof(JobApplicationsController.GetCandidateFit))]
|
||||
[InlineData(typeof(JobApplicationsController), nameof(JobApplicationsController.GetFocusPlan))]
|
||||
[InlineData(typeof(StrategySnapshotController), nameof(StrategySnapshotController.Enqueue))]
|
||||
[InlineData(typeof(JobApplicationsController), nameof(JobApplicationsController.GetInterviewPrep))]
|
||||
[InlineData(typeof(JobApplicationsController), nameof(JobApplicationsController.GenerateTailoredCvDraft))]
|
||||
[InlineData(typeof(JobApplicationsController), nameof(JobApplicationsController.GenerateApplicationPackage))]
|
||||
@@ -94,7 +94,9 @@ public sealed class ProEntitlementAuthorizationTests
|
||||
{
|
||||
var method = controller.GetMethod(action);
|
||||
Assert.NotNull(method);
|
||||
Assert.Contains(method!.GetCustomAttributes(typeof(AuthorizeAttribute), true).Cast<AuthorizeAttribute>(),
|
||||
var attributes = method!.GetCustomAttributes(typeof(AuthorizeAttribute), true).Cast<AuthorizeAttribute>()
|
||||
.Concat(controller.GetCustomAttributes(typeof(AuthorizeAttribute), true).Cast<AuthorizeAttribute>());
|
||||
Assert.Contains(attributes,
|
||||
attribute => attribute.Policy == ProEntitlement.Policy);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class StrategySnapshotOperationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Producer_returns_202_and_duplicate_click_reuses_the_operation()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await fixture.SeedAsync();
|
||||
|
||||
await using var scope = fixture.Provider.CreateAsyncScope();
|
||||
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-1");
|
||||
var controller = Controller(scope);
|
||||
var first = Assert.IsType<AcceptedResult>(await controller.Enqueue(1, new StrategySnapshotRequest(null), default));
|
||||
var duplicate = Assert.IsType<AcceptedResult>(await controller.Enqueue(1, new StrategySnapshotRequest(null), default));
|
||||
var firstBody = Assert.IsType<StrategySnapshotOperationResponse>(first.Value);
|
||||
var duplicateBody = Assert.IsType<StrategySnapshotOperationResponse>(duplicate.Value);
|
||||
|
||||
Assert.True(firstBody.Created);
|
||||
Assert.False(duplicateBody.Created);
|
||||
Assert.Equal(firstBody.Operation.Id, duplicateBody.Operation.Id);
|
||||
Assert.Equal(OperationStatuses.Queued, firstBody.Operation.Status);
|
||||
Assert.Single(await scope.ServiceProvider.GetRequiredService<JobTrackerContext>().UserOperations.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_persists_one_result_and_provider_metadata_then_GET_is_read_only()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await fixture.SeedAsync();
|
||||
await fixture.EnqueueAsync();
|
||||
|
||||
Assert.True(await fixture.Provider.GetRequiredService<AiOperationWorker>().RunOnceAsync(default));
|
||||
Assert.False(await fixture.Provider.GetRequiredService<AiOperationWorker>().RunOnceAsync(default));
|
||||
|
||||
await using var scope = fixture.Provider.CreateAsyncScope();
|
||||
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-1");
|
||||
var controller = Controller(scope);
|
||||
var result = Assert.IsType<OkObjectResult>((await controller.Get(1, null, default)).Result);
|
||||
Assert.Equal("Lead with delivery evidence.", Assert.IsType<FocusPlanDto>(result.Value).StrategicSummary);
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
Assert.Single(await db.AiWorkspaceNotes.ToListAsync());
|
||||
var operation = Assert.Single(await db.UserOperations.ToListAsync());
|
||||
Assert.Equal(OperationStatuses.Succeeded, operation.Status);
|
||||
Assert.Equal("ollama", operation.Provider);
|
||||
Assert.Equal("qwen-test", operation.Model);
|
||||
Assert.Equal(1, fixture.GenerationCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_provider_shape_is_retryable_and_does_not_publish_partial_output()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync("not-json");
|
||||
await fixture.SeedAsync();
|
||||
await fixture.EnqueueAsync();
|
||||
|
||||
Assert.True(await fixture.Provider.GetRequiredService<AiOperationWorker>().RunOnceAsync(default));
|
||||
|
||||
await using var scope = fixture.Provider.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
var operation = await db.UserOperations.IgnoreQueryFilters().SingleAsync();
|
||||
Assert.Equal(OperationStatuses.WaitingForRetry, operation.Status);
|
||||
Assert.Equal("invalid_provider_response", operation.FailureCategory);
|
||||
Assert.Empty(await db.AiWorkspaceNotes.IgnoreQueryFilters().ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Latest_operation_and_result_are_tenant_scoped()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await fixture.SeedAsync();
|
||||
await fixture.SeedUserAsync("user-2", pro: true);
|
||||
await fixture.EnqueueAsync();
|
||||
|
||||
await using var scope = fixture.Provider.CreateAsyncScope();
|
||||
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-2");
|
||||
var controller = Controller(scope);
|
||||
Assert.IsType<NotFoundResult>((await controller.LatestOperation(1, null, default)).Result);
|
||||
Assert.IsType<NotFoundObjectResult>((await controller.Get(1, null, default)).Result);
|
||||
}
|
||||
|
||||
private static StrategySnapshotController Controller(AsyncServiceScope scope)
|
||||
{
|
||||
var controller = ActivatorUtilities.CreateInstance<StrategySnapshotController>(scope.ServiceProvider);
|
||||
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
|
||||
return controller;
|
||||
}
|
||||
|
||||
private sealed class Fixture : IAsyncDisposable
|
||||
{
|
||||
private const string ValidResponse = """{"strategicSummary":"Lead with delivery evidence.","cvBulletIdeas":["Built APIs"],"proofPointsToLeadWith":["Led delivery"],"coverLetterAngles":["Platform relevance"]}""";
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly Mock<ISummarizerService> _summarizer;
|
||||
public ServiceProvider Provider { get; }
|
||||
public int GenerationCalls { get; private set; }
|
||||
|
||||
private Fixture(SqliteConnection connection, ServiceProvider provider, Mock<ISummarizerService> summarizer)
|
||||
{
|
||||
_connection = connection;
|
||||
Provider = provider;
|
||||
_summarizer = summarizer;
|
||||
}
|
||||
|
||||
public static async Task<Fixture> CreateAsync(string response = ValidResponse)
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
Fixture? fixture = null;
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
summarizer.Setup(item => item.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
fixture!.GenerationCalls++;
|
||||
return new AiGenerationResult(response, "ollama", "qwen-test", RouteReason: "local_primary");
|
||||
});
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AiQueue:HeartbeatSeconds"] = "5",
|
||||
["AiQueue:MaxAttempts"] = "3",
|
||||
["Ai:ExternalProcessingEnabled"] = "false",
|
||||
}).Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddLogging();
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<CurrentUserService>();
|
||||
services.AddScoped<ICurrentUserService>(provider => provider.GetRequiredService<CurrentUserService>());
|
||||
services.AddDbContext<JobTrackerContext>((_, options) => options.UseSqlite(connection));
|
||||
services.AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>().AddEntityFrameworkStores<JobTrackerContext>();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<AiPrivacyPolicy>();
|
||||
services.AddSingleton<AiOperationExecutionScope>();
|
||||
services.AddScoped<UserOperationStore>();
|
||||
services.AddScoped<AiOperationAdmission>();
|
||||
services.AddScoped<StrategySnapshotService>();
|
||||
services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHandler>();
|
||||
services.AddSingleton<AiOperationWorker>();
|
||||
services.AddSingleton(summarizer.Object);
|
||||
var provider = services.BuildServiceProvider();
|
||||
fixture = new Fixture(connection, provider, summarizer);
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
await scope.ServiceProvider.GetRequiredService<JobTrackerContext>().Database.EnsureCreatedAsync();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
public async Task SeedAsync()
|
||||
{
|
||||
await SeedUserAsync("user-1", pro: true);
|
||||
await using var scope = Provider.CreateAsyncScope();
|
||||
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-1");
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
var company = new Company { Id = 1, Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
db.JobApplications.Add(new JobApplication { Id = 1, CompanyId = 1, Company = company, JobTitle = "Backend Developer", Description = "Needs .NET and SQL.", OwnerUserId = "user-1" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task SeedUserAsync(string userId, bool pro)
|
||||
{
|
||||
await using var scope = Provider.CreateAsyncScope();
|
||||
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||||
if (pro && !await roles.RoleExistsAsync("Premium")) Assert.True((await roles.CreateAsync(new IdentityRole("Premium"))).Succeeded);
|
||||
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var user = new ApplicationUser { Id = userId, UserName = $"{userId}@example.test", Email = $"{userId}@example.test", EmailConfirmed = true, AiEnabled = true, ProfileCvText = "Built .NET APIs and led delivery." };
|
||||
Assert.True((await users.CreateAsync(user)).Succeeded);
|
||||
if (pro) Assert.True((await users.AddToRoleAsync(user, "Premium")).Succeeded);
|
||||
}
|
||||
|
||||
public async Task EnqueueAsync()
|
||||
{
|
||||
await using var scope = Provider.CreateAsyncScope();
|
||||
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-1");
|
||||
var controller = Controller(scope);
|
||||
Assert.IsType<AcceptedResult>(await controller.Enqueue(1, new StrategySnapshotRequest(null), default));
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_summarizer.Reset();
|
||||
await Provider.DisposeAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user