134aac7bcf
Add a content-free usage ledger with legacy backfill. Reserve Workspace and durable Strategy/CV work before execution so deleted history or duplicate admission cannot reset limits.
231 lines
11 KiB
C#
231 lines
11 KiB
C#
using System.Security.Claims;
|
|
using JobTrackerApi.Controllers;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using JobTrackerApi.Tests.TestSupport;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
public sealed class SqliteDateTimeOffsetCompatibilityTests
|
|
{
|
|
[Fact]
|
|
public void MariaDb_branch_translates_the_same_date_ordering_and_range_queries()
|
|
{
|
|
var currentUser = new Mock<ICurrentUserService>();
|
|
currentUser.SetupGet(x => x.UserId).Returns("user-1");
|
|
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
|
.UseMySql(
|
|
"Server=localhost;Database=translation_only;User=test;Password=test",
|
|
new MariaDbServerVersion(new Version(11, 0, 0)))
|
|
.Options;
|
|
using var db = new JobTrackerContext(options, currentUser.Object);
|
|
var monthStart = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero);
|
|
|
|
var orderedSql = db.CvVariants.Where(x => x.OwnerUserId == "user-1").OrderByDescending(x => x.UpdatedAtUtc).ToQueryString();
|
|
var rangeSql = db.AiInteractions.Where(x => x.OwnerUserId == "user-1" && x.CreatedAtUtc >= monthStart).ToQueryString();
|
|
|
|
Assert.Contains("ORDER BY", orderedSql, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("CreatedAtUtc", rangeSql, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Affected_workspace_queries_order_dates_and_preserve_owner_scope()
|
|
{
|
|
await using var fixture = await SqliteFixture.CreateAsync("user-1");
|
|
var db = fixture.Db;
|
|
var company = new Company { OwnerUserId = "user-1", Name = "Acme" };
|
|
db.Companies.Add(company);
|
|
await db.SaveChangesAsync();
|
|
var job = new JobApplication { OwnerUserId = "user-1", CompanyId = company.Id, JobTitle = "Developer", Status = "Applied" };
|
|
db.JobApplications.Add(job);
|
|
await db.SaveChangesAsync();
|
|
|
|
var older = DateTimeOffset.UtcNow.AddDays(-2);
|
|
var newer = DateTimeOffset.UtcNow.AddDays(-1);
|
|
db.CvVariants.AddRange(
|
|
Variant("user-1", job.Id, "old", older),
|
|
Variant("user-1", job.Id, "new", newer),
|
|
Variant("user-2", null, "other", DateTimeOffset.UtcNow));
|
|
db.AiInteractions.AddRange(
|
|
Interaction("user-1", job.Id, older),
|
|
Interaction("user-1", job.Id, newer),
|
|
Interaction("user-2", job.Id, DateTimeOffset.UtcNow));
|
|
await db.SaveChangesAsync();
|
|
|
|
var variants = new CvVariantService(db, new CareerProfileService(db), new ThemedCvRenderer());
|
|
var history = new AiWorkspaceService(db, Mock.Of<ISummarizerService>());
|
|
var listed = await variants.ListAsync("user-1", default);
|
|
var interactions = await history.HistoryAsync("user-1", job.Id, null, default);
|
|
var workspace = await new ApplicationWorkspaceService(db, new ApplicationChecklistService(db))
|
|
.GetOverviewAsync("user-1", job.Id, default);
|
|
var variantCatalog = new Mock<ICvVariantService>();
|
|
variantCatalog.Setup(x => x.ListAsync("user-1", It.IsAny<CancellationToken>())).ReturnsAsync(listed);
|
|
var assets = await new ApplicationAssetsService(db, variantCatalog.Object, new ApplicationIntelligenceService(db, new JobCvMatchService()))
|
|
.GetCvAsync("user-1", job.Id, default);
|
|
|
|
Assert.Equal(new[] { "new", "old" }, listed.Select(x => x.Name));
|
|
Assert.Equal(new[] { newer, older }, interactions.Select(x => x.CreatedAtUtc));
|
|
Assert.Equal("new", workspace!.Cv.VariantName);
|
|
Assert.Equal(newer, workspace.LastAiAtUtc);
|
|
Assert.Equal(2, workspace.AiInteractionCount);
|
|
Assert.Equal("new", assets!.AttachedVariantName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Usage_and_extraction_run_endpoints_work_on_sqlite()
|
|
{
|
|
await using var fixture = await SqliteFixture.CreateAsync("user-1");
|
|
var db = fixture.Db;
|
|
var user = new ApplicationUser { Id = "user-1" };
|
|
var users = TestHostFactory.CreateUserManager();
|
|
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
|
users.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new[] { "Premium" });
|
|
var now = DateTimeOffset.UtcNow;
|
|
var company = new Company { OwnerUserId = "user-1", Name = "Acme" };
|
|
db.Companies.Add(company);
|
|
await db.SaveChangesAsync();
|
|
var job = new JobApplication { OwnerUserId = "user-1", CompanyId = company.Id, JobTitle = "Developer", Status = "Applied" };
|
|
db.JobApplications.Add(job);
|
|
await db.SaveChangesAsync();
|
|
db.AiInteractions.AddRange(
|
|
Interaction("user-1", job.Id, now),
|
|
Interaction("user-1", job.Id, now.AddMonths(-2)),
|
|
Interaction("user-2", job.Id, now));
|
|
db.AiUsageRecords.AddRange(
|
|
Usage("user-1", "current", now),
|
|
Usage("user-1", "old", now.AddMonths(-2)),
|
|
Usage("user-2", "other", now));
|
|
db.CvExtractionRuns.AddRange(
|
|
Run("user-1", now.AddDays(-1), "old"),
|
|
Run("user-1", now, "new"),
|
|
Run("user-2", now.AddDays(1), "other"));
|
|
await db.SaveChangesAsync();
|
|
|
|
var usageResult = await new AiUsageController(users.Object, db).Get(default);
|
|
var usage = Assert.IsType<AiUsageController.UsageDto>(Assert.IsType<OkObjectResult>(usageResult.Result).Value);
|
|
Assert.Equal(1, usage.CurrentMonth.Calls);
|
|
Assert.Equal(2, usage.AllTime.Calls);
|
|
|
|
var workspaceService = new Mock<IAiWorkspaceService>();
|
|
workspaceService.SetupGet(x => x.Modules).Returns(Array.Empty<string>());
|
|
var generate = await new AiWorkspaceController(users.Object, workspaceService.Object, new ConfigurationBuilder().Build(), db)
|
|
.Generate(job.Id, new AiWorkspaceController.GenerateRequest("job-analysis", null, null), default);
|
|
Assert.IsType<NotFoundResult>(generate.Result);
|
|
Assert.Equal(2, await db.AiUsageRecords.CountAsync());
|
|
|
|
var newestArtifactPath = Path.Combine(fixture.Paths.CvArtifactsRoot, "new.txt");
|
|
await File.WriteAllTextAsync(newestArtifactPath, "synthetic CV");
|
|
db.CvUploadArtifacts.AddRange(
|
|
new CvUploadArtifact { OwnerUserId = "user-1", OriginalFileName = "old.txt", StoragePath = "missing", Sha256 = "old", ByteSize = 1, UploadedAtUtc = now.AddDays(-1) },
|
|
new CvUploadArtifact { OwnerUserId = "user-1", OriginalFileName = "new.txt", StoragePath = newestArtifactPath, Sha256 = "new", ByteSize = 12, UploadedAtUtc = now });
|
|
await db.SaveChangesAsync();
|
|
var queue = new Mock<ICvProcessingQueue>();
|
|
var profile = new ProfileCvController(users.Object, Mock.Of<ISummarizerService>(), db, fixture.Paths, cvProcessingQueue: queue.Object)
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
|
};
|
|
var runsResult = await profile.GetRuns();
|
|
var runs = Assert.IsAssignableFrom<IEnumerable<CvExtractionRunListItem>>(Assert.IsType<OkObjectResult>(runsResult.Result).Value).ToList();
|
|
Assert.Equal(new[] { "new", "old" }, runs.Select(x => x.Trigger));
|
|
Assert.IsType<AcceptedResult>(await profile.Reprocess());
|
|
Assert.Equal("new.txt", (await db.CvExtractionRuns.OrderByDescending(x => x.Id).FirstAsync()).Artifact!.OriginalFileName);
|
|
}
|
|
|
|
private static CvVariant Variant(string owner, int? jobId, string name, DateTimeOffset updated) => new()
|
|
{
|
|
OwnerUserId = owner,
|
|
JobApplicationId = jobId,
|
|
PublicSlug = Guid.NewGuid().ToString("N"),
|
|
Name = name,
|
|
SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings()),
|
|
CreatedAtUtc = updated,
|
|
UpdatedAtUtc = updated,
|
|
};
|
|
|
|
private static AiUsageRecord Usage(string owner, string sourceId, DateTimeOffset created) => new()
|
|
{
|
|
OwnerUserId = owner,
|
|
SourceType = "test",
|
|
SourceId = sourceId,
|
|
TaskType = "workspace.test",
|
|
CallCount = 1,
|
|
InputCharacterCount = 10,
|
|
OutputCharacterCount = 5,
|
|
EstimatedTokenCount = 4,
|
|
CreatedAtUtc = created,
|
|
};
|
|
|
|
private static AiInteraction Interaction(string owner, int jobId, DateTimeOffset created) => new()
|
|
{
|
|
OwnerUserId = owner,
|
|
JobApplicationId = jobId,
|
|
Module = "job-analysis",
|
|
Title = "Analysis",
|
|
Provider = "test",
|
|
ResultJson = "{}",
|
|
InputCharacterCount = 10,
|
|
OutputCharacterCount = 5,
|
|
EstimatedTokenCount = 4,
|
|
CreatedAtUtc = created,
|
|
};
|
|
|
|
private static CvExtractionRun Run(string owner, DateTimeOffset started, string trigger) => new()
|
|
{
|
|
OwnerUserId = owner,
|
|
Trigger = trigger,
|
|
Status = "applied",
|
|
ParserVersion = "test",
|
|
NormalizerVersion = "test",
|
|
LlmPromptVersion = "test",
|
|
StartedAtUtc = started,
|
|
};
|
|
|
|
private sealed class SqliteFixture : IAsyncDisposable
|
|
{
|
|
private readonly SqliteConnection _connection;
|
|
private readonly string _tempRoot;
|
|
public JobTrackerContext Db { get; }
|
|
public AppPaths Paths { get; }
|
|
|
|
private SqliteFixture(SqliteConnection connection, JobTrackerContext db, AppPaths paths, string tempRoot)
|
|
{
|
|
_connection = connection;
|
|
Db = db;
|
|
Paths = paths;
|
|
_tempRoot = tempRoot;
|
|
}
|
|
|
|
public static async Task<SqliteFixture> CreateAsync(string owner)
|
|
{
|
|
var connection = new SqliteConnection("Data Source=:memory:");
|
|
await connection.OpenAsync();
|
|
var currentUser = new Mock<ICurrentUserService>();
|
|
currentUser.SetupGet(x => x.UserId).Returns(owner);
|
|
var db = new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options, currentUser.Object);
|
|
await db.Database.EnsureCreatedAsync();
|
|
|
|
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-sqlite-tests-{Guid.NewGuid():N}");
|
|
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = tempRoot }).Build();
|
|
var environment = new Mock<IHostEnvironment>();
|
|
environment.SetupGet(x => x.ContentRootPath).Returns(tempRoot);
|
|
return new SqliteFixture(connection, db, new AppPaths(config, environment.Object), tempRoot);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await Db.DisposeAsync();
|
|
await _connection.DisposeAsync();
|
|
if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true);
|
|
}
|
|
}
|
|
}
|