using System.Security.Claims; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.MariaDb.Tests; public class CvImportConnectionLossTests { [Fact] public async Task Connection_loss_after_all_writes_before_commit_rolls_back_and_exact_retry_is_durable() { await using var database = await MariaDbDatabase.CreateAsync(); const string owner = "rd21-owner"; int runId; await using (var seed = database.Context()) { seed.Users.Add(new() { Id = owner, UserName = "connection-loss-synthetic" }); var run = new CvExtractionRun { OwnerUserId = owner, Trigger = "document_import", ParserVersion = ProfileCvController.DocumentParserVersion, Status = "pending_review", StartedAtUtc = DateTimeOffset.UtcNow, RawExtractedText = "C#", DocumentJson = "{}", StructuredProfileJson = StructuredCvProfileJson.SerializePersisted(new() { Skills = { "C#" } }) }; seed.CvExtractionRuns.Add(run); await seed.SaveChangesAsync(); runId = run.Id; } bool killed = false; ProfileCvController Controller(JobTrackerContext db, bool interrupt) { var user = db.Users.Single(u => u.Id == owner); var users = TestHostFactory.CreateUserManager(); users.Setup(u => u.GetUserAsync(It.IsAny())).ReturnsAsync(user); users.Setup(u => u.UpdateAsync(user)).Returns(async () => { await db.SaveChangesAsync(); if (interrupt) { var connection = Assert.IsType(db.Database.GetDbConnection()); var thread = connection.ServerThread; Assert.True(thread > 0); await using var killer = database.Context(); // Only the connection opened by this guarded disposable database fixture is terminated. await killer.Database.ExecuteSqlRawAsync("KILL CONNECTION " + thread.ToString(System.Globalization.CultureInfo.InvariantCulture)); killed = true; } return IdentityResult.Success; }); return new(users.Object, Mock.Of(), db, null!) { ControllerContext = new() { HttpContext = new DefaultHttpContext() } }; } var request = new ProfileCvController.ApplyReviewedRequest(0, new[] { new CvImportDecision("skills.0", "use") }); await using (var broken = database.Context()) { using var lifetime = new CancellationTokenSource(); var controller = Controller(broken, true); controller.HttpContext.RequestAborted = lifetime.Token; var applying = controller.ApplyReviewed(runId, request); var failure = await Record.ExceptionAsync(() => applying.WaitAsync(TimeSpan.FromSeconds(15))); if (failure is TimeoutException) { lifetime.Cancel(); await Record.ExceptionAsync(() => applying.WaitAsync(TimeSpan.FromSeconds(5))); } Assert.True(killed); Assert.NotNull(failure); Assert.IsNotType(failure); } await using (var verify = database.Context()) { var run = await verify.CvExtractionRuns.SingleAsync(); Assert.Equal("pending_review", run.Status); Assert.Null(run.ReviewJson); Assert.Empty(await verify.CareerProfileVersions.ToListAsync()); Assert.Empty(await verify.CareerSkills.ToListAsync()); Assert.Null((await verify.Users.SingleAsync()).CurrentCvExtractionRunId); var snapshot = await new CareerProfileService(verify).LoadSnapshotAsync(owner, default); Assert.Equal(0, snapshot.Revision); Assert.Empty(snapshot.Profile.Skills); Assert.IsType(await Controller(verify, false).ApplyReviewed(runId, request)); } await using var replay = database.Context(); Assert.IsType(await Controller(replay, false).ApplyReviewed(runId, request)); Assert.Single(await replay.CareerProfileVersions.ToListAsync()); Assert.Equal("C#", (await replay.CareerSkills.SingleAsync()).Name); Assert.Equal("applied", (await replay.CvExtractionRuns.SingleAsync()).Status); } }