fix(cv): protect revisions and measure rendering readiness
This commit is contained in:
@@ -376,7 +376,7 @@ public sealed class CvBuilderTests
|
||||
var created = await svc.CreateAsync("user-1", "My CV", null, new CvVariantSettings { ThemeId = "modern" }, default);
|
||||
Assert.Equal(1, created.Version);
|
||||
|
||||
var saved = await svc.SaveAsync("user-1", created.Id, "My CV v2", new CvVariantSettings { ThemeId = "nordic" }, "autosave", default);
|
||||
var saved = await svc.SaveAsync("user-1", created.Id, "My CV v2", new CvVariantSettings { ThemeId = "nordic" }, "autosave", 1, default);
|
||||
Assert.Equal(2, saved!.Version);
|
||||
|
||||
var versions = await svc.ListVersionsAsync("user-1", created.Id, default);
|
||||
@@ -410,9 +410,9 @@ public sealed class CvBuilderTests
|
||||
var (db, svc) = NewService("user-1");
|
||||
await using var _ = db;
|
||||
var v = await svc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "modern" }, default);
|
||||
await svc.SaveAsync("user-1", v.Id, null, new CvVariantSettings { ThemeId = "creative" }, "autosave", default);
|
||||
await svc.SaveAsync("user-1", v.Id, null, new CvVariantSettings { ThemeId = "creative" }, "autosave", 1, default);
|
||||
|
||||
var restored = await svc.RestoreVersionAsync("user-1", v.Id, 1, default);
|
||||
var restored = await svc.RestoreVersionAsync("user-1", v.Id, 1, 2, default);
|
||||
Assert.Equal(3, restored!.Version);
|
||||
Assert.Equal("modern", CvVariantSettingsJson.Deserialize(restored.SettingsJson).ThemeId);
|
||||
}
|
||||
@@ -533,6 +533,8 @@ public sealed class CvBuilderTests
|
||||
Assert.Contains("counter-reset:cv-section", html);
|
||||
Assert.Contains("--cv-accent-color:#126b55", html);
|
||||
Assert.Contains("Consolas", html);
|
||||
// A lone quote here caused Chromium to consume all following layout and @page rules.
|
||||
Assert.Contains("content:\"\";", html);
|
||||
Assert.True(CvThemeCatalog.Resolve("code").AtsFriendly);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CvRevisionReadinessTests
|
||||
{
|
||||
private static JobTrackerContext Context(SqliteConnection connection, string owner = "revision-owner")
|
||||
{
|
||||
var principal = new Mock<ICurrentUserService>();
|
||||
principal.SetupGet(x => x.UserId).Returns(owner);
|
||||
return new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options, principal.Object);
|
||||
}
|
||||
|
||||
private static CvVariantService Service(JobTrackerContext db) => new(db, new CareerProfileService(db), new ThemedCvRenderer());
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Independent_stale_save_or_restore_cannot_replace_the_newer_revision(bool restore)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var first = Context(connection);
|
||||
await first.Database.EnsureCreatedAsync();
|
||||
var a = Service(first);
|
||||
var cv = await a.CreateAsync("revision-owner", "Revision fixture", null, new CvVariantSettings { Headline = "Original" }, default);
|
||||
await using var second = Context(connection);
|
||||
var b = Service(second);
|
||||
Assert.Equal(1, (await b.GetAsync("revision-owner", cv.Id, default))!.Version);
|
||||
|
||||
await a.SaveAsync("revision-owner", cv.Id, null, new CvVariantSettings { Headline = "Newer reviewed content" }, "manual", 1, default);
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => restore
|
||||
? b.RestoreVersionAsync("revision-owner", cv.Id, 1, 1, default)
|
||||
: b.SaveAsync("revision-owner", cv.Id, null, new CvVariantSettings { Headline = "Stale draft" }, "autosave", 1, default));
|
||||
|
||||
await using var verify = Context(connection);
|
||||
var saved = await verify.CvVariants.SingleAsync();
|
||||
Assert.Equal(2, saved.Version);
|
||||
Assert.Equal("Newer reviewed content", CvVariantSettingsJson.Deserialize(saved.SettingsJson).Headline);
|
||||
var history = await verify.CvVariantVersions.OrderBy(x => x.Version).ToListAsync();
|
||||
Assert.Equal(new[] { 1, 2 }, history.Select(x => x.Version));
|
||||
Assert.Equal("Original", CvVariantSettingsJson.Deserialize(history[0].SettingsJson).Headline);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fresh_request_with_a_stale_expected_version_is_rejected_before_appending_history()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var first = Context(connection);
|
||||
await first.Database.EnsureCreatedAsync();
|
||||
var service = Service(first);
|
||||
var cv = await service.CreateAsync("revision-owner", "CV", null, null, default);
|
||||
await service.SaveAsync("revision-owner", cv.Id, null, new CvVariantSettings { Headline = "Winner" }, "manual", 1, default);
|
||||
await using var fresh = Context(connection);
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => Service(fresh).SaveAsync("revision-owner", cv.Id, "Stale rename", new CvVariantSettings(), "autosave", 1, default));
|
||||
Assert.Equal(2, await fresh.CvVariantVersions.CountAsync());
|
||||
Assert.Equal("CV", (await fresh.CvVariants.SingleAsync()).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Settings_history_restore_and_override_reset_preserve_the_canonical_source_contract()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var career = new CareerProfileService(db);
|
||||
var profile = new StructuredCvProfile
|
||||
{
|
||||
Contact = new() { FullName = "Åse Fixture", Headline = "Canonical source" },
|
||||
Jobs = { new() { Id = "source-entry", Title = "Canonical role", Company = "Nordlys", Bullets = { "Canonical evidence" } } },
|
||||
Skills = { "C#", "Norsk" },
|
||||
};
|
||||
await career.SaveVersionAsync("revision-owner", profile, "manual", default);
|
||||
var service = Service(db);
|
||||
var lens = new CvVariantSettings
|
||||
{
|
||||
Headline = "CV-only headline", ThemeId = "code", PageMarginMm = 28, Density = "compact",
|
||||
Sections = { new() { Key = "skills", Hidden = true }, new() { Key = "experience" }, new() { Key = "summary" } },
|
||||
Overrides = { ["source-entry"] = new() { Title = "CV-only role", Bullets = new() { "Reviewed override" } } },
|
||||
};
|
||||
var cv = await service.CreateAsync("revision-owner", "Variant", null, lens, default);
|
||||
var originalHistory = (await db.CvVariantVersions.SingleAsync()).SettingsJson;
|
||||
Assert.Equal("Canonical role", (await career.LoadStructuredAsync("revision-owner", default)).Jobs[0].Title);
|
||||
var rendered = await service.RenderAsync("revision-owner", cv.Id, new("Fallback", null), default);
|
||||
Assert.Contains("CV-only role", rendered!.Html);
|
||||
Assert.DoesNotContain("Canonical evidence", rendered.Html);
|
||||
Assert.DoesNotContain("section-skills", rendered.Html);
|
||||
|
||||
// History is the immutable lens; inherited source values intentionally remain live.
|
||||
profile.Contact.Headline = "Updated canonical source";
|
||||
profile.Jobs[0].Title = "Updated canonical role";
|
||||
await career.SaveVersionAsync("revision-owner", profile, "manual", default);
|
||||
Assert.Equal(originalHistory, (await db.CvVariantVersions.SingleAsync()).SettingsJson);
|
||||
var reset = new CvVariantSettings { ThemeId = "minimal" };
|
||||
await service.SaveAsync("revision-owner", cv.Id, null, reset, "manual", 1, default);
|
||||
rendered = await service.RenderAsync("revision-owner", cv.Id, new("Fallback", null), default);
|
||||
Assert.Contains("Updated canonical role", rendered!.Html);
|
||||
Assert.Contains("Updated canonical source", rendered.Html);
|
||||
|
||||
var restored = await service.RestoreVersionAsync("revision-owner", cv.Id, 1, 2, default);
|
||||
Assert.Equal(3, restored!.Version);
|
||||
Assert.Equal(originalHistory, restored.SettingsJson);
|
||||
await service.SaveAsync("revision-owner", cv.Id, null, new CvVariantSettings { Headline = "After restore" }, "autosave", 3, default);
|
||||
Assert.Equal(originalHistory, (await db.CvVariantVersions.SingleAsync(x => x.Version == 1)).SettingsJson);
|
||||
Assert.Equal(originalHistory, (await db.CvVariantVersions.SingleAsync(x => x.Version == 3)).SettingsJson);
|
||||
Assert.Equal(new[] { 4, 3, 2, 1 }, (await service.ListVersionsAsync("revision-owner", cv.Id, default)).Select(x => x.Version));
|
||||
Assert.Equal("Updated canonical role", (await career.LoadStructuredAsync("revision-owner", default)).Jobs[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Foreign_account_cannot_read_save_or_restore_revision_history()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var owner = Context(connection);
|
||||
await owner.Database.EnsureCreatedAsync();
|
||||
var cv = await Service(owner).CreateAsync("revision-owner", "Private", null, null, default);
|
||||
await using var other = Context(connection, "other-account");
|
||||
var service = Service(other);
|
||||
Assert.Null(await service.GetAsync("other-account", cv.Id, default));
|
||||
Assert.Empty(await service.ListVersionsAsync("other-account", cv.Id, default));
|
||||
Assert.Null(await service.SaveAsync("other-account", cv.Id, "Overwrite", new(), "manual", 1, default));
|
||||
Assert.Null(await service.RestoreVersionAsync("other-account", cv.Id, 1, 1, default));
|
||||
Assert.Single(await owner.CvVariantVersions.ToListAsync());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user