0954876295
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
305 lines
15 KiB
C#
305 lines
15 KiB
C#
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<PdfCapacityException>(() => 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<PdfCapacityException>(() => 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<PdfCapacityException>(() => 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<OperationCanceledException>(() => 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<OperationCanceledException>(() => task);
|
|
else if (failure == "timeout") await Assert.ThrowsAsync<PdfRenderTimeoutException>(() => task);
|
|
else await Assert.ThrowsAsync<InvalidOperationException>(() => 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<System.ComponentModel.Win32Exception>(() => 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<OperationCanceledException>(() => active);
|
|
fixture.AssertProcessesExited();
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 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<PdfRenderOptions>().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<PdfRenderAdmission>.Instance);
|
|
using (admission.Enter("a", default)) admission.Disable();
|
|
Assert.Throws<PdfCapacityException>(() => admission.Enter("b", default));
|
|
}
|
|
|
|
[Fact]
|
|
public void Failing_observability_cannot_strand_an_admission_slot()
|
|
{
|
|
var logger = new Mock<ILogger<PdfRenderAdmission>>();
|
|
var broken = true;
|
|
logger.Setup(x => x.Log(It.IsAny<LogLevel>(), It.IsAny<EventId>(), It.IsAny<It.IsAnyType>(),
|
|
It.IsAny<Exception?>(), It.IsAny<Func<It.IsAnyType, Exception?, string>>()))
|
|
.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<InvalidOperationException>(() => 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<ClaimsPrincipal>())).ReturnsAsync(user);
|
|
var ai = new Mock<ISummarizerService>();
|
|
ai.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>())).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<PdfCapacityException>(() => controller.ExportProfileCvPdf(new ProfileCvController.RewriteSectionRequest { SourceText = user.ProfileCvText }, default));
|
|
Assert.Single(fixture.Started());
|
|
}
|
|
finally { fixture.Release(); await active; }
|
|
Assert.IsType<FileContentResult>(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<string, string?> { ["Data:Root"] = Root }).Build();
|
|
var environment = new Mock<IHostEnvironment>();
|
|
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<PdfRenderAdmission>.Instance);
|
|
Exporter = CreateExporter();
|
|
}
|
|
|
|
public PlaywrightCvPdfExporter CreateExporter()
|
|
{
|
|
var lifetime = new Mock<IHostApplicationLifetime>();
|
|
lifetime.SetupGet(x => x.ApplicationStopping).Returns(Stopping.Token);
|
|
return new PlaywrightCvPdfExporter(Paths, NullLogger<PlaywrightCvPdfExporter>.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);
|
|
}
|
|
}
|
|
}
|