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("alert(1)" } }; var model = CvVariantResolver.Build(profile, new CvVariantSettings(), "F", null); var html = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern" }).Html; Assert.Contains("bold", html); Assert.Contains("italic", html); Assert.Contains("under", html); Assert.Contains("site", html); Assert.Contains("<script>", html); // the real tag is escaped, inert Assert.DoesNotContain("", HeadingFont = "Arial;}", 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); } // ---- Variant service (CRUD, autosave history, public, render) ---- private static (JobTrackerContext db, CvVariantService svc) NewService(string userId, string? dbName = null) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(dbName ?? Guid.NewGuid().ToString()) .Options; var currentUser = new Mock(); 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 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)); } }