Files
jobtrackingapp/JobTrackerApi.Tests/CvBuilderTests.cs
T
2026-08-24 20:21:23 +02:00

449 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class CvBuilderTests
{
private static StructuredCvProfile Rich() => new()
{
Contact = { FullName = "Ada Lovelace", Headline = "Engineer", Email = "ada@example.com", Location = "Oslo" },
Summary = { "Builder of engines." },
Jobs =
{
new StructuredCvJob { Id = "job1", Title = "Senior Eng", Company = "Acme", Location = "Oslo", Start = "Jan 2020", End = "Present", IsCurrent = true, Bullets = { "Built X" }, Skills = { "C#" } },
new StructuredCvJob { Id = "job2", Title = "Eng", Company = "Beta", Start = "2018", End = "2019", Bullets = { "Built Y" } },
},
Education = { new StructuredCvEducation { Id = "ed1", Qualification = "BSc", Institution = "Uni" } },
Skills = { "C#", "SQL" },
Projects = { new StructuredCvProject { Id = "pr1", Name = "Proj", Role = "Lead", Skills = { "React" } } },
Certifications = { new StructuredCvCertification { Id = "c1", Name = "AZ-204", Issuer = "MS" } },
Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } },
};
// ---- Resolver (the lens over the master profile) ----
[Fact]
public void Resolver_defaults_include_every_populated_section()
{
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "Fallback", null);
Assert.Equal("Ada Lovelace", model.FullName);
var keys = model.Sections.Select(s => s.Key).ToList();
Assert.Contains("summary", keys);
Assert.Contains("experience", keys);
Assert.Contains("skills", keys);
Assert.Contains("languages", keys);
}
[Fact]
public void Resolver_hides_a_section_when_configured()
{
var settings = new CvVariantSettings { Sections = { new CvSectionSetting { Key = "skills", Hidden = true } } };
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
Assert.DoesNotContain("skills", model.Sections.Select(s => s.Key));
// Other sections still render (appended after the explicit order).
Assert.Contains("experience", model.Sections.Select(s => s.Key));
}
[Fact]
public void Resolver_respects_explicit_section_order()
{
var settings = new CvVariantSettings
{
Sections = { new CvSectionSetting { Key = "skills" }, new CvSectionSetting { Key = "summary" } },
};
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
var keys = model.Sections.Select(s => s.Key).ToList();
Assert.True(keys.IndexOf("skills") < keys.IndexOf("summary"));
}
[Fact]
public void Resolver_applies_item_override_bullets_without_touching_the_master()
{
var profile = Rich();
var settings = new CvVariantSettings();
settings.Overrides["job1"] = new CvItemOverride { Bullets = new() { "Tailored bullet" } };
var model = CvVariantResolver.Build(profile, settings, "F", null);
var exp = model.Sections.First(s => s.Key == "experience");
Assert.Equal(new[] { "Tailored bullet" }, exp.Entries[0].Bullets);
// Master profile is untouched — the override is a lens, not a write.
Assert.Equal(new[] { "Built X" }, profile.Jobs[0].Bullets);
}
[Fact]
public void Resolver_hides_a_single_item_by_key()
{
var settings = new CvVariantSettings();
settings.Overrides["job2"] = new CvItemOverride { Hidden = true };
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
var exp = model.Sections.First(s => s.Key == "experience");
Assert.Single(exp.Entries);
Assert.Equal("Senior Eng", exp.Entries[0].Title);
}
[Fact]
public void Resolver_adds_variant_only_custom_sections()
{
var settings = new CvVariantSettings
{
CustomSections = { new CvCustomSectionSetting { Key = "vol", Title = "Volunteering", Items = { "Coached juniors" } } },
};
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
Assert.Contains(model.Sections, s => s.Title == "Volunteering" && s.Bullets.Contains("Coached juniors"));
}
[Fact]
public void Resolver_places_custom_sections_in_the_shared_section_order()
{
var settings = new CvVariantSettings
{
Sections =
{
new CvSectionSetting { Key = "custom:vol" },
new CvSectionSetting { Key = "summary" },
},
CustomSections =
{
new CvCustomSectionSetting { Key = "vol", Title = "Volunteering", Items = { "Coached juniors" } },
},
};
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
Assert.True(model.Sections.FindIndex(section => section.Key == "custom:vol")
< model.Sections.FindIndex(section => section.Key == "summary"));
}
// ---- Renderer (one path, every theme is data) ----
[Fact]
public void Every_catalog_theme_renders_valid_html()
{
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings { ShowPhoto = true }, "F", "data:image/png;base64,AAAA");
foreach (var theme in CvThemeCatalog.Themes)
{
var result = renderer.Render(model, theme, new CvVariantSettings { ThemeId = theme.Id });
Assert.Contains("<html", result.Html);
Assert.Contains("Ada Lovelace", result.Html);
Assert.Contains("Senior Eng", result.Html);
Assert.Equal(theme.Id, result.ThemeId);
}
}
[Fact]
public void Two_column_theme_emits_a_sidebar_single_does_not()
{
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null);
var technical = renderer.Render(model, CvThemeCatalog.Resolve("technical"), new CvVariantSettings { ThemeId = "technical" });
var minimal = renderer.Render(model, CvThemeCatalog.Resolve("minimal"), new CvVariantSettings { ThemeId = "minimal" });
Assert.Contains("class=\"sidebar\"", technical.Html);
Assert.DoesNotContain("class=\"sidebar\"", minimal.Html);
}
[Fact]
public void Bullets_support_safe_inline_markdown_and_escape_everything_else()
{
var renderer = new ThemedCvRenderer();
var profile = new StructuredCvProfile { Summary = { "**bold** and *italic* and __under__ and [site](https://x.io) <script>alert(1)</script>" } };
var model = CvVariantResolver.Build(profile, new CvVariantSettings(), "F", null);
var html = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern" }).Html;
Assert.Contains("<strong>bold</strong>", html);
Assert.Contains("<em>italic</em>", html);
Assert.Contains("<u>under</u>", html);
Assert.Contains("<a href=\"https://x.io\">site</a>", html);
Assert.Contains("&lt;script&gt;", html); // the real tag is escaped, inert
Assert.DoesNotContain("<script>", html);
}
[Fact]
public void Item_order_reorders_entries_without_touching_the_master()
{
var profile = Rich(); // jobs: job1 (Senior Eng), job2 (Eng)
var settings = new CvVariantSettings
{
Sections = { new CvSectionSetting { Key = "experience", ItemOrder = new() { "job2", "job1" } } },
};
var model = CvVariantResolver.Build(profile, settings, "F", null);
var exp = model.Sections.First(s => s.Key == "experience");
Assert.Equal(new[] { "Eng", "Senior Eng" }, exp.Entries.Select(e => e.Title));
Assert.Equal(new[] { "Senior Eng", "Eng" }, profile.Jobs.Select(j => j.Title)); // master untouched
}
[Fact]
public void Rendered_output_has_print_break_rules_and_themes_expose_ats_flag()
{
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null);
var html = renderer.Render(model, CvThemeCatalog.Resolve("ats-classic"), new CvVariantSettings { ThemeId = "ats-classic" }).Html;
Assert.Contains("break-inside:avoid", html);
Assert.True(CvThemeCatalog.Resolve("ats-classic").AtsFriendly);
Assert.False(CvThemeCatalog.Resolve("technical").AtsFriendly); // sidebar = not ATS-safe
}
[Fact]
public void Long_content_wraps_and_can_flow_across_pages_without_shrinking_typography()
{
var profile = Rich();
profile.Contact.FullName = new string('N', 180);
profile.Contact.Email = $"{new string('e', 180)}@example.com";
profile.Jobs[0].Title = new string('T', 220);
profile.Jobs[0].Company = new string('C', 220);
profile.Jobs[0].Bullets = Enumerable.Range(1, 7)
.Select(index => index == 1 ? new string('x', 500) : $"Detailed achievement {index} with readable typography.")
.ToList();
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(profile, new CvVariantSettings(), "F", null);
var html = renderer.Render(model, CvThemeCatalog.Resolve("technical"), new CvVariantSettings { ThemeId = "technical" }).Html;
Assert.Contains("class=\"entry entry-flow\"", html);
Assert.Contains("class=\"item-flow\"", html);
Assert.Contains("overflow-wrap:anywhere", html);
Assert.Contains("grid-template-columns:62mm minmax(0,1fr)", html);
Assert.Contains("overflow:visible", html);
Assert.Contains("white-space:normal", html);
Assert.DoesNotContain("transform:scale", html);
}
[Fact]
public void Header_band_and_sidebar_contact_text_own_their_contrasting_palette()
{
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null);
var modern = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern" }).Html;
var lightAccent = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern", AccentColor = "#f8fafc" }).Html;
var technicalTheme = CvThemeCatalog.Resolve("technical");
var technical = renderer.Render(model, technicalTheme, new CvVariantSettings { ThemeId = "technical" }).Html;
Assert.Contains(".header-band .name,.header-band .headline,.header-band .contact,.header-band .contact-item,.header-band a{color:#fff;}", modern);
Assert.Contains(".header-band .name,.header-band .headline,.header-band .contact,.header-band .contact-item,.header-band a{color:#000;}", lightAccent);
Assert.Contains($".sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{technicalTheme.SidebarInk};}}", technical);
}
[Fact]
public void Variant_settings_reject_css_injection_in_visual_overrides()
{
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings
{
AccentColor = "#fff;} </style><script>alert(1)</script>",
HeadingFont = "Arial;}</style><script>alert(2)</script>",
BodyFont = "'Segoe UI', Roboto, Arial, sans-serif",
});
Assert.Null(settings.AccentColor);
Assert.Null(settings.HeadingFont);
Assert.Equal("'Segoe UI', Roboto, Arial, sans-serif", settings.BodyFont);
}
[Fact]
public void Accent_override_reaches_the_css()
{
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null);
var result = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern", AccentColor = "#123456" });
Assert.Contains("#123456", result.Html);
}
[Fact]
public void Design_and_layout_overrides_are_normalized_and_reach_the_shared_renderer()
{
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings
{
ThemeId = "modern",
Layout = "sidebar-right",
TextColor = "#123456",
MutedColor = "#456789",
BackgroundColor = "#fafafa",
BaseFontSizePt = 99,
HeadingSizePt = 15,
LineHeight = 1.55,
PageMarginMm = 20,
SidebarWidthMm = 70,
SidebarSections = new() { "skills", "languages" },
SkillsStyle = "text",
});
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("modern"), settings).Html;
Assert.Equal(13, settings.BaseFontSizePt);
Assert.Contains("grid-template-columns:minmax(0,1fr) 70mm", html);
Assert.Contains("color:#123456", html);
Assert.Contains("background:#fafafa", html);
Assert.Contains("line-height:1.55", html);
Assert.Contains("font-size:13pt", html);
Assert.Contains("class=\"skills-text\">C# · SQL</p>", html);
}
[Fact]
public void Norwegian_variant_localizes_default_labels_dates_and_keeps_custom_links_clickable()
{
var profile = Rich();
profile.Jobs[0].StartDate = "2020-01";
profile.Contact.GitHub = "github.com/ada";
profile.Contact.Links.Add(new StructuredCvLink { Label = "Portfolio", Url = "https://ada.example/work" });
var settings = new CvVariantSettings { Language = "no", DateFormat = "year" };
var model = CvVariantResolver.Build(profile, settings, "F", null);
var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("modern"), settings).Html;
Assert.Equal("Arbeidserfaring", model.Sections.First(section => section.Key == "experience").Title);
Assert.Equal("2020 nå", model.Sections.First(section => section.Key == "experience").Entries[0].Meta);
Assert.Contains("href=\"https://github.com/ada\"", html);
Assert.Contains("href=\"https://ada.example/work\">Portfolio</a>", html);
}
// ---- Variant service (CRUD, autosave history, public, render) ----
private static (JobTrackerContext db, CvVariantService svc) NewService(string userId, string? dbName = null)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(dbName ?? Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(s => s.UserId).Returns(userId);
var db = new JobTrackerContext(options, currentUser.Object);
var svc = new CvVariantService(db, new CareerProfileService(db), new ThemedCvRenderer());
return (db, svc);
}
[Fact]
public async Task Create_then_save_increments_version_and_keeps_history()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
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);
Assert.Equal(2, saved!.Version);
var versions = await svc.ListVersionsAsync("user-1", created.Id, default);
Assert.Equal(new[] { 2, 1 }, versions.Select(v => v.Version));
Assert.True(versions[0].IsCurrent);
}
[Fact]
public async Task Restore_reapplies_old_settings_as_a_new_version()
{
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);
var restored = await svc.RestoreVersionAsync("user-1", v.Id, 1, default);
Assert.Equal(3, restored!.Version);
Assert.Equal("modern", CvVariantSettingsJson.Deserialize(restored.SettingsJson).ThemeId);
}
[Fact]
public async Task Duplicate_copies_settings_with_a_fresh_slug()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
var v = await svc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "elegant" }, default);
var copy = await svc.DuplicateAsync("user-1", v.Id, null, default);
Assert.NotEqual(v.PublicSlug, copy!.PublicSlug);
Assert.Equal("elegant", CvVariantSettingsJson.Deserialize(copy.SettingsJson).ThemeId);
}
[Fact]
public async Task Create_rejects_a_job_application_owned_by_another_user()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
var company = new Company { OwnerUserId = "user-2", Name = "Other tenant" };
var foreignJob = new JobApplication { OwnerUserId = "user-2", JobTitle = "Private role", Company = company };
db.Add(foreignJob);
await db.SaveChangesAsync();
await Assert.ThrowsAsync<ArgumentException>(() =>
svc.CreateAsync("user-1", "CV", foreignJob.Id, new CvVariantSettings(), default));
Assert.Empty(await db.CvVariants.IgnoreQueryFilters().ToListAsync());
}
[Fact]
public async Task Delete_removes_the_variant_and_its_versions()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
var v = await svc.CreateAsync("user-1", "CV", null, null, default);
Assert.True(await svc.DeleteAsync("user-1", v.Id, default));
Assert.Null(await svc.GetAsync("user-1", v.Id, default));
Assert.Empty(await db.CvVariantVersions.IgnoreQueryFilters().Where(x => x.CvVariantId == v.Id).ToListAsync());
}
[Fact]
public async Task Render_pulls_content_from_the_master_profile()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
await new CareerProfileService(db).SaveVersionAsync("user-1", Rich(), "manual", default);
var v = await svc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "modern" }, default);
var render = await svc.RenderAsync("user-1", v.Id, new CvRenderPerson("Fallback", null), default);
Assert.NotNull(render);
Assert.Contains("Ada Lovelace", render!.Html);
Assert.Contains("Senior Eng", render.Html);
}
[Fact]
public async Task Public_render_bypasses_the_tenant_filter()
{
var dbName = Guid.NewGuid().ToString();
var (ownerDb, ownerSvc) = NewService("user-1", dbName);
await new CareerProfileService(ownerDb).SaveVersionAsync("user-1", Rich(), "manual", default);
var v = await ownerSvc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "minimal" }, default);
await ownerSvc.SetPublicAsync("user-1", v.Id, true, default);
await ownerDb.DisposeAsync();
// Anonymous context: no current user, so the tenant filter denies everything by default.
var (anonDb, anonSvc) = NewService(null!, dbName);
await using var _ = anonDb;
var owner = await anonSvc.GetPublicOwnerAsync(v.PublicSlug, default);
Assert.Equal("user-1", owner);
var result = await anonSvc.RenderPublicAsync(v.PublicSlug, new CvRenderPerson("Ada Lovelace", null), default);
Assert.NotNull(result);
Assert.Contains("Ada Lovelace", result!.Value.render.Html);
}
[Fact]
public async Task Private_variant_is_not_served_publicly()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
var v = await svc.CreateAsync("user-1", "CV", null, null, default);
Assert.Null(await svc.GetPublicOwnerAsync(v.PublicSlug, default));
}
[Fact]
public async Task Re_enabling_public_access_rotates_the_revoked_link()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
var v = await svc.CreateAsync("user-1", "CV", null, null, default);
await svc.SetPublicAsync("user-1", v.Id, true, default);
var revokedSlug = v.PublicSlug;
await svc.SetPublicAsync("user-1", v.Id, false, default);
await svc.SetPublicAsync("user-1", v.Id, true, default);
Assert.NotEqual(revokedSlug, v.PublicSlug);
Assert.Null(await svc.GetPublicOwnerAsync(revokedSlug, default));
Assert.Equal("user-1", await svc.GetPublicOwnerAsync(v.PublicSlug, default));
}
[Fact]
public void Premium_theme_policy_keeps_three_free_themes_available()
{
Assert.Equal(3, CvThemeCatalog.Themes.Count(t => CvThemeCatalog.CanUse(t.Id, false)));
Assert.Equal(5, CvThemeCatalog.Themes.Count(t => t.Premium));
Assert.All(CvThemeCatalog.Themes, t => Assert.True(CvThemeCatalog.CanUse(t.Id, true)));
Assert.False(CvThemeCatalog.CanUse("unknown", true));
}
}