199 lines
11 KiB
C#
199 lines
11 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.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.MariaDb.Tests;
|
|
|
|
public sealed partial class CoverLetterWriterTests
|
|
{
|
|
private const string Owner = "letter-owner";
|
|
private static JobTrackerContext Context(MariaDbDatabase connection, string owner = Owner) => connection.Context(owner);
|
|
|
|
private static JobApplicationsController Controller(JobTrackerContext db) => new(db,
|
|
Mock.Of<ISummarizerService>(), TestHostFactory.CreateUserManager(new ApplicationUser { Id = Owner }).Object)
|
|
{
|
|
ControllerContext = new() { HttpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, Owner)], "test")),
|
|
} },
|
|
};
|
|
|
|
private static ApplicationAssetsService Assets(JobTrackerContext db) => new(db, Mock.Of<ICvVariantService>(), Mock.Of<IApplicationIntelligenceService>());
|
|
private static T Request<T>(object value) => JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(value), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
|
|
|
private static async Task<JobApplication> Seed(JobTrackerContext db, string? text = null)
|
|
{
|
|
var job = new JobApplication { OwnerUserId = Owner, Company = new Company { OwnerUserId = Owner, Name = "Letters" }, JobTitle = "Role", Status = "Prospect", CoverLetterText = text };
|
|
db.Add(job);
|
|
await db.SaveChangesAsync();
|
|
return job;
|
|
}
|
|
|
|
private static Task<IActionResult> Update(JobTrackerContext db, JobApplication job, string? text, int? revision = null) =>
|
|
Controller(db).Update(job.Id, Request<UpdateJobApplicationRequest>(new
|
|
{
|
|
jobTitle = job.JobTitle, companyId = job.CompanyId, status = job.Status, coverLetterText = text,
|
|
expectedVersion = revision ?? job.Version,
|
|
}), default);
|
|
|
|
[Fact]
|
|
public async Task Create_generic_draft_and_dedicated_writes_share_history_and_restore()
|
|
{
|
|
await using var connection = await MariaDbDatabase.CreateAsync();
|
|
await using var db = Context(connection);
|
|
var seed = await Seed(db);
|
|
var created = await Controller(db).Create(Request<CreateJobApplicationRequest>(new { jobTitle = "Created", companyId = seed.CompanyId, coverLetterText = " initial body " }), default);
|
|
Assert.IsType<CreatedAtActionResult>(created.Result);
|
|
var job = await db.JobApplications.SingleAsync(x => x.JobTitle == "Created");
|
|
Assert.Equal(" initial body ", (await db.CoverLetterVersions.SingleAsync(x => x.JobApplicationId == job.Id)).Text);
|
|
Assert.IsType<NoContentResult>(await Update(db, job, " generic body "));
|
|
Assert.Equal(" generic body ", job.CoverLetterText);
|
|
Assert.IsType<NoContentResult>(await Controller(db).SaveApplicationDrafts(job.Id,
|
|
new SaveApplicationDraftsRequest(" draft body ", null, null, ExpectedVersion: job.Version), default));
|
|
Assert.Equal("draft body", job.CoverLetterText);
|
|
var saved = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, " approved AI body ", CoverLetterSources.Ai, "improve", job.Version, default);
|
|
Assert.Equal(4, saved!.CurrentVersion);
|
|
var restored = await Assets(db).RestoreCoverLetterAsync(Owner, job.Id, 3, job.Version, default);
|
|
Assert.Equal("draft body", restored!.Text);
|
|
Assert.Equal(5, restored.CurrentVersion);
|
|
var history = await db.CoverLetterVersions.Where(x => x.JobApplicationId == job.Id).OrderBy(x => x.Version).ToListAsync();
|
|
Assert.Equal(new[] { " initial body ", " generic body ", "draft body", "approved AI body", "draft body" }, history.Select(x => x.Text));
|
|
Assert.Equal(CoverLetterSources.Ai, history[3].Source);
|
|
Assert.Equal("improve", history[3].AiAction);
|
|
Assert.Equal(CoverLetterSources.Restore, history[4].Source);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Draft_null_preserves_generic_null_clears_and_no_ops_acknowledge_without_duplicate_history()
|
|
{
|
|
await using var connection = await MariaDbDatabase.CreateAsync();
|
|
await using var db = Context(connection);
|
|
var job = await Seed(db);
|
|
await Update(db, job, "first");
|
|
var firstRevision = job.Version;
|
|
await Controller(db).SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, null, null, ExpectedVersion: job.Version), default);
|
|
Assert.Equal("first", job.CoverLetterText);
|
|
Assert.Equal(firstRevision + 1, job.Version);
|
|
await Update(db, job, "first");
|
|
Assert.Single(await db.CoverLetterVersions.ToListAsync());
|
|
await Update(db, job, null);
|
|
Assert.Null(job.CoverLetterText);
|
|
Assert.Equal(new[] { "first", "" }, await db.CoverLetterVersions.OrderBy(x => x.Version).Select(x => x.Text).ToArrayAsync());
|
|
var revision = job.Version;
|
|
var saved = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, " ", CoverLetterSources.Manual, null, revision, default);
|
|
Assert.Equal(revision + 1, saved!.Revision);
|
|
Assert.Equal(2, saved.CurrentVersion);
|
|
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => Assets(db).SaveCoverLetterAsync(Owner, job.Id, "stale", CoverLetterSources.Manual, null, revision, default));
|
|
Assert.Equal(2, await db.CoverLetterVersions.CountAsync());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("generic")]
|
|
[InlineData("draft")]
|
|
[InlineData("dedicated")]
|
|
public async Task Legacy_text_gets_a_recoverable_baseline_before_first_replacement(string writer)
|
|
{
|
|
await using var connection = await MariaDbDatabase.CreateAsync();
|
|
await using var db = Context(connection);
|
|
var job = await Seed(db, " legacy original ");
|
|
await Write(db, job, writer, "replacement");
|
|
Assert.Equal(new[] { " legacy original ", "replacement" }, await db.CoverLetterVersions.OrderBy(x => x.Version).Select(x => x.Text).ToArrayAsync());
|
|
var restored = await Assets(db).RestoreCoverLetterAsync(Owner, job.Id, 1, job.Version, default);
|
|
Assert.Equal("legacy original", restored!.Text);
|
|
Assert.Equal(3, restored.CurrentVersion);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public async Task Legacy_change_after_existing_history_is_preserved_before_replacement(bool cleared)
|
|
{
|
|
await using var connection = await MariaDbDatabase.CreateAsync();
|
|
await using var db = Context(connection);
|
|
var job = await Seed(db, cleared ? null : "legacy replacement");
|
|
db.CoverLetterVersions.Add(new CoverLetterVersion
|
|
{
|
|
OwnerUserId = Owner, JobApplicationId = job.Id, Version = 4, Text = "older saved version",
|
|
Source = CoverLetterSources.Ai, AiAction = "generate",
|
|
});
|
|
await db.SaveChangesAsync();
|
|
await Update(db, job, "new approved version");
|
|
var versions = await db.CoverLetterVersions.OrderBy(x => x.Version).ToListAsync();
|
|
Assert.Equal(new[] { 4, 5, 6 }, versions.Select(x => x.Version));
|
|
Assert.Equal(new[] { "older saved version", cleared ? "" : "legacy replacement", "new approved version" }, versions.Select(x => x.Text));
|
|
Assert.Equal(CoverLetterSources.Ai, versions[0].Source);
|
|
Assert.Equal("generate", versions[0].AiAction);
|
|
}
|
|
|
|
private static async Task Write(JobTrackerContext db, JobApplication job, string writer, string text)
|
|
{
|
|
if (writer == "generic") Assert.IsType<NoContentResult>(await Update(db, job, text));
|
|
else if (writer == "draft") Assert.IsType<NoContentResult>(await Controller(db).SaveApplicationDrafts(job.Id,
|
|
new SaveApplicationDraftsRequest(text, "new notes", null, ExpectedVersion: job.Version), default));
|
|
else await Assets(db).SaveCoverLetterAsync(Owner, job.Id, text, CoverLetterSources.Manual, null, job.Version, default);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("generic")]
|
|
[InlineData("draft")]
|
|
[InlineData("dedicated")]
|
|
public async Task History_failure_rolls_back_letter_parent_revision_and_other_fields(string writer)
|
|
{
|
|
await using var connection = await MariaDbDatabase.CreateAsync();
|
|
await using var db = Context(connection);
|
|
var job = await Seed(db, "original");
|
|
await db.Database.ExecuteSqlRawAsync("CREATE TRIGGER reject_letter BEFORE INSERT ON CoverLetterVersions FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'letter history failure';");
|
|
await Assert.ThrowsAsync<DbUpdateException>(() => Write(db, job, writer, "replacement"));
|
|
Assert.Empty(db.ChangeTracker.Entries());
|
|
await db.SaveChangesAsync();
|
|
await using var verify = Context(connection);
|
|
var actual = await verify.JobApplications.SingleAsync();
|
|
Assert.Equal("original", actual.CoverLetterText);
|
|
Assert.Null(actual.Notes);
|
|
Assert.Equal(1, actual.Version);
|
|
Assert.Empty(await verify.CoverLetterVersions.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_history_failure_cannot_leave_a_job_without_its_initial_snapshot()
|
|
{
|
|
await using var connection = await MariaDbDatabase.CreateAsync();
|
|
await using var db = Context(connection);
|
|
var seed = await Seed(db);
|
|
await db.Database.ExecuteSqlRawAsync("CREATE TRIGGER reject_initial_letter BEFORE INSERT ON CoverLetterVersions FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'initial history failure';");
|
|
await Assert.ThrowsAsync<DbUpdateException>(() => Controller(db).Create(Request<CreateJobApplicationRequest>(new
|
|
{
|
|
jobTitle = "Failed create", companyId = seed.CompanyId, coverLetterText = "initial letter",
|
|
}), default));
|
|
Assert.Empty(db.ChangeTracker.Entries());
|
|
await db.SaveChangesAsync();
|
|
await using var verify = Context(connection);
|
|
Assert.Single(await verify.JobApplications.ToListAsync());
|
|
Assert.Empty(await verify.CoverLetterVersions.ToListAsync());
|
|
Assert.Empty(await verify.JobEvents.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Other_tenant_cannot_write_or_read_letter_history_through_any_writer()
|
|
{
|
|
await using var connection = await MariaDbDatabase.CreateAsync();
|
|
await using var db = Context(connection);
|
|
var job = await Seed(db, "private");
|
|
await using var other = Context(connection, "other");
|
|
Assert.IsType<NotFoundResult>(await Update(other, job, "stolen"));
|
|
Assert.IsType<NotFoundResult>(await Controller(other).SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest("stolen", null, null, ExpectedVersion: 1), default));
|
|
Assert.Null(await Assets(other).SaveCoverLetterAsync("other", job.Id, "stolen", CoverLetterSources.Manual, null, 1, default));
|
|
Assert.Null(await Assets(other).GetCoverLetterAsync("other", job.Id, default));
|
|
Assert.Empty(await db.CoverLetterVersions.ToListAsync());
|
|
Assert.Equal("private", (await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText);
|
|
}
|
|
}
|