feat(cv): redesign document editing with revision-safe letters and writing tools
This commit is contained in:
@@ -56,6 +56,18 @@ public sealed class AccountDataExportTests
|
||||
var otherApplication = new JobApplication { OwnerUserId = other.Id, CompanyId = company.Id, JobTitle = "OTHER_TENANT_PRIVATE" };
|
||||
db.JobApplications.AddRange(application, otherApplication);
|
||||
await db.SaveChangesAsync();
|
||||
application.CoverLetterDocumentSettingsJson = "{\"senderName\":\"live-letter-design\"}";
|
||||
db.CoverLetterVersions.Add(new CoverLetterVersion
|
||||
{
|
||||
OwnerUserId = owner.Id, JobApplicationId = application.Id, Version = 1, Text = "historical body",
|
||||
DocumentSettingsJson = "{\"senderName\":\"history-letter-design\"}",
|
||||
});
|
||||
db.SubmittedApplicationPackages.Add(new SubmittedApplicationPackage
|
||||
{
|
||||
OwnerUserId = owner.Id, JobApplicationId = application.Id, Version = 1, CoverLetterText = "frozen body",
|
||||
CoverLetterDocumentSettingsJson = "{\"senderName\":\"frozen-letter-design\"}",
|
||||
RenderedCoverLetterHtml = "<p>frozen-letter-render</p>",
|
||||
});
|
||||
|
||||
var attachmentPath = Path.Combine(paths.AttachmentsRoot, application.Id.ToString(), "evidence.txt");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
|
||||
@@ -109,6 +121,10 @@ public sealed class AccountDataExportTests
|
||||
Assert.Contains("Synthetic Owner", readable);
|
||||
Assert.Contains("Readable application note", readable);
|
||||
Assert.Contains("export-proof", readable);
|
||||
Assert.Contains("live-letter-design", readable);
|
||||
Assert.Contains("history-letter-design", readable);
|
||||
Assert.Contains("frozen-letter-design", readable);
|
||||
Assert.Contains("frozen-letter-render", readable);
|
||||
Assert.DoesNotContain("OTHER_TENANT_PRIVATE", readable);
|
||||
Assert.DoesNotContain("PASSWORD_HASH_MUST_NOT_EXPORT", readable);
|
||||
Assert.DoesNotContain("SECURITY_STAMP_MUST_NOT_EXPORT", readable);
|
||||
|
||||
@@ -66,6 +66,43 @@ public sealed class AiWorkspaceTests
|
||||
|
||||
private static AiGenerateRequest Req(string module, string? mode = null) => new(module, mode, null);
|
||||
|
||||
private static async Task SeedLinkedProfileAsync(JobTrackerContext db, int jobId, StructuredCvProfile? profile = null, CvVariantSettings? settings = null, string profileOwner = "user-1")
|
||||
{
|
||||
db.CareerProfiles.Add(new CareerProfile { OwnerUserId = profileOwner, ProfileJson = StructuredCvProfileJson.Serialize(profile ?? new StructuredCvProfile { Skills = { "C#" } }) });
|
||||
db.CvVariants.Add(new CvVariant {
|
||||
OwnerUserId = "user-1", JobApplicationId = jobId, Name = "Source CV", PublicSlug = Guid.NewGuid().ToString("N"),
|
||||
SettingsJson = CvVariantSettingsJson.Serialize(settings ?? new()), Version = 1,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("generate", "no-linked-cv")]
|
||||
[InlineData("improve", "no-linked-cv")]
|
||||
[InlineData("grammar", "identity-only")]
|
||||
[InlineData("shorten", "hidden-facts")]
|
||||
[InlineData("generate", "foreign-profile")]
|
||||
public async Task Letter_requires_owned_visible_career_facts_before_inference_even_with_draft_and_advert(string action, string source)
|
||||
{
|
||||
var (db, svc, ai) = New("user-1");
|
||||
await using var _ = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
if (source != "no-linked-cv")
|
||||
{
|
||||
var profile = new StructuredCvProfile { Contact = new StructuredCvContact { FullName = "Ada Example", Headline = "Engineer" } };
|
||||
var settings = new CvVariantSettings();
|
||||
if (source == "hidden-facts") { profile.Skills.Add("C#"); settings.Sections.Add(new() { Key = "skills", Hidden = true }); }
|
||||
if (source == "foreign-profile") profile.Skills.Add("Foreign profile skill");
|
||||
await SeedLinkedProfileAsync(db, jobId, profile, settings, source == "foreign-profile" ? "user-2" : "user-1");
|
||||
}
|
||||
var error = await Assert.ThrowsAsync<ArgumentException>(() => svc.GenerateAsync("user-1", jobId,
|
||||
"Unverified legacy fallback claiming 99 years experience", "Ada", new("cover-letter", "professional", "Invent impressive results", "en", "An existing unverified letter draft", action), "p", default));
|
||||
Assert.Equal("cover_letter_source_required", error.Message);
|
||||
Assert.Equal(0, ai.Calls);
|
||||
Assert.Empty(await db.AiInteractions.IgnoreQueryFilters().ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_stores_an_interaction_and_returns_it()
|
||||
{
|
||||
@@ -107,6 +144,7 @@ public sealed class AiWorkspaceTests
|
||||
var (db, svc, _) = New("user-1");
|
||||
await using var _ = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
await SeedLinkedProfileAsync(db, jobId);
|
||||
|
||||
var res = await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("cover-letter", "banana"), "p", default);
|
||||
|
||||
@@ -209,6 +247,7 @@ public sealed class AiWorkspaceTests
|
||||
await using var _ = db;
|
||||
ai.Next = " ";
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
await SeedLinkedProfileAsync(db, jobId);
|
||||
await Assert.ThrowsAsync<AiUnavailableException>(() => svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("cover-letter"), "p", default));
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,13 @@ public sealed class BackupControllerTests
|
||||
var otherJob = new JobApplication { JobTitle = "Other tenant role", OwnerUserId = "user-2" };
|
||||
db.JobApplications.AddRange(job, otherJob);
|
||||
await db.SaveChangesAsync();
|
||||
db.CoverLetterVersions.AddRange(
|
||||
new CoverLetterVersion { OwnerUserId = "user-1", JobApplicationId = job.Id, Version = 1, Text = "Original letter", Source = CoverLetterSources.Manual },
|
||||
new CoverLetterVersion { OwnerUserId = "user-1", JobApplicationId = job.Id, Version = 2, Text = "Approved letter", Source = CoverLetterSources.Ai, AiAction = "improve", DocumentSettingsJson = "{\"senderName\":\"Historical sender\"}" },
|
||||
new CoverLetterVersion { OwnerUserId = "user-2", JobApplicationId = otherJob.Id, Version = 1, Text = "Other tenant private letter" });
|
||||
db.SubmittedApplicationPackages.AddRange(
|
||||
new SubmittedApplicationPackage { OwnerUserId = "user-1", JobApplicationId = job.Id, Version = 1, CoverLetterText = "Frozen letter", RenderedCoverLetterHtml = "<p>Exact frozen HTML</p>", CoverLetterDocumentSettingsJson = "{\"subject\":\"Frozen subject\"}" },
|
||||
new SubmittedApplicationPackage { OwnerUserId = "user-2", JobApplicationId = otherJob.Id, Version = 1, RenderedCoverLetterHtml = "Other tenant private frozen HTML" });
|
||||
db.EmailSendAttempts.Add(new EmailSendAttempt
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -95,6 +102,17 @@ public sealed class BackupControllerTests
|
||||
var protectedText = System.Text.Encoding.UTF8.GetString(file.FileContents);
|
||||
var json = Convert.FromBase64String(provider.CreateProtector("JobTrackerApi.Backup.v1").Unprotect(protectedText));
|
||||
using var document = System.Text.Json.JsonDocument.Parse(json);
|
||||
var letters = document.RootElement.GetProperty("Data").GetProperty("CoverLetterVersions").EnumerateArray().ToArray();
|
||||
Assert.Equal(2, letters.Length);
|
||||
Assert.Equal("Original letter", letters[0].GetProperty("Text").GetString());
|
||||
Assert.Equal(2, letters[1].GetProperty("Version").GetInt32());
|
||||
Assert.Equal("Approved letter", letters[1].GetProperty("Text").GetString());
|
||||
Assert.Equal(CoverLetterSources.Ai, letters[1].GetProperty("Source").GetString());
|
||||
Assert.Equal("improve", letters[1].GetProperty("AiAction").GetString());
|
||||
Assert.Equal("{\"senderName\":\"Historical sender\"}", letters[1].GetProperty("DocumentSettingsJson").GetString());
|
||||
var frozen = Assert.Single(document.RootElement.GetProperty("Data").GetProperty("SubmittedApplicationPackages").EnumerateArray());
|
||||
Assert.Equal("<p>Exact frozen HTML</p>", frozen.GetProperty("RenderedCoverLetterHtml").GetString());
|
||||
Assert.Equal("{\"subject\":\"Frozen subject\"}", frozen.GetProperty("CoverLetterDocumentSettingsJson").GetString());
|
||||
var attempt = Assert.Single(document.RootElement.GetProperty("Data").GetProperty("EmailSendAttempts").EnumerateArray());
|
||||
Assert.Equal("gmail", attempt.GetProperty("Provider").GetString());
|
||||
Assert.Equal("sent", attempt.GetProperty("Status").GetString());
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CoverLetterDocumentContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void Document_style_reuses_cv_allowlists_clamps_non_finite_values_and_escapes_all_metadata()
|
||||
{
|
||||
var settings = CoverLetterDocumentSettingsJson.Normalize(new()
|
||||
{
|
||||
ThemeId = "unknown", BodyFont = "Arial; background:url(https://invalid.test)", AccentColor = "#fff;bad",
|
||||
BaseFontSizePt = 100, LineHeight = double.PositiveInfinity, PageMarginMm = -10, PageSize = "<style>", SourceCvId = -1,
|
||||
});
|
||||
Assert.Equal("modern", settings.ThemeId);
|
||||
Assert.Equal("#2563eb", settings.AccentColor);
|
||||
Assert.Equal("'Segoe UI', Roboto, Arial, sans-serif", settings.BodyFont);
|
||||
Assert.Equal(13, settings.BaseFontSizePt);
|
||||
Assert.Equal(1.42, settings.LineHeight);
|
||||
Assert.Equal(8, settings.PageMarginMm);
|
||||
Assert.Equal("a4", settings.PageSize);
|
||||
Assert.Null(settings.SourceCvId);
|
||||
var html = new CoverLetterDocumentRenderer().Render("**bold**\n- bullet\n*italic*\n[x](javascript:alert(1))", new()
|
||||
{
|
||||
SenderTitle = "<iframe src=x>", SenderEmail = "<img src=x>", SenderPhone = "<script>x</script>",
|
||||
Recipient = "<object data=x>", Subject = "</title><script>x</script>", Date = "<svg onload=x>", PageSize = "letter",
|
||||
}).Html;
|
||||
Assert.Contains("<strong>bold</strong>", html);
|
||||
Assert.Contains("<li>bullet</li>", html);
|
||||
Assert.Contains("<em>italic</em>", html);
|
||||
Assert.Contains("@page{size:215.9mm 279.4mm", html);
|
||||
Assert.DoesNotContain("<script>", html);
|
||||
Assert.DoesNotContain("<img src", html);
|
||||
Assert.DoesNotContain("<iframe", html);
|
||||
Assert.DoesNotContain("href=\"javascript:", html);
|
||||
Assert.DoesNotContain("<svg", html);
|
||||
Assert.Contains("<object", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renderer_escapes_untrusted_content_and_exposes_shared_physical_page_contract()
|
||||
{
|
||||
var result = new CoverLetterDocumentRenderer().Render("<script>alert(1)</script>", new()
|
||||
{ SenderName = "<img src=x onerror=alert(1)>", AccentColor = "red; background:url(https://invalid.test)", PageMarginMm = 16 });
|
||||
Assert.DoesNotContain("<script>alert", result.Html);
|
||||
Assert.DoesNotContain("<img src=x", result.Html);
|
||||
Assert.DoesNotContain("https://invalid.test", result.Html);
|
||||
Assert.Contains("class=\"page letter-paper\"", result.Html);
|
||||
Assert.Contains("--cv-page-margin:16mm", result.Html);
|
||||
Assert.Contains("@page{size:210mm 297mm", result.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Letter_design_and_frozen_render_are_additive_nullable_persistence_fields()
|
||||
{
|
||||
Assert.NotNull(typeof(JobApplication).GetProperty("CoverLetterDocumentSettingsJson"));
|
||||
Assert.NotNull(typeof(CoverLetterVersion).GetProperty("DocumentSettingsJson"));
|
||||
Assert.NotNull(typeof(SubmittedApplicationPackage).GetProperty("CoverLetterDocumentSettingsJson"));
|
||||
Assert.NotNull(typeof(SubmittedApplicationPackage).GetProperty("RenderedCoverLetterHtml"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
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 partial class CoverLetterWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Document_settings_only_saves_advance_shared_revision_preserve_omission_and_restore_old_defaults()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
var job = await Seed(db, "same body");
|
||||
var service = Assets(db);
|
||||
var saved = await service.SaveCoverLetterAsync(Owner, job.Id, "same body", CoverLetterSources.Manual, null, job.Version, default,
|
||||
new() { SenderName = "Reviewed sender", ThemeId = "minimal", AccentColor = "#123456", SourceCvId = 42 });
|
||||
Assert.Equal(2, saved!.Revision);
|
||||
Assert.Equal(2, saved.CurrentVersion);
|
||||
Assert.Equal("Reviewed sender", saved.DocumentSettings!.SenderName);
|
||||
Assert.Equal("same body", saved.Text);
|
||||
var baseline = await db.CoverLetterVersions.SingleAsync(x => x.Version == 1);
|
||||
Assert.Null(baseline.DocumentSettingsJson);
|
||||
Assert.Equal(CoverLetterSources.Legacy, baseline.Source);
|
||||
var settingsJson = job.CoverLetterDocumentSettingsJson;
|
||||
await Update(db, job, "generic body");
|
||||
await Controller(db).SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, "notes", null, ExpectedVersion: job.Version), default);
|
||||
await service.SaveCoverLetterAsync(Owner, job.Id, "dedicated body", CoverLetterSources.Manual, null, job.Version, default);
|
||||
Assert.Equal(settingsJson, job.CoverLetterDocumentSettingsJson);
|
||||
var noOpRevision = job.Version;
|
||||
var noOp = await service.SaveCoverLetterAsync(Owner, job.Id, "dedicated body", CoverLetterSources.Manual, null, job.Version, default,
|
||||
CoverLetterDocumentSettingsJson.Deserialize(settingsJson));
|
||||
Assert.Equal(noOpRevision + 1, noOp!.Revision);
|
||||
Assert.Equal(4, noOp.CurrentVersion);
|
||||
var restored = await service.RestoreCoverLetterAsync(Owner, job.Id, 1, job.Version, default);
|
||||
Assert.Equal("same body", restored!.Text);
|
||||
Assert.Equal("modern", restored.DocumentSettings!.ThemeId);
|
||||
Assert.Equal("", restored.DocumentSettings.SenderName);
|
||||
Assert.Null(restored.DocumentSettings.SourceCvId);
|
||||
Assert.Equal(5, restored.CurrentVersion);
|
||||
Assert.Equal(settingsJson, (await db.CoverLetterVersions.SingleAsync(x => x.Version == 2)).DocumentSettingsJson);
|
||||
var restoredDesign = await service.RestoreCoverLetterAsync(Owner, job.Id, 2, job.Version, default);
|
||||
Assert.Equal("same body", restoredDesign!.Text);
|
||||
Assert.Equal("Reviewed sender", restoredDesign.DocumentSettings!.SenderName);
|
||||
Assert.Equal(6, restoredDesign.CurrentVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_is_read_only_and_copied_settings_never_follow_later_cv_changes()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
var job = await Seed(db);
|
||||
var cv = new CvVariant { OwnerUserId = Owner, Name = "Source CV", PublicSlug = Guid.NewGuid().ToString("N"), SettingsJson = CvVariantSettingsJson.Serialize(new() { AccentColor = "#123456" }) };
|
||||
db.CvVariants.Add(cv);
|
||||
await db.SaveChangesAsync();
|
||||
var settings = new CoverLetterDocumentSettings { SenderName = "Copied name", SourceCvId = cv.Id, AccentColor = "#123456" };
|
||||
var service = Assets(db);
|
||||
await service.SaveCoverLetterAsync(Owner, job.Id, "approved body", CoverLetterSources.Manual, null, job.Version, default, settings);
|
||||
var revision = job.Version;
|
||||
var before = job.CoverLetterDocumentSettingsJson;
|
||||
var preview = await service.PreviewCoverLetterAsync(Owner, job.Id, "unapproved preview **bold**", new() { Subject = "Preview only" }, default);
|
||||
Assert.Contains("unapproved preview <strong>bold</strong>", preview!.Html);
|
||||
cv.SettingsJson = CvVariantSettingsJson.Serialize(new() { AccentColor = "#abcdef" });
|
||||
await db.SaveChangesAsync();
|
||||
var render = await service.RenderSavedCoverLetterAsync(Owner, job.Id, revision, default);
|
||||
Assert.Contains("#123456", render!.Html);
|
||||
Assert.Contains("Copied name", render.Html);
|
||||
Assert.Contains("approved body", render.Html);
|
||||
Assert.DoesNotContain("unapproved preview", render.Html);
|
||||
Assert.Equal(before, job.CoverLetterDocumentSettingsJson);
|
||||
Assert.Equal(revision, job.Version);
|
||||
Assert.Single(await db.CoverLetterVersions.ToListAsync());
|
||||
Assert.Null(await service.PreviewCoverLetterAsync("foreign", job.Id, "stolen", settings, default));
|
||||
Assert.Null(await service.RenderSavedCoverLetterAsync("foreign", job.Id, revision, default));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(99)]
|
||||
public async Task Export_requires_current_positive_revision_before_allocating_pdf(int? expectedVersion)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
var job = await Seed(db, "saved body");
|
||||
var pdf = new Mock<ICvPdfExporter>(MockBehavior.Strict);
|
||||
var user = new ApplicationUser { Id = Owner };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var controller = new ApplicationAssetsController(users.Object, Assets(db), Mock.Of<ISubmittedApplicationPackageService>(), pdf.Object)
|
||||
{
|
||||
ControllerContext = new() { HttpContext = new DefaultHttpContext() },
|
||||
};
|
||||
Assert.IsType<ConflictObjectResult>(await controller.ExportCoverLetterPdf(job.Id, default, expectedVersion));
|
||||
pdf.VerifyNoOtherCalls();
|
||||
pdf.Setup(x => x.ExportAsync(Owner, It.Is<TailoredCvRenderResult>(r => r.Html.Contains("saved body")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CvPdfArtifact("cover-letter.pdf", "unused.pdf", [37, 80, 68, 70]));
|
||||
Assert.IsType<FileContentResult>(await controller.ExportCoverLetterPdf(job.Id, default, job.Version));
|
||||
pdf.Verify(x => x.ExportAsync(Owner, It.IsAny<TailoredCvRenderResult>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.IsType<NotFoundResult>(await controller.ExportCoverLetterPdf(int.MaxValue, default, job.Version));
|
||||
pdf.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_settings_save_and_unsupported_schema_cannot_change_approved_design()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
var job = await Seed(db);
|
||||
var service = Assets(db);
|
||||
await service.SaveCoverLetterAsync(Owner, job.Id, "approved", CoverLetterSources.Manual, null, 1, default, new() { Subject = "Approved subject" });
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => service.SaveCoverLetterAsync(Owner, job.Id, "stale", CoverLetterSources.Manual, null, 1, default, new() { Subject = "Stale" }));
|
||||
Assert.Equal("Approved subject", (await service.GetCoverLetterAsync(Owner, job.Id, default))!.DocumentSettings!.Subject);
|
||||
var user = new ApplicationUser { Id = Owner };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var controller = new ApplicationAssetsController(users.Object, service, Mock.Of<ISubmittedApplicationPackageService>());
|
||||
Assert.IsType<BadRequestObjectResult>((await controller.SaveCoverLetter(job.Id,
|
||||
new("bad", null, null, job.Version, new() { SchemaVersion = 2 }), default)).Result);
|
||||
Assert.IsType<BadRequestObjectResult>((await controller.PreviewCoverLetter(job.Id,
|
||||
new("bad", new() { SchemaVersion = 2 }), default)).Result);
|
||||
Assert.Single(await db.CoverLetterVersions.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submitted_letter_html_and_design_remain_exact_after_live_edits_and_old_packages_stay_null()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
var job = await Seed(db);
|
||||
var service = Assets(db);
|
||||
await service.SaveCoverLetterAsync(Owner, job.Id, "frozen body", CoverLetterSources.Manual, null, job.Version, default,
|
||||
new() { SenderName = "Frozen sender", Subject = "Frozen subject" });
|
||||
var old = new SubmittedApplicationPackage { OwnerUserId = Owner, JobApplicationId = job.Id, Version = 1, CoverLetterText = "old text-only" };
|
||||
db.Add(old);
|
||||
await db.SaveChangesAsync();
|
||||
var packages = new SubmittedApplicationPackageService(db, Mock.Of<ICvVariantService>(), Mock.Of<IAttachmentStorage>());
|
||||
var captured = await packages.CaptureAsync(Owner, job.Id, new("Profile name must not replace sender", null), default);
|
||||
var stored = await db.SubmittedApplicationPackages.AsNoTracking().SingleAsync(x => x.Id == captured!.Id);
|
||||
Assert.Contains("frozen body", stored.RenderedCoverLetterHtml);
|
||||
Assert.Contains("Frozen sender", stored.RenderedCoverLetterHtml);
|
||||
Assert.DoesNotContain("Profile name must not replace sender", stored.RenderedCoverLetterHtml);
|
||||
await service.SaveCoverLetterAsync(Owner, job.Id, "new body", CoverLetterSources.Manual, null, job.Version, default,
|
||||
new() { SenderName = "New sender", Subject = "New subject" });
|
||||
var detail = await packages.GetAsync(Owner, job.Id, captured!.Id, default);
|
||||
Assert.Equal(stored.RenderedCoverLetterHtml, detail!.RenderedCoverLetterHtml);
|
||||
Assert.Equal("Frozen sender", detail.CoverLetterDocumentSettings!.SenderName);
|
||||
Assert.Equal("frozen body", detail.CoverLetterText);
|
||||
var oldDetail = await packages.GetAsync(Owner, job.Id, old.Id, default);
|
||||
Assert.Equal("old text-only", oldDetail!.CoverLetterText);
|
||||
Assert.Null(oldDetail.RenderedCoverLetterHtml);
|
||||
Assert.Null(oldDetail.CoverLetterDocumentSettings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
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 partial class CoverLetterWriterTests
|
||||
{
|
||||
private const string Owner = "letter-owner";
|
||||
private static JobTrackerContext Context(SqliteConnection connection, string owner = 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 = 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)
|
||||
{
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
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 = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
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 = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
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 = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
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 = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
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 = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
var job = await Seed(db, "original");
|
||||
await db.Database.ExecuteSqlRawAsync("CREATE TRIGGER reject_letter BEFORE INSERT ON CoverLetterVersions BEGIN SELECT RAISE(ABORT, 'letter history failure'); END;");
|
||||
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 = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
var seed = await Seed(db);
|
||||
await db.Database.ExecuteSqlRawAsync("CREATE TRIGGER reject_initial_letter BEFORE INSERT ON CoverLetterVersions BEGIN SELECT RAISE(ABORT, 'initial history failure'); END;");
|
||||
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 = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,23 @@ public sealed class CvAiReadinessTests
|
||||
private static string Assessment(string proposal, string source, string verdict = "supported") => JsonSerializer.Serialize(new
|
||||
{ verdict, coverageComplete = true, claims = new[] { new { text = proposal, verdict, sourceQuotes = new[] { source } } } });
|
||||
|
||||
[Theory]
|
||||
[InlineData("improve")]
|
||||
[InlineData("suggest")]
|
||||
[InlineData("grammar")]
|
||||
[InlineData("shorten")]
|
||||
public async Task Contextual_actions_require_review_and_preserve_canonical_facts(string action)
|
||||
{
|
||||
await using var fixture = new Fixture(); await fixture.Open();
|
||||
var proposal = await fixture.Suggestions.GenerateAsync("owner", fixture.Cv.Id, 1,
|
||||
"section:summary", action, Source, null, "Use supplied facts only.", default);
|
||||
await fixture.AssertUnchanged();
|
||||
var accepted = await fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id, proposal.ProposalToken, null, default);
|
||||
Assert.Equal(2, accepted.Version);
|
||||
Assert.Equal(Source, (await fixture.Career.LoadStructuredForOwnerAsync("owner", default)).Summary.Single());
|
||||
Assert.Equal("ai:" + action, (await fixture.Db.CvVariantVersions.OrderBy(value => value.Version).LastAsync()).Source);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Paraphrase)]
|
||||
[InlineData("Maintained internal web applications.")]
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
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 CvExportRevisionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1, "owner", 409)]
|
||||
[InlineData(0, "owner", 409)]
|
||||
[InlineData(1, "other", 404)]
|
||||
[InlineData(2, "other", 404)]
|
||||
[InlineData(2, "owner", 200)]
|
||||
[InlineData(null, "owner", 200)]
|
||||
public async Task Export_checks_the_owned_render_revision_before_allocating_a_pdf(int? expectedVersion, string caller, int status)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var current = new Mock<ICurrentUserService>();
|
||||
current.SetupGet(x => x.UserId).Returns("owner");
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options;
|
||||
await using var seed = new JobTrackerContext(options, current.Object);
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
var variants = new CvVariantService(seed, new CareerProfileService(seed), new ThemedCvRenderer());
|
||||
var cv = await variants.CreateAsync("owner", "CV", null, new CvVariantSettings { Headline = "Old headline" }, default);
|
||||
await variants.SaveAsync("owner", cv.Id, null, new CvVariantSettings { Headline = "Saved headline" }, "manual", 1, default);
|
||||
|
||||
current.SetupGet(x => x.UserId).Returns(caller);
|
||||
await using var db = new JobTrackerContext(options, current.Object);
|
||||
var renderer = new Mock<IThemedCvRenderer>();
|
||||
renderer.Setup(x => x.Render(It.IsAny<CvRenderModel>(), It.IsAny<CvTheme>(), It.IsAny<CvVariantSettings>()))
|
||||
.Returns((CvRenderModel model, CvTheme theme, CvVariantSettings settings) => new ThemedCvRenderer().Render(model, theme, settings));
|
||||
var service = new CvVariantService(db, new CareerProfileService(db), renderer.Object);
|
||||
var pdf = new Mock<ICvPdfExporter>();
|
||||
pdf.Setup(x => x.ExportAsync(caller, It.IsAny<TailoredCvRenderResult>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CvPdfArtifact("CV.pdf", "unused", [1, 2, 3]));
|
||||
var user = new ApplicationUser { Id = caller };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var controller = new CvVariantController(users.Object, service, pdf.Object, Mock.Of<ISummarizerService>())
|
||||
{ ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
|
||||
|
||||
var result = await controller.ExportPdf(cv.Id, default, expectedVersion);
|
||||
|
||||
if (status == 200)
|
||||
{
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, Assert.IsType<FileContentResult>(result).FileContents);
|
||||
pdf.Verify(x => x.ExportAsync(caller, It.Is<TailoredCvRenderResult>(r => r.Html.Contains("Saved headline") && !r.Html.Contains("Old headline")), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (status == 409)
|
||||
{
|
||||
var conflict = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(409, conflict.StatusCode);
|
||||
Assert.Equal("cv_revision_conflict", JsonSerializer.SerializeToElement(conflict.Value).GetProperty("code").GetString());
|
||||
}
|
||||
else Assert.IsType<NotFoundResult>(result);
|
||||
pdf.VerifyNoOtherCalls();
|
||||
renderer.VerifyNoOtherCalls();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,39 @@ public sealed class MigrationChainTests
|
||||
{
|
||||
private const string SnapshotMigration = "20260711181039_SyncModelSnapshot";
|
||||
|
||||
[Fact]
|
||||
public async Task Letter_document_upgrade_adds_nullable_fields_without_rewriting_old_letters_or_frozen_bytes()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
var migrator = db.GetService<IMigrator>();
|
||||
await migrator.MigrateAsync("20261023090000_AddJobRevision");
|
||||
await ExecuteAsync(connection, """
|
||||
INSERT INTO Companies (Name) VALUES ('Letter migration fixture');
|
||||
INSERT INTO JobApplications
|
||||
(CompanyId, JobTitle, Status, ResponseReceived, OwnerUserId, SavedAt, CoverLetterText,
|
||||
HasResume, HasCoverLetter, HasPortfolio, HasOtherAttachment, IsDeleted, Version)
|
||||
VALUES (1, 'Durable role', 'Saved', 0, 'rd21-owner', '2026-10-25', ' old body ', 0, 0, 0, 0, 0, 7);
|
||||
INSERT INTO CoverLetterVersions (OwnerUserId, JobApplicationId, Version, Text, Source, CreatedAtUtc)
|
||||
VALUES ('rd21-owner', 1, 3, ' historical letter ', 'ai', '2026-10-25');
|
||||
INSERT INTO SubmittedApplicationPackages (OwnerUserId, JobApplicationId, Version, CoverLetterText, RenderedCvHtml, ApplicationMaterialJson, CreatedAtUtc)
|
||||
VALUES ('rd21-owner', 1, 1, ' frozen old letter ', '<p>old exact CV</p>', '{}', '2026-10-25');
|
||||
""");
|
||||
await migrator.MigrateAsync();
|
||||
Assert.Equal(" old body ", await ScalarAsync<string>(connection, "SELECT CoverLetterText FROM JobApplications WHERE Id=1;"));
|
||||
Assert.Equal(7L, await ScalarAsync<long>(connection, "SELECT Version FROM JobApplications WHERE Id=1;"));
|
||||
Assert.Equal(" historical letter ", await ScalarAsync<string>(connection, "SELECT Text FROM CoverLetterVersions WHERE Version=3;"));
|
||||
Assert.Equal("<p>old exact CV</p>", await ScalarAsync<string>(connection, "SELECT RenderedCvHtml FROM SubmittedApplicationPackages WHERE Id=1;"));
|
||||
Assert.Equal(1L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM JobApplications WHERE CoverLetterDocumentSettingsJson IS NULL;"));
|
||||
Assert.Equal(1L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM CoverLetterVersions WHERE DocumentSettingsJson IS NULL;"));
|
||||
Assert.Equal(1L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM SubmittedApplicationPackages WHERE RenderedCoverLetterHtml IS NULL AND CoverLetterDocumentSettingsJson IS NULL;"));
|
||||
await migrator.MigrateAsync("20261023090000_AddJobRevision");
|
||||
Assert.Equal(" frozen old letter ", await ScalarAsync<string>(connection, "SELECT CoverLetterText FROM SubmittedApplicationPackages WHERE Id=1;"));
|
||||
await migrator.MigrateAsync();
|
||||
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rd21_revision_upgrade_and_rollback_preserve_existing_job_content()
|
||||
{
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed class Rd21JobPersistenceTests
|
||||
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);
|
||||
var pending = controller.Update(id, Request(companyId, "Delayed title", null, 1) with { Status = "Offer", CoverLetterText = "Uncommitted letter" }, default);
|
||||
await reached.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
@@ -89,6 +89,8 @@ public sealed class Rd21JobPersistenceTests
|
||||
Assert.Equal("Prospect", saved.Status);
|
||||
Assert.Equal("Original job title", (await verify.Jobs.SingleAsync()).JobTitle);
|
||||
Assert.Empty(await verify.JobEvents.ToListAsync());
|
||||
Assert.Empty(await verify.CoverLetterVersions.ToListAsync());
|
||||
Assert.Null(saved.CoverLetterText);
|
||||
Assert.Empty(delayed.ChangeTracker.Entries());
|
||||
|
||||
// A deliberate reload/review uses the winner's content and new revision, never a blind retry.
|
||||
@@ -248,7 +250,7 @@ public sealed class Rd21JobPersistenceTests
|
||||
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());
|
||||
if (coverLetter) Assert.Equal(2, await verify.CoverLetterVersions.CountAsync()); // Legacy baseline plus approved replacement.
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
Reference in New Issue
Block a user