feat(cv): qualify templates and reliable local import processing

This commit is contained in:
cesnimda
2026-10-28 14:36:12 +01:00
parent 73ea888375
commit 3f4db8ca81
69 changed files with 2597 additions and 180 deletions
@@ -4,6 +4,7 @@ using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
@@ -14,6 +15,42 @@ namespace JobTrackerApi.MariaDb.Tests;
public sealed partial class CoverLetterWriterTests
{
[Fact]
public async Task Premium_letter_entitlement_uses_actual_roles_and_preserves_downgraded_saved_design()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
db.Users.Add(new() { Id = Owner, UserName = "synthetic-letter-owner" });
var job = await Seed(db, "Original letter");
var design = new CoverLetterDocumentSettings { DesignVersion = 2, ThemeId = "technical" };
await Assert.ThrowsAsync<CoverLetterThemeAccessException>(() => Assets(db).SaveCoverLetterAsync(
Owner, job.Id, "Rejected", CoverLetterSources.Manual, null, 1, default, design));
Assert.Empty(await db.CoverLetterVersions.ToListAsync());
Assert.Equal("Original letter", (await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText);
var role = new IdentityRole("Premium") { NormalizedName = "PREMIUM" };
db.Roles.Add(role);
db.UserRoles.Add(new IdentityUserRole<string> { UserId = Owner, RoleId = role.Id });
await db.SaveChangesAsync();
var saved = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, "Approved premium letter", CoverLetterSources.Manual, null, 1, default, design);
Assert.Equal(2, saved!.Revision);
Assert.Equal("technical", saved.DocumentSettings!.ThemeId);
await db.UserRoles.Where(row => row.UserId == Owner).ExecuteDeleteAsync();
var retained = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, "Approved after downgrade", CoverLetterSources.Manual, null, saved.Revision, default, design);
Assert.Equal(3, retained!.Revision);
var historyCount = await db.CoverLetterVersions.CountAsync();
await Assert.ThrowsAsync<CoverLetterThemeAccessException>(() => Assets(db).SaveCoverLetterAsync(Owner, job.Id,
"Rejected switch", CoverLetterSources.Manual, null, retained.Revision, default, new() { DesignVersion = 2, ThemeId = "executive" }));
await using var verify = Context(connection);
var actual = await verify.JobApplications.AsNoTracking().SingleAsync();
Assert.Equal("Approved after downgrade", actual.CoverLetterText);
Assert.Equal(3, actual.Version);
Assert.Equal("technical", CoverLetterDocumentSettingsJson.Deserialize(actual.CoverLetterDocumentSettingsJson).ThemeId);
Assert.Equal(historyCount, await verify.CoverLetterVersions.CountAsync());
Assert.Contains("Approved after downgrade", (await Assets(verify).RenderSavedCoverLetterAsync(Owner, job.Id, 3, default))!.Html);
}
[Fact]
public async Task Document_schema_upgrade_preserves_legacy_letter_history_and_frozen_rows_on_mariadb()
{
@@ -20,6 +20,63 @@ public class CvImportLifecycleConcurrencyTests
private const string Owner = "rd21-owner";
private static readonly ProfileCvController.ApplyReviewedRequest Review = new(0, new[] { new CvImportDecision("skills.0", "use") });
[Fact]
public async Task Document_only_claim_is_owner_bound_and_translates_on_MariaDb_without_restricting_general_claims()
{
await using var database = await MariaDbDatabase.CreateAsync();
Guid documentId, legacyId, strategyId, mismatchedId, wrongTriggerId;
await using (var seed = database.Context())
{
seed.Users.AddRange(new ApplicationUser { Id = Owner, UserName = "claim-owner" },
new ApplicationUser { Id = "other-owner", UserName = "claim-other" });
var document = new CvExtractionRun { OwnerUserId = Owner, ParserVersion = ProfileCvController.DocumentParserVersion, Trigger = "document_import", Status = "queued" };
var foreign = new CvExtractionRun { OwnerUserId = "other-owner", ParserVersion = ProfileCvController.DocumentParserVersion, Trigger = "document_import", Status = "queued" };
var legacy = new CvExtractionRun { OwnerUserId = Owner, ParserVersion = "legacy-text", Trigger = "document_import", Status = "queued" };
var wrongTrigger = new CvExtractionRun { OwnerUserId = Owner, ParserVersion = ProfileCvController.DocumentParserVersion, Trigger = "text_extraction", Status = "queued" };
seed.CvExtractionRuns.AddRange(document, foreign, legacy, wrongTrigger);
await seed.SaveChangesAsync();
UserOperation Queued(CvExtractionRun run, int priority)
{
var operation = Operation(run.Id, OperationStatuses.Queued);
operation.IdempotencyKey = operation.Id.ToString("N");
operation.LeaseToken = null;
operation.Priority = priority;
return operation;
}
var eligible = Queued(document, 100);
var legacyOperation = Queued(legacy, 1000);
var mismatched = Queued(foreign, 900);
var wrongTriggerOperation = Queued(wrongTrigger, 800);
var strategy = Queued(document, 1100);
strategy.TaskType = StrategySnapshotService.TaskType;
strategy.SubjectType = "job_application";
seed.UserOperations.AddRange(eligible, legacyOperation, mismatched, wrongTriggerOperation, strategy);
await seed.SaveChangesAsync();
(documentId, legacyId, strategyId, mismatchedId, wrongTriggerId) =
(eligible.Id, legacyOperation.Id, strategy.Id, mismatched.Id, wrongTriggerOperation.Id);
}
await using var neutral = database.Context(null!);
var store = new UserOperationStore(neutral, TimeProvider.System);
var claimed = Assert.IsType<UserOperationLease>(await store.ClaimNextAsync(TimeSpan.FromMinutes(1), default, documentImportsOnly: true));
Assert.Equal(documentId, claimed.OperationId);
Assert.Equal(Owner, claimed.OwnerUserId);
Assert.Null(await store.ClaimNextAsync(TimeSpan.FromMinutes(1), default, documentImportsOnly: true));
var generalCv = Assert.IsType<UserOperationLease>(await store.ClaimNextAsync(TimeSpan.FromMinutes(1), default, new[] { CvProcessingQueue.TaskType }));
Assert.Equal(legacyId, generalCv.OperationId);
var generalOther = Assert.IsType<UserOperationLease>(await store.ClaimNextAsync(TimeSpan.FromMinutes(1), default, new[] { StrategySnapshotService.TaskType }));
Assert.Equal(strategyId, generalOther.OperationId);
var excluded = await neutral.UserOperations.IgnoreQueryFilters().AsNoTracking()
.Where(operation => operation.Id == mismatchedId || operation.Id == wrongTriggerId).ToListAsync();
Assert.Equal(2, excluded.Count);
Assert.All(excluded, operation =>
{
Assert.Equal(OperationStatuses.Queued, operation.Status);
Assert.Equal(0, operation.AttemptCount);
Assert.Null(operation.LeaseToken);
});
}
[Fact]
public async Task Acknowledged_discard_prevents_an_apply_that_already_read_pending()
{
@@ -392,6 +392,30 @@
"System.Drawing.Common": "6.0.0"
}
},
"SkiaSharp": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "TghYLOUaP3q6rTjQw7U9uQaaXh8lEs9rfA1U7/VtmyAptVDtj/SmjtcWzFWmcC/VMWxe3QgbV9uyvqDL7ySFtQ==",
"dependencies": {
"SkiaSharp.NativeAssets.Win32": "4.153.1",
"SkiaSharp.NativeAssets.macOS": "4.153.1"
}
},
"SkiaSharp.NativeAssets.Linux.NoDependencies": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "NrnPjcxjcRau4GMOY0BTpUdMey+MnmLU3jC5AuExEFHPS2NkX1tdkXW0n/c5nQMMHBTAMpHnt7qEOpXs2FNfIQ=="
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "YTojWeWIhnpF7uC8vpMxB5wRONKkDB2t3r/nNPA3DL2pgj9fqMXRevA/rIanYazWnco4jo1ufgp3HkXuh1Tz8A=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "Yqg6rChTVh0BUE8QQxtOA3uUBnihfgID4yex/AvDa0+os2bVcBjmI3Qj9tnOB+R1hd/Jry1Gwl8qw5JI9HZG8Q=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.10",
@@ -545,6 +569,8 @@
"Pomelo.EntityFrameworkCore.MySql": "[9.0.0, )",
"QRCoder": "[1.8.0, )",
"SQLitePCLRaw.lib.e_sqlite3": "[2.1.12, )",
"SkiaSharp": "[4.153.1, )",
"SkiaSharp.NativeAssets.Linux.NoDependencies": "[4.153.1, )",
"Stripe.net": "[52.2.0, )",
"System.IdentityModel.Tokens.Jwt": "[8.14.0, )",
"System.Security.Cryptography.Pkcs": "[8.0.2, )"