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(), 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(), Mock.Of()); private static T Request(object value) => JsonSerializer.Deserialize(JsonSerializer.Serialize(value), new JsonSerializerOptions(JsonSerializerDefaults.Web))!; private static async Task 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 Update(JobTrackerContext db, JobApplication job, string? text, int? revision = null) => Controller(db).Update(job.Id, Request(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(new { jobTitle = "Created", companyId = seed.CompanyId, coverLetterText = " initial body " }), default); Assert.IsType(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(await Update(db, job, " generic body ")); Assert.Equal(" generic body ", job.CoverLetterText); Assert.False(job.HasCoverLetter); Assert.IsType(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(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(() => 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(await Update(db, job, text)); else if (writer == "draft") Assert.IsType(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(() => 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(() => Controller(db).Create(Request(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(await Update(other, job, "stolen")); Assert.IsType(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); } }