using System.Diagnostics; using System.Security.Claims; using JobTrackerApi.Controllers; using JobTrackerApi.Models; using JobTrackerApi.Tests.TestSupport; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using JobTrackerApi.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using Xunit; namespace JobTrackerApi.Tests; [CollectionDefinition("PDF process environment", DisableParallelization = true)] public sealed class PdfProcessCollection; [Collection("PDF process environment")] public sealed class PdfRenderAdmissionTests { [Fact] public async Task Default_limit_refuses_second_actual_browser_until_first_exits() { using var fixture = new Fixture(); var first = fixture.Exporter.ExportAsync("owner-a", Render("hold"), default); try { await fixture.WaitForStarts(1); var excess = fixture.Exporter.ExportAsync("owner-b", Render("hold"), default); // Assert the real process boundary, not just a mocked semaphore. await Assert.ThrowsAsync(() => excess); Assert.True(excess.IsFaulted, $"An excess export must be rejected; actual browser launches: {fixture.Started().Length}."); Assert.Single(fixture.Started()); } finally { fixture.Release(); await first; } } [Fact] public async Task Two_exporter_instances_share_global_and_owner_capacity_without_waiting() { using var fixture = new Fixture(limit: 2); var other = fixture.CreateExporter(); var a = fixture.Exporter.ExportAsync("a", Render("hold"), default); await fixture.WaitForStarts(1); await Assert.ThrowsAsync(() => other.ExportAsync("a", Render("hold"), default)); var b = other.ExportAsync("b", Render("hold"), default); try { await fixture.WaitForStarts(2); for (var i = 0; i < 10; i++) await Assert.ThrowsAsync(() => other.ExportAsync($"c-{i}", Render("hold"), default)); Assert.Equal(2, fixture.Started().Length); } finally { fixture.Release(); await Task.WhenAll(a, b); } fixture.AssertProcessesExited(); Assert.StartsWith("%PDF-", System.Text.Encoding.UTF8.GetString((await other.ExportAsync("a", Render("success"), default)).Bytes)); } [Fact] public async Task Cancelled_admission_has_no_queue_or_late_launch() { using var fixture = new Fixture(); var a = fixture.Exporter.ExportAsync("a", Render("hold"), default); await fixture.WaitForStarts(1); using var cancelled = new CancellationTokenSource(); cancelled.Cancel(); await Assert.ThrowsAnyAsync(() => fixture.Exporter.ExportAsync("b", Render("success"), cancelled.Token)); fixture.Release(); await a; Assert.Single(fixture.Started()); await fixture.Exporter.ExportAsync("b", Render("success"), default); } [Theory] [InlineData("fail")] [InlineData("no-output")] [InlineData("cancel")] [InlineData("timeout")] public async Task Repeated_failures_restore_all_slots_and_remove_partial_outputs(string failure) { using var fixture = new Fixture(limit: 2, timeout: 1); for (var cycle = 0; cycle < 3; cycle++) { using var cancel = new CancellationTokenSource(); var mode = failure is "cancel" or "timeout" ? "hold" : failure; var tasks = new[] { fixture.Exporter.ExportAsync("a", Render(mode), cancel.Token), fixture.Exporter.ExportAsync("b", Render(mode), cancel.Token) }; await fixture.WaitForStarts((cycle + 1) * 2); if (failure == "cancel") cancel.Cancel(); foreach (var task in tasks) { if (failure == "cancel") await Assert.ThrowsAnyAsync(() => task); else if (failure == "timeout") await Assert.ThrowsAsync(() => task); else await Assert.ThrowsAsync(() => task); } fixture.AssertProcessesExited(); Assert.Empty(Directory.GetFiles(fixture.Paths.CvExportsRoot, "*.pdf", SearchOption.AllDirectories)); foreach (var started in fixture.Started()) Assert.False(Directory.Exists(Path.GetDirectoryName(File.ReadAllText(started)))); } var next = await Task.WhenAll(fixture.Exporter.ExportAsync("a", Render("success"), default), fixture.Exporter.ExportAsync("b", Render("success"), default)); Assert.All(next, artifact => Assert.True(artifact.Bytes.Length > 0)); } [Fact] public async Task Launch_exception_does_not_consume_capacity() { using var fixture = new Fixture(); var browser = Environment.GetEnvironmentVariable("CV_PDF_BROWSER_PATH"); var invalid = Path.Combine(fixture.Root, "not-an-executable"); File.WriteAllText(invalid, "inert"); Environment.SetEnvironmentVariable("CV_PDF_BROWSER_PATH", invalid); await Assert.ThrowsAsync(() => fixture.Exporter.ExportAsync("a", Render("success"), default)); Environment.SetEnvironmentVariable("CV_PDF_BROWSER_PATH", browser); await fixture.Exporter.ExportAsync("a", Render("success"), default); } [Fact] public async Task Browser_output_pipes_are_drained_without_deadlock() { using var fixture = new Fixture(timeout: 5); var result = await fixture.Exporter.ExportAsync("a", Render("logs"), default); Assert.True(result.Bytes.Length > 0); fixture.AssertProcessesExited(); } [Fact] public async Task Shutdown_cancels_active_process_and_rejects_new_work() { using var fixture = new Fixture(); var active = fixture.Exporter.ExportAsync("a", Render("hold"), default); await fixture.WaitForStarts(1); fixture.Stopping.Cancel(); await Assert.ThrowsAnyAsync(() => active); fixture.AssertProcessesExited(); await Assert.ThrowsAnyAsync(() => fixture.Exporter.ExportAsync("b", Render("success"), default)); Assert.Single(fixture.Started()); } [Theory] [InlineData("MaxConcurrentRenders", "0")] [InlineData("MaxConcurrentRenders", "-1")] [InlineData("MaxConcurrentRenders", "9")] [InlineData("MaxConcurrentRenders", "not-a-number")] [InlineData("RenderTimeoutSeconds", "0")] [InlineData("RenderTimeoutSeconds", "-1")] [InlineData("RenderTimeoutSeconds", "61")] public async Task Invalid_configuration_prevents_host_start(string name, string value) { var builder = Host.CreateApplicationBuilder(); builder.Configuration[$"CvExports:{name}"] = value; builder.Services.AddOptions().BindConfiguration("CvExports").ValidateDataAnnotations().ValidateOnStart(); using var host = builder.Build(); var error = await Record.ExceptionAsync(() => host.StartAsync()); Assert.True(error is OptionsValidationException or InvalidOperationException, error?.ToString() ?? "Host unexpectedly started"); } [Fact] public void Unconfirmed_process_termination_disables_new_admission_even_after_lease_disposal() { var admission = new PdfRenderAdmission(Options.Create(new PdfRenderOptions()), NullLogger.Instance); using (admission.Enter("a", default)) admission.Disable(); Assert.Throws(() => admission.Enter("b", default)); } [Fact] public void Failing_observability_cannot_strand_an_admission_slot() { var logger = new Mock>(); var broken = true; logger.Setup(x => x.Log(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) .Callback(() => { if (broken) throw new InvalidOperationException("Fixture logging failure"); }); var admission = new PdfRenderAdmission(Options.Create(new PdfRenderOptions()), logger.Object); for (var i = 0; i < 3; i++) Assert.Throws(() => admission.Enter("a", default)); broken = false; using var lease = admission.Enter("a", default); } [Theory] [InlineData(true, 503, "pdf_capacity_exceeded")] [InlineData(false, 504, "pdf_render_timeout")] public async Task Api_errors_are_safe_deterministic_and_distinct(bool busy, int status, string code) { var context = new DefaultHttpContext(); context.Response.Body = new MemoryStream(); Assert.True(await new PdfRenderExceptionHandler().TryHandleAsync(context, busy ? new PdfCapacityException() : new PdfRenderTimeoutException(), default)); Assert.Equal(status, context.Response.StatusCode); Assert.Equal("no-store", context.Response.Headers.CacheControl); Assert.Equal(busy ? "3" : "", context.Response.Headers.RetryAfter.ToString()); context.Response.Body.Position = 0; Assert.Contains(code, await new StreamReader(context.Response.Body).ReadToEndAsync()); } [Fact] public async Task Profile_controller_uses_same_real_exporter_boundary_after_local_AI_preparation() { using var fixture = new Fixture(); var active = fixture.Exporter.ExportAsync("other-owner", Render("hold"), default); await fixture.WaitForStarts(1); var user = new ApplicationUser { Id = "profile-owner", ProfileCvText = "## Summary\nSynthetic engineer" }; var users = TestHostFactory.CreateUserManager(user); users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); var ai = new Mock(); ai.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("## Summary\nSynthetic engineer"); await using var db = TestHostFactory.CreateInMemoryDb(user.Id); var controller = new ProfileCvController(users.Object, ai.Object, db, fixture.Paths, cvAiClassifier: NoOpCvAiClassifier.Instance, cvAiNormalizer: NoOpCvAiNormalizer.Instance, cvTemplateRenderer: new CvTemplateRenderer(), cvPdfExporter: fixture.Exporter) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; try { await Assert.ThrowsAsync(() => controller.ExportProfileCvPdf(new ProfileCvController.RewriteSectionRequest { SourceText = user.ProfileCvText }, default)); Assert.Single(fixture.Started()); } finally { fixture.Release(); await active; } Assert.IsType(await controller.ExportProfileCvPdf(new ProfileCvController.RewriteSectionRequest { SourceText = user.ProfileCvText }, default)); } private static TailoredCvRenderResult Render(string mode) => new("synthetic", "synthetic.pdf", mode); private sealed class Fixture : IDisposable { public string Root { get; } = Path.Combine(Path.GetTempPath(), $"jobtracker-b03-{Guid.NewGuid():N}"); public PlaywrightCvPdfExporter Exporter { get; } public AppPaths Paths { get; } public CancellationTokenSource Stopping { get; } = new(); private readonly IConfiguration _config; private readonly PdfRenderAdmission _admission; private readonly string? _oldBrowser = Environment.GetEnvironmentVariable("CV_PDF_BROWSER_PATH"); private readonly string? _oldRoot = Environment.GetEnvironmentVariable("PDF_STUB_ROOT"); public Fixture(int limit = 1, int timeout = 60) { Directory.CreateDirectory(Root); Environment.SetEnvironmentVariable("PDF_STUB_ROOT", Root); Environment.SetEnvironmentVariable("CV_PDF_BROWSER_PATH", Path.Combine(AppContext.BaseDirectory, "PdfBrowserStub", OperatingSystem.IsWindows() ? "PdfBrowserStub.exe" : "PdfBrowserStub")); _config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Data:Root"] = Root }).Build(); var environment = new Mock(); environment.SetupGet(x => x.ContentRootPath).Returns(Root); Paths = new AppPaths(_config, environment.Object); _admission = new PdfRenderAdmission(Options.Create(new PdfRenderOptions { MaxConcurrentRenders = limit, RenderTimeoutSeconds = timeout }), NullLogger.Instance); Exporter = CreateExporter(); } public PlaywrightCvPdfExporter CreateExporter() { var lifetime = new Mock(); lifetime.SetupGet(x => x.ApplicationStopping).Returns(Stopping.Token); return new PlaywrightCvPdfExporter(Paths, NullLogger.Instance, _config, _admission, lifetime.Object); } public void AssertProcessesExited() { foreach (var file in Started()) { try { using var process = Process.GetProcessById(int.Parse(Path.GetFileNameWithoutExtension(file))); Assert.True(process.HasExited, "Capacity must not be returned before the child exits."); } catch (ArgumentException) { } } } public string[] Started() => Directory.GetFiles(Root, "*.started"); public async Task WaitForStarts(int count) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); while (Started().Length < count) await Task.Delay(20, timeout.Token); } public void Release() => File.WriteAllText(Path.Combine(Root, "release"), "release"); public void Dispose() { Release(); // Only stop fixture-owned process IDs, after checking their executable identity. foreach (var file in Started()) { try { using var process = Process.GetProcessById(int.Parse(Path.GetFileNameWithoutExtension(file))); if (!process.HasExited && process.ProcessName == "PdfBrowserStub") { process.Kill(entireProcessTree: true); process.WaitForExit(5000); } } catch (ArgumentException) { } } Environment.SetEnvironmentVariable("CV_PDF_BROWSER_PATH", _oldBrowser); Environment.SetEnvironmentVariable("PDF_STUB_ROOT", _oldRoot); Stopping.Dispose(); Directory.Delete(Root, recursive: true); } } }