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

245 lines
13 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.False(job.HasCoverLetter);
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.False(job.HasCoverLetter);
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);
Assert.False(job.HasCoverLetter);
var saved = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, " approved AI body ", CoverLetterSources.Ai, "improve", job.Version, default);
Assert.Equal(4, saved!.CurrentVersion);
Assert.True(job.HasCoverLetter);
var restored = await Assets(db).RestoreCoverLetterAsync(Owner, job.Id, 3, job.Version, default);
Assert.Equal("draft body", restored!.Text);
Assert.Equal(5, restored.CurrentVersion);
Assert.True(job.HasCoverLetter);
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);
}
[Theory]
[InlineData("generic", false)]
[InlineData("generic", true)]
[InlineData("draft", false)]
[InlineData("draft", true)]
public async Task Generic_and_draft_letter_changes_preserve_existing_attachment_flag(string writer, bool hasCoverLetter)
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db);
job.HasCoverLetter = hasCoverLetter;
await db.SaveChangesAsync();
await Write(db, job, writer, "Intended body");
Assert.Equal(hasCoverLetter, job.HasCoverLetter);
if (writer == "generic") Assert.IsType<NoContentResult>(await Update(db, job, null));
else await Write(db, job, writer, "");
Assert.Null(job.CoverLetterText);
Assert.Equal(hasCoverLetter, job.HasCoverLetter);
Assert.Equal(new[] { "Intended body", "" }, await db.CoverLetterVersions.OrderBy(value => value.Version).Select(value => value.Text).ToArrayAsync());
}
[Fact]
public async Task Dedicated_letter_no_op_keeps_flag_but_changed_nonempty_body_sets_it_and_clear_preserves_it()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db, "Existing body");
var service = Assets(db);
var revision = job.Version;
var unchanged = await service.SaveCoverLetterAsync(Owner, job.Id, " Existing body ", CoverLetterSources.Manual, null, revision, default);
Assert.False(job.HasCoverLetter);
Assert.Equal(revision + 1, unchanged!.Revision);
Assert.Empty(await db.CoverLetterVersions.ToListAsync());
await service.SaveCoverLetterAsync(Owner, job.Id, "Changed body", CoverLetterSources.Manual, null, job.Version, default);
Assert.True(job.HasCoverLetter);
var cleared = await service.SaveCoverLetterAsync(Owner, job.Id, null, CoverLetterSources.Manual, null, job.Version, default);
Assert.Null(job.CoverLetterText);
Assert.True(job.HasCoverLetter);
Assert.Equal(3, cleared!.CurrentVersion);
}
[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);
}
}