diff --git a/.env.example b/.env.example
index 4b57a4f..22aed4c 100644
--- a/.env.example
+++ b/.env.example
@@ -4,9 +4,10 @@
# =============================================================================
# DATABASE
# =============================================================================
-DATABASE_PROVIDER=sqlite
-# Required for mariadb/mysql; ignored for sqlite.
+DATABASE_PROVIDER=mariadb
+# Required: never silently falls back to SQLite. Production secrets belong outside Git.
JOBTRACKER_CONNECTION_STRING=
+DATABASE_SERVER_VERSION=12.2.2
# =============================================================================
# AUTHENTICATION, IDENTITY, BILLING
diff --git a/.gitea/workflows/mariadb-integration.yml b/.gitea/workflows/mariadb-integration.yml
new file mode 100644
index 0000000..17d682b
--- /dev/null
+++ b/.gitea/workflows/mariadb-integration.yml
@@ -0,0 +1,29 @@
+name: MariaDB persistence qualification
+on:
+ push:
+ branches: [codex/rd21-mariadb-standardisation]
+ pull_request:
+ workflow_dispatch:
+jobs:
+ mariadb:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.317'
+ - name: Check isolated database runner guards
+ run: python3 scripts/test-mariadb-integration.py
+ - name: Require Docker and Compose
+ # Unavailable infrastructure fails this explicit lane; it must never skip to SQLite.
+ run: |
+ docker info
+ docker compose version
+ - name: Qualify pinned MariaDB 12.2.2
+ run: python3 scripts/mariadb-integration.py
+ - name: Retain MariaDB evidence
+ if: always()
+ uses: actions/upload-artifact@v3
+ with:
+ name: mariadb-persistence
+ path: .artifacts/mariadb/integration/
diff --git a/JobTrackerApi.MariaDb.Tests/JobTrackerApi.MariaDb.Tests.csproj b/JobTrackerApi.MariaDb.Tests/JobTrackerApi.MariaDb.Tests.csproj
new file mode 100644
index 0000000..9ae2882
--- /dev/null
+++ b/JobTrackerApi.MariaDb.Tests/JobTrackerApi.MariaDb.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+ net9.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/JobTrackerApi.MariaDb.Tests/MariaDbDatabase.cs b/JobTrackerApi.MariaDb.Tests/MariaDbDatabase.cs
new file mode 100644
index 0000000..5d2a405
--- /dev/null
+++ b/JobTrackerApi.MariaDb.Tests/MariaDbDatabase.cs
@@ -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 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
+ {
+ ["Data:Root"] = database._root,
+ ["Database:Provider"] = "mariadb",
+ ["ConnectionStrings:JobTracker"] = database.ConnectionString,
+ ["Auth:AdminEmail"] = "",
+ ["Auth:AdminPassword"] = "",
+ });
+ builder.Logging.ClearProviders();
+ builder.Services.AddSingleton(Mock.Of(user => user.UserId == "rd21-owner"));
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddDbContext(options => database.Configure(options));
+ builder.Services.AddIdentityCore().AddRoles().AddEntityFrameworkStores();
+ 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();
+ Configure(options);
+ if (interceptor is not null) options.AddInterceptors(interceptor);
+ return new(options.Options, Mock.Of(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);
+ }
+}
diff --git a/JobTrackerApi.MariaDb.Tests/README.md b/JobTrackerApi.MariaDb.Tests/README.md
new file mode 100644
index 0000000..2353d90
--- /dev/null
+++ b/JobTrackerApi.MariaDb.Tests/README.md
@@ -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_` 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.
diff --git a/JobTrackerApi.MariaDb.Tests/Rd21CareerPersistenceTests.cs b/JobTrackerApi.MariaDb.Tests/Rd21CareerPersistenceTests.cs
new file mode 100644
index 0000000..b94c302
--- /dev/null
+++ b/JobTrackerApi.MariaDb.Tests/Rd21CareerPersistenceTests.cs
@@ -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> Users(JobTrackerContext db, bool identitySucceeds)
+ {
+ var user = db.Users.Single();
+ var users = TestHostFactory.CreateUserManager();
+ users.Setup(x => x.GetUserAsync(It.IsAny())).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().Object, db, null!)
+ { ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
+
+ private static CareerProfileSaveRequest Request(string title, int? expectedVersion) =>
+ JsonSerializer.Deserialize(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((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(Assert.IsType((await staleController.Get(default)).Result).Value);
+ Assert.Equal(1, source.Revision);
+ Assert.Equal("Original", source.Profile.Jobs.Single().Title);
+ Assert.IsType((await Controller(first).Put(Request("Acknowledged winner", 1), default)).Result);
+ var rejected = await staleController.Put(Request("Stale draft", 1), default);
+ Assert.IsType(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(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(() => b.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Title = "Stale" } } }, "manual", 1, default));
+ await stale.SaveChangesAsync();
+ await Assert.ThrowsAsync(() => 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 Save(string title)
+ {
+ await using var db = Context(database);
+ var controller = Controller(db);
+ Assert.Equal(0, Assert.IsType(Assert.IsType((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());
+ Assert.Single(outcomes.OfType());
+ 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> SavingChangesAsync(DbContextEventData eventData, InterceptionResult 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(() => 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((await controller.Put(Request("Rejected", expectedVersion), default)).Result);
+ Assert.IsType((await controller.Restore(1, new(expectedVersion), default)).Result);
+ var imports = ImportController(db);
+ Assert.IsType((await imports.Parse(new("# Synthetic", expectedVersion))).Result);
+ Assert.IsType(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(result);
+ else Assert.IsType(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(await imports.GetRunDiff(run.Id));
+ Assert.Equal(0, JsonSerializer.SerializeToElement(diff.Value).GetProperty("revision").GetInt32());
+ Assert.IsType((await Controller(db).Put(Request("Winner", 0), default)).Result);
+ Assert.IsType(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(result.Result);
+ else Assert.IsType(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(Assert.IsType(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())).ReturnsAsync(owner);
+ var controller = new ProfileCvController(users.Object, Mock.Of(), read, null!)
+ {
+ ControllerContext = new() { HttpContext = new DefaultHttpContext() },
+ };
+ var response = Assert.IsType((await controller.GetRuns()).Result);
+ Assert.Equal(200, response.StatusCode);
+ var actual = Assert.IsAssignableFrom>(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());
+ }
+}
diff --git a/JobTrackerApi.MariaDb.Tests/Rd21JobPersistenceTests.cs b/JobTrackerApi.MariaDb.Tests/Rd21JobPersistenceTests.cs
new file mode 100644
index 0000000..7e18096
--- /dev/null
+++ b/JobTrackerApi.MariaDb.Tests/Rd21JobPersistenceTests.cs
@@ -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(), 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(TaskCreationOptions.RunContinuationsAsynchronously);
+ var summarizer = new Mock();
+ summarizer.Setup(x => x.SummarizeAsync(It.IsAny(), 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 { "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(await Controller(winner).Update(id,
+ Request(companyId, "Original job title", "Acknowledged concurrent notes", 1), default));
+ }
+ finally { release.TrySetResult("Delayed summary"); }
+ Assert.IsType(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(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(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(
+ JsonSerializer.Serialize(new { summary = new[] { text }, expectedVersion = 1 }),
+ new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
+ await using var first = Context(connection);
+ Assert.IsType(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(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(() => 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(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(await Controller(db).Update(foreign.Id, Request(other.Id, "Stolen", null, 1), default));
+ Assert.IsType(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 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(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())).ReturnsAsync(user);
+ var controller = new ApplicationAssetsController(users.Object,
+ new ApplicationAssetsService(db, Mock.Of(), Mock.Of()),
+ Mock.Of())
+ {
+ 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(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(() => Save(stale, "Stale snapshot text"));
+ else
+ Assert.Equal(409, Assert.IsAssignableFrom(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(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(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(() => Controller(waiting).Update(id,
+ Request(companyId, "Timed out title", "Timed out notes", 1) with { Status = "Offer" }, default));
+ var updateFailure = Assert.IsType(exception.InnerException);
+ Assert.Equal(1205, Assert.IsType(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(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);
+ }
+}
diff --git a/JobTrackerApi.MariaDb.Tests/StartupAndLockTests.cs b/JobTrackerApi.MariaDb.Tests/StartupAndLockTests.cs
new file mode 100644
index 0000000..d3cfa04
--- /dev/null
+++ b/JobTrackerApi.MariaDb.Tests/StartupAndLockTests.cs
@@ -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(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().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();
+ 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>();
+ 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(() => 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);
+ }
+}
diff --git a/JobTrackerApi.MariaDb.Tests/TestHostFactory.cs b/JobTrackerApi.MariaDb.Tests/TestHostFactory.cs
new file mode 100644
index 0000000..8cfdb55
--- /dev/null
+++ b/JobTrackerApi.MariaDb.Tests/TestHostFactory.cs
@@ -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> CreateUserManager(ApplicationUser? lookupUser = null)
+ {
+ var manager = new Mock>(
+ Mock.Of>(), Options.Create(new IdentityOptions()),
+ new PasswordHasher(), Array.Empty>(),
+ Array.Empty>(), new UpperInvariantLookupNormalizer(),
+ new IdentityErrorDescriber(), null!, NullLogger>.Instance);
+ if (lookupUser is not null)
+ manager.Setup(x => x.FindByIdAsync(It.IsAny()))
+ .ReturnsAsync((string id) => lookupUser.Id == id ? lookupUser : null);
+ return manager;
+ }
+}
diff --git a/JobTrackerApi.MariaDb.Tests/packages.lock.json b/JobTrackerApi.MariaDb.Tests/packages.lock.json
new file mode 100644
index 0000000..c546d4e
--- /dev/null
+++ b/JobTrackerApi.MariaDb.Tests/packages.lock.json
@@ -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, )"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/JobTrackerApi.Tests/DatabaseConfigurationTests.cs b/JobTrackerApi.Tests/DatabaseConfigurationTests.cs
new file mode 100644
index 0000000..6c9fb58
--- /dev/null
+++ b/JobTrackerApi.Tests/DatabaseConfigurationTests.cs
@@ -0,0 +1,113 @@
+using JobTrackerApi.Data;
+using JobTrackerApi.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Pomelo.EntityFrameworkCore.MySql.Infrastructure.Internal;
+using Xunit;
+
+namespace JobTrackerApi.Tests;
+
+public sealed class DatabaseConfigurationTests
+{
+ private const string UnreachableMariaDb = "Server=127.0.0.1;Port=1;Database=jt_config_test;User=config_test;Password=synthetic;Connection Timeout=1";
+ private static IConfiguration Config(string? provider = null, string? connection = null, string? version = null) =>
+ new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
+ {
+ ["Database:Provider"] = provider,
+ ["ConnectionStrings:JobTracker"] = connection,
+ ["Database:ServerVersion"] = version,
+ }).Build();
+
+ [Fact]
+ public void Default_registration_uses_pinned_MariaDb_without_connecting_to_a_server()
+ {
+ var configuration = Config(connection: UnreachableMariaDb);
+ var options = new DbContextOptionsBuilder();
+ DatabaseConfiguration.Configure(options, configuration, "unused.db");
+ Assert.Equal("mariadb", DatabaseConfiguration.GetProvider(configuration));
+ var relational = Assert.Single(options.Options.Extensions.OfType());
+ Assert.Equal(UnreachableMariaDb, relational.ConnectionString);
+ // Inspect the provider's configured version without opening a connection or generating SQL.
+#pragma warning disable EF1001
+ var mysql = Assert.IsType(relational);
+ Assert.IsType(mysql.ServerVersion);
+ Assert.Equal(new Version(12, 2, 2), mysql.ServerVersion!.Version);
+#pragma warning restore EF1001
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("mariadb")]
+ [InlineData("mysql")]
+ public void Missing_MariaDb_connection_fails_closed_instead_of_creating_a_Sqlite_database(string? provider)
+ {
+ var options = new DbContextOptionsBuilder();
+ Assert.Throws(() => DatabaseConfiguration.Configure(options, Config(provider), "must-not-create.db"));
+ Assert.Empty(options.Options.Extensions.OfType());
+ }
+
+ [Theory]
+ [InlineData("postgres")]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("maria-db")]
+ public void Unknown_provider_is_rejected_even_when_a_connection_is_configured(string provider)
+ {
+ Assert.Throws(() => DatabaseConfiguration.Configure(
+ new DbContextOptionsBuilder(), Config(provider, "Data Source=must-not-create.db"), "unused.db"));
+ Assert.Throws(() => DatabaseConfiguration.GetProvider(Config(provider)));
+ }
+
+ [Theory]
+ [InlineData("mariadb")]
+ [InlineData(" MYSQL ")]
+ public void MariaDb_server_version_is_configurable_without_auto_detection(string provider)
+ {
+ var options = new DbContextOptionsBuilder();
+ DatabaseConfiguration.Configure(options, Config(provider, UnreachableMariaDb, "11.4.10"), "unused.db");
+#pragma warning disable EF1001
+ Assert.Equal(new Version(11, 4, 10), options.Options.Extensions.OfType().Single().ServerVersion!.Version);
+#pragma warning restore EF1001
+ }
+
+ [Fact]
+ public void Invalid_server_version_fails_during_configuration()
+ {
+ Assert.Throws(() => DatabaseConfiguration.Configure(
+ new DbContextOptionsBuilder(), Config("mariadb", UnreachableMariaDb, "latest"), "unused.db"));
+ }
+
+ [Theory]
+ [InlineData(null, "Data Source=preserved.db")]
+ [InlineData("Data Source=explicit.db", "Data Source=explicit.db")]
+ public void Explicit_Sqlite_preserves_existing_file_selection(string? connection, string expected)
+ {
+ var options = new DbContextOptionsBuilder();
+ DatabaseConfiguration.Configure(options, Config(" SQLITE ", connection), "preserved.db");
+ var relational = Assert.Single(options.Options.Extensions.OfType());
+ Assert.Equal("SqliteOptionsExtension", relational.GetType().Name);
+ Assert.Equal(expected, relational.ConnectionString);
+ }
+
+ [Fact]
+ public async Task Default_MariaDb_backup_selection_cannot_run_the_Sqlite_backup_path()
+ {
+ var root = Path.Combine(Path.GetTempPath(), $"jt-config-backup-{Guid.NewGuid():N}");
+ var configuration = new ConfigurationBuilder().AddConfiguration(Config(connection: UnreachableMariaDb))
+ .AddInMemoryCollection(new Dictionary { ["Data:Root"] = root }).Build();
+ var environment = new Mock();
+ environment.SetupGet(x => x.ContentRootPath).Returns(root);
+ try
+ {
+ var runner = new SqliteDatabaseBackupRunner(configuration, new AppPaths(configuration, environment.Object), NullLogger.Instance);
+ Assert.False(runner.IsSupported);
+ Assert.Null(await runner.RunOnceAsync(default));
+ Assert.False(Directory.Exists(runner.BackupsRoot));
+ }
+ finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
+ }
+}
diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs
index c57cfb0..c7ec652 100644
--- a/JobTrackerApi/Controllers/JobApplicationsController.cs
+++ b/JobTrackerApi/Controllers/JobApplicationsController.cs
@@ -939,7 +939,7 @@ Canonical profile:
_db.ChangeTracker.Clear();
return Conflict(new { code = "revision_conflict", message = "This job changed while saving. Your draft was not saved. Reload and review before saving again." });
}
- catch (DbUpdateException)
+ catch
{
_db.ChangeTracker.Clear();
throw;
diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs
index 078a0ad..1979088 100644
--- a/JobTrackerApi/Controllers/ProfileCvController.cs
+++ b/JobTrackerApi/Controllers/ProfileCvController.cs
@@ -216,8 +216,11 @@ public sealed partial class ProfileCvController : ControllerBase
var runsQuery = _db.CvExtractionRuns
.AsNoTracking()
- .Where(x => x.OwnerUserId == user.Id)
- .Select(x => new CvExtractionRunListItem(
+ .Where(x => x.OwnerUserId == user.Id);
+ var isSqlite = _db.Database.IsSqlite();
+ if (!isSqlite)
+ runsQuery = runsQuery.OrderByDescending(x => x.StartedAtUtc).Take(10);
+ var runs = await runsQuery.Select(x => new CvExtractionRunListItem(
x.Id,
x.Trigger,
x.Status,
@@ -229,10 +232,9 @@ public sealed partial class ProfileCvController : ControllerBase
x.NormalizerVersion,
x.LlmPromptVersion,
x.ErrorMessage,
- null));
- var runs = _db.Database.IsSqlite()
- ? (await runsQuery.ToListAsync(HttpContext.RequestAborted)).OrderByDescending(x => x.StartedAtUtc).Take(10).ToList()
- : await runsQuery.OrderByDescending(x => x.StartedAtUtc).Take(10).ToListAsync(HttpContext.RequestAborted);
+ null)).ToListAsync(HttpContext.RequestAborted);
+ if (isSqlite)
+ runs = runs.OrderByDescending(x => x.StartedAtUtc).Take(10).ToList();
var runIds = runs.Select(run => run.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToList();
var operations = await _db.UserOperations.AsNoTracking()
diff --git a/JobTrackerApi/Data/DatabaseConfiguration.cs b/JobTrackerApi/Data/DatabaseConfiguration.cs
new file mode 100644
index 0000000..c5c83c2
--- /dev/null
+++ b/JobTrackerApi/Data/DatabaseConfiguration.cs
@@ -0,0 +1,39 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace JobTrackerApi.Data;
+
+public static class DatabaseConfiguration
+{
+ public static string GetProvider(IConfiguration configuration) =>
+ (configuration["Database:Provider"] ?? "mariadb").Trim().ToLowerInvariant() switch
+ {
+ "mysql" or "mariadb" => "mariadb",
+ "sqlite" => "sqlite",
+ _ => throw new InvalidOperationException("Database:Provider must be mariadb (or mysql) or explicitly sqlite for transitional preservation."),
+ };
+
+ public static string GetConnectionString(IConfiguration configuration, string sqlitePath)
+ {
+ var provider = GetProvider(configuration);
+ var connection = configuration.GetConnectionString("JobTracker");
+ if (!string.IsNullOrWhiteSpace(connection)) return connection;
+ if (provider == "sqlite") return $"Data Source={sqlitePath}";
+ throw new InvalidOperationException("ConnectionStrings:JobTracker is required for MariaDB. SQLite is only used when Database:Provider is explicitly sqlite.");
+ }
+
+ public static void Configure(DbContextOptionsBuilder options, IConfiguration configuration, string sqlitePath)
+ {
+ var provider = GetProvider(configuration);
+ var connection = GetConnectionString(configuration, sqlitePath);
+ if (provider == "mariadb")
+ {
+ var configuredVersion = configuration["Database:ServerVersion"] ?? "12.2.2";
+ if (!Version.TryParse(configuredVersion, out var version) || version.Major < 1 || version.Build < 0)
+ throw new InvalidOperationException("Database:ServerVersion must be a MariaDB version in major.minor.patch form.");
+ // AutoDetect would open a server connection while options are being registered.
+ options.UseMySql(connection, new MariaDbServerVersion(version), mysql => mysql.MigrationsAssembly("JobTrackerApi"));
+ }
+ else
+ options.UseSqlite(connection, sqlite => sqlite.MigrationsAssembly("JobTrackerApi"));
+ }
+}
diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs
index c313d73..397d615 100644
--- a/JobTrackerApi/Program.cs
+++ b/JobTrackerApi/Program.cs
@@ -97,31 +97,7 @@ builder.Services.AddDbContext((sp, options) =>
var cfg = sp.GetRequiredService();
var paths = sp.GetRequiredService();
- var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
- var cs = cfg.GetConnectionString("JobTracker");
- if (string.IsNullOrWhiteSpace(cs))
- {
- cs = $"Data Source={paths.GetDbPath()}";
- provider = "sqlite";
- }
-
- if (provider is "mysql" or "mariadb")
- {
- // Avoid ServerVersion.AutoDetect here because it forces an immediate DB connection
- // during service registration, which can crash the API if MariaDB is temporarily
- // unavailable or on a different network during deploy startup.
- options.UseMySql(cs, new MariaDbServerVersion(new Version(11, 0, 0)), mysql =>
- {
- mysql.MigrationsAssembly("JobTrackerApi");
- });
- }
- else
- {
- options.UseSqlite(cs, sqlite =>
- {
- sqlite.MigrationsAssembly("JobTrackerApi");
- });
- }
+ DatabaseConfiguration.Configure(options, cfg, paths.GetDbPath());
// We create Identity tables on startup in environments where `dotnet ef` isn't available.
// That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it.
diff --git a/JobTrackerApi/Services/DatabaseBackupRunner.cs b/JobTrackerApi/Services/DatabaseBackupRunner.cs
index 894db99..e823e3b 100644
--- a/JobTrackerApi/Services/DatabaseBackupRunner.cs
+++ b/JobTrackerApi/Services/DatabaseBackupRunner.cs
@@ -1,4 +1,5 @@
using Microsoft.Data.Sqlite;
+using JobTrackerApi.Data;
namespace JobTrackerApi.Services
{
@@ -28,15 +29,8 @@ namespace JobTrackerApi.Services
{
_logger = logger;
- var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
- var cs = cfg.GetConnectionString("JobTracker");
- if (string.IsNullOrWhiteSpace(cs))
- {
- cs = $"Data Source={paths.GetDbPath()}";
- provider = "sqlite";
- }
-
- _connectionString = cs;
+ var provider = DatabaseConfiguration.GetProvider(cfg);
+ _connectionString = DatabaseConfiguration.GetConnectionString(cfg, paths.GetDbPath());
IsSupported = provider == "sqlite";
BackupsRoot = Path.Combine(paths.DataRoot, "backups");
_retainCount = Math.Clamp(cfg.GetValue("Backups:RetainCount", 14), 1, 365);
diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs
index a43a7be..b083ea4 100644
--- a/JobTrackerApi/Services/StartupInitializationExtensions.cs
+++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs
@@ -264,8 +264,8 @@ public static class StartupInitializationExtensions
var paths = scope.ServiceProvider.GetRequiredService();
var users = scope.ServiceProvider.GetRequiredService>();
var roles = scope.ServiceProvider.GetRequiredService>();
- var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
- var useSqliteBootstrap = provider is not "mysql" and not "mariadb";
+ var provider = DatabaseConfiguration.GetProvider(app.Configuration);
+ var useSqliteBootstrap = provider == "sqlite";
// Reconciliation opens the file before EF creates it; enable WAL explicitly so readers do not block commits.
if (useSqliteBootstrap) db.Database.ExecuteSqlRaw("PRAGMA journal_mode = WAL;");
@@ -519,6 +519,9 @@ public static class StartupInitializationExtensions
// Correspondences/Attachments) -- re-run once more after Migrate() below via
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
ReconcileCoreAppColumnsMySql(conn);
+ EnsureMySqlColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE `AspNetUsers` ADD COLUMN `FirstName` longtext NULL;");
+ EnsureMySqlColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE `AspNetUsers` ADD COLUMN `LastName` longtext NULL;");
+ EnsureMySqlColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE `AspNetUsers` ADD COLUMN `DisplayName` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvUploadArtifactId` int NULL;");
@@ -848,7 +851,7 @@ public static class StartupInitializationExtensions
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService();
- var runtimeProvider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
+ var runtimeProvider = DatabaseConfiguration.GetProvider(app.Configuration);
using var conn = db.Database.GetDbConnection();
conn.Open();
if (!CoreSchemaReady(conn, runtimeProvider))
diff --git a/JobTrackerApi/appsettings.Development.json b/JobTrackerApi/appsettings.Development.json
index e6fcac5..3866960 100644
--- a/JobTrackerApi/appsettings.Development.json
+++ b/JobTrackerApi/appsettings.Development.json
@@ -1,4 +1,11 @@
{
+ "Database": {
+ "Provider": "mariadb",
+ "ServerVersion": "12.2.2"
+ },
+ "ConnectionStrings": {
+ "JobTracker": "Server=127.0.0.1;Port=33306;Database=jobtracker_dev;User ID=jobtracker_dev;Password=jt-development-only;SslMode=None"
+ },
"Logging": {
"LogLevel": {
"Default": "Information",
diff --git a/docker-compose.database.yml b/docker-compose.database.yml
new file mode 100644
index 0000000..7f7156e
--- /dev/null
+++ b/docker-compose.database.yml
@@ -0,0 +1,42 @@
+# Standalone local database project. Never combine with production Compose/volumes/.env.
+name: jobtracker-local
+services:
+ mariadb-dev:
+ image: mariadb:12.2.2@sha256:42a27baaba3e8f3f0159d16381d5a7156661aa91dcc65f663cabb6a6f9503c00
+ platform: linux/amd64
+ environment:
+ MARIADB_ROOT_PASSWORD: jt-development-root-only
+ MARIADB_DATABASE: jobtracker_dev
+ MARIADB_USER: jobtracker_dev
+ MARIADB_PASSWORD: jt-development-only
+ ports:
+ - "127.0.0.1:33306:3306"
+ volumes:
+ - mariadb_dev:/var/lib/mysql
+ command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 2s
+ timeout: 5s
+ retries: 45
+ start_period: 20s
+ mariadb-test:
+ profiles: [integration]
+ network_mode: bridge
+ image: mariadb:12.2.2@sha256:42a27baaba3e8f3f0159d16381d5a7156661aa91dcc65f663cabb6a6f9503c00
+ platform: linux/amd64
+ environment:
+ MARIADB_ROOT_PASSWORD: jt-integration-only
+ ports:
+ - "127.0.0.1:${MARIADB_TEST_PORT:-33307}:3306"
+ tmpfs:
+ - /var/lib/mysql
+ command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 2s
+ timeout: 5s
+ retries: 45
+ start_period: 20s
+volumes:
+ mariadb_dev:
diff --git a/docker-compose.yml b/docker-compose.yml
index 2496560..4714bbd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -17,7 +17,8 @@ services:
- Exports__DailyFolder=/data/exports
- AccountLifecycle__DeletionEnabled=${ACCOUNT_DELETION_ENABLED:-false}
- AccountLifecycle__TombstonesRoot=/account-lifecycle/tombstones
- - Database__Provider=${DATABASE_PROVIDER:-sqlite}
+ - Database__Provider=${DATABASE_PROVIDER:-mariadb}
+ - Database__ServerVersion=${DATABASE_SERVER_VERSION:-12.2.2}
- ConnectionStrings__JobTracker=${JOBTRACKER_CONNECTION_STRING}
# If you enable HTTPS at a reverse proxy (recommended), handle redirects there.
- HttpsRedirection__Enabled=false
diff --git a/docs/redesign/phase-9/p9-05/diagnostics/data.spec.ts b/docs/redesign/phase-9/p9-05/diagnostics/data.spec.ts
index 67ee915..2c40d36 100644
--- a/docs/redesign/phase-9/p9-05/diagnostics/data.spec.ts
+++ b/docs/redesign/phase-9/p9-05/diagnostics/data.spec.ts
@@ -9,7 +9,7 @@ async function login(request: APIRequestContext, email: string) {
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
}
-test('P9 characterize retained RD21 stale writes separately from CV revision safety', async ({ playwright }, info) => {
+test('P9 RD21 source revisions reject stale writes and preserve acknowledged winners', async ({ playwright }, info) => {
const admin = await playwright.request.newContext();
const first = await playwright.request.newContext();
const second = await playwright.request.newContext();
@@ -19,31 +19,47 @@ test('P9 characterize retained RD21 stale writes separately from CV revision saf
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
const a = await login(first, email); const b = await login(second, email);
const submitted = { version: '1', contact: { fullName: 'Synthetic concurrent owner' }, summary: ['Initial summary'] };
- expect((await first.put(`${api}/career/profile`, { headers: a, data: { profile: submitted } })).status()).toBe(200);
- const careerA = (await (await first.get(`${api}/career/profile`)).json()).profile;
- const careerB = (await (await second.get(`${api}/career/profile`)).json()).profile;
+ const initialCareer = await (await first.get(`${api}/career/profile`)).json();
+ expect((await first.put(`${api}/career/profile`, { headers: a, data: { expectedVersion: initialCareer.revision, profile: submitted } })).status()).toBe(200);
+ const sourceA = await (await first.get(`${api}/career/profile`)).json();
+ const sourceB = await (await second.get(`${api}/career/profile`)).json();
+ expect(sourceB).toEqual(sourceA);
+ const careerA = sourceA.profile; const careerB = sourceB.profile;
careerA.summary = ['First editor committed summary']; careerB.contact.headline = 'Second editor headline';
- expect((await first.put(`${api}/career/profile`, { headers: a, data: { profile: careerA } })).status()).toBe(200);
- const staleCareer = await second.put(`${api}/career/profile`, { headers: b, data: { profile: careerB } });
- expect([200, 409]).toContain(staleCareer.status());
- const careerAfter = (await (await first.get(`${api}/career/profile`)).json()).profile;
+ expect((await first.put(`${api}/career/profile`, { headers: a, data: { expectedVersion: sourceA.revision, profile: careerA } })).status()).toBe(200);
+ const careerWinner = await (await first.get(`${api}/career/profile`)).json();
+ expect(careerWinner.revision).toBe(sourceA.revision + 1);
+ expect(careerWinner.profile.summary).toEqual(careerA.summary);
+ const careerHistory = await (await first.get(`${api}/career/profile/versions`)).json();
+ const staleCareer = await second.put(`${api}/career/profile`, { headers: b, data: { expectedVersion: sourceB.revision, profile: careerB } });
+ expect(staleCareer.status()).toBe(409);
+ const careerReadback = await (await second.get(`${api}/career/profile`)).json();
+ expect(careerReadback).toEqual(careerWinner);
+ expect(await (await first.get(`${api}/career/profile/versions`)).json()).toEqual(careerHistory);
+ const careerAfter = careerReadback.profile;
const company = await first.post(`${api}/companies`, { headers: a, data: { name: 'Synthetic stale job fixture' } });
expect(company.status()).toBe(201);
const created = await first.post(`${api}/jobapplications`, { headers: a, data: { companyId: (await company.json()).id, jobTitle: 'Original job title', status: 'Saved' } });
expect(created.status()).toBe(201); const jobId = (await created.json()).id;
const jobA = await (await first.get(`${api}/jobapplications/${jobId}`)).json();
const jobB = await (await second.get(`${api}/jobapplications/${jobId}`)).json();
+ expect(jobB).toEqual(jobA);
jobA.jobTitle = 'First editor committed title'; jobB.notes = 'Second editor notes';
- expect([200, 204]).toContain((await first.put(`${api}/jobapplications/${jobId}`, { headers: a, data: jobA })).status());
- const staleJob = await second.put(`${api}/jobapplications/${jobId}`, { headers: b, data: jobB });
- expect([200, 204, 409]).toContain(staleJob.status());
- const jobAfter = await (await first.get(`${api}/jobapplications/${jobId}`)).json();
+ expect((await first.put(`${api}/jobapplications/${jobId}`, { headers: a, data: { ...jobA, expectedVersion: jobA.version } })).status()).toBe(204);
+ const jobWinner = await (await first.get(`${api}/jobapplications/${jobId}`)).json();
+ expect(jobWinner.version).toBe(jobA.version + 1);
+ expect(jobWinner.jobTitle).toBe(jobA.jobTitle);
+ const staleJob = await second.put(`${api}/jobapplications/${jobId}`, { headers: b, data: { ...jobB, expectedVersion: jobB.version } });
+ expect(staleJob.status()).toBe(409);
+ const jobAfter = await (await second.get(`${api}/jobapplications/${jobId}`)).json();
+ expect(jobAfter).toEqual(jobWinner);
const careerLostFirstEdit = careerAfter.summary[0] !== careerA.summary[0];
const jobLostFirstEdit = jobAfter.jobTitle !== jobA.jobTitle;
+ expect(careerLostFirstEdit).toBe(false); expect(jobLostFirstEdit).toBe(false);
await info.attach('phase9-rd21-characterization', { body: JSON.stringify({ id: 'RD21',
career: { staleStatus: staleCareer.status(), expectedFirstSummary: careerA.summary, actualSummary: careerAfter.summary, lostFirstEdit: careerLostFirstEdit },
job: { staleStatus: staleJob.status(), expectedFirstTitle: jobA.jobTitle, actualTitle: jobAfter.jobTitle, lostFirstEdit: jobLostFirstEdit },
- safetyQualification: careerLostFirstEdit || jobLostFirstEdit ? 'FAIL: stale writes can overwrite acknowledged edits' : 'Finite scenario retained prior edits',
- limitation: 'This passing characterization records observed behavior; it does not accept unsafe stale writes or provide a new revision contract. Creation-intent idempotency remains separate.' }, null, 2), contentType: 'application/json' });
+ safetyQualification: 'Both stale replacements rejected with 409; exact winner state and Career history retained',
+ limitation: 'Finite sequential A/B scenario on the configured isolated API; not simultaneous provider contention or creation-intent idempotency proof.' }, null, 2), contentType: 'application/json' });
} finally { await second.dispose(); await first.dispose(); await admin.dispose(); }
});
diff --git a/docs/redesign/phase-9/p9-05/diagnostics/sustained.spec.ts b/docs/redesign/phase-9/p9-05/diagnostics/sustained.spec.ts
index a0be739..109a51f 100644
--- a/docs/redesign/phase-9/p9-05/diagnostics/sustained.spec.ts
+++ b/docs/redesign/phase-9/p9-05/diagnostics/sustained.spec.ts
@@ -29,7 +29,7 @@ test('P9 bounded sustained reads with 250 jobs and long CV while using native wo
const profile = { version: '1', contact: { fullName: 'Åse Synthetic', email: 'synthetic@example.test' }, summary: ['Documented accessible workflows.'],
jobs: Array.from({ length: 8 }, (_, index) => ({ id: `p9-role-${index}`, title: `Platform engineer ${index}`, company: 'Nordlys synthetic',
start: '2017-01', end: '2025-12', bullets: Array.from({ length: 6 }, (_, bullet) => `Evidence ${index}.${bullet}: ${'Documented reliable workflows. Dokumenterte pålitelige arbeidsflyter. '.repeat(3).trim()}`) })) };
- expect((await page.request.put(`${api}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
+ expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${api}/career/profile`)).json()).revision, profile } })).status()).toBe(200);
const savedProfile = (await (await page.request.get(`${api}/career/profile`)).json()).profile;
expect(savedProfile.jobs).toHaveLength(8);
expect(savedProfile.jobs.map((job: { start: string; end: string; bullets: string[] }) => ({ start: job.start, end: job.end, bullets: job.bullets })))
diff --git a/docs/redesign/phase-9/p9-05/volume/volume.spec.ts b/docs/redesign/phase-9/p9-05/volume/volume.spec.ts
index bd21947..e65bcaf 100644
--- a/docs/redesign/phase-9/p9-05/volume/volume.spec.ts
+++ b/docs/redesign/phase-9/p9-05/volume/volume.spec.ts
@@ -26,7 +26,7 @@ test('P9 volume: 100 CV revisions retain exact history, stale rejection and addi
const account = await owner(playwright);
try {
const { api, headers } = account;
- expect((await api.put(`${base}/career/profile`, { headers, data: { profile: { version: '1', contact: { fullName: 'Volume fixture' }, summary: ['Canonical unchanged'] } } })).status()).toBe(200);
+ expect((await api.put(`${base}/career/profile`, { headers, data: { expectedVersion: (await (await api.get(`${base}/career/profile`)).json()).revision, profile: { version: '1', contact: { fullName: 'Volume fixture' }, summary: ['Canonical unchanged'] } } })).status()).toBe(200);
const source = await (await api.get(`${base}/career/profile`)).json();
const created = await api.post(`${base}/cv/variants`, { headers, data: { name: 'Volume CV', settings: { themeId: 'minimal' } } });
expect(created.status()).toBe(200); let cv = await created.json(); const id = cv.id;
diff --git a/job-tracker-ui/mariadb-tests/save-recovery.spec.ts b/job-tracker-ui/mariadb-tests/save-recovery.spec.ts
new file mode 100644
index 0000000..f315b45
--- /dev/null
+++ b/job-tracker-ui/mariadb-tests/save-recovery.spec.ts
@@ -0,0 +1,95 @@
+import { test, expect, type APIRequestContext } from '@playwright/test';
+import { execFileSync } from 'node:child_process';
+import { translations } from '../src/i18n/translations';
+
+const api = 'http://localhost:5302/api';
+const password = 'E2ePassword123!';
+const t = translations.en;
+async function login(request: APIRequestContext, email: string) {
+ expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
+ const me = await (await request.get(`${api}/auth/me`)).json();
+ return { id: me.id as string, headers: { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(c => c.name === 'XSRF-TOKEN')!.value,
+ 'X-Session-Context': me.sessionContext as string } };
+}
+function sql(database: string, query: string) {
+ expect(database).toMatch(/^jt_it_[a-f0-9]{32}$/);
+ const client = process.env.JOBTRACKER_MARIADB_CLIENT;
+ const port = process.env.JOBTRACKER_MARIADB_BROWSER_PORT;
+ expect(client).toBeTruthy(); expect(Number(port)).toBeGreaterThan(0); expect(Number(port)).toBeLessThan(65536);
+ const output = execFileSync(client!, ['--host=127.0.0.1', `--port=${Number(port)}`, '--user=root', `--database=${database}`,
+ '--batch', '--raw', '--skip-column-names', `--execute=${query}`], { encoding: 'utf8',
+ env: { ...process.env, MYSQL_PWD: 'jt-integration-only' } });
+ return output.trim();
+}
+function stored(database: string, owner: string) {
+ expect(owner).toMatch(/^[a-zA-Z0-9-]{1,64}$/);
+ return JSON.parse(sql(database, `SET TRANSACTION READ ONLY; START TRANSACTION; SELECT JSON_OBJECT('revision',c.Version,'profile',c.ProfileJson,'projection',u.ProfileCvStructureJson,'history',(SELECT COUNT(*) FROM CareerProfileVersions h WHERE h.OwnerUserId=c.OwnerUserId)) FROM CareerProfiles c JOIN AspNetUsers u ON u.Id=c.OwnerUserId WHERE c.OwnerUserId='${owner}'; COMMIT;`));
+}
+
+test('MariaDB native Career conflict and network failure retain drafts; reviewed retry persists through refresh and independent session', async ({ page, playwright }, info) => {
+ const database = info.config.metadata.mariaDbDatabase as string;
+ const server = sql(database, 'SELECT VERSION();');
+ expect(server).toMatch(/^12\.2\.2-MariaDB(?:-|$)/);
+ const admin = await playwright.request.newContext();
+ const second = await playwright.request.newContext();
+ const fresh = await playwright.request.newContext();
+ try {
+ const adminOwner = await login(admin, 'e2e@example.test');
+ const email = `mdb-recovery-${Date.now()}@example.test`;
+ expect((await admin.post(`${api}/users`, { headers: adminOwner.headers, data: { email, password, roles: [] } })).status()).toBe(200);
+ const owner = await login(page.request, email);
+ const remoteOwner = await login(second, email);
+ const source = await (await page.request.get(`${api}/career/profile`)).json();
+ const seed = await page.request.put(`${api}/career/profile`, { headers: owner.headers, data: { expectedVersion: source.revision,
+ profile: { version: '1', contact: { fullName: 'MariaDB recovery fixture', phone: '+47 900 00 001' }, summary: ['Initial summary'] } } });
+ expect(seed.status()).toBe(200);
+ await page.addInitScript(() => localStorage.setItem('uiLanguage', 'en'));
+ await page.goto('/career?section=profile');
+ const summary = page.getByLabel(t.profileCvStructuredSummary, { exact: true });
+ await expect(summary).toHaveValue('Initial summary');
+ const draft = 'Retained MariaDB draft reviewed after conflict';
+ await summary.fill(draft);
+ const remote = await (await second.get(`${api}/career/profile`)).json();
+ remote.profile.contact.phone = '+47 900 00 077';
+ const winnerResponse = await second.put(`${api}/career/profile`, { headers: remoteOwner.headers,
+ data: { expectedVersion: remote.revision, profile: remote.profile } });
+ expect(winnerResponse.status()).toBe(200);
+ const winner = await winnerResponse.json();
+ const acknowledged = stored(database, owner.id);
+ expect(acknowledged.revision).toBe(winner.revision);
+ const conflict = page.waitForResponse(r => r.url() === `${api}/career/profile` && r.request().method() === 'PUT');
+ await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click();
+ expect((await conflict).status()).toBe(409);
+ await expect(summary).toHaveValue(draft);
+ await expect(page.getByRole('main').getByRole('alert').filter({ hasText: t.revisionConflict })).toBeVisible();
+ expect(stored(database, owner.id)).toEqual(acknowledged);
+ await page.reload();
+ await expect(page.getByLabel(t.profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 077');
+ await summary.fill(draft);
+ await page.route('**/api/career/profile', route => route.request().method() === 'PUT' ? route.abort('failed') : route.continue());
+ const failed = page.waitForEvent('requestfailed', r => r.url() === `${api}/career/profile` && r.method() === 'PUT');
+ await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click();
+ await failed;
+ await expect(summary).toHaveValue(draft);
+ await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled();
+ expect(stored(database, owner.id)).toEqual(acknowledged);
+ await page.unroute('**/api/career/profile');
+ const saving = page.waitForResponse(r => r.url() === `${api}/career/profile` && r.request().method() === 'PUT');
+ await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click();
+ const response = await saving;
+ expect(response.status()).toBe(200);
+ const saved = stored(database, owner.id);
+ expect(saved.revision).toBe(winner.revision + 1);
+ expect(saved.history).toBe(acknowledged.history + 1);
+ expect(JSON.parse(saved.profile)).toMatchObject({ summary: [draft], contact: { phone: '+47 900 00 077' } });
+ expect(saved.projection).toBe(saved.profile);
+ await page.reload();
+ await expect(summary).toHaveValue(draft);
+ await login(fresh, email);
+ expect((await (await fresh.get(`${api}/career/profile`)).json()).profile.summary).toEqual([draft]);
+ expect((await fresh.get(`${api}/profile-cv/runs`)).status()).toBe(200);
+ expect(stored(database, owner.id)).toEqual(saved);
+ await info.attach('mariadb-durable-recovery', { body: JSON.stringify({ acknowledged, saved, conflict:409, server,
+ limits:'Native browser/API and independent read-only MariaDB connections; controlled request abort; no production or power-loss durability claim.' }), contentType:'application/json' });
+ } finally { await admin.dispose(); await second.dispose(); await fresh.dispose(); }
+});
diff --git a/job-tracker-ui/playwright.config.ts b/job-tracker-ui/playwright.config.ts
index cc5a944..71dd643 100644
--- a/job-tracker-ui/playwright.config.ts
+++ b/job-tracker-ui/playwright.config.ts
@@ -50,6 +50,9 @@ export default defineConfig({
App__CommitSha: "e2e1234",
Cors__Origins__0: appUrl,
Data__Root: dataRoot,
+ // Temporary SQLite browser lane until all fixture/DB proof helpers migrate to MariaDB.
+ Database__Provider: "sqlite",
+ ConnectionStrings__JobTracker: "",
Workers__AiOperationsEnabled: "false",
// Browser provider responses are fixtures; the server must not start a real NAV search.
Workers__SavedJobSearchAlertsEnabled: "false",
diff --git a/job-tracker-ui/playwright.mariadb.config.ts b/job-tracker-ui/playwright.mariadb.config.ts
new file mode 100644
index 0000000..080312b
--- /dev/null
+++ b/job-tracker-ui/playwright.mariadb.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from '@playwright/test';
+import base from './playwright.config';
+import { validateMariaDbBrowserConnection } from './scripts/mariadb-browser-connection.mjs';
+
+const connection = process.env.JOBTRACKER_MARIADB_BROWSER_CONNECTION || '';
+const database = validateMariaDbBrowserConnection(connection, process.env.JOBTRACKER_MARIADB_BROWSER_PORT || '');
+const servers = Array.isArray(base.webServer) ? base.webServer : [base.webServer!];
+export default defineConfig({ ...base, testDir: './mariadb-tests', retries: 0,
+ metadata: { ...base.metadata, mariaDbDatabase: database },
+ webServer: [{ ...servers[0], reuseExistingServer: false, env: { ...servers[0].env, Database__Provider: 'mariadb',
+ Database__ServerVersion: '12.2.2', ConnectionStrings__JobTracker: connection } },
+ { ...servers[1], reuseExistingServer: false }],
+});
diff --git a/job-tracker-ui/scripts/mariadb-browser-connection.mjs b/job-tracker-ui/scripts/mariadb-browser-connection.mjs
new file mode 100644
index 0000000..432f994
--- /dev/null
+++ b/job-tracker-ui/scripts/mariadb-browser-connection.mjs
@@ -0,0 +1,8 @@
+// This destructive fixture accepts only the runner's canonical disposable connection.
+export function validateMariaDbBrowserConnection(connection, port) {
+ const match = /^Server=127\.0\.0\.1;Port=([0-9]{1,5});Database=(jt_it_[a-f0-9]{32});User ID=root;Password=jt-integration-only;SslMode=None;?$/.exec(connection);
+ if (!match || Number(match[1]) < 1 || Number(match[1]) > 65535 || match[1] !== port) {
+ throw new Error('MariaDB browser lane requires a canonical disposable loopback connection and matching port.');
+ }
+ return match[2];
+}
diff --git a/job-tracker-ui/scripts/mariadb-browser-connection.test.mjs b/job-tracker-ui/scripts/mariadb-browser-connection.test.mjs
new file mode 100644
index 0000000..8df4ed5
--- /dev/null
+++ b/job-tracker-ui/scripts/mariadb-browser-connection.test.mjs
@@ -0,0 +1,13 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { validateMariaDbBrowserConnection as validate } from './mariadb-browser-connection.mjs';
+const database = 'jt_it_' + 'a'.repeat(32);
+const connection = `Server=127.0.0.1;Port=33308;Database=${database};User ID=root;Password=jt-integration-only;SslMode=None`;
+test('accepts the explicit disposable connection', () => assert.equal(validate(connection, '33308'), database));
+for (const invalid of [connection + ';Database=existing_dev', connection + ';Server=remote',
+ connection + ';Host=remote', connection.replace('127.0.0.1', 'remote'),
+ connection.replace(database, 'existing_dev'), connection.replace('33308', '65536'),
+ connection.replace('33308', '0'), connection + ';ConnectionProtocol=Pipe']) {
+ test(`rejects unsafe connection ${invalid}`, () => assert.throws(() => validate(invalid, '33308')));
+}
+test('rejects independent-reader port mismatch', () => assert.throws(() => validate(connection, '33309')));
diff --git a/scripts/mariadb-integration.py b/scripts/mariadb-integration.py
new file mode 100644
index 0000000..b1b579c
--- /dev/null
+++ b/scripts/mariadb-integration.py
@@ -0,0 +1,90 @@
+"""Run the explicit MariaDB persistence lane, never a SQLite fallback."""
+import argparse
+import datetime
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import socket
+import subprocess
+import uuid
+
+ROOT = Path(__file__).resolve().parent.parent
+
+def compose_command(project):
+ if not re.fullmatch(r'jobtracker-it-[a-z0-9]+', project):
+ raise ValueError('Only generated integration projects may be managed.')
+ return ['docker', 'compose', '--project-name', project, '--project-directory', str(ROOT),
+ '-f', str(ROOT / 'docker-compose.database.yml'), '--profile', 'integration']
+
+def test_connection(port):
+ if not 0 < port < 65536: raise ValueError('Invalid integration port.')
+ return f'Server=127.0.0.1;Port={port};User ID=root;Password=jt-integration-only;SslMode=None'
+
+def existing_connection(environment):
+ value = environment.get('JOBTRACKER_MARIADB_TEST_ADMIN', '')
+ if not value.strip(): raise ValueError('--existing-server requires explicit JOBTRACKER_MARIADB_TEST_ADMIN.')
+ # The fixture independently enforces loopback TCP, exact server version and generated schemas.
+ return value
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--existing-server', action='store_true', help='Use an explicitly configured isolated loopback server; fixture guards still apply.')
+ parser.add_argument('--output', default=str(ROOT / '.artifacts/mariadb/integration'))
+ args = parser.parse_args()
+ environment = os.environ.copy()
+ output = Path(args.output).resolve()
+ output.mkdir(parents=True, exist_ok=True)
+ project = 'jobtracker-it-' + uuid.uuid4().hex
+ compose = compose_command(project)
+ managed = not args.existing_server
+ started = datetime.datetime.now(datetime.timezone.utc).isoformat()
+ commands = []
+ code = 1
+ def run(command, name):
+ commands.append({'command': command, 'log': str(output / name)})
+ with (output / name).open('w', encoding='utf-8') as log:
+ result = subprocess.run(command, cwd=ROOT, env=environment, stdout=log, stderr=subprocess.STDOUT)
+ commands[-1]['exitCode'] = result.returncode
+ return result.returncode
+ try:
+ if managed:
+ with socket.socket() as probe:
+ probe.bind(('127.0.0.1', 0))
+ port = probe.getsockname()[1]
+ environment['MARIADB_TEST_PORT'] = str(port)
+ environment['JOBTRACKER_MARIADB_TEST_ADMIN'] = test_connection(port)
+ code = run(compose + ['up', '-d', '--wait', '--wait-timeout', '120', 'mariadb-test'], 'compose-start.log')
+ else:
+ existing_connection(environment)
+ code = 0
+ if code == 0:
+ dotnet = environment.get('DOTNET_HOST_PATH') or shutil.which('dotnet')
+ if not dotnet: raise ValueError('Pinned .NET SDK is required; set DOTNET_HOST_PATH.')
+ project_file = 'JobTrackerApi.MariaDb.Tests/JobTrackerApi.MariaDb.Tests.csproj'
+ code = run([dotnet, 'restore', project_file, '--locked-mode'], 'restore.log')
+ if code == 0:
+ code = run([dotnet, 'test', project_file, '--configuration', 'Release', '--no-restore',
+ '--logger', 'trx;LogFileName=mariadb.trx', '--results-directory', str(output)], 'tests.log')
+ except Exception:
+ code = 1
+ raise
+ finally:
+ if managed:
+ # Never use down/-v: this generated project only owns its disposable test service.
+ run(compose + ['logs', '--no-color', 'mariadb-test'], 'server.log')
+ cleanup = run(compose + ['rm', '--stop', '--force', 'mariadb-test'], 'compose-cleanup.log')
+ if cleanup:
+ print('Test container cleanup failed; inspect compose-cleanup.log.')
+ if code == 0: code = cleanup
+ record = {'startedUtc':started, 'finishedUtc':datetime.datetime.now(datetime.timezone.utc).isoformat(),
+ 'mode':'compose' if managed else 'explicit-existing-loopback', 'expectedVersion':environment.get('JOBTRACKER_MARIADB_TEST_VERSION','12.2.2'),
+ 'project':project if managed else None, 'exitCode':code, 'commands':commands}
+ (output / 'run.json').write_text(json.dumps(record, indent=2)+'\n', encoding='utf-8')
+ print(json.dumps({'exitCode':code, 'evidence':str(output), 'mode':record['mode']}))
+ return code
+
+if __name__ == '__main__':
+ try: raise SystemExit(main())
+ except ValueError as error: raise SystemExit(str(error))
diff --git a/scripts/test-mariadb-integration.py b/scripts/test-mariadb-integration.py
new file mode 100644
index 0000000..c5cfecf
--- /dev/null
+++ b/scripts/test-mariadb-integration.py
@@ -0,0 +1,50 @@
+"""Offline guard tests for the isolated MariaDB lane."""
+import importlib.util
+from pathlib import Path
+import unittest
+from unittest import mock
+import tempfile
+import json
+
+spec = importlib.util.spec_from_file_location('mariadb_integration', Path(__file__).with_name('mariadb-integration.py'))
+runner = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(runner)
+
+class Guards(unittest.TestCase):
+ def test_compose_project_is_generated_and_test_only(self):
+ command = runner.compose_command('jobtracker-it-abc123')
+ self.assertIn('jobtracker-it-abc123', command)
+ self.assertIn('docker-compose.database.yml', ' '.join(command))
+ self.assertNotIn('docker-compose.yml', command)
+ for name in ['production', 'jobtracker-local', '', 'jobtracker-it-x;whoami']:
+ with self.assertRaises(ValueError): runner.compose_command(name)
+
+ def test_credentials_and_address_are_local_test_only(self):
+ self.assertEqual(runner.test_connection(33307), 'Server=127.0.0.1;Port=33307;User ID=root;Password=jt-integration-only;SslMode=None')
+ for port in [0, 65536, -1]:
+ with self.assertRaises(ValueError): runner.test_connection(port)
+
+ def test_existing_server_requires_explicit_environment(self):
+ with self.assertRaises(ValueError): runner.existing_connection({})
+ with self.assertRaises(ValueError): runner.existing_connection({'JOBTRACKER_MARIADB_TEST_ADMIN':' '})
+ self.assertEqual(runner.existing_connection({'JOBTRACKER_MARIADB_TEST_ADMIN':'local-test'}), 'local-test')
+
+ def test_cleanup_failure_cannot_turn_into_a_green_ci_run(self):
+ with tempfile.TemporaryDirectory() as folder, mock.patch('sys.argv', ['runner','--output',folder]), \
+ mock.patch.dict(runner.os.environ, {'DOTNET_HOST_PATH':'test-dotnet'}, clear=True), \
+ mock.patch.object(runner.subprocess, 'run') as execute:
+ execute.side_effect = [mock.Mock(returncode=n) for n in [0,0,0,0,17]]
+ self.assertEqual(runner.main(), 17)
+ record=json.loads((Path(folder)/'run.json').read_text())
+ self.assertEqual(record['exitCode'],17)
+ self.assertEqual(record['commands'][-1]['command'][-4:], ['rm','--stop','--force','mariadb-test'])
+ self.assertNotIn('down',record['commands'][-1]['command'])
+
+ def test_setup_exception_is_recorded_as_failure(self):
+ with tempfile.TemporaryDirectory() as folder, mock.patch('sys.argv',['runner','--output',folder]), \
+ mock.patch.dict(runner.os.environ,{},clear=True), mock.patch.object(runner.shutil,'which',return_value=None), \
+ mock.patch.object(runner.subprocess,'run',return_value=mock.Mock(returncode=0)):
+ with self.assertRaises(ValueError): runner.main()
+ self.assertEqual(json.loads((Path(folder)/'run.json').read_text())['exitCode'],1)
+
+if __name__ == '__main__': unittest.main()