228 lines
15 KiB
C#
228 lines
15 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
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.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore.Migrations;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.MariaDb.Tests;
|
|
|
|
public class CvImportReviewedPersistenceTests
|
|
{
|
|
private readonly Xunit.Abstractions.ITestOutputHelper _output;
|
|
public CvImportReviewedPersistenceTests(Xunit.Abstractions.ITestOutputHelper output) => _output = output;
|
|
|
|
[Fact]
|
|
public async Task Existing_profile_fields_survive_and_import_metadata_uses_the_canonical_revision()
|
|
{
|
|
await using var database = await MariaDbDatabase.CreateAsync();
|
|
await using var db = database.Context();
|
|
var run = await Seed(db);
|
|
await new CareerProfileService(db).SaveVersionAsync(Owner, new() { Summary = { "Preserve my summary" }, Contact = new() { GitHub = "https://github.com/existing" }, OtherSections = { new() { Title = "Private", Items = { "Preserve my custom fact" } } } }, "manual", 0, default);
|
|
(await db.Users.SingleAsync()).CurrentCvProfileVersion = 99;
|
|
await db.SaveChangesAsync();
|
|
var controller = Controller(db);
|
|
var clock = System.Diagnostics.Stopwatch.StartNew();
|
|
Assert.IsType<OkObjectResult>(await controller.ApplyReviewed(run.Id, new(1, new[] { new CvImportDecision("skills.0", "use") })));
|
|
clock.Stop();
|
|
_output.WriteLine("ReviewedImportApplyMs={0:F3}; provider=MariaDB12.2.2; includes=validation,merge,transaction,Identity_projection", clock.Elapsed.TotalMilliseconds);
|
|
await using var verify = database.Context();
|
|
var snapshot = await new CareerProfileService(verify).LoadSnapshotAsync(Owner, default);
|
|
Assert.Equal(2, snapshot.Revision);
|
|
Assert.Equal(2, snapshot.Profile.Metadata.ProfileVersion);
|
|
Assert.Equal("Preserve my summary", Assert.Single(snapshot.Profile.Summary));
|
|
Assert.Equal("https://github.com/existing", snapshot.Profile.Contact.GitHub);
|
|
Assert.Equal("Preserve my custom fact", Assert.Single(Assert.Single(snapshot.Profile.OtherSections).Items));
|
|
}
|
|
private const string Owner = "rd21-owner";
|
|
private static ProfileCvController Controller(JobTrackerContext db, bool identitySucceeds = true)
|
|
{
|
|
var user = db.Users.Single(u => u.Id == Owner);
|
|
var users = TestHostFactory.CreateUserManager();
|
|
users.Setup(u => u.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
|
users.Setup(u => u.UpdateAsync(user)).Returns(async () =>
|
|
{
|
|
if (!identitySucceeds) return IdentityResult.Failed(new IdentityError { Description = "Synthetic rollback" });
|
|
await db.SaveChangesAsync();
|
|
return IdentityResult.Success;
|
|
});
|
|
return new(users.Object, Mock.Of<ISummarizerService>(), db, null!)
|
|
{ ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
|
|
}
|
|
|
|
private static async Task<CvExtractionRun> Seed(JobTrackerContext db)
|
|
{
|
|
db.Users.Add(new() { Id = Owner, UserName = "import-synthetic" });
|
|
var run = new CvExtractionRun
|
|
{
|
|
OwnerUserId = Owner, Trigger = "document_import", ParserVersion = ProfileCvController.DocumentParserVersion,
|
|
Status = "pending_review", RawExtractedText = "Skills\nC#\nSQL", DocumentJson = "{\"synthetic\":true}",
|
|
StructuredProfileJson = StructuredCvProfileJson.Serialize(new() { Skills = { "C#", "SQL" } }),
|
|
};
|
|
db.CvExtractionRuns.Add(run);
|
|
await db.SaveChangesAsync();
|
|
return run;
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public async Task Source_mapped_new_records_fill_empty_profile_or_roll_back_without_rejected_proposals(bool succeeds)
|
|
{
|
|
await using var database = await MariaDbDatabase.CreateAsync();
|
|
await using var db = database.Context();
|
|
var run = await Seed(db);
|
|
var blocks = new List<CvImportBlock>();
|
|
var sequence = 0;
|
|
CvImportRecordMapping Map(string category, params (string Name, string Value)[] values)
|
|
{
|
|
var fields = values.Select(value =>
|
|
{
|
|
var block = new CvImportBlock("b" + ++sequence, value.Value, "paragraph", 1);
|
|
blocks.Add(block);
|
|
return new CvImportMappedField(value.Name, value.Value, block.Id);
|
|
}).ToArray();
|
|
return new("record-" + sequence, category, null, fields);
|
|
}
|
|
var mappings = new[]
|
|
{
|
|
Map("contact", ("fullName", "Åse Synthetic"), ("email", "ase@example.test"), ("phone", "+47 400 00 001"), ("headline", "Engineer"), ("location", "Oslo"), ("website", "https://example.test"), ("linkedIn", "https://linkedin.com/in/synthetic"), ("gitHub", "https://github.com/synthetic"), ("linkLabel", "Portfolio"), ("linkUrl", "https://portfolio.example.test")),
|
|
Map("jobs", ("title", "Engineer"), ("company", "First AS"), ("location", "Oslo"), ("start", "2018"), ("end", "2020"), ("bullets", "Built tools."), ("skills", "Rust")),
|
|
Map("jobs", ("title", "Lead"), ("company", "Second AS"), ("start", "2021"), ("end", "Present"), ("isCurrent", "true"), ("bullets", "Led delivery.")),
|
|
Map("education", ("qualification", "Bachelor"), ("qualificationLevel", "Bachelor"), ("institution", "First University"), ("location", "Bergen"), ("start", "2010"), ("end", "2013"), ("details", "Databases")),
|
|
Map("education", ("qualification", "Master"), ("institution", "Second University"), ("start", "2013"), ("end", "2015"), ("details", "Distributed systems")),
|
|
Map("certifications", ("name", "Synthetic certificate"), ("issuer", "Example Institute"), ("date", "2022"), ("location", "Online"), ("details", "Recorded assessment")),
|
|
Map("projects", ("name", "Atlas"), ("role", "Developer"), ("location", "Remote"), ("start", "2020"), ("end", "2021"), ("bullets", "Built search."), ("skills", "React")),
|
|
Map("languages", ("name", "Norsk"), ("level", "Morsmål"), ("notes", "Stated level")), Map("languages", ("name", "English")),
|
|
Map("skills", ("value", "Rust"), ("value", "Python")), Map("summary", ("value", "Source-backed professional summary.")),
|
|
Map("otherSections", ("title", "Courses"), ("items", "Systems course")), Map("awards", ("value", "Synthetic award")),
|
|
Map("publications", ("value", "Synthetic publication")), Map("organisations", ("value", "Synthetic organisation")),
|
|
Map("interests", ("value", "Hiking")), Map("references", ("value", "Available on request")),
|
|
};
|
|
run.RawExtractedText = string.Join('\n', blocks.Select(b => b.Text));
|
|
run.DocumentJson = JsonSerializer.Serialize(new CvImportDocument(run.RawExtractedText, false, "application/pdf", 1, run.RawExtractedText.Length, "synthetic.pdf", blocks, Array.Empty<string>(), "synthetic-test-document"));
|
|
run.Artifact = new() { OwnerUserId = Owner, OriginalFileName = "synthetic.pdf", StoredFileName = "synthetic.pdf", MimeType = "application/pdf", Sha256 = new string('a', 64) };
|
|
await db.SaveChangesAsync();
|
|
var artifactId = run.ArtifactId;
|
|
var request = new ProfileCvController.ApplyReviewedRequest(0, new[] { new CvImportDecision("skills.0", "keep"), new CvImportDecision("skills.1", "keep") }, mappings);
|
|
var result = await Controller(db, succeeds).ApplyReviewed(run.Id, request);
|
|
if (succeeds)
|
|
{
|
|
Assert.IsType<OkObjectResult>(result);
|
|
Assert.IsType<OkObjectResult>(await Controller(db).ApplyReviewed(run.Id, request));
|
|
}
|
|
else Assert.IsType<BadRequestObjectResult>(result);
|
|
await db.SaveChangesAsync();
|
|
await using var verify = database.Context();
|
|
var snapshot = await new CareerProfileService(verify).LoadSnapshotAsync(Owner, default);
|
|
Assert.Equal(succeeds ? 1 : 0, snapshot.Revision);
|
|
Assert.Equal(succeeds ? 2 : 0, await verify.CareerExperiences.CountAsync());
|
|
Assert.Equal(succeeds ? 2 : 0, await verify.CareerEducations.CountAsync());
|
|
Assert.Equal(succeeds ? 2 : 0, await verify.CareerLanguages.CountAsync());
|
|
Assert.Equal(succeeds ? 1 : 0, await verify.CareerProjects.CountAsync());
|
|
Assert.Equal(succeeds ? 1 : 0, await verify.CareerCertifications.CountAsync());
|
|
Assert.Equal(succeeds ? new[] { "Python", "Rust" } : Array.Empty<string>(), await verify.CareerSkills.OrderBy(s => s.Name).Select(s => s.Name!).ToArrayAsync());
|
|
Assert.Equal(succeeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync());
|
|
Assert.Equal(succeeds ? "applied" : "pending_review", (await verify.CvExtractionRuns.SingleAsync()).Status);
|
|
if (!succeeds) return;
|
|
var profile = snapshot.Profile;
|
|
Assert.Equal("Åse Synthetic", profile.Contact.FullName);
|
|
Assert.Equal("https://portfolio.example.test", Assert.Single(profile.Contact.Links).Url);
|
|
Assert.Equal("2018", profile.Jobs[0].Start);
|
|
Assert.Equal("Oslo", profile.Jobs[0].Location);
|
|
Assert.True(profile.Jobs[1].IsCurrent);
|
|
Assert.Null(profile.Languages.Single(l => l.Name == "English").Level);
|
|
Assert.Equal("Databases", Assert.Single(profile.Education[0].Details));
|
|
Assert.Equal("React", Assert.Single(profile.Projects[0].Skills));
|
|
Assert.Equal("Courses", Assert.Single(profile.OtherSections).Title);
|
|
Assert.Single(profile.Awards); Assert.Single(profile.Publications); Assert.Single(profile.Organisations); Assert.Single(profile.Interests); Assert.Single(profile.References); Assert.Single(profile.Summary);
|
|
Assert.DoesNotContain("SQL", StructuredCvProfileJson.SerializePersisted(profile));
|
|
Assert.Equal(artifactId, profile.Metadata.Fields["jobs.0.title"].SourceArtifactId);
|
|
Assert.Equal(run.Id, profile.Metadata.Fields["jobs.0.title"].SourceExtractionRunId);
|
|
Assert.Equal("user-source-mapping", profile.Metadata.Fields["jobs.0.title"].Method);
|
|
Assert.Contains("Mappings", (await verify.CvExtractionRuns.SingleAsync()).ReviewJson);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public async Task Reviewed_selection_commits_once_or_rolls_back_entire_projection(bool succeeds)
|
|
{
|
|
await using var database = await MariaDbDatabase.CreateAsync();
|
|
await using var db = database.Context();
|
|
var run = await Seed(db);
|
|
var request = new ProfileCvController.ApplyReviewedRequest(0, new[] { new CvImportDecision("skills.0", "use"), new CvImportDecision("skills.1", "keep") });
|
|
var result = await Controller(db, succeeds).ApplyReviewed(run.Id, request);
|
|
if (succeeds)
|
|
{
|
|
Assert.IsType<OkObjectResult>(result);
|
|
Assert.IsType<OkObjectResult>(await Controller(db).ApplyReviewed(run.Id, request));
|
|
Assert.IsType<ConflictObjectResult>(await Controller(db).ApplyReviewed(run.Id, new(0, new[] { new CvImportDecision("skills.1", "use") })));
|
|
}
|
|
else Assert.IsType<BadRequestObjectResult>(result);
|
|
await db.SaveChangesAsync();
|
|
await using var verify = database.Context();
|
|
var saved = await verify.CvExtractionRuns.SingleAsync();
|
|
Assert.Equal(succeeds ? "applied" : "pending_review", saved.Status);
|
|
Assert.Equal(succeeds, saved.ReviewJson is not null);
|
|
Assert.Equal(succeeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync());
|
|
Assert.Equal(succeeds ? new[] { "C#" } : Array.Empty<string>(), await verify.CareerSkills.Select(s => s.Name).ToArrayAsync());
|
|
Assert.Equal(succeeds, (await verify.Users.SingleAsync()).CurrentCvExtractionRunId.HasValue);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Stale_review_does_not_overwrite_acknowledged_edit_or_mark_run_applied()
|
|
{
|
|
await using var database = await MariaDbDatabase.CreateAsync();
|
|
await using var db = database.Context();
|
|
var run = await Seed(db);
|
|
await new CareerProfileService(db).SaveVersionAsync(Owner, new() { Skills = { "Saved elsewhere" } }, "manual", 0, default);
|
|
Assert.IsType<ConflictObjectResult>(await Controller(db).ApplyReviewed(run.Id, new(0, new[] { new CvImportDecision("skills.0", "use") })));
|
|
await db.SaveChangesAsync();
|
|
await using var verify = database.Context();
|
|
Assert.Equal("Saved elsewhere", (await new CareerProfileService(verify).LoadStructuredAsync(Owner, default)).Skills.Single());
|
|
Assert.Equal("pending_review", (await verify.CvExtractionRuns.SingleAsync()).Status);
|
|
Assert.Single(await verify.CareerProfileVersions.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Review_owner_boundary_and_unknown_fields_fail_closed()
|
|
{
|
|
await using var database = await MariaDbDatabase.CreateAsync();
|
|
await using var db = database.Context();
|
|
var run = await Seed(db);
|
|
Assert.IsType<BadRequestObjectResult>(await Controller(db).ApplyReviewed(run.Id, new(0, new[] { new CvImportDecision("metadata.owner", "correct", "other") })));
|
|
(await db.CvExtractionRuns.SingleAsync()).OwnerUserId = "another-owner";
|
|
await db.SaveChangesAsync();
|
|
Assert.IsType<NotFoundResult>(await Controller(db).GetImportReview(run.Id));
|
|
Assert.IsType<NotFoundResult>(await Controller(db).ApplyReviewed(run.Id, new(0, Array.Empty<CvImportDecision>())));
|
|
Assert.Empty(await db.CareerProfiles.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Additive_import_schema_down_up_preserves_canonical_and_historical_facts()
|
|
{
|
|
await using var database = await MariaDbDatabase.CreateAsync();
|
|
await using var db = database.Context();
|
|
var run = await Seed(db);
|
|
await new CareerProfileService(db).SaveVersionAsync(Owner, new() { Skills = { "Existing" }, Contact = new() { GitHub = "https://github.com/example" } }, "manual", 0, default);
|
|
var before = (await db.CareerProfiles.SingleAsync()).ProfileJson;
|
|
await db.GetService<IMigrator>().MigrateAsync("20261025090000_AddCoverLetterDocumentDesign");
|
|
db.ChangeTracker.Clear();
|
|
await db.GetService<IMigrator>().MigrateAsync();
|
|
Assert.Equal(before, (await db.CareerProfiles.AsNoTracking().SingleAsync()).ProfileJson);
|
|
Assert.Equal(before, (await db.CareerProfileVersions.AsNoTracking().SingleAsync()).ProfileJson);
|
|
Assert.Null((await db.CvExtractionRuns.AsNoTracking().SingleAsync()).ReviewJson);
|
|
Assert.Equal("pending_review", (await db.CvExtractionRuns.AsNoTracking().SingleAsync()).Status);
|
|
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
|
|
}
|
|
}
|