Files
jobtrackingapp/JobTrackerApi.MariaDb.Tests/Rd21JobPersistenceTests.cs
T

354 lines
19 KiB
C#

using System.Text.Json;
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using Moq;
using Xunit;
namespace JobTrackerApi.MariaDb.Tests;
public sealed class Rd21JobPersistenceTests
{
private static JobTrackerContext Context(MariaDbDatabase connection, string owner = "rd21-owner") => connection.Context(owner);
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 = await MariaDbDatabase.CreateAsync();
int id, companyId;
await using (var seed = Context(connection))
{
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 = await MariaDbDatabase.CreateAsync();
int id;
await using (var seed = Context(connection))
{
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 = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
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
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'synthetic RD21 event failure';
""");
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());
await db.Database.ExecuteSqlRawAsync("DROP TRIGGER rd21_reject_event;");
Assert.IsType<NoContentResult>(await Controller(db).Update(job.Id,
Request(company.Id, "Recovered title", "Recovered notes", 1) with { Status = "Offer" }, default));
await using var recovered = Context(connection);
Assert.Equal("Recovered title", (await recovered.JobApplications.SingleAsync()).JobTitle);
Assert.Equal(2, (await recovered.JobApplications.SingleAsync()).Version);
Assert.Equal(new[] { "OfferReceived", "StatusChanged" },
await recovered.JobEvents.OrderBy(x => x.Type).Select(x => x.Type).ToArrayAsync());
}
[Fact]
public async Task Versioned_job_save_cannot_cross_tenant_job_or_company_ownership()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
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 = await MariaDbDatabase.CreateAsync();
int id;
await using (var seed = Context(connection))
{
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);
if (coverLetter)
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => Save(stale, "Stale snapshot text"));
else
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(await Save(stale, "Stale snapshot text")).StatusCode);
await using var verify = Context(connection);
var saved = await verify.JobApplications.SingleAsync();
Assert.Equal("First approved text", coverLetter ? saved.CoverLetterText : saved.Notes);
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 = await MariaDbDatabase.CreateAsync();
int id, companyId;
await using (var seed = Context(connection))
{
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());
}
[Fact]
public async Task Held_writer_timeout_rolls_back_and_a_reviewed_save_recovers_after_release()
{
await using var database = await MariaDbDatabase.CreateAsync();
int id, companyId;
await using (var seed = Context(database))
{
var company = new Company { OwnerUserId = "rd21-owner", Name = "Writer lock" };
seed.Add(company);
await seed.SaveChangesAsync();
var job = new JobApplication
{
OwnerUserId = "rd21-owner", CompanyId = company.Id,
JobTitle = "Approved title", Notes = "Approved notes", Status = "Prospect",
};
job.Job = JobOpportunitySync.Create(job);
seed.Add(job);
await seed.SaveChangesAsync();
id = job.Id; companyId = job.CompanyId;
}
await using var holder = Context(database);
await using var transaction = await holder.Database.BeginTransactionAsync();
await holder.Database.ExecuteSqlInterpolatedAsync($"UPDATE JobApplications SET Notes = 'Uncommitted lock holder' WHERE Id = {id}");
await using var waiting = Context(database);
await waiting.Database.OpenConnectionAsync();
await waiting.Database.ExecuteSqlRawAsync("SET SESSION innodb_lock_wait_timeout = 1;");
try
{
// The server's lock timeout supplies the synchronization; no sleep or retry hides lock failures.
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(waiting).Update(id,
Request(companyId, "Timed out title", "Timed out notes", 1) with { Status = "Offer" }, default));
var updateFailure = Assert.IsType<DbUpdateException>(exception.InnerException);
Assert.Equal(1205, Assert.IsType<MySqlException>(updateFailure.InnerException).Number);
Assert.Empty(waiting.ChangeTracker.Entries());
await waiting.SaveChangesAsync();
}
finally { await transaction.RollbackAsync(); }
await using (var verify = Context(database))
{
var saved = await verify.JobApplications.SingleAsync();
Assert.Equal("Approved title", saved.JobTitle);
Assert.Equal("Approved notes", saved.Notes);
Assert.Equal("Prospect", saved.Status);
Assert.Equal(1, saved.Version);
Assert.Equal("Approved title", (await verify.Jobs.SingleAsync()).JobTitle);
Assert.Empty(await verify.JobEvents.ToListAsync());
}
Assert.IsType<NoContentResult>(await Controller(waiting).Update(id,
Request(companyId, "Reviewed recovery", "Approved notes", 1), default));
await using var recovered = Context(database);
Assert.Equal("Reviewed recovery", (await recovered.JobApplications.SingleAsync()).JobTitle);
Assert.Equal(2, (await recovered.JobApplications.SingleAsync()).Version);
}
}