fix(rd21): guard career and job saves with durable revisions
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
using System.Text.Json;
|
||||
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 Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class Rd21JobPersistenceTests
|
||||
{
|
||||
private static JobTrackerContext Context(SqliteConnection connection, string owner = "rd21-owner")
|
||||
{
|
||||
var current = new Mock<ICurrentUserService>();
|
||||
current.SetupGet(x => x.UserId).Returns(owner);
|
||||
return new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options, current.Object);
|
||||
}
|
||||
|
||||
private static JobApplicationsController Controller(JobTrackerContext db) => new(db,
|
||||
Mock.Of<ISummarizerService>(), TestHostFactory.CreateUserManager(new ApplicationUser { Id = "rd21-owner" }).Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "rd21-owner") }, "test")),
|
||||
} },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Write_that_overlaps_a_delayed_save_wins_without_partial_events_or_opportunity_changes()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
int id, companyId;
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
var company = new Company { OwnerUserId = "rd21-owner", Name = "RD21 overlap" };
|
||||
seed.Add(company);
|
||||
await seed.SaveChangesAsync();
|
||||
var job = new JobApplication { OwnerUserId = "rd21-owner", CompanyId = company.Id, JobTitle = "Original job title", Status = "Prospect" };
|
||||
job.Job = JobOpportunitySync.Create(job);
|
||||
seed.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id; companyId = company.Id;
|
||||
}
|
||||
|
||||
var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
summarizer.Setup(x => x.SummarizeAsync(It.IsAny<string>(), 160, 60)).Returns(() =>
|
||||
{
|
||||
reached.TrySetResult();
|
||||
return release.Task;
|
||||
});
|
||||
var user = new ApplicationUser { Id = "rd21-owner", AiEnabled = true };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Premium" });
|
||||
await using var delayed = Context(connection);
|
||||
var controller = new JobApplicationsController(delayed, summarizer.Object, users.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "test")),
|
||||
} },
|
||||
};
|
||||
var pending = controller.Update(id, Request(companyId, "Delayed title", null, 1) with { Status = "Offer" }, default);
|
||||
await reached.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
await using var winner = Context(connection);
|
||||
Assert.IsType<NoContentResult>(await Controller(winner).Update(id,
|
||||
Request(companyId, "Original job title", "Acknowledged concurrent notes", 1), default));
|
||||
}
|
||||
finally { release.TrySetResult("Delayed summary"); }
|
||||
Assert.IsType<ConflictObjectResult>(await pending);
|
||||
|
||||
await using var verify = Context(connection);
|
||||
var saved = await verify.JobApplications.SingleAsync();
|
||||
Assert.Equal(2, saved.Version);
|
||||
Assert.Equal("Original job title", saved.JobTitle);
|
||||
Assert.Equal("Acknowledged concurrent notes", saved.Notes);
|
||||
Assert.Equal("Prospect", saved.Status);
|
||||
Assert.Equal("Original job title", (await verify.Jobs.SingleAsync()).JobTitle);
|
||||
Assert.Empty(await verify.JobEvents.ToListAsync());
|
||||
Assert.Empty(delayed.ChangeTracker.Entries());
|
||||
|
||||
// A deliberate reload/review uses the winner's content and new revision, never a blind retry.
|
||||
await using var reviewed = Context(connection);
|
||||
Assert.IsType<NoContentResult>(await Controller(reviewed).Update(id,
|
||||
Request(companyId, "Reviewed final title", saved.Notes, saved.Version), default));
|
||||
await using var finalSession = Context(connection);
|
||||
var final = await finalSession.JobApplications.SingleAsync();
|
||||
Assert.Equal(3, final.Version);
|
||||
Assert.Equal("Reviewed final title", final.JobTitle);
|
||||
Assert.Equal("Acknowledged concurrent notes", final.Notes);
|
||||
}
|
||||
|
||||
private static UpdateJobApplicationRequest Request(int companyId, string title, string? notes, int? expectedVersion) =>
|
||||
JsonSerializer.Deserialize<UpdateJobApplicationRequest>(JsonSerializer.Serialize(new
|
||||
{
|
||||
jobTitle = title, companyId, status = "Prospect", notes, expectedVersion,
|
||||
location = "Oslo", salary = "unchanged salary", description = "Original description",
|
||||
}), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
|
||||
[Fact]
|
||||
public async Task Structured_tailored_cv_snapshot_cannot_replace_a_newer_approved_document()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
int id;
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
var job = new JobApplication { OwnerUserId = "rd21-owner", Company = new Company { Name = "RD21 CV", OwnerUserId = "rd21-owner" }, JobTitle = "Role" };
|
||||
seed.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id;
|
||||
}
|
||||
SaveTailoredCvDraftRequest Draft(string text) => JsonSerializer.Deserialize<SaveTailoredCvDraftRequest>(
|
||||
JsonSerializer.Serialize(new { summary = new[] { text }, expectedVersion = 1 }),
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
await using var first = Context(connection);
|
||||
Assert.IsType<NoContentResult>(await Controller(first).SaveTailoredCvDraft(id, Draft("Approved CV evidence"), default));
|
||||
await using var stale = Context(connection);
|
||||
var rejected = await Controller(stale).SaveTailoredCvDraft(id, Draft("Stale CV evidence"), default);
|
||||
await using var verify = Context(connection);
|
||||
Assert.Contains("Approved CV evidence", (await verify.JobApplications.SingleAsync()).TailoredCvText);
|
||||
Assert.Contains("Approved CV evidence", (await verify.TailoredCvDrafts.SingleAsync()).SummaryJson);
|
||||
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(rejected).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_job_transaction_cannot_acknowledge_or_leave_partial_data_for_a_later_save()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var company = new Company { OwnerUserId = "rd21-owner", Name = "RD21 rollback" };
|
||||
db.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
var job = new JobApplication { OwnerUserId = "rd21-owner", CompanyId = company.Id, JobTitle = "Approved title", Notes = "Approved notes", Status = "Prospect" };
|
||||
job.Job = JobOpportunitySync.Create(job);
|
||||
db.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TRIGGER rd21_reject_event BEFORE INSERT ON JobEvents
|
||||
BEGIN SELECT RAISE(ABORT, 'synthetic RD21 event failure'); END;
|
||||
""");
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() => Controller(db).Update(job.Id,
|
||||
Request(company.Id, "Uncommitted title", "Uncommitted notes", 1) with { Status = "Offer" }, default));
|
||||
Assert.Empty(db.ChangeTracker.Entries());
|
||||
await db.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
var actual = await verify.JobApplications.SingleAsync();
|
||||
Assert.Equal("Approved title", actual.JobTitle);
|
||||
Assert.Equal("Approved notes", actual.Notes);
|
||||
Assert.Equal("Prospect", actual.Status);
|
||||
Assert.Equal(1, actual.Version);
|
||||
Assert.Equal("Approved title", (await verify.Jobs.SingleAsync()).JobTitle);
|
||||
Assert.Empty(await verify.JobEvents.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Versioned_job_save_cannot_cross_tenant_job_or_company_ownership()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var mine = new Company { OwnerUserId = "rd21-owner", Name = "Owned company" };
|
||||
var other = new Company { OwnerUserId = "other-owner", Name = "Private company" };
|
||||
var job = new JobApplication { OwnerUserId = "rd21-owner", Company = mine, JobTitle = "Owned job", Status = "Prospect" };
|
||||
var foreign = new JobApplication { OwnerUserId = "other-owner", Company = other, JobTitle = "Private job", Status = "Prospect" };
|
||||
db.AddRange(job, foreign);
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
Assert.IsType<NotFoundResult>(await Controller(db).Update(foreign.Id, Request(other.Id, "Stolen", null, 1), default));
|
||||
Assert.IsType<BadRequestObjectResult>(await Controller(db).Update(job.Id, Request(other.Id, "Changed", null, 1), default));
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal("Owned job", (await verify.JobApplications.SingleAsync()).JobTitle);
|
||||
Assert.Equal(mine.Id, (await verify.JobApplications.SingleAsync()).CompanyId);
|
||||
await using var foreignSession = Context(connection, "other-owner");
|
||||
Assert.Equal("Private job", (await foreignSession.JobApplications.SingleAsync()).JobTitle);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Related_text_save_cannot_silently_replace_a_newer_acknowledged_draft(bool coverLetter)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
int id;
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = "rd21-owner", Company = new Company { OwnerUserId = "rd21-owner", Name = "RD21" },
|
||||
JobTitle = "Role", CoverLetterText = "Original letter", Notes = "Original notes",
|
||||
};
|
||||
seed.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id;
|
||||
}
|
||||
|
||||
async Task<IActionResult> Save(JobTrackerContext db, string text)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new { text, notes = text, expectedVersion = 1 });
|
||||
if (!coverLetter)
|
||||
return await Controller(db).SaveApplicationDrafts(id,
|
||||
JsonSerializer.Deserialize<SaveApplicationDraftsRequest>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!, default);
|
||||
var user = new ApplicationUser { Id = "rd21-owner" };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var controller = new ApplicationAssetsController(users.Object,
|
||||
new ApplicationAssetsService(db, Mock.Of<ICvVariantService>(), Mock.Of<IApplicationIntelligenceService>()),
|
||||
Mock.Of<ISubmittedApplicationPackageService>())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "test")),
|
||||
} },
|
||||
};
|
||||
return (await controller.SaveCoverLetter(id,
|
||||
JsonSerializer.Deserialize<ApplicationAssetsController.SaveCoverLetterRequest>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!, default)).Result!;
|
||||
}
|
||||
|
||||
await using var first = Context(connection);
|
||||
var acknowledged = await Save(first, "First approved text");
|
||||
Assert.True(acknowledged is OkObjectResult or NoContentResult);
|
||||
await using (var immediate = Context(connection))
|
||||
Assert.Equal("First approved text", coverLetter ? (await immediate.JobApplications.SingleAsync()).CoverLetterText : (await immediate.JobApplications.SingleAsync()).Notes);
|
||||
await using var stale = Context(connection);
|
||||
IActionResult rejected;
|
||||
try { rejected = await Save(stale, "Stale snapshot text"); }
|
||||
catch (DbUpdateConcurrencyException) { rejected = new ConflictObjectResult("revision_conflict"); }
|
||||
await using var verify = Context(connection);
|
||||
var saved = await verify.JobApplications.SingleAsync();
|
||||
Assert.Equal("First approved text", coverLetter ? saved.CoverLetterText : saved.Notes);
|
||||
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(rejected).StatusCode);
|
||||
if (coverLetter) Assert.Single(await verify.CoverLetterVersions.ToListAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Stale_or_unversioned_snapshot_cannot_erase_an_acknowledged_job_edit(bool omitRevision)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
int id, companyId;
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
var company = new Company { OwnerUserId = "rd21-owner", Name = "Synthetic RD21" };
|
||||
seed.Companies.Add(company);
|
||||
await seed.SaveChangesAsync();
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = "rd21-owner", Company = company, CompanyId = company.Id, JobTitle = "Original job title",
|
||||
Status = "Prospect", Location = "Oslo", Salary = "unchanged salary", Description = "Original description",
|
||||
};
|
||||
job.Job = JobOpportunitySync.Create(job);
|
||||
seed.JobApplications.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id; companyId = company.Id;
|
||||
}
|
||||
|
||||
// These are the two snapshots actually read by independent editors.
|
||||
await using var editorA = Context(connection);
|
||||
await using var editorBRead = Context(connection);
|
||||
var original = await editorBRead.JobApplications.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("Original job title", original.JobTitle);
|
||||
Assert.IsType<NoContentResult>(await Controller(editorA).Update(id,
|
||||
Request(companyId, "First editor committed title", null, 1), default));
|
||||
|
||||
await using (var immediatelyAfterAcknowledgement = Context(connection))
|
||||
{
|
||||
Assert.Equal("First editor committed title", (await immediatelyAfterAcknowledgement.JobApplications.SingleAsync()).JobTitle);
|
||||
Assert.Equal("First editor committed title", (await immediatelyAfterAcknowledgement.Jobs.SingleAsync()).JobTitle);
|
||||
}
|
||||
|
||||
await using var editorBWrite = Context(connection);
|
||||
var rejected = await Controller(editorBWrite).Update(id,
|
||||
Request(companyId, original.JobTitle, "Second editor stale notes", omitRevision ? null : 1), default);
|
||||
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(rejected).StatusCode);
|
||||
|
||||
await using var independentSession = Context(connection);
|
||||
var persisted = await independentSession.JobApplications.SingleAsync();
|
||||
Assert.Equal("First editor committed title", persisted.JobTitle);
|
||||
Assert.Null(persisted.Notes);
|
||||
Assert.Equal("Oslo", persisted.Location);
|
||||
Assert.Equal("unchanged salary", persisted.Salary);
|
||||
Assert.Equal("First editor committed title", (await independentSession.Jobs.SingleAsync()).JobTitle);
|
||||
Assert.Empty(await independentSession.JobEvents.ToListAsync());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user