fix(database): qualify RD21 saves against pinned MariaDB 12.2.2

This commit is contained in:
cesnimda
2026-10-24 01:47:50 +02:00
parent 0d16a691e0
commit a2c9bda0d1
30 changed files with 2226 additions and 63 deletions
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.14" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../JobTrackerApi/JobTrackerApi.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,130 @@
using System.Net;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using MySqlConnector;
using Moq;
namespace JobTrackerApi.MariaDb.Tests;
// Each test owns its schema. Never connect contexts to the administrator's selected database.
internal sealed class MariaDbDatabase : IAsyncDisposable
{
private readonly string _adminConnection;
private readonly string _root;
private bool _created;
public string Name { get; } = $"jt_it_{Guid.NewGuid():N}";
public string ConnectionString { get; private set; } = "";
public MariaDbServerVersion ServerVersion { get; private set; } = null!;
public string ActualVersion { get; private set; } = "";
public WebApplication App { get; private set; } = null!;
private MariaDbDatabase(string adminConnection)
{
_adminConnection = adminConnection;
_root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), Name));
}
internal static MySqlConnectionStringBuilder ValidateAdminConnection(string? value)
{
if (string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException("Explicit MariaDB test lane requires JOBTRACKER_MARIADB_TEST_ADMIN; no skip or SQLite fallback is allowed.");
var builder = new MySqlConnectionStringBuilder(value);
if (!(builder.Server.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| IPAddress.TryParse(builder.Server, out var address) && IPAddress.IsLoopback(address)))
throw new InvalidOperationException("MariaDB integration tests require a loopback server.");
if (builder.ConnectionProtocol != MySqlConnectionProtocol.Sockets)
throw new InvalidOperationException("MariaDB integration tests require loopback TCP.");
builder.Database = "";
builder.Pooling = false;
builder.ConnectionTimeout = 5;
builder.DefaultCommandTimeout = 30;
return builder;
}
public static async Task<MariaDbDatabase> CreateAsync()
{
var admin = ValidateAdminConnection(Environment.GetEnvironmentVariable("JOBTRACKER_MARIADB_TEST_ADMIN"));
var database = new MariaDbDatabase(admin.ConnectionString);
try
{
await using var connection = new MySqlConnection(admin.ConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT VERSION();";
database.ActualVersion = (string)(await command.ExecuteScalarAsync())!;
var expected = Environment.GetEnvironmentVariable("JOBTRACKER_MARIADB_TEST_VERSION") ?? "12.2.2";
if (!Version.TryParse(expected, out var pin) || pin.Build < 0 || pin.Revision >= 0 || pin.Major < 11)
throw new InvalidOperationException("JOBTRACKER_MARIADB_TEST_VERSION must pin a supported MariaDB major.minor.patch version.");
if (!database.ActualVersion.StartsWith(expected + "-", StringComparison.Ordinal)
|| !database.ActualVersion.Contains("MariaDB", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Expected MariaDB {expected}; actual server is {database.ActualVersion}.");
database.ServerVersion = new MariaDbServerVersion(pin);
command.CommandText = $"CREATE DATABASE `{database.Name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;";
await command.ExecuteNonQueryAsync();
database._created = true;
admin.Database = database.Name;
database.ConnectionString = admin.ConnectionString;
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
EnvironmentName = "Production",
ApplicationName = typeof(StartupInitializationExtensions).Assembly.GetName().Name,
});
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Data:Root"] = database._root,
["Database:Provider"] = "mariadb",
["ConnectionStrings:JobTracker"] = database.ConnectionString,
["Auth:AdminEmail"] = "",
["Auth:AdminPassword"] = "",
});
builder.Logging.ClearProviders();
builder.Services.AddSingleton(Mock.Of<ICurrentUserService>(user => user.UserId == "rd21-owner"));
builder.Services.AddSingleton<AppPaths>();
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
builder.Services.AddDbContext<JobTrackerContext>(options => database.Configure(options));
builder.Services.AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>().AddEntityFrameworkStores<JobTrackerContext>();
database.App = builder.Build();
await database.App.InitializeJobTrackerAsync();
return database;
}
catch
{
await database.DisposeAsync();
throw;
}
}
private void Configure(DbContextOptionsBuilder options) => options.UseMySql(ConnectionString, ServerVersion)
.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning));
public JobTrackerContext Context(string owner = "rd21-owner", IInterceptor? interceptor = null)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>();
Configure(options);
if (interceptor is not null) options.AddInterceptors(interceptor);
return new(options.Options, Mock.Of<ICurrentUserService>(user => user.UserId == owner));
}
public async ValueTask DisposeAsync()
{
if (App is not null) await App.DisposeAsync();
if (_created)
{
await using var connection = new MySqlConnection(_adminConnection);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = $"DROP DATABASE `{Name}`;";
await command.ExecuteNonQueryAsync();
_created = false;
}
var temp = Path.GetFullPath(Path.GetTempPath()).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
if (!_root.StartsWith(temp, StringComparison.OrdinalIgnoreCase) || Path.GetFileName(_root) != Name)
throw new InvalidOperationException("Refusing cleanup outside this fixture's temporary directory.");
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
}
}
+16
View File
@@ -0,0 +1,16 @@
# Explicit MariaDB persistence lane
Run with the pinned .NET SDK and a disposable **loopback** MariaDB 12.2.2 instance:
```powershell
$env:DOTNET_ROOT = 'C:\Users\Connor\.dotnet'
# Set JOBTRACKER_MARIADB_TEST_ADMIN through the local test environment; do not commit its secret.
# Its account needs CREATE/DROP DATABASE and full privileges on newly-created jt_it_* schemas.
rtk proxy C:\Users\Connor\.dotnet\dotnet.exe test JobTrackerApi.MariaDb.Tests/JobTrackerApi.MariaDb.Tests.csproj --logger 'trx;LogFileName=mariadb.trx' --results-directory .artifacts/mariadb
```
Missing credentials, unreachable server, a non-loopback host, or a server version mismatch fails this lane. It never skips or substitutes SQLite. The default exact version is `12.2.2`; `JOBTRACKER_MARIADB_TEST_VERSION` can select another explicit `major.minor.patch` for a separately labelled compatibility run. The test output records the server, EF, provider and applied migration versions.
The administrator connection's database is cleared before use. Each test creates a new `jt_it_<guid>` database, initializes it through `InitializeJobTrackerAsync`, and drops only that fixture's successfully-created database. Connection pooling is disabled. The additive migration test briefly removes/reapplies RD21 only inside its own new schema. These tests must never be pointed at production; the loopback check is a guard, not permission to tunnel a production server locally.
Controllers use the same minimal Identity/summarizer mocks as the existing RD21 suite; application persistence, transactions, foreign keys, indexes, revision predicates and startup reconciliation use the real server. Independent contexts use independent TCP connections. No HTTP hosting, external AI provider, browser, deployed identity or production acceptance is established by this lane.
@@ -0,0 +1,412 @@
using System.Security.Claims;
using System.Text.Json;
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 Microsoft.EntityFrameworkCore.Diagnostics;
using Moq;
using Xunit;
namespace JobTrackerApi.MariaDb.Tests;
public sealed class Rd21CareerPersistenceTests
{
private static JobTrackerContext Context(MariaDbDatabase connection, IInterceptor? interceptor = null) => connection.Context(interceptor: interceptor);
private static CareerProfileController Controller(JobTrackerContext db, bool identitySucceeds = true)
=> new(Users(db, identitySucceeds).Object, new CareerProfileService(db), db)
{
ControllerContext = new() { HttpContext = new DefaultHttpContext() },
};
private static Mock<UserManager<ApplicationUser>> Users(JobTrackerContext db, bool identitySucceeds)
{
var user = db.Users.Single();
var users = TestHostFactory.CreateUserManager();
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(x => x.UpdateAsync(user)).Returns(async () =>
{
if (!identitySucceeds) return IdentityResult.Failed(new IdentityError { Description = "Synthetic Identity failure" });
await db.SaveChangesAsync();
return IdentityResult.Success;
});
return users;
}
private static ProfileCvController ImportController(JobTrackerContext db, bool identitySucceeds = true) =>
new(Users(db, identitySucceeds).Object, new Mock<ISummarizerService>().Object, db, null!)
{ ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
private static CareerProfileSaveRequest Request(string title, int? expectedVersion) =>
JsonSerializer.Deserialize<CareerProfileSaveRequest>(JsonSerializer.Serialize(new
{
profile = new StructuredCvProfile { Jobs = { new() { Id = "stable-role", Title = title } } },
expectedVersion,
}), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
[Fact]
public async Task Independent_stale_snapshot_cannot_replace_acknowledged_career_edit()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using (var seed = Context(connection))
{
seed.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
await seed.SaveChangesAsync();
Assert.IsType<OkObjectResult>((await Controller(seed).Put(Request("Original", 0), default)).Result);
}
await using var first = Context(connection);
await using var stale = Context(connection);
var staleController = Controller(stale);
var source = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>((await staleController.Get(default)).Result).Value);
Assert.Equal(1, source.Revision);
Assert.Equal("Original", source.Profile.Jobs.Single().Title);
Assert.IsType<OkObjectResult>((await Controller(first).Put(Request("Acknowledged winner", 1), default)).Result);
var rejected = await staleController.Put(Request("Stale draft", 1), default);
Assert.IsType<ConflictObjectResult>(rejected.Result);
await stale.SaveChangesAsync();
await using var verify = Context(connection);
Assert.Equal("Acknowledged winner", (await verify.CareerExperiences.SingleAsync()).Title);
Assert.Equal(2, (await verify.CareerProfiles.SingleAsync()).Version);
Assert.Equal(2, await verify.CareerProfileVersions.CountAsync());
Assert.Contains("Acknowledged winner", (await verify.Users.SingleAsync()).ProfileCvStructureJson);
}
[Fact]
public async Task Identity_failure_rolls_back_canonical_children_history_and_projection()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
await db.SaveChangesAsync();
var result = await Controller(db, false).Put(Request("Must not persist", 0), default);
Assert.IsType<BadRequestObjectResult>(result.Result);
await db.SaveChangesAsync();
await using var verify = Context(connection);
Assert.Empty(await verify.CareerProfiles.ToListAsync());
Assert.Empty(await verify.CareerExperiences.ToListAsync());
Assert.Empty(await verify.CareerProfileVersions.ToListAsync());
Assert.Null((await verify.Users.SingleAsync()).ProfileCvStructureJson);
}
[Fact]
public async Task Stale_tracked_entity_is_guarded_at_database_update_and_stale_restore_is_rejected()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var first = Context(connection);
var a = new CareerProfileService(first);
await a.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Original" } } }, "manual", 0, default);
await using var stale = Context(connection);
await stale.CareerProfiles.SingleAsync();
await a.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Winner" } } }, "manual", 1, default);
var b = new CareerProfileService(stale);
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => b.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Title = "Stale" } } }, "manual", 1, default));
await stale.SaveChangesAsync();
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => b.RestoreVersionAsync("rd21-owner", 1, 1, default));
await using var verify = Context(connection);
Assert.Equal("Winner", (await verify.CareerExperiences.SingleAsync()).Title);
Assert.Equal(2, await verify.CareerProfileVersions.CountAsync());
Assert.Equal(0, (await b.LoadSnapshotAsync("another-owner", default)).Revision);
}
[Fact]
public async Task Competing_first_save_sources_have_one_acknowledged_winner()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using (var setup = Context(database))
{
setup.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
await setup.SaveChangesAsync();
}
var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var readers = 0;
async Task<IActionResult?> Save(string title)
{
await using var db = Context(database);
var controller = Controller(db);
Assert.Equal(0, Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>((await controller.Get(default)).Result).Value).Revision);
if (Interlocked.Increment(ref readers) == 2) ready.TrySetResult();
await release.Task.WaitAsync(TimeSpan.FromSeconds(15));
return (await controller.Put(Request(title, 0), default)).Result;
}
var a = Save("Candidate A");
var b = Save("Candidate B");
try { await ready.Task.WaitAsync(TimeSpan.FromSeconds(10)); }
finally { release.TrySetResult(); }
var outcomes = await Task.WhenAll(a, b);
Assert.Single(outcomes.OfType<OkObjectResult>());
Assert.Single(outcomes.OfType<ConflictObjectResult>());
await using var verify = Context(database);
var profile = await verify.CareerProfiles.SingleAsync();
Assert.Equal(1, profile.Version);
Assert.Single(await verify.CareerProfileVersions.ToListAsync());
Assert.Equal(profile.ProfileJson, (await verify.Users.SingleAsync()).ProfileCvStructureJson);
Assert.Equal(StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson).Jobs.Single().Title, (await verify.CareerExperiences.SingleAsync()).Title);
}
private sealed class FailAfterChildDeletion : SaveChangesInterceptor
{
private int _saves;
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
if (++_saves == 3) throw new InvalidOperationException("Synthetic child insert failure");
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
[Fact]
public async Task Database_failure_after_deletion_restores_children_history_and_revision_and_clears_tracker()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using (var seed = Context(connection))
{
await new CareerProfileService(seed).SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Original" } } }, "manual", 0, default);
}
await using var failed = Context(connection, new FailAfterChildDeletion());
await Assert.ThrowsAsync<InvalidOperationException>(() => new CareerProfileService(failed).SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Title = "Rejected" } } }, "manual", 1, default));
Assert.Empty(failed.ChangeTracker.Entries());
await failed.SaveChangesAsync();
await using var verify = Context(connection);
Assert.Equal("Original", (await verify.CareerExperiences.SingleAsync()).Title);
Assert.Equal(1, (await verify.CareerProfiles.SingleAsync()).Version);
Assert.Single(await verify.CareerProfileVersions.ToListAsync());
await new CareerProfileService(failed).SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Recovered" } } }, "manual", 1, default);
await using var recovered = Context(connection);
Assert.Equal("Recovered", (await recovered.CareerExperiences.SingleAsync()).Title);
Assert.Equal(2, (await recovered.CareerProfiles.SingleAsync()).Version);
Assert.Equal(2, await recovered.CareerProfileVersions.CountAsync());
}
[Theory]
[InlineData(null)]
[InlineData(-1)]
public async Task Replacement_restore_parse_and_import_fail_closed_without_valid_revision(int? expectedVersion)
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
await db.SaveChangesAsync();
var controller = Controller(db);
Assert.IsType<BadRequestObjectResult>((await controller.Put(Request("Rejected", expectedVersion), default)).Result);
Assert.IsType<BadRequestObjectResult>((await controller.Restore(1, new(expectedVersion), default)).Result);
var imports = ImportController(db);
Assert.IsType<BadRequestObjectResult>((await imports.Parse(new("# Synthetic", expectedVersion))).Result);
Assert.IsType<BadRequestObjectResult>(await imports.AcceptRun(1, new(null, expectedVersion)));
Assert.Empty(await db.CareerProfiles.ToListAsync());
Assert.Empty(await db.CvExtractionRuns.ToListAsync());
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Reviewed_import_commits_or_rolls_back_canonical_projection_and_application_metadata(bool identitySucceeds)
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
var run = new CvExtractionRun
{
OwnerUserId = "rd21-owner", Trigger = "upload", Status = "pending_review",
StructuredProfileJson = StructuredCvProfileJson.Serialize(new() { Jobs = { new() { Id = "imported-role", Title = "Reviewed role" } } }),
};
db.CvExtractionRuns.Add(run);
await db.SaveChangesAsync();
var result = await ImportController(db, identitySucceeds).AcceptRun(run.Id, new(null, 0));
if (identitySucceeds) Assert.IsType<OkObjectResult>(result);
else Assert.IsType<BadRequestObjectResult>(result);
await db.SaveChangesAsync();
await using var verify = Context(connection);
var savedRun = await verify.CvExtractionRuns.SingleAsync();
var user = await verify.Users.SingleAsync();
Assert.Equal(identitySucceeds ? "applied" : "pending_review", savedRun.Status);
Assert.Equal(identitySucceeds, savedRun.AppliedAtUtc.HasValue);
Assert.Equal(identitySucceeds, user.CurrentCvExtractionRunId.HasValue);
Assert.Equal(identitySucceeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync());
Assert.Equal(identitySucceeds ? 1 : 0, await verify.CareerExperiences.CountAsync());
Assert.Equal(identitySucceeds, user.ProfileCvStructureJson is not null);
}
[Fact]
public async Task Import_acceptance_requires_the_revision_shown_in_its_diff()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
var run = new CvExtractionRun { OwnerUserId = "rd21-owner", Trigger = "upload", Status = "pending_review", StructuredProfileJson = "{}" };
db.CvExtractionRuns.Add(run);
await db.SaveChangesAsync();
var imports = ImportController(db);
var diff = Assert.IsType<OkObjectResult>(await imports.GetRunDiff(run.Id));
Assert.Equal(0, JsonSerializer.SerializeToElement(diff.Value).GetProperty("revision").GetInt32());
Assert.IsType<OkObjectResult>((await Controller(db).Put(Request("Winner", 0), default)).Result);
Assert.IsType<ConflictObjectResult>(await imports.AcceptRun(run.Id, new(null, 0)));
await using var verify = Context(connection);
Assert.Equal("pending_review", (await verify.CvExtractionRuns.SingleAsync()).Status);
Assert.Equal("Winner", (await verify.CareerExperiences.SingleAsync()).Title);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Parsing_commits_or_rolls_back_its_entire_import_application(bool identitySucceeds)
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
await db.SaveChangesAsync();
const string text = "# Contact\nSynthetic Person\n\n# Professional Summary\nBuilt APIs.\n\n# Skills\nC#\nSQL";
var result = await ImportController(db, identitySucceeds).Parse(new(text, 0));
if (identitySucceeds) Assert.IsType<OkObjectResult>(result.Result);
else Assert.IsType<BadRequestObjectResult>(result.Result);
await db.SaveChangesAsync();
await using var verify = Context(connection);
Assert.Equal(identitySucceeds ? 1 : 0, await verify.CvExtractionRuns.CountAsync());
Assert.Equal(identitySucceeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync());
var user = await verify.Users.SingleAsync();
Assert.Equal(identitySucceeds, user.CurrentCvExtractionRunId.HasValue);
Assert.Equal(identitySucceeds, user.ProfileCvStructureJson is not null);
if (identitySucceeds)
{
var profile = await verify.CareerProfiles.SingleAsync();
Assert.Equal(profile.ProfileJson, user.ProfileCvStructureJson);
Assert.Equal(profile.ProfileJson, (await verify.CvExtractionRuns.SingleAsync()).StructuredProfileJson);
Assert.Equal(user.ProfileCvText, (await new CareerProfileService(verify).LoadSnapshotAsync("rd21-owner", default)).CvText);
}
}
[Fact]
public async Task Editor_snapshot_uses_relational_canonical_source_and_never_writes_during_read()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic", ProfileCvText = "Stored source text" });
var profile = new CareerProfile
{
OwnerUserId = "rd21-owner", Version = 7,
ProfileJson = StructuredCvProfileJson.SerializePersisted(new() { Jobs = { new() { Title = "Outdated projection" } } }),
LongTailJson = CareerProfileMapper.SerializeLongTail(new() { Summary = { "Canonical summary" } }),
Experiences = { new() { OwnerUserId = "rd21-owner", ItemKey = "canonical-role", Title = "Canonical role" } },
};
db.CareerProfiles.Add(profile);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var snapshot = await new CareerProfileService(db).LoadSnapshotAsync("rd21-owner", default);
Assert.Equal(7, snapshot.Revision);
Assert.Equal("Canonical role", snapshot.Profile.Jobs.Single().Title);
Assert.Equal("canonical-role", snapshot.Profile.Jobs.Single().Id);
Assert.Equal("Canonical summary", snapshot.Profile.Summary.Single());
Assert.Equal("Stored source text", snapshot.CvText);
Assert.Empty(db.ChangeTracker.Entries());
Assert.Equal(7, (await db.CareerProfiles.SingleAsync()).Version);
Assert.Single(await db.CareerExperiences.ToListAsync());
Assert.Empty(await db.CareerProfileVersions.ToListAsync());
}
[Fact]
public async Task Empty_canonical_profile_preserves_legacy_raw_text_in_its_revision_zero_snapshot()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic", ProfileCvText = "Legacy raw CV text" });
await db.SaveChangesAsync();
var response = await Controller(db).Get(default);
var dto = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(response.Result).Value);
Assert.Equal(0, dto.Revision);
Assert.Empty(dto.Profile.Jobs);
Assert.Equal("Legacy raw CV text", dto.CvText);
Assert.Empty(await db.CareerProfiles.ToListAsync());
Assert.Empty(await db.CareerProfileVersions.ToListAsync());
}
[Fact]
public async Task Extraction_history_returns_only_the_ten_newest_owned_runs_with_exact_metadata()
{
await using var database = await MariaDbDatabase.CreateAsync();
var started = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
var owner = new ApplicationUser { Id = "rd21-owner", UserName = "history-owner@example.test" };
CvExtractionRun[] expected;
Guid latestOperationId;
await using (var seed = Context(database))
{
seed.Users.AddRange(owner, new ApplicationUser { Id = "other-owner", UserName = "history-other@example.test" });
var owned = Enumerable.Range(0, 13).Select(index => new CvExtractionRun
{
OwnerUserId = owner.Id, Trigger = "upload", Status = index == 12 ? "failed" : "applied",
Artifact = new CvUploadArtifact
{
OwnerUserId = owner.Id, OriginalFileName = $"owned-{index}.pdf", StoredFileName = $"stored-{index}.pdf",
MimeType = "application/pdf", Sha256 = new string('a', 64), StoragePath = $"fixture-{index}", ByteSize = 32,
},
StartedAtUtc = started.AddMinutes(index), CompletedAtUtc = started.AddMinutes(index).AddSeconds(10),
AppliedAtUtc = index == 12 ? null : started.AddMinutes(index).AddSeconds(20),
ParserVersion = $"parser-{index}", NormalizerVersion = $"normalizer-{index}", LlmPromptVersion = $"prompt-{index}",
ErrorMessage = index == 12 ? "Synthetic parser failure" : null,
}).ToArray();
seed.CvExtractionRuns.AddRange(owned);
seed.CvExtractionRuns.AddRange(Enumerable.Range(0, 3).Select(index => new CvExtractionRun
{
OwnerUserId = "other-owner", Trigger = "private-upload", Status = "pending_review", StartedAtUtc = started.AddDays(1).AddMinutes(index),
}));
await seed.SaveChangesAsync();
expected = owned.OrderByDescending(run => run.StartedAtUtc).Take(10).ToArray();
var subject = expected[0].Id.ToString(System.Globalization.CultureInfo.InvariantCulture);
var latest = new UserOperation
{
OwnerUserId = owner.Id, TaskType = CvProcessingQueue.TaskType, SubjectType = CvProcessingQueue.SubjectType,
SubjectId = subject, IdempotencyKey = "history-latest", Status = OperationStatuses.Succeeded,
CreatedAtUtc = started.AddHours(2).UtcDateTime, AvailableAtUtc = started.UtcDateTime,
};
seed.UserOperations.AddRange(latest,
new UserOperation
{
OwnerUserId = owner.Id, TaskType = CvProcessingQueue.TaskType, SubjectId = subject,
IdempotencyKey = "history-older", Status = OperationStatuses.Failed,
CreatedAtUtc = started.AddHours(1).UtcDateTime, AvailableAtUtc = started.UtcDateTime,
},
new UserOperation
{
OwnerUserId = "other-owner", TaskType = CvProcessingQueue.TaskType, SubjectId = subject,
IdempotencyKey = "history-private", Status = OperationStatuses.Running,
CreatedAtUtc = started.AddDays(2).UtcDateTime, AvailableAtUtc = started.UtcDateTime,
});
await seed.SaveChangesAsync();
latestOperationId = latest.Id;
}
await using var read = Context(database);
var users = TestHostFactory.CreateUserManager(owner);
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(owner);
var controller = new ProfileCvController(users.Object, Mock.Of<ISummarizerService>(), read, null!)
{
ControllerContext = new() { HttpContext = new DefaultHttpContext() },
};
var response = Assert.IsType<OkObjectResult>((await controller.GetRuns()).Result);
Assert.Equal(200, response.StatusCode);
var actual = Assert.IsAssignableFrom<IEnumerable<CvExtractionRunListItem>>(response.Value).ToArray();
Assert.Equal(10, actual.Length);
Assert.Equal(expected.Select(run => run.Id), actual.Select(run => run.Id));
Assert.Equal(expected.Select(run => run.StartedAtUtc), actual.Select(run => run.StartedAtUtc));
for (var index = 0; index < actual.Length; index++)
{
Assert.Equal(expected[index].Trigger, actual[index].Trigger);
Assert.Equal(expected[index].Status, actual[index].Status);
Assert.Equal(expected[index].Artifact!.OriginalFileName, actual[index].ArtifactFileName);
Assert.Equal(expected[index].CompletedAtUtc, actual[index].CompletedAtUtc);
Assert.Equal(expected[index].AppliedAtUtc, actual[index].AppliedAtUtc);
Assert.Equal(expected[index].ParserVersion, actual[index].ParserVersion);
Assert.Equal(expected[index].NormalizerVersion, actual[index].NormalizerVersion);
Assert.Equal(expected[index].LlmPromptVersion, actual[index].LlmPromptVersion);
Assert.Equal(expected[index].ErrorMessage, actual[index].ErrorMessage);
}
Assert.Equal(latestOperationId, actual[0].Operation!.Id);
Assert.Equal(OperationStatuses.Succeeded, actual[0].Operation!.Status);
Assert.All(actual.Skip(1), run => Assert.Null(run.Operation));
Assert.Empty(read.ChangeTracker.Entries());
}
}
@@ -0,0 +1,353 @@
using System.Text.Json;
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using Moq;
using Xunit;
namespace JobTrackerApi.MariaDb.Tests;
public sealed class Rd21JobPersistenceTests
{
private static JobTrackerContext Context(MariaDbDatabase connection, string owner = "rd21-owner") => connection.Context(owner);
private static JobApplicationsController Controller(JobTrackerContext db) => new(db,
Mock.Of<ISummarizerService>(), TestHostFactory.CreateUserManager(new ApplicationUser { Id = "rd21-owner" }).Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "rd21-owner") }, "test")),
} },
};
[Fact]
public async Task Write_that_overlaps_a_delayed_save_wins_without_partial_events_or_opportunity_changes()
{
await using var connection = await MariaDbDatabase.CreateAsync();
int id, companyId;
await using (var seed = Context(connection))
{
var company = new Company { OwnerUserId = "rd21-owner", Name = "RD21 overlap" };
seed.Add(company);
await seed.SaveChangesAsync();
var job = new JobApplication { OwnerUserId = "rd21-owner", CompanyId = company.Id, JobTitle = "Original job title", Status = "Prospect" };
job.Job = JobOpportunitySync.Create(job);
seed.Add(job);
await seed.SaveChangesAsync();
id = job.Id; companyId = company.Id;
}
var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
var summarizer = new Mock<ISummarizerService>();
summarizer.Setup(x => x.SummarizeAsync(It.IsAny<string>(), 160, 60)).Returns(() =>
{
reached.TrySetResult();
return release.Task;
});
var user = new ApplicationUser { Id = "rd21-owner", AiEnabled = true };
var users = TestHostFactory.CreateUserManager(user);
users.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Premium" });
await using var delayed = Context(connection);
var controller = new JobApplicationsController(delayed, summarizer.Object, users.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "test")),
} },
};
var pending = controller.Update(id, Request(companyId, "Delayed title", null, 1) with { Status = "Offer" }, default);
await reached.Task.WaitAsync(TimeSpan.FromSeconds(10));
try
{
await using var winner = Context(connection);
Assert.IsType<NoContentResult>(await Controller(winner).Update(id,
Request(companyId, "Original job title", "Acknowledged concurrent notes", 1), default));
}
finally { release.TrySetResult("Delayed summary"); }
Assert.IsType<ConflictObjectResult>(await pending);
await using var verify = Context(connection);
var saved = await verify.JobApplications.SingleAsync();
Assert.Equal(2, saved.Version);
Assert.Equal("Original job title", saved.JobTitle);
Assert.Equal("Acknowledged concurrent notes", saved.Notes);
Assert.Equal("Prospect", saved.Status);
Assert.Equal("Original job title", (await verify.Jobs.SingleAsync()).JobTitle);
Assert.Empty(await verify.JobEvents.ToListAsync());
Assert.Empty(delayed.ChangeTracker.Entries());
// A deliberate reload/review uses the winner's content and new revision, never a blind retry.
await using var reviewed = Context(connection);
Assert.IsType<NoContentResult>(await Controller(reviewed).Update(id,
Request(companyId, "Reviewed final title", saved.Notes, saved.Version), default));
await using var finalSession = Context(connection);
var final = await finalSession.JobApplications.SingleAsync();
Assert.Equal(3, final.Version);
Assert.Equal("Reviewed final title", final.JobTitle);
Assert.Equal("Acknowledged concurrent notes", final.Notes);
}
private static UpdateJobApplicationRequest Request(int companyId, string title, string? notes, int? expectedVersion) =>
JsonSerializer.Deserialize<UpdateJobApplicationRequest>(JsonSerializer.Serialize(new
{
jobTitle = title, companyId, status = "Prospect", notes, expectedVersion,
location = "Oslo", salary = "unchanged salary", description = "Original description",
}), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
[Fact]
public async Task Structured_tailored_cv_snapshot_cannot_replace_a_newer_approved_document()
{
await using var connection = await MariaDbDatabase.CreateAsync();
int id;
await using (var seed = Context(connection))
{
var job = new JobApplication { OwnerUserId = "rd21-owner", Company = new Company { Name = "RD21 CV", OwnerUserId = "rd21-owner" }, JobTitle = "Role" };
seed.Add(job);
await seed.SaveChangesAsync();
id = job.Id;
}
SaveTailoredCvDraftRequest Draft(string text) => JsonSerializer.Deserialize<SaveTailoredCvDraftRequest>(
JsonSerializer.Serialize(new { summary = new[] { text }, expectedVersion = 1 }),
new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
await using var first = Context(connection);
Assert.IsType<NoContentResult>(await Controller(first).SaveTailoredCvDraft(id, Draft("Approved CV evidence"), default));
await using var stale = Context(connection);
var rejected = await Controller(stale).SaveTailoredCvDraft(id, Draft("Stale CV evidence"), default);
await using var verify = Context(connection);
Assert.Contains("Approved CV evidence", (await verify.JobApplications.SingleAsync()).TailoredCvText);
Assert.Contains("Approved CV evidence", (await verify.TailoredCvDrafts.SingleAsync()).SummaryJson);
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(rejected).StatusCode);
}
[Fact]
public async Task Failed_job_transaction_cannot_acknowledge_or_leave_partial_data_for_a_later_save()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var company = new Company { OwnerUserId = "rd21-owner", Name = "RD21 rollback" };
db.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication { OwnerUserId = "rd21-owner", CompanyId = company.Id, JobTitle = "Approved title", Notes = "Approved notes", Status = "Prospect" };
job.Job = JobOpportunitySync.Create(job);
db.Add(job);
await db.SaveChangesAsync();
await db.Database.ExecuteSqlRawAsync("""
CREATE TRIGGER rd21_reject_event BEFORE INSERT ON JobEvents
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'synthetic RD21 event failure';
""");
await Assert.ThrowsAsync<DbUpdateException>(() => Controller(db).Update(job.Id,
Request(company.Id, "Uncommitted title", "Uncommitted notes", 1) with { Status = "Offer" }, default));
Assert.Empty(db.ChangeTracker.Entries());
await db.SaveChangesAsync();
await using var verify = Context(connection);
var actual = await verify.JobApplications.SingleAsync();
Assert.Equal("Approved title", actual.JobTitle);
Assert.Equal("Approved notes", actual.Notes);
Assert.Equal("Prospect", actual.Status);
Assert.Equal(1, actual.Version);
Assert.Equal("Approved title", (await verify.Jobs.SingleAsync()).JobTitle);
Assert.Empty(await verify.JobEvents.ToListAsync());
await db.Database.ExecuteSqlRawAsync("DROP TRIGGER rd21_reject_event;");
Assert.IsType<NoContentResult>(await Controller(db).Update(job.Id,
Request(company.Id, "Recovered title", "Recovered notes", 1) with { Status = "Offer" }, default));
await using var recovered = Context(connection);
Assert.Equal("Recovered title", (await recovered.JobApplications.SingleAsync()).JobTitle);
Assert.Equal(2, (await recovered.JobApplications.SingleAsync()).Version);
Assert.Equal(new[] { "OfferReceived", "StatusChanged" },
await recovered.JobEvents.OrderBy(x => x.Type).Select(x => x.Type).ToArrayAsync());
}
[Fact]
public async Task Versioned_job_save_cannot_cross_tenant_job_or_company_ownership()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var mine = new Company { OwnerUserId = "rd21-owner", Name = "Owned company" };
var other = new Company { OwnerUserId = "other-owner", Name = "Private company" };
var job = new JobApplication { OwnerUserId = "rd21-owner", Company = mine, JobTitle = "Owned job", Status = "Prospect" };
var foreign = new JobApplication { OwnerUserId = "other-owner", Company = other, JobTitle = "Private job", Status = "Prospect" };
db.AddRange(job, foreign);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
Assert.IsType<NotFoundResult>(await Controller(db).Update(foreign.Id, Request(other.Id, "Stolen", null, 1), default));
Assert.IsType<BadRequestObjectResult>(await Controller(db).Update(job.Id, Request(other.Id, "Changed", null, 1), default));
await using var verify = Context(connection);
Assert.Equal("Owned job", (await verify.JobApplications.SingleAsync()).JobTitle);
Assert.Equal(mine.Id, (await verify.JobApplications.SingleAsync()).CompanyId);
await using var foreignSession = Context(connection, "other-owner");
Assert.Equal("Private job", (await foreignSession.JobApplications.SingleAsync()).JobTitle);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Related_text_save_cannot_silently_replace_a_newer_acknowledged_draft(bool coverLetter)
{
await using var connection = await MariaDbDatabase.CreateAsync();
int id;
await using (var seed = Context(connection))
{
var job = new JobApplication
{
OwnerUserId = "rd21-owner", Company = new Company { OwnerUserId = "rd21-owner", Name = "RD21" },
JobTitle = "Role", CoverLetterText = "Original letter", Notes = "Original notes",
};
seed.Add(job);
await seed.SaveChangesAsync();
id = job.Id;
}
async Task<IActionResult> Save(JobTrackerContext db, string text)
{
var json = JsonSerializer.Serialize(new { text, notes = text, expectedVersion = 1 });
if (!coverLetter)
return await Controller(db).SaveApplicationDrafts(id,
JsonSerializer.Deserialize<SaveApplicationDraftsRequest>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!, default);
var user = new ApplicationUser { Id = "rd21-owner" };
var users = TestHostFactory.CreateUserManager(user);
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var controller = new ApplicationAssetsController(users.Object,
new ApplicationAssetsService(db, Mock.Of<ICvVariantService>(), Mock.Of<IApplicationIntelligenceService>()),
Mock.Of<ISubmittedApplicationPackageService>())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "test")),
} },
};
return (await controller.SaveCoverLetter(id,
JsonSerializer.Deserialize<ApplicationAssetsController.SaveCoverLetterRequest>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!, default)).Result!;
}
await using var first = Context(connection);
var acknowledged = await Save(first, "First approved text");
Assert.True(acknowledged is OkObjectResult or NoContentResult);
await using (var immediate = Context(connection))
Assert.Equal("First approved text", coverLetter ? (await immediate.JobApplications.SingleAsync()).CoverLetterText : (await immediate.JobApplications.SingleAsync()).Notes);
await using var stale = Context(connection);
if (coverLetter)
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => Save(stale, "Stale snapshot text"));
else
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(await Save(stale, "Stale snapshot text")).StatusCode);
await using var verify = Context(connection);
var saved = await verify.JobApplications.SingleAsync();
Assert.Equal("First approved text", coverLetter ? saved.CoverLetterText : saved.Notes);
if (coverLetter) Assert.Single(await verify.CoverLetterVersions.ToListAsync());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Stale_or_unversioned_snapshot_cannot_erase_an_acknowledged_job_edit(bool omitRevision)
{
await using var connection = await MariaDbDatabase.CreateAsync();
int id, companyId;
await using (var seed = Context(connection))
{
var company = new Company { OwnerUserId = "rd21-owner", Name = "Synthetic RD21" };
seed.Companies.Add(company);
await seed.SaveChangesAsync();
var job = new JobApplication
{
OwnerUserId = "rd21-owner", Company = company, CompanyId = company.Id, JobTitle = "Original job title",
Status = "Prospect", Location = "Oslo", Salary = "unchanged salary", Description = "Original description",
};
job.Job = JobOpportunitySync.Create(job);
seed.JobApplications.Add(job);
await seed.SaveChangesAsync();
id = job.Id; companyId = company.Id;
}
// These are the two snapshots actually read by independent editors.
await using var editorA = Context(connection);
await using var editorBRead = Context(connection);
var original = await editorBRead.JobApplications.AsNoTracking().SingleAsync();
Assert.Equal("Original job title", original.JobTitle);
Assert.IsType<NoContentResult>(await Controller(editorA).Update(id,
Request(companyId, "First editor committed title", null, 1), default));
await using (var immediatelyAfterAcknowledgement = Context(connection))
{
Assert.Equal("First editor committed title", (await immediatelyAfterAcknowledgement.JobApplications.SingleAsync()).JobTitle);
Assert.Equal("First editor committed title", (await immediatelyAfterAcknowledgement.Jobs.SingleAsync()).JobTitle);
}
await using var editorBWrite = Context(connection);
var rejected = await Controller(editorBWrite).Update(id,
Request(companyId, original.JobTitle, "Second editor stale notes", omitRevision ? null : 1), default);
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(rejected).StatusCode);
await using var independentSession = Context(connection);
var persisted = await independentSession.JobApplications.SingleAsync();
Assert.Equal("First editor committed title", persisted.JobTitle);
Assert.Null(persisted.Notes);
Assert.Equal("Oslo", persisted.Location);
Assert.Equal("unchanged salary", persisted.Salary);
Assert.Equal("First editor committed title", (await independentSession.Jobs.SingleAsync()).JobTitle);
Assert.Empty(await independentSession.JobEvents.ToListAsync());
}
[Fact]
public async Task Held_writer_timeout_rolls_back_and_a_reviewed_save_recovers_after_release()
{
await using var database = await MariaDbDatabase.CreateAsync();
int id, companyId;
await using (var seed = Context(database))
{
var company = new Company { OwnerUserId = "rd21-owner", Name = "Writer lock" };
seed.Add(company);
await seed.SaveChangesAsync();
var job = new JobApplication
{
OwnerUserId = "rd21-owner", CompanyId = company.Id,
JobTitle = "Approved title", Notes = "Approved notes", Status = "Prospect",
};
job.Job = JobOpportunitySync.Create(job);
seed.Add(job);
await seed.SaveChangesAsync();
id = job.Id; companyId = job.CompanyId;
}
await using var holder = Context(database);
await using var transaction = await holder.Database.BeginTransactionAsync();
await holder.Database.ExecuteSqlInterpolatedAsync($"UPDATE JobApplications SET Notes = 'Uncommitted lock holder' WHERE Id = {id}");
await using var waiting = Context(database);
await waiting.Database.OpenConnectionAsync();
await waiting.Database.ExecuteSqlRawAsync("SET SESSION innodb_lock_wait_timeout = 1;");
try
{
// The server's lock timeout supplies the synchronization; no sleep or retry hides lock failures.
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(waiting).Update(id,
Request(companyId, "Timed out title", "Timed out notes", 1) with { Status = "Offer" }, default));
var updateFailure = Assert.IsType<DbUpdateException>(exception.InnerException);
Assert.Equal(1205, Assert.IsType<MySqlException>(updateFailure.InnerException).Number);
Assert.Empty(waiting.ChangeTracker.Entries());
await waiting.SaveChangesAsync();
}
finally { await transaction.RollbackAsync(); }
await using (var verify = Context(database))
{
var saved = await verify.JobApplications.SingleAsync();
Assert.Equal("Approved title", saved.JobTitle);
Assert.Equal("Approved notes", saved.Notes);
Assert.Equal("Prospect", saved.Status);
Assert.Equal(1, saved.Version);
Assert.Equal("Approved title", (await verify.Jobs.SingleAsync()).JobTitle);
Assert.Empty(await verify.JobEvents.ToListAsync());
}
Assert.IsType<NoContentResult>(await Controller(waiting).Update(id,
Request(companyId, "Reviewed recovery", "Approved notes", 1), default));
await using var recovered = Context(database);
Assert.Equal("Reviewed recovery", (await recovered.JobApplications.SingleAsync()).JobTitle);
Assert.Equal(2, (await recovered.JobApplications.SingleAsync()).Version);
}
}
@@ -0,0 +1,157 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using MySqlConnector;
using Xunit;
using Xunit.Abstractions;
namespace JobTrackerApi.MariaDb.Tests;
public sealed class StartupAndLockTests(ITestOutputHelper output)
{
[Fact]
public async Task Full_startup_creates_every_table_and_column_required_by_the_persisted_EF_model()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using var db = database.Context();
await using var connection = new MySqlConnection(database.ConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE();";
await using var reader = await command.ExecuteReaderAsync();
var actual = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (await reader.ReadAsync()) actual.Add($"{reader.GetString(0)}.{reader.GetString(1)}");
var missing = db.Model.GetRelationalModel().Tables
.SelectMany(table => table.Columns.Select(column => $"{table.Name}.{column.Name}"))
.Where(column => !actual.Contains(column)).Order().ToArray();
output.WriteLine($"Server: {database.ActualVersion}; actual columns: {actual.Count}; missing ({missing.Length}): {string.Join(", ", missing)}");
Assert.True(missing.Length == 0, $"Startup omitted persisted EF columns: {string.Join(", ", missing)}");
}
[Fact]
public async Task Fresh_repeated_and_populated_startup_preserve_owned_data_and_complete_migrations()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using (var fresh = database.Context())
{
Assert.Equal("Pomelo.EntityFrameworkCore.MySql", fresh.Database.ProviderName);
Assert.Empty(await fresh.Database.GetPendingMigrationsAsync());
var applied = (await fresh.Database.GetAppliedMigrationsAsync()).ToArray();
Assert.Contains("20261023090000_AddJobRevision", applied);
output.WriteLine($"MariaDB server: {database.ActualVersion}; EF: {typeof(DbContext).Assembly.GetName().Version}; Pomelo: {typeof(MariaDbServerVersion).Assembly.GetName().Version}");
output.WriteLine($"Applied migrations ({applied.Length}): {string.Join(", ", applied)}");
fresh.Users.AddRange(
new ApplicationUser { Id = "rd21-owner", UserName = "owner@example.test", UiLanguage = "nb-NO" },
new ApplicationUser { Id = "other", UserName = "other@example.test", UiLanguage = "en" });
fresh.Companies.AddRange(new Company { OwnerUserId = "rd21-owner", Name = "Owned" },
new Company { OwnerUserId = "other", Name = "Private" });
await fresh.SaveChangesAsync();
}
await database.App.InitializeJobTrackerAsync();
await database.App.InitializeJobTrackerAsync();
await using var verify = database.Context();
Assert.Equal(new[] { "other", "rd21-owner" }, await verify.Users.AsNoTracking().OrderBy(x => x.Id).Select(x => x.Id).ToArrayAsync());
Assert.Equal("nb-NO", (await verify.Users.AsNoTracking().SingleAsync(x => x.Id == "rd21-owner")).UiLanguage);
Assert.Equal("Owned", (await verify.Companies.AsNoTracking().SingleAsync()).Name);
Assert.Equal(2, await verify.Companies.IgnoreQueryFilters().CountAsync());
Assert.Empty(await verify.Database.GetPendingMigrationsAsync());
}
[Fact]
public async Task Rd21_additive_migration_preserves_existing_job_fields_and_backfills_revision_one()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using var db = database.Context();
var company = new Company { OwnerUserId = "rd21-owner", Name = "Preserve me" };
db.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication
{
OwnerUserId = "rd21-owner", CompanyId = company.Id,
JobTitle = "Historical title", Notes = "Historical notes", Status = "Prospect",
CoverLetterText = "Historical letter", TailoredCvText = "Historical CV",
};
job.Job = JobOpportunitySync.Create(job);
db.Add(job);
await db.SaveChangesAsync();
var migrations = (await db.Database.GetAppliedMigrationsAsync()).ToArray();
Assert.Equal("20261023090000_AddJobRevision", migrations[^1]);
// Only this test's newly-created schema is downgraded; no existing database is selected.
await db.GetService<IMigrator>().MigrateAsync(migrations[^2]);
Assert.Equal(new[] { migrations[^1] }, await db.Database.GetPendingMigrationsAsync());
db.ChangeTracker.Clear();
await database.App.InitializeJobTrackerAsync();
await using var verify = database.Context();
var actual = await verify.JobApplications.AsNoTracking().SingleAsync();
Assert.Equal(job.Id, actual.Id);
Assert.Equal(job.CompanyId, actual.CompanyId);
Assert.Equal("Historical title", actual.JobTitle);
Assert.Equal("Historical notes", actual.Notes);
Assert.Equal("Prospect", actual.Status);
Assert.Equal("Historical letter", actual.CoverLetterText);
Assert.Equal("Historical CV", actual.TailoredCvText);
Assert.Equal("rd21-owner", actual.OwnerUserId);
Assert.Equal(1, actual.Version);
Assert.Equal(migrations, await verify.Database.GetAppliedMigrationsAsync());
Assert.Empty(await verify.Database.GetPendingMigrationsAsync());
}
[Fact]
public async Task Open_reader_does_not_block_independent_usage_and_identity_writes()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using (var seed = database.Context())
{
seed.Users.AddRange(new ApplicationUser { Id = "rd21-owner", UserName = "owner@example.test", UiLanguage = "en" },
new ApplicationUser { Id = "other", UserName = "other@example.test", UiLanguage = "en" });
seed.Companies.AddRange(new Company { OwnerUserId = "rd21-owner", Name = "Owned" },
new Company { OwnerUserId = "other", Name = "Other" });
await seed.SaveChangesAsync();
}
await using var connection = new MySqlConnection(database.ConnectionString);
await connection.OpenAsync();
await using var snapshot = await connection.BeginTransactionAsync(System.Data.IsolationLevel.RepeatableRead);
await using var command = connection.CreateCommand();
command.Transaction = snapshot;
command.CommandText = "SELECT Id FROM Companies ORDER BY Id;";
await using var reader = await command.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
await using var scope = database.App.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var reservation = await new AiUsageMeter(db, TimeProvider.System).ReserveAsync("rd21-owner",
new AccountEntitlements(true, true, 1_000_000, 10, 10_000), "synchronous", "held-reader", "synchronous.summary", 40, 10, default);
Assert.True(reservation.Created);
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var owner = (await users.FindByIdAsync("rd21-owner"))!;
owner.UiLanguage = "nb-NO";
Assert.True((await users.UpdateAsync(owner)).Succeeded);
await using var verify = database.Context();
var usage = await verify.AiUsageRecords.AsNoTracking().SingleAsync();
Assert.Equal(reservation.Record.Id, usage.Id);
Assert.Equal("held-reader", usage.SourceId);
Assert.Equal(1, usage.CallCount);
Assert.Equal("nb-NO", (await verify.Users.AsNoTracking().SingleAsync(x => x.Id == "rd21-owner")).UiLanguage);
Assert.Equal("en", (await verify.Users.AsNoTracking().SingleAsync(x => x.Id == "other")).UiLanguage);
Assert.True(await reader.ReadAsync());
Assert.False(await reader.ReadAsync());
}
[Theory]
[InlineData(null)]
[InlineData("Server=database.example.test;User ID=test;Password=synthetic")]
[InlineData("Server=192.0.2.1;Database=development;User ID=test")]
public void Unsafe_or_missing_admin_configuration_fails_closed(string? connection)
=> Assert.Throws<InvalidOperationException>(() => MariaDbDatabase.ValidateAdminConnection(connection));
[Fact]
public void Admin_database_name_is_never_used_as_the_test_database()
{
var connection = MariaDbDatabase.ValidateAdminConnection("Server=127.0.0.1;Database=development;User ID=test");
Assert.Equal("", connection.Database);
Assert.False(connection.Pooling);
}
}
@@ -0,0 +1,23 @@
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
namespace JobTrackerApi.MariaDb.Tests;
internal static class TestHostFactory
{
public static Mock<UserManager<ApplicationUser>> CreateUserManager(ApplicationUser? lookupUser = null)
{
var manager = new Mock<UserManager<ApplicationUser>>(
Mock.Of<IUserStore<ApplicationUser>>(), Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(), Array.Empty<IUserValidator<ApplicationUser>>(),
Array.Empty<IPasswordValidator<ApplicationUser>>(), new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(), null!, NullLogger<UserManager<ApplicationUser>>.Instance);
if (lookupUser is not null)
manager.Setup(x => x.FindByIdAsync(It.IsAny<string>()))
.ReturnsAsync((string id) => lookupUser.Id == id ? lookupUser : null);
return manager;
}
}
@@ -0,0 +1,554 @@
{
"version": 1,
"dependencies": {
"net9.0": {
"Microsoft.EntityFrameworkCore.Relational": {
"type": "Direct",
"requested": "[9.0.14, )",
"resolved": "9.0.14",
"contentHash": "R73NifQ3oiAEmvQokCrUtDaVMRO9UQXp52cpwosTZkAmpK2XtmJbHIrZRUjvLWkCzTlwjTDP27bH04PlyNeJoA==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "9.0.14",
"Microsoft.Extensions.Caching.Memory": "9.0.14",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.14",
"Microsoft.Extensions.Logging": "9.0.14"
}
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[17.12.0, )",
"resolved": "17.12.0",
"contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==",
"dependencies": {
"Microsoft.CodeCoverage": "17.12.0",
"Microsoft.TestPlatform.TestHost": "17.12.0"
}
},
"Moq": {
"type": "Direct",
"requested": "[4.20.72, )",
"resolved": "4.20.72",
"contentHash": "EA55cjyNn8eTNWrgrdZJH5QLFp2L43oxl1tlkoYUKIE9pRwL784OWiTXeCV5ApS+AMYEAlt7Fo03A2XfouvHmQ==",
"dependencies": {
"Castle.Core": "5.1.1"
}
},
"Pomelo.EntityFrameworkCore.MySql": {
"type": "Direct",
"requested": "[9.0.0, )",
"resolved": "9.0.0",
"contentHash": "cl7S4s6CbJno0LjNxrBHNc2xxmCliR5i40ATPZk/eTywVaAbHCbdc9vbGc3QThvwGjHqrDHT8vY9m1VF/47o0g==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Relational": "[9.0.0, 9.0.999]",
"MySqlConnector": "2.4.0"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.2, )",
"resolved": "2.9.2",
"contentHash": "7LhFS2N9Z6Xgg8aE5lY95cneYivRMfRI8v+4PATa4S64D5Z/Plkg0qa8dTRHSiGRgVZ/CL2gEfJDE5AUhOX+2Q==",
"dependencies": {
"xunit.analyzers": "1.16.0",
"xunit.assert": "2.9.2",
"xunit.core": "[2.9.2]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[2.8.2, )",
"resolved": "2.8.2",
"contentHash": "vm1tbfXhFmjFMUmS4M0J0ASXz3/U5XvXBa6DOQUL3fEz4Vt6YPhv+ESCarx6M6D+9kJkJYZKCNvJMas1+nVfmQ=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
"dependencies": {
"System.Diagnostics.EventLog": "6.0.0"
}
},
"MailKit": {
"type": "Transitive",
"resolved": "4.17.0",
"contentHash": "1nUAVLxM9fhT/78we6/AGsCesnpn5dRNLLeRqOfr52Wnk87pzVwo5YMTMyqnmoXrYc7piGhmayiMA/OgDluIjg==",
"dependencies": {
"MimeKit": "4.17.0",
"System.Formats.Asn1": "8.0.1"
}
},
"Microsoft.AspNetCore.Authentication.JwtBearer": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "CHG/cxMJa3Peh5PYqJPLPHdwaGjXcoCmD1mUjo4xH2HilA6K0DKoVEr5ollVCqkQDGGutEfkzab10r8+pSeuMQ==",
"dependencies": {
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1"
}
},
"Microsoft.AspNetCore.Cryptography.Internal": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "bof0qT0STpIdoApYAqXHojQG70r3Is15tADEo47U+DUt5ffIQ0jfMGI1h7sxHisRd35uMVTmeVYOMrs0QMHcYA=="
},
"Microsoft.AspNetCore.Cryptography.KeyDerivation": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "puDppSpkkTU4ftiQaG9WVhSkAQM0zqgQaF8UvhJg+u6B4kXgAJzSvGG/bMd1eUfLnh94KeFAYedEwcAlTXgpoA==",
"dependencies": {
"Microsoft.AspNetCore.Cryptography.Internal": "9.0.14"
}
},
"Microsoft.AspNetCore.Identity.EntityFrameworkCore": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "Yh57bLwfy6v/k9U3H3gU9WgxY0UO2J4gEQtKKikCY409c2WrJ+eeo4jTAywSuouB1vYeKQkCYjKj8QCjMikXXA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Relational": "9.0.14",
"Microsoft.Extensions.Identity.Stores": "9.0.14"
}
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "ywJcbdtem5tFlg73eCFb/ORdsZlASaTf0XixxsTQ8Z/ama/83CXB/nvBrvCrxkk62AKpdTxiESK0SvgmZ9HKFA==",
"dependencies": {
"Microsoft.OpenApi": "1.6.17"
}
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "17.12.0",
"contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "EG3EHsTmmSf5Sk/JNSBMly1DZdBgg3OOb6/1Fg/eG/SEMTShpFnQPMi/PgiVfF4CPIS6d+BPrW91EeJyLhDOIQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.10"
}
},
"Microsoft.EntityFrameworkCore": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "Bxw540n++gVwFbrh3NyxwV4ZwPonhbBmhiq7JdDyBUibLAPGvqvsu73Us3LGfRoLbQrhPEgu6cKSvQq+5ABAJA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "9.0.14",
"Microsoft.EntityFrameworkCore.Analyzers": "9.0.14",
"Microsoft.Extensions.Caching.Memory": "9.0.14",
"Microsoft.Extensions.Logging": "9.0.14"
}
},
"Microsoft.EntityFrameworkCore.Abstractions": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "lU0DBe/uZ5hNW1G6tQcikcJhjmDE4eRjRQzaiFi41zxXUMndtZQaGGVNVWXeW6M9K6T212WnBA5cwydzBYmKNA=="
},
"Microsoft.EntityFrameworkCore.Analyzers": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "u7j9NgArLjf3OzeXX1jx9K59Ytki0CfhMloXxons/cIhq/35QilP6R+NfPlQy/nMax+edeGRRfEg9hWFsjqsXA=="
},
"Microsoft.EntityFrameworkCore.Sqlite": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "eI3HYgsuiAB3/Q2pI1fX3zgjoDmPgrtAZs/gH8ZU5CPI5iSkTXqKFtlCMAkmG75y/TrIdG58mNWAkV5fYAfy1w==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Sqlite.Core": "9.0.14",
"Microsoft.Extensions.Caching.Memory": "9.0.14",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.14",
"Microsoft.Extensions.DependencyModel": "9.0.14",
"Microsoft.Extensions.Logging": "9.0.14",
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.10",
"SQLitePCLRaw.core": "2.1.10",
"System.Text.Json": "9.0.14"
}
},
"Microsoft.EntityFrameworkCore.Sqlite.Core": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "7+gc35/VABrS4ma0urEfmvVQI1NXRaPGghA29A1fIpxVoz5YySQ2C9ryH71dCJCRDzf+JSxmaHmw/wUfwKll4w==",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "9.0.14",
"Microsoft.EntityFrameworkCore.Relational": "9.0.14",
"Microsoft.Extensions.Caching.Memory": "9.0.14",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.14",
"Microsoft.Extensions.DependencyModel": "9.0.14",
"Microsoft.Extensions.Logging": "9.0.14",
"SQLitePCLRaw.core": "2.1.10",
"System.Text.Json": "9.0.14"
}
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "fnHxSqrDnPRTO/AbHR6N5mZOY9NmP14rx8aZiresGF27ypqH+J8ZEgXsiXbHVe8az2IGrZWP2a+uNUGBlzVlHQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "9.0.14"
}
},
"Microsoft.Extensions.Caching.Memory": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "VoOzKJaoQZ0pe9RKKEunAO5v37HA/WWBxxiYIOThmHb+OnQPk/+ZvdR5zcbF0XedIUK/agAYORkrb08AQ4ywrQ==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "9.0.14",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.14",
"Microsoft.Extensions.Logging.Abstractions": "9.0.14",
"Microsoft.Extensions.Options": "9.0.14",
"Microsoft.Extensions.Primitives": "9.0.14"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "hoAjCCsJ/fANjaP6hUPru11b/y2evaDJ1m7uRBQU57Mrq0k1NkZLA3qP0zN+EOj2mvM9DXGlNwnLf3H1tRF05w==",
"dependencies": {
"Microsoft.Extensions.Primitives": "9.0.14"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "p6qFGRiZK2kSNWf/smkpb2qrnwMGb5WX9RV7MV9TKxmhEjgB6fx8kjRT5CbKsF1kaTzJU6q/P/8YxfEDO2Hb8g==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.14"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "LezJ0enh6upO5EnPwACOZc/DdT1A8lvX6HPl/0rbe0eGt9rTDDPfx+Ny9OYZqf4g25Y3hOfWBQtRfMzueINNVQ=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "3KI67cvKZqHMBffWushySB0FqrFphIK3mnxnN9YoBdjsTPzSSIMYej4cqtHM4LgFMHcO33C5ObOHqzls+7eJng=="
},
"Microsoft.Extensions.Identity.Core": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "OTHDdyiC3v1w6G3BcDEmfBvRqtUlKkIGAe7dANhyZqPT9QhyvYVNP5MFd8ZAz26rJMrjuootC/Hm2tMPkiVT6g==",
"dependencies": {
"Microsoft.AspNetCore.Cryptography.KeyDerivation": "9.0.14",
"Microsoft.Extensions.Logging": "9.0.14",
"Microsoft.Extensions.Options": "9.0.14"
}
},
"Microsoft.Extensions.Identity.Stores": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "Y1d2mH3JcGkAMeWrGtLemSNaa43Q52KtpSTJ5USBu0sj1tylqkN9EOaisNx6fzgvDQQeMi5WlacStdJMaIN2YQ==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "9.0.14",
"Microsoft.Extensions.Identity.Core": "9.0.14",
"Microsoft.Extensions.Logging": "9.0.14"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "z1VeGpJzXezF8DJScU7q4NS30NC/X5tnmv/F6t8AWu4PG4oGX2cVGcfaRRxaFLS1Kq77hd4f8xqySj5F8VZImQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.14",
"Microsoft.Extensions.Logging.Abstractions": "9.0.14",
"Microsoft.Extensions.Options": "9.0.14"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "uodlxeHHtvEgrAXzHa5T+ESnQhxCk/QSONpiabWjZ8Hn+PcMkXtmbX8YkL1XcFGHqj7hZXUAe2pq7gBptPTz2Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.14"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "hwn0jONblkFl4k5qcDBKuE/BrLxgBN9OOttnoIRsJgDlsxhwuGoVaHJtWdpTg8adDRMlgMrgeuv8WjMG7UnfUg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.14",
"Microsoft.Extensions.Primitives": "9.0.14"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "1bP1fEv6MdXvX4TsxrT94AE2aOIPI9p0xgVsxUliB91wDXHUwbBHV1hXKbfu0ZHEdBuYEusyTVoUwUXp71fh8w=="
},
"Microsoft.IdentityModel.Abstractions": {
"type": "Transitive",
"resolved": "8.14.0",
"contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ=="
},
"Microsoft.IdentityModel.JsonWebTokens": {
"type": "Transitive",
"resolved": "8.14.0",
"contentHash": "4jOpiA4THdtpLyMdAb24dtj7+6GmvhOhxf5XHLYWmPKF8ApEnApal1UnJsKO4HxUWRXDA6C4WQVfYyqsRhpNpQ==",
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.14.0"
}
},
"Microsoft.IdentityModel.Logging": {
"type": "Transitive",
"resolved": "8.14.0",
"contentHash": "eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==",
"dependencies": {
"Microsoft.IdentityModel.Abstractions": "8.14.0"
}
},
"Microsoft.IdentityModel.Protocols": {
"type": "Transitive",
"resolved": "8.14.0",
"contentHash": "rLr9HmibIpwkrOnsYyF3SGKx+6q2ewKDc3xzITngydqflG3TDVqAaby7yFRbP8du43If2S44fseoAkaL8A0Ivg==",
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.14.0"
}
},
"Microsoft.IdentityModel.Protocols.OpenIdConnect": {
"type": "Transitive",
"resolved": "8.14.0",
"contentHash": "afwKt+g52FXMu5Hsgpm6cFSFh40WYbnvXKJDCCBjcV10m6eXRwN2SBpg3auyyA6mYDZVaEtxYaOLXqsZAz4oig==",
"dependencies": {
"Microsoft.IdentityModel.Protocols": "8.14.0",
"System.IdentityModel.Tokens.Jwt": "8.14.0"
}
},
"Microsoft.IdentityModel.Tokens": {
"type": "Transitive",
"resolved": "8.14.0",
"contentHash": "lKIZiBiGd36k02TCdMHp1KlNWisyIvQxcYJvIkz7P4gSQ9zi8dgh6S5Grj8NNG7HWYIPfQymGyoZ6JB5d1Lo1g==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.IdentityModel.Logging": "8.14.0"
}
},
"Microsoft.OpenApi": {
"type": "Transitive",
"resolved": "1.6.17",
"contentHash": "Le+kehlmrlQfuDFUt1zZ2dVwrhFQtKREdKBo+rexOwaCoYP0/qpgT9tLxCsZjsgR5Itk1UKPcbgO+FyaNid/bA=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "17.12.0",
"contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==",
"dependencies": {
"System.Reflection.Metadata": "1.6.0"
}
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "17.12.0",
"contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "17.12.0",
"Newtonsoft.Json": "13.0.1"
}
},
"Microsoft.Win32.SystemEvents": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A=="
},
"MimeKit": {
"type": "Transitive",
"resolved": "4.17.0",
"contentHash": "h/KXsCreJf8RpR/PSAtlbvYtZFndp9N8Wn1Bs248AL7ITyhn+AiIY5bLTvt60tbN2mu6g8KadtBNWygTji2lqg==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"System.Security.Cryptography.Pkcs": "8.0.1"
}
},
"MySqlConnector": {
"type": "Transitive",
"resolved": "2.4.0",
"contentHash": "78M+gVOjbdZEDIyXQqcA7EYlCGS3tpbUELHvn6638A2w0pkPI625ixnzsa5staAd3N9/xFmPJtkKDYwsXpFi/w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2",
"Microsoft.Extensions.Logging.Abstractions": "8.0.2"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"Otp.NET": {
"type": "Transitive",
"resolved": "1.4.1",
"contentHash": "UXjZ80T6BfVACJWmEcz4UFZ93hwX3STKojKvVpPk3qUhubjnLwIiZMsI4Hh91HhYxC0JlatNdNrVK2wkv4O9/Q=="
},
"QRCoder": {
"type": "Transitive",
"resolved": "1.8.0",
"contentHash": "RuvX3PEXU6pbY/I5ItAk800jm62r+YnoPLgyS2WTgwxkOnGkOfU9ORiipHUF0LkLyqM8rlroUCA319JjRYfRFQ==",
"dependencies": {
"System.Drawing.Common": "6.0.0"
}
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.10",
"contentHash": "UxWuisvZ3uVcVOLJQv7urM/JiQH+v3TmaJc1BLKl5Dxfm/nTzTUrqswCqg/INiYLi61AXnHo1M1JPmPqqLnAdg==",
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.10",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.10"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "2.1.10",
"contentHash": "Ii8JCbC7oiVclaE/mbDEK000EFIJ+ShRPwAvvV89GOZhQ+ZLtlnSWl6ksCNMKu/VGXA4Nfi2B7LhN/QFN9oBcw==",
"dependencies": {
"System.Memory": "4.5.3"
}
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.12",
"contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.10",
"contentHash": "uZVTi02C1SxqzgT0HqTWatIbWGb40iIkfc3FpFCpE/r7g6K0PqzDUeefL6P6HPhDtc6BacN3yQysfzP7ks+wSQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.10"
}
},
"Stripe.net": {
"type": "Transitive",
"resolved": "52.2.0",
"contentHash": "kStsQTsxalKPyLXVP5ep+t5NL49NNOKmKh+X+I6Fexb2LJUQ2ZUvtj52bkJFJJDaCJJrbeM47y0qvOXqKFwExA==",
"dependencies": {
"Newtonsoft.Json": "13.0.3",
"System.Configuration.ConfigurationManager": "9.0.0"
}
},
"System.Configuration.ConfigurationManager": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "PdkuMrwDhXoKFo/JxISIi9E8L+QGn9Iquj2OKDWHB6Y/HnUOuBouF7uS3R4Hw3FoNmwwMo6hWgazQdyHIIs27A==",
"dependencies": {
"System.Diagnostics.EventLog": "9.0.0",
"System.Security.Cryptography.ProtectedData": "9.0.0"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "qd01+AqPhbAG14KtdtIqFk+cxHQFZ/oqRSCoxU1F+Q6Kv0cl726sl7RzU9yLFGd4BUOKdN4XojXF0pQf/R6YeA=="
},
"System.Drawing.Common": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==",
"dependencies": {
"Microsoft.Win32.SystemEvents": "6.0.0"
}
},
"System.Formats.Asn1": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A=="
},
"System.IdentityModel.Tokens.Jwt": {
"type": "Transitive",
"resolved": "8.14.0",
"contentHash": "EYGgN/S+HK7S6F3GaaPLFAfK0UzMrkXFyWCvXpQWFYmZln3dqtbyIO7VuTM/iIIPMzkelg8ZLlBPvMhxj6nOAA==",
"dependencies": {
"Microsoft.IdentityModel.JsonWebTokens": "8.14.0",
"Microsoft.IdentityModel.Tokens": "8.14.0"
}
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.5.3",
"contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA=="
},
"System.Reflection.Metadata": {
"type": "Transitive",
"resolved": "1.6.0",
"contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ=="
},
"System.Security.Cryptography.Pkcs": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA=="
},
"System.Security.Cryptography.ProtectedData": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "CJW+x/F6fmRQ7N6K8paasTw9PDZp4t7G76UjGNlSDgoHPF0h08vTzLYbLZpOLEJSg35d5wy2jCXGo84EN05DpQ=="
},
"System.Text.Json": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "zahtYv0C3LAIUUSiEwuL9QAtal8X1cp32qua8JWMjgoik33BpRU9ziLT9QJukF39y/G43VeTn0XFcDujzeFMMA=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.16.0",
"contentHash": "hptYM7vGr46GUIgZt21YHO4rfuBAQS2eINbFo16CV/Dqq+24Tp+P5gDCACu1AbFfW4Sp/WRfDPSK8fmUUb8s0Q=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.2",
"contentHash": "QkNBAQG4pa66cholm28AxijBjrmki98/vsEh4Sx5iplzotvPgpiotcxqJQMRC8d7RV7nIT8ozh97957hDnZwsQ=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.2",
"contentHash": "O6RrNSdmZ0xgEn5kT927PNwog5vxTtKrWMihhhrT0Sg9jQ7iBDciYOwzBgP2krBEk5/GBXI18R1lKvmnxGcb4w==",
"dependencies": {
"xunit.extensibility.core": "[2.9.2]",
"xunit.extensibility.execution": "[2.9.2]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.2",
"contentHash": "Ol+KlBJz1x8BrdnhN2DeOuLrr1I/cTwtHCggL9BvYqFuVd/TUSzxNT5O0NxCIXth30bsKxgMfdqLTcORtM52yQ==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.2",
"contentHash": "rKMpq4GsIUIJibXuZoZ8lYp5EpROlnYaRpwu9Zr0sRZXE7JqJfEEbCsUriZqB+ByXCLFBJyjkTRULMdC+U566g==",
"dependencies": {
"xunit.extensibility.core": "[2.9.2]"
}
},
"jobtrackerapi": {
"type": "Project",
"dependencies": {
"MailKit": "[4.17.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[9.0.14, )",
"Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[9.0.14, )",
"Microsoft.AspNetCore.OpenApi": "[9.0.14, )",
"Microsoft.EntityFrameworkCore.Sqlite": "[9.0.14, )",
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "[8.14.0, )",
"Otp.NET": "[1.4.1, )",
"Pomelo.EntityFrameworkCore.MySql": "[9.0.0, )",
"QRCoder": "[1.8.0, )",
"SQLitePCLRaw.lib.e_sqlite3": "[2.1.12, )",
"Stripe.net": "[52.2.0, )",
"System.IdentityModel.Tokens.Jwt": "[8.14.0, )"
}
}
}
}
}