Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f17dba2ec0 | |||
| b313e20bad | |||
| e80815f8df | |||
| 57dd88322a | |||
| 0554e27af1 | |||
| 3727a723e8 | |||
| 7092697cb3 | |||
| 5ac11549b2 | |||
| 2b26953d6f | |||
| 1a1b7c8221 | |||
| 780b9bc64f | |||
| 75cd04cfc2 | |||
| 549b2cba5a | |||
| e75c4a4775 | |||
| a2c9bda0d1 | |||
| 0d16a691e0 | |||
| 1b89a779d0 | |||
| 05a738693e | |||
| 12f342c09c | |||
| 8f2cad67f7 | |||
| 8e7e3f6387 | |||
| 0fb108bd67 | |||
| b9e052cd22 | |||
| a4cf429fb3 | |||
| d518d471f9 | |||
| 4b7727e22b | |||
| 82be698dcf | |||
| 322b025350 | |||
| e982ff7938 | |||
| 25a481cd57 | |||
| 8cfc684c64 | |||
| 1c24e406f1 | |||
| 2f6227fb4f | |||
| 9604589785 | |||
| 77968da90c | |||
| 68ee6cacb0 | |||
| a24e6b3159 | |||
| 5edbe59692 | |||
| 421f902d21 | |||
| ef94d579cb | |||
| b2a9fd68b0 | |||
| 508d68e16d | |||
| 32e464f612 | |||
| 2f772e1322 | |||
| f51e0506cc | |||
| a15336cc94 | |||
| fe9ab3d7d3 | |||
| 59ac1b5049 | |||
| 9b17b04d61 | |||
| 9bb8b1a0cf | |||
| e301f37279 | |||
| 44458f4f3a | |||
| b39a5af4de | |||
| dda1687345 | |||
| ddb9c3da4f | |||
| b333ef55d5 | |||
| e698ed6c57 | |||
| c5cfc8756c | |||
| 8bbaf1f875 | |||
| 584530b694 | |||
| ab89b6984c | |||
| d8c0187305 | |||
| bba52af4b8 | |||
| 6db62095c6 | |||
| d824f59d2b | |||
| 1d34406d1b | |||
| f9e1d06bf7 | |||
| 4760fd102c | |||
| 7ecc0d6605 | |||
| 8af9168014 | |||
| 36fc5d54e7 | |||
| 3028d8172c | |||
| 5addcde1e2 |
+3
-2
@@ -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
|
||||
|
||||
@@ -5,3 +5,7 @@
|
||||
|
||||
# Preserve the exact exported bytes used by the spreadsheet regression evidence.
|
||||
/docs/audits/2026-09-07/validation/evidence/*.csv binary
|
||||
# Preserve vendored font/license bytes across Windows and Linux checkouts.
|
||||
JobTrackerApi/DocumentFonts/*.ttf -text
|
||||
# Upstream notices retain their exact bytes, including original trailing spaces.
|
||||
JobTrackerApi/DocumentFonts/*.txt text eol=lf -whitespace
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: MariaDB persistence qualification
|
||||
on:
|
||||
push:
|
||||
branches: [codex/rd21-mariadb-standardisation, fix/p9-ci-and-requalification]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
mariadb:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require verified runner isolation
|
||||
# Remove this blocker only after the operator evidence and workflow review in the handoff.
|
||||
run: |
|
||||
echo '::error::BLOCKED: hosted runner isolation is unverified. See docs/redesign/phase-9/p9-requal-01/CI_OPERATOR_HANDOFF.md before enabling database execution.'
|
||||
exit 1
|
||||
- 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/
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.MariaDb.Tests;
|
||||
|
||||
public sealed class DocumentsIndexTests
|
||||
{
|
||||
private const string Owner = "rd21-owner";
|
||||
private const string Other = "documents-other";
|
||||
private const string PrivateContent = "PRIVATE_DOCUMENT_CONTENT";
|
||||
|
||||
[Fact]
|
||||
public async Task Startup_mixed_collations_support_all_sources_counts_pagination_and_read_only_metadata()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await SeedAsync(database);
|
||||
await using var db = database.Context();
|
||||
var collations = await db.Database.SqlQueryRaw<string>("""
|
||||
SELECT DISTINCT COLLATION_NAME AS Value FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND
|
||||
((TABLE_NAME = 'CvVariants' AND COLUMN_NAME = 'Name')
|
||||
OR (TABLE_NAME = 'JobApplications' AND COLUMN_NAME = 'JobTitle')
|
||||
OR (TABLE_NAME = 'Attachments' AND COLUMN_NAME = 'FileName')
|
||||
OR (TABLE_NAME = 'SubmittedApplicationPackages' AND COLUMN_NAME = 'CvVariantName')
|
||||
OR (TABLE_NAME = 'EmailDrafts' AND COLUMN_NAME = 'Subject'))
|
||||
""").ToListAsync();
|
||||
Assert.True(collations.Count > 1, "The startup-created schema must reproduce mixed source collations.");
|
||||
var before = await SnapshotAsync(database);
|
||||
var all = await GetAsync(db);
|
||||
Assert.Equal(7, all.Total);
|
||||
Assert.Equal(new[] { "application-material", "attachment", "cover-letter", "cv", "email-draft", "submitted-package", "tailored-cv" },
|
||||
all.Items.Select(item => item.Type).Order());
|
||||
var first = await GetAsync(db, pageSize: 3);
|
||||
var second = await GetAsync(db, page: 2, pageSize: 3);
|
||||
var third = await GetAsync(db, page: 3, pageSize: 3);
|
||||
Assert.Equal(7, first.Total);
|
||||
Assert.Equal(3, first.TotalPages);
|
||||
Assert.Equal(new[] { 3, 3, 1 }, new[] { first.Items.Count, second.Items.Count, third.Items.Count });
|
||||
Assert.Equal(all.Items.Select(item => item.Id), first.Items.Concat(second.Items).Concat(third.Items).Select(item => item.Id));
|
||||
var letter = Assert.Single(all.Items, item => item.Type == "cover-letter");
|
||||
Assert.Equal(1, letter.HistoryCount);
|
||||
Assert.Equal(2L, letter.Version);
|
||||
Assert.True(letter.HasCurrentContent);
|
||||
Assert.Equal("2026-01-02T00:00:00.0000000Z", letter.RecordedAt);
|
||||
Assert.Equal(1, Assert.Single(all.Items, item => item.Type == "submitted-package").AttachmentCount);
|
||||
Assert.Equal("/api/attachments/download/1", Assert.Single(all.Items, item => item.Type == "attachment").DownloadHref);
|
||||
Assert.DoesNotContain(PrivateContent, JsonSerializer.Serialize(all));
|
||||
Assert.Empty(db.ChangeTracker.Entries());
|
||||
Assert.Equal(before, await SnapshotAsync(database));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Name_search_preserves_unicode_accents_literal_wildcards_and_binary_ties()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await SeedAsync(database);
|
||||
await using var db = database.Context();
|
||||
var names = new[] { "ÆØÅ résumé", "resume", "100%", "under_score", "bang!", "decoy", "same", "same" };
|
||||
db.CvVariants.AddRange(names.Select((name, index) => Variant(10 + index, Owner, name)));
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
foreach (var (query, expected) in new[] { ("æøå", "cv:10"), ("RÉSUMÉ", "cv:10"), ("resume", "cv:11"), ("%", "cv:12"), ("_", "cv:13"), ("!", "cv:14") })
|
||||
{
|
||||
var result = await GetAsync(db, q: query, type: "cv", pageSize: 1);
|
||||
Assert.Equal(1, result.Total);
|
||||
Assert.Equal(expected, Assert.Single(result.Items).Id);
|
||||
}
|
||||
var ties = await GetAsync(db, q: "same", type: "cv");
|
||||
Assert.Equal(new[] { "cv:16", "cv:17" }, ties.Items.Select(item => item.Id));
|
||||
Assert.Empty((await GetAsync(db, q: PrivateContent)).Items);
|
||||
Assert.Empty(db.ChangeTracker.Entries());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_joins_and_filters_exclude_foreign_documents_and_damaged_associations()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await SeedAsync(database);
|
||||
await using var db = database.Context();
|
||||
var damaged = Variant(3, Owner, "Damaged association");
|
||||
damaged.JobApplicationId = 2;
|
||||
db.CvVariants.Add(damaged);
|
||||
db.EmailDrafts.Add(Draft(Owner, 2, "Foreign linked draft"));
|
||||
db.SubmittedApplicationPackages.Add(new() { OwnerUserId = Owner, JobApplicationId = 2, Version = 99, CvVariantName = "Foreign linked package" });
|
||||
db.CoverLetterVersions.Add(new() { OwnerUserId = Other, JobApplicationId = 1, Version = 99, Text = PrivateContent });
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
var before = await SnapshotAsync(database);
|
||||
var own = await GetAsync(db);
|
||||
Assert.Equal(8, own.Total);
|
||||
Assert.DoesNotContain(own.Items, item => item.Name.StartsWith("Foreign", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(own.Items, item => item.CompanyName == "Foreign company");
|
||||
var orphan = Assert.Single(own.Items, item => item.Id == "cv:3");
|
||||
Assert.Null(orphan.JobApplicationId);
|
||||
Assert.Null(orphan.CompanyName);
|
||||
Assert.Null(orphan.JobTitle);
|
||||
Assert.Equal(1, Assert.Single(own.Items, item => item.Type == "cover-letter").HistoryCount);
|
||||
Assert.Empty((await GetAsync(db, job: 2)).Items);
|
||||
Assert.Equal(7, (await GetAsync(db, job: 1)).Total);
|
||||
Assert.Equal(1, (await GetAsync(db, type: "cv", visibility: "public")).Total);
|
||||
Assert.Equal(4, (await GetAsync(db, state: "draft")).Total);
|
||||
var foreign = await GetAsync(db, owner: Other);
|
||||
Assert.Equal(7, foreign.Total);
|
||||
Assert.All(foreign.Items, item => Assert.Equal(2, item.JobApplicationId));
|
||||
Assert.Empty(db.ChangeTracker.Entries());
|
||||
Assert.Equal(before, await SnapshotAsync(database));
|
||||
}
|
||||
|
||||
private static Task<DocumentsPageDto> GetAsync(JobTrackerContext db, int page = 1, int pageSize = 100,
|
||||
string? q = null, string type = "all", string state = "all", string visibility = "all", int? job = null, string owner = Owner)
|
||||
=> new DocumentsIndexService(db).GetAsync(owner, page, pageSize, q, type, state, visibility, job, "name-asc", default);
|
||||
|
||||
private static CvVariant Variant(int id, string owner, string name) => new()
|
||||
{
|
||||
Id = id, OwnerUserId = owner, Name = name, PublicSlug = $"documents-{id}", SettingsJson = PrivateContent,
|
||||
Version = 1, CreatedAtUtc = DateTimeOffset.Parse("2026-01-01T00:00:00Z"), UpdatedAtUtc = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
private static EmailDraft Draft(string owner, int job, string subject) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(), OwnerUserId = owner, JobApplicationId = job, Subject = subject, Provider = "gmail",
|
||||
To = "private@example.test", BodyText = PrivateContent, ClientRequestId = Guid.NewGuid().ToString(),
|
||||
CreatedAtUtc = new DateTime(2026, 1, 1), UpdatedAtUtc = new DateTime(2026, 1, 1),
|
||||
};
|
||||
|
||||
private static async Task SeedAsync(MariaDbDatabase database)
|
||||
{
|
||||
await using var db = database.Context();
|
||||
foreach (var (id, owner) in new[] { (1, Owner), (2, Other) })
|
||||
{
|
||||
db.Users.Add(new() { Id = owner, UserName = owner });
|
||||
db.Companies.Add(new() { Id = id, OwnerUserId = owner, Name = id == 1 ? "Nordlys" : "Foreign company" });
|
||||
db.JobApplications.Add(new()
|
||||
{
|
||||
Id = id, OwnerUserId = owner, CompanyId = id, JobTitle = id == 1 ? "Owned role" : "Foreign role",
|
||||
CoverLetterText = PrivateContent, TailoredCvText = PrivateContent, RecruiterMessageDraft = PrivateContent,
|
||||
});
|
||||
var cv = Variant(id, owner, id == 1 ? "Public CV" : "Foreign CV");
|
||||
cv.JobApplicationId = id;
|
||||
cv.IsPublic = true;
|
||||
db.CvVariants.Add(cv);
|
||||
db.CoverLetterVersions.Add(new() { OwnerUserId = owner, JobApplicationId = id, Version = 2, Text = PrivateContent, CreatedAtUtc = DateTimeOffset.Parse("2026-01-02T00:00:00Z") });
|
||||
db.Attachments.Add(new() { Id = id, JobApplicationId = id, FileName = "Portfolio.pdf", FilePath = PrivateContent, FileType = "application/pdf", FileSize = 41, UploadDate = new DateTime(2026, 1, 1) });
|
||||
db.SubmittedApplicationPackages.Add(new()
|
||||
{
|
||||
OwnerUserId = owner, JobApplicationId = id, Version = 1, CvVariantName = "Captured CV", CoverLetterText = PrivateContent,
|
||||
Attachments = [new() { FileName = "Captured.pdf", FilePath = PrivateContent, FileType = "application/pdf", Sha256 = new string('a', 64) }],
|
||||
});
|
||||
db.EmailDrafts.Add(Draft(owner, id, "Saved subject"));
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> SnapshotAsync(MariaDbDatabase database)
|
||||
{
|
||||
await using var db = database.Context();
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
Jobs = await db.JobApplications.IgnoreQueryFilters().AsNoTracking().OrderBy(x => x.Id).ToListAsync(),
|
||||
Variants = await db.CvVariants.IgnoreQueryFilters().AsNoTracking().OrderBy(x => x.Id).ToListAsync(),
|
||||
Letters = await db.CoverLetterVersions.IgnoreQueryFilters().AsNoTracking().OrderBy(x => x.Id).ToListAsync(),
|
||||
Attachments = await db.Attachments.IgnoreQueryFilters().AsNoTracking().OrderBy(x => x.Id).ToListAsync(),
|
||||
Packages = await db.SubmittedApplicationPackages.IgnoreQueryFilters().AsNoTracking().OrderBy(x => x.Id).ToListAsync(),
|
||||
Archived = await db.SubmittedPackageAttachments.IgnoreQueryFilters().AsNoTracking().OrderBy(x => x.Id).ToListAsync(),
|
||||
Drafts = await db.EmailDrafts.IgnoreQueryFilters().AsNoTracking().OrderBy(x => x.Id).ToListAsync(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.14" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../JobTrackerApi/JobTrackerApi.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Net;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using MySqlConnector;
|
||||
using Moq;
|
||||
|
||||
namespace JobTrackerApi.MariaDb.Tests;
|
||||
|
||||
// Each test owns its schema. Never connect contexts to the administrator's selected database.
|
||||
internal sealed class MariaDbDatabase : IAsyncDisposable
|
||||
{
|
||||
private readonly string _adminConnection;
|
||||
private readonly string _root;
|
||||
private bool _created;
|
||||
public string Name { get; } = $"jt_it_{Guid.NewGuid():N}";
|
||||
public string ConnectionString { get; private set; } = "";
|
||||
public MariaDbServerVersion ServerVersion { get; private set; } = null!;
|
||||
public string ActualVersion { get; private set; } = "";
|
||||
public WebApplication App { get; private set; } = null!;
|
||||
|
||||
private MariaDbDatabase(string adminConnection)
|
||||
{
|
||||
_adminConnection = adminConnection;
|
||||
_root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), Name));
|
||||
}
|
||||
|
||||
internal static MySqlConnectionStringBuilder ValidateAdminConnection(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new InvalidOperationException("Explicit MariaDB test lane requires JOBTRACKER_MARIADB_TEST_ADMIN; no skip or SQLite fallback is allowed.");
|
||||
var builder = new MySqlConnectionStringBuilder(value);
|
||||
if (!(builder.Server.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|
||||
|| IPAddress.TryParse(builder.Server, out var address) && IPAddress.IsLoopback(address)))
|
||||
throw new InvalidOperationException("MariaDB integration tests require a loopback server.");
|
||||
if (builder.ConnectionProtocol != MySqlConnectionProtocol.Sockets)
|
||||
throw new InvalidOperationException("MariaDB integration tests require loopback TCP.");
|
||||
builder.Database = "";
|
||||
builder.Pooling = false;
|
||||
builder.ConnectionTimeout = 5;
|
||||
builder.DefaultCommandTimeout = 30;
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static async Task<MariaDbDatabase> CreateAsync()
|
||||
{
|
||||
var admin = ValidateAdminConnection(Environment.GetEnvironmentVariable("JOBTRACKER_MARIADB_TEST_ADMIN"));
|
||||
var database = new MariaDbDatabase(admin.ConnectionString);
|
||||
try
|
||||
{
|
||||
await using var connection = new MySqlConnection(admin.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT VERSION();";
|
||||
database.ActualVersion = (string)(await command.ExecuteScalarAsync())!;
|
||||
var expected = Environment.GetEnvironmentVariable("JOBTRACKER_MARIADB_TEST_VERSION") ?? "12.2.2";
|
||||
if (!Version.TryParse(expected, out var pin) || pin.Build < 0 || pin.Revision >= 0 || pin.Major < 11)
|
||||
throw new InvalidOperationException("JOBTRACKER_MARIADB_TEST_VERSION must pin a supported MariaDB major.minor.patch version.");
|
||||
if (!database.ActualVersion.StartsWith(expected + "-", StringComparison.Ordinal)
|
||||
|| !database.ActualVersion.Contains("MariaDB", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"Expected MariaDB {expected}; actual server is {database.ActualVersion}.");
|
||||
database.ServerVersion = new MariaDbServerVersion(pin);
|
||||
command.CommandText = $"CREATE DATABASE `{database.Name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
database._created = true;
|
||||
admin.Database = database.Name;
|
||||
database.ConnectionString = admin.ConnectionString;
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
EnvironmentName = "Production",
|
||||
ApplicationName = typeof(StartupInitializationExtensions).Assembly.GetName().Name,
|
||||
});
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Data:Root"] = database._root,
|
||||
["Database:Provider"] = "mariadb",
|
||||
["ConnectionStrings:JobTracker"] = database.ConnectionString,
|
||||
["Auth:AdminEmail"] = "",
|
||||
["Auth:AdminPassword"] = "",
|
||||
});
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Services.AddSingleton(Mock.Of<ICurrentUserService>(user => user.UserId == "rd21-owner"));
|
||||
builder.Services.AddSingleton<AppPaths>();
|
||||
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
|
||||
builder.Services.AddDbContext<JobTrackerContext>(options => database.Configure(options));
|
||||
builder.Services.AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>().AddEntityFrameworkStores<JobTrackerContext>();
|
||||
database.App = builder.Build();
|
||||
await database.App.InitializeJobTrackerAsync();
|
||||
return database;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await database.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void Configure(DbContextOptionsBuilder options) => options.UseMySql(ConnectionString, ServerVersion)
|
||||
.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning));
|
||||
|
||||
public JobTrackerContext Context(string owner = "rd21-owner", IInterceptor? interceptor = null)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>();
|
||||
Configure(options);
|
||||
if (interceptor is not null) options.AddInterceptors(interceptor);
|
||||
return new(options.Options, Mock.Of<ICurrentUserService>(user => user.UserId == owner));
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (App is not null) await App.DisposeAsync();
|
||||
if (_created)
|
||||
{
|
||||
await using var connection = new MySqlConnection(_adminConnection);
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = $"DROP DATABASE `{Name}`;";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
_created = false;
|
||||
}
|
||||
var temp = Path.GetFullPath(Path.GetTempPath()).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
if (!_root.StartsWith(temp, StringComparison.OrdinalIgnoreCase) || Path.GetFileName(_root) != Name)
|
||||
throw new InvalidOperationException("Refusing cleanup outside this fixture's temporary directory.");
|
||||
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Explicit MariaDB persistence lane
|
||||
|
||||
Run with the pinned .NET SDK and a disposable **loopback** MariaDB 12.2.2 instance:
|
||||
|
||||
```powershell
|
||||
$env:DOTNET_ROOT = 'C:\Users\Connor\.dotnet'
|
||||
# Set JOBTRACKER_MARIADB_TEST_ADMIN through the local test environment; do not commit its secret.
|
||||
# Its account needs CREATE/DROP DATABASE and full privileges on newly-created jt_it_* schemas.
|
||||
rtk proxy C:\Users\Connor\.dotnet\dotnet.exe test JobTrackerApi.MariaDb.Tests/JobTrackerApi.MariaDb.Tests.csproj --logger 'trx;LogFileName=mariadb.trx' --results-directory .artifacts/mariadb
|
||||
```
|
||||
|
||||
Missing credentials, unreachable server, a non-loopback host, or a server version mismatch fails this lane. It never skips or substitutes SQLite. The default exact version is `12.2.2`; `JOBTRACKER_MARIADB_TEST_VERSION` can select another explicit `major.minor.patch` for a separately labelled compatibility run. The test output records the server, EF, provider and applied migration versions.
|
||||
|
||||
The administrator connection's database is cleared before use. Each test creates a new `jt_it_<guid>` database, initializes it through `InitializeJobTrackerAsync`, and drops only that fixture's successfully-created database. Connection pooling is disabled. The additive migration test briefly removes/reapplies RD21 only inside its own new schema. These tests must never be pointed at production; the loopback check is a guard, not permission to tunnel a production server locally.
|
||||
|
||||
Controllers use the same minimal Identity/summarizer mocks as the existing RD21 suite; application persistence, transactions, foreign keys, indexes, revision predicates and startup reconciliation use the real server. Independent contexts use independent TCP connections. No HTTP hosting, external AI provider, browser, deployed identity or production acceptance is established by this lane.
|
||||
@@ -0,0 +1,412 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.MariaDb.Tests;
|
||||
|
||||
public sealed class Rd21CareerPersistenceTests
|
||||
{
|
||||
private static JobTrackerContext Context(MariaDbDatabase connection, IInterceptor? interceptor = null) => connection.Context(interceptor: interceptor);
|
||||
|
||||
private static CareerProfileController Controller(JobTrackerContext db, bool identitySucceeds = true)
|
||||
=> new(Users(db, identitySucceeds).Object, new CareerProfileService(db), db)
|
||||
{
|
||||
ControllerContext = new() { HttpContext = new DefaultHttpContext() },
|
||||
};
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> Users(JobTrackerContext db, bool identitySucceeds)
|
||||
{
|
||||
var user = db.Users.Single();
|
||||
var users = TestHostFactory.CreateUserManager();
|
||||
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
users.Setup(x => x.UpdateAsync(user)).Returns(async () =>
|
||||
{
|
||||
if (!identitySucceeds) return IdentityResult.Failed(new IdentityError { Description = "Synthetic Identity failure" });
|
||||
await db.SaveChangesAsync();
|
||||
return IdentityResult.Success;
|
||||
});
|
||||
return users;
|
||||
}
|
||||
|
||||
private static ProfileCvController ImportController(JobTrackerContext db, bool identitySucceeds = true) =>
|
||||
new(Users(db, identitySucceeds).Object, new Mock<ISummarizerService>().Object, db, null!)
|
||||
{ ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
|
||||
|
||||
private static CareerProfileSaveRequest Request(string title, int? expectedVersion) =>
|
||||
JsonSerializer.Deserialize<CareerProfileSaveRequest>(JsonSerializer.Serialize(new
|
||||
{
|
||||
profile = new StructuredCvProfile { Jobs = { new() { Id = "stable-role", Title = title } } },
|
||||
expectedVersion,
|
||||
}), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
|
||||
[Fact]
|
||||
public async Task Independent_stale_snapshot_cannot_replace_acknowledged_career_edit()
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
seed.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
await seed.SaveChangesAsync();
|
||||
Assert.IsType<OkObjectResult>((await Controller(seed).Put(Request("Original", 0), default)).Result);
|
||||
}
|
||||
await using var first = Context(connection);
|
||||
await using var stale = Context(connection);
|
||||
var staleController = Controller(stale);
|
||||
var source = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>((await staleController.Get(default)).Result).Value);
|
||||
Assert.Equal(1, source.Revision);
|
||||
Assert.Equal("Original", source.Profile.Jobs.Single().Title);
|
||||
Assert.IsType<OkObjectResult>((await Controller(first).Put(Request("Acknowledged winner", 1), default)).Result);
|
||||
var rejected = await staleController.Put(Request("Stale draft", 1), default);
|
||||
Assert.IsType<ConflictObjectResult>(rejected.Result);
|
||||
await stale.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal("Acknowledged winner", (await verify.CareerExperiences.SingleAsync()).Title);
|
||||
Assert.Equal(2, (await verify.CareerProfiles.SingleAsync()).Version);
|
||||
Assert.Equal(2, await verify.CareerProfileVersions.CountAsync());
|
||||
Assert.Contains("Acknowledged winner", (await verify.Users.SingleAsync()).ProfileCvStructureJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Identity_failure_rolls_back_canonical_children_history_and_projection()
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = Context(connection);
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
await db.SaveChangesAsync();
|
||||
var result = await Controller(db, false).Put(Request("Must not persist", 0), default);
|
||||
Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||
await db.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
Assert.Empty(await verify.CareerProfiles.ToListAsync());
|
||||
Assert.Empty(await verify.CareerExperiences.ToListAsync());
|
||||
Assert.Empty(await verify.CareerProfileVersions.ToListAsync());
|
||||
Assert.Null((await verify.Users.SingleAsync()).ProfileCvStructureJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_tracked_entity_is_guarded_at_database_update_and_stale_restore_is_rejected()
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
await using var first = Context(connection);
|
||||
var a = new CareerProfileService(first);
|
||||
await a.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Original" } } }, "manual", 0, default);
|
||||
await using var stale = Context(connection);
|
||||
await stale.CareerProfiles.SingleAsync();
|
||||
await a.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Winner" } } }, "manual", 1, default);
|
||||
var b = new CareerProfileService(stale);
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => b.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Title = "Stale" } } }, "manual", 1, default));
|
||||
await stale.SaveChangesAsync();
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => b.RestoreVersionAsync("rd21-owner", 1, 1, default));
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal("Winner", (await verify.CareerExperiences.SingleAsync()).Title);
|
||||
Assert.Equal(2, await verify.CareerProfileVersions.CountAsync());
|
||||
Assert.Equal(0, (await b.LoadSnapshotAsync("another-owner", default)).Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Competing_first_save_sources_have_one_acknowledged_winner()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await using (var setup = Context(database))
|
||||
{
|
||||
setup.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
await setup.SaveChangesAsync();
|
||||
}
|
||||
var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var readers = 0;
|
||||
async Task<IActionResult?> Save(string title)
|
||||
{
|
||||
await using var db = Context(database);
|
||||
var controller = Controller(db);
|
||||
Assert.Equal(0, Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>((await controller.Get(default)).Result).Value).Revision);
|
||||
if (Interlocked.Increment(ref readers) == 2) ready.TrySetResult();
|
||||
await release.Task.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
return (await controller.Put(Request(title, 0), default)).Result;
|
||||
}
|
||||
var a = Save("Candidate A");
|
||||
var b = Save("Candidate B");
|
||||
try { await ready.Task.WaitAsync(TimeSpan.FromSeconds(10)); }
|
||||
finally { release.TrySetResult(); }
|
||||
var outcomes = await Task.WhenAll(a, b);
|
||||
Assert.Single(outcomes.OfType<OkObjectResult>());
|
||||
Assert.Single(outcomes.OfType<ConflictObjectResult>());
|
||||
await using var verify = Context(database);
|
||||
var profile = await verify.CareerProfiles.SingleAsync();
|
||||
Assert.Equal(1, profile.Version);
|
||||
Assert.Single(await verify.CareerProfileVersions.ToListAsync());
|
||||
Assert.Equal(profile.ProfileJson, (await verify.Users.SingleAsync()).ProfileCvStructureJson);
|
||||
Assert.Equal(StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson).Jobs.Single().Title, (await verify.CareerExperiences.SingleAsync()).Title);
|
||||
}
|
||||
|
||||
private sealed class FailAfterChildDeletion : SaveChangesInterceptor
|
||||
{
|
||||
private int _saves;
|
||||
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (++_saves == 3) throw new InvalidOperationException("Synthetic child insert failure");
|
||||
return base.SavingChangesAsync(eventData, result, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Database_failure_after_deletion_restores_children_history_and_revision_and_clears_tracker()
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
await new CareerProfileService(seed).SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Original" } } }, "manual", 0, default);
|
||||
}
|
||||
await using var failed = Context(connection, new FailAfterChildDeletion());
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => new CareerProfileService(failed).SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Title = "Rejected" } } }, "manual", 1, default));
|
||||
Assert.Empty(failed.ChangeTracker.Entries());
|
||||
await failed.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal("Original", (await verify.CareerExperiences.SingleAsync()).Title);
|
||||
Assert.Equal(1, (await verify.CareerProfiles.SingleAsync()).Version);
|
||||
Assert.Single(await verify.CareerProfileVersions.ToListAsync());
|
||||
await new CareerProfileService(failed).SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Recovered" } } }, "manual", 1, default);
|
||||
await using var recovered = Context(connection);
|
||||
Assert.Equal("Recovered", (await recovered.CareerExperiences.SingleAsync()).Title);
|
||||
Assert.Equal(2, (await recovered.CareerProfiles.SingleAsync()).Version);
|
||||
Assert.Equal(2, await recovered.CareerProfileVersions.CountAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData(-1)]
|
||||
public async Task Replacement_restore_parse_and_import_fail_closed_without_valid_revision(int? expectedVersion)
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = Context(connection);
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
await db.SaveChangesAsync();
|
||||
var controller = Controller(db);
|
||||
Assert.IsType<BadRequestObjectResult>((await controller.Put(Request("Rejected", expectedVersion), default)).Result);
|
||||
Assert.IsType<BadRequestObjectResult>((await controller.Restore(1, new(expectedVersion), default)).Result);
|
||||
var imports = ImportController(db);
|
||||
Assert.IsType<BadRequestObjectResult>((await imports.Parse(new("# Synthetic", expectedVersion))).Result);
|
||||
Assert.IsType<BadRequestObjectResult>(await imports.AcceptRun(1, new(null, expectedVersion)));
|
||||
Assert.Empty(await db.CareerProfiles.ToListAsync());
|
||||
Assert.Empty(await db.CvExtractionRuns.ToListAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Reviewed_import_commits_or_rolls_back_canonical_projection_and_application_metadata(bool identitySucceeds)
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = Context(connection);
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
var run = new CvExtractionRun
|
||||
{
|
||||
OwnerUserId = "rd21-owner", Trigger = "upload", Status = "pending_review",
|
||||
StructuredProfileJson = StructuredCvProfileJson.Serialize(new() { Jobs = { new() { Id = "imported-role", Title = "Reviewed role" } } }),
|
||||
};
|
||||
db.CvExtractionRuns.Add(run);
|
||||
await db.SaveChangesAsync();
|
||||
var result = await ImportController(db, identitySucceeds).AcceptRun(run.Id, new(null, 0));
|
||||
if (identitySucceeds) Assert.IsType<OkObjectResult>(result);
|
||||
else Assert.IsType<BadRequestObjectResult>(result);
|
||||
await db.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
var savedRun = await verify.CvExtractionRuns.SingleAsync();
|
||||
var user = await verify.Users.SingleAsync();
|
||||
Assert.Equal(identitySucceeds ? "applied" : "pending_review", savedRun.Status);
|
||||
Assert.Equal(identitySucceeds, savedRun.AppliedAtUtc.HasValue);
|
||||
Assert.Equal(identitySucceeds, user.CurrentCvExtractionRunId.HasValue);
|
||||
Assert.Equal(identitySucceeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync());
|
||||
Assert.Equal(identitySucceeds ? 1 : 0, await verify.CareerExperiences.CountAsync());
|
||||
Assert.Equal(identitySucceeds, user.ProfileCvStructureJson is not null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_acceptance_requires_the_revision_shown_in_its_diff()
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = Context(connection);
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
var run = new CvExtractionRun { OwnerUserId = "rd21-owner", Trigger = "upload", Status = "pending_review", StructuredProfileJson = "{}" };
|
||||
db.CvExtractionRuns.Add(run);
|
||||
await db.SaveChangesAsync();
|
||||
var imports = ImportController(db);
|
||||
var diff = Assert.IsType<OkObjectResult>(await imports.GetRunDiff(run.Id));
|
||||
Assert.Equal(0, JsonSerializer.SerializeToElement(diff.Value).GetProperty("revision").GetInt32());
|
||||
Assert.IsType<OkObjectResult>((await Controller(db).Put(Request("Winner", 0), default)).Result);
|
||||
Assert.IsType<ConflictObjectResult>(await imports.AcceptRun(run.Id, new(null, 0)));
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal("pending_review", (await verify.CvExtractionRuns.SingleAsync()).Status);
|
||||
Assert.Equal("Winner", (await verify.CareerExperiences.SingleAsync()).Title);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Parsing_commits_or_rolls_back_its_entire_import_application(bool identitySucceeds)
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = Context(connection);
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
await db.SaveChangesAsync();
|
||||
const string text = "# Contact\nSynthetic Person\n\n# Professional Summary\nBuilt APIs.\n\n# Skills\nC#\nSQL";
|
||||
var result = await ImportController(db, identitySucceeds).Parse(new(text, 0));
|
||||
if (identitySucceeds) Assert.IsType<OkObjectResult>(result.Result);
|
||||
else Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||
await db.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal(identitySucceeds ? 1 : 0, await verify.CvExtractionRuns.CountAsync());
|
||||
Assert.Equal(identitySucceeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync());
|
||||
var user = await verify.Users.SingleAsync();
|
||||
Assert.Equal(identitySucceeds, user.CurrentCvExtractionRunId.HasValue);
|
||||
Assert.Equal(identitySucceeds, user.ProfileCvStructureJson is not null);
|
||||
if (identitySucceeds)
|
||||
{
|
||||
var profile = await verify.CareerProfiles.SingleAsync();
|
||||
Assert.Equal(profile.ProfileJson, user.ProfileCvStructureJson);
|
||||
Assert.Equal(profile.ProfileJson, (await verify.CvExtractionRuns.SingleAsync()).StructuredProfileJson);
|
||||
Assert.Equal(user.ProfileCvText, (await new CareerProfileService(verify).LoadSnapshotAsync("rd21-owner", default)).CvText);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editor_snapshot_uses_relational_canonical_source_and_never_writes_during_read()
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = Context(connection);
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic", ProfileCvText = "Stored source text" });
|
||||
var profile = new CareerProfile
|
||||
{
|
||||
OwnerUserId = "rd21-owner", Version = 7,
|
||||
ProfileJson = StructuredCvProfileJson.SerializePersisted(new() { Jobs = { new() { Title = "Outdated projection" } } }),
|
||||
LongTailJson = CareerProfileMapper.SerializeLongTail(new() { Summary = { "Canonical summary" } }),
|
||||
Experiences = { new() { OwnerUserId = "rd21-owner", ItemKey = "canonical-role", Title = "Canonical role" } },
|
||||
};
|
||||
db.CareerProfiles.Add(profile);
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
var snapshot = await new CareerProfileService(db).LoadSnapshotAsync("rd21-owner", default);
|
||||
Assert.Equal(7, snapshot.Revision);
|
||||
Assert.Equal("Canonical role", snapshot.Profile.Jobs.Single().Title);
|
||||
Assert.Equal("canonical-role", snapshot.Profile.Jobs.Single().Id);
|
||||
Assert.Equal("Canonical summary", snapshot.Profile.Summary.Single());
|
||||
Assert.Equal("Stored source text", snapshot.CvText);
|
||||
Assert.Empty(db.ChangeTracker.Entries());
|
||||
Assert.Equal(7, (await db.CareerProfiles.SingleAsync()).Version);
|
||||
Assert.Single(await db.CareerExperiences.ToListAsync());
|
||||
Assert.Empty(await db.CareerProfileVersions.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_canonical_profile_preserves_legacy_raw_text_in_its_revision_zero_snapshot()
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = Context(connection);
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic", ProfileCvText = "Legacy raw CV text" });
|
||||
await db.SaveChangesAsync();
|
||||
var response = await Controller(db).Get(default);
|
||||
var dto = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(response.Result).Value);
|
||||
Assert.Equal(0, dto.Revision);
|
||||
Assert.Empty(dto.Profile.Jobs);
|
||||
Assert.Equal("Legacy raw CV text", dto.CvText);
|
||||
Assert.Empty(await db.CareerProfiles.ToListAsync());
|
||||
Assert.Empty(await db.CareerProfileVersions.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Extraction_history_returns_only_the_ten_newest_owned_runs_with_exact_metadata()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
var started = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
var owner = new ApplicationUser { Id = "rd21-owner", UserName = "history-owner@example.test" };
|
||||
CvExtractionRun[] expected;
|
||||
Guid latestOperationId;
|
||||
await using (var seed = Context(database))
|
||||
{
|
||||
seed.Users.AddRange(owner, new ApplicationUser { Id = "other-owner", UserName = "history-other@example.test" });
|
||||
var owned = Enumerable.Range(0, 13).Select(index => new CvExtractionRun
|
||||
{
|
||||
OwnerUserId = owner.Id, Trigger = "upload", Status = index == 12 ? "failed" : "applied",
|
||||
Artifact = new CvUploadArtifact
|
||||
{
|
||||
OwnerUserId = owner.Id, OriginalFileName = $"owned-{index}.pdf", StoredFileName = $"stored-{index}.pdf",
|
||||
MimeType = "application/pdf", Sha256 = new string('a', 64), StoragePath = $"fixture-{index}", ByteSize = 32,
|
||||
},
|
||||
StartedAtUtc = started.AddMinutes(index), CompletedAtUtc = started.AddMinutes(index).AddSeconds(10),
|
||||
AppliedAtUtc = index == 12 ? null : started.AddMinutes(index).AddSeconds(20),
|
||||
ParserVersion = $"parser-{index}", NormalizerVersion = $"normalizer-{index}", LlmPromptVersion = $"prompt-{index}",
|
||||
ErrorMessage = index == 12 ? "Synthetic parser failure" : null,
|
||||
}).ToArray();
|
||||
seed.CvExtractionRuns.AddRange(owned);
|
||||
seed.CvExtractionRuns.AddRange(Enumerable.Range(0, 3).Select(index => new CvExtractionRun
|
||||
{
|
||||
OwnerUserId = "other-owner", Trigger = "private-upload", Status = "pending_review", StartedAtUtc = started.AddDays(1).AddMinutes(index),
|
||||
}));
|
||||
await seed.SaveChangesAsync();
|
||||
expected = owned.OrderByDescending(run => run.StartedAtUtc).Take(10).ToArray();
|
||||
var subject = expected[0].Id.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var latest = new UserOperation
|
||||
{
|
||||
OwnerUserId = owner.Id, TaskType = CvProcessingQueue.TaskType, SubjectType = CvProcessingQueue.SubjectType,
|
||||
SubjectId = subject, IdempotencyKey = "history-latest", Status = OperationStatuses.Succeeded,
|
||||
CreatedAtUtc = started.AddHours(2).UtcDateTime, AvailableAtUtc = started.UtcDateTime,
|
||||
};
|
||||
seed.UserOperations.AddRange(latest,
|
||||
new UserOperation
|
||||
{
|
||||
OwnerUserId = owner.Id, TaskType = CvProcessingQueue.TaskType, SubjectId = subject,
|
||||
IdempotencyKey = "history-older", Status = OperationStatuses.Failed,
|
||||
CreatedAtUtc = started.AddHours(1).UtcDateTime, AvailableAtUtc = started.UtcDateTime,
|
||||
},
|
||||
new UserOperation
|
||||
{
|
||||
OwnerUserId = "other-owner", TaskType = CvProcessingQueue.TaskType, SubjectId = subject,
|
||||
IdempotencyKey = "history-private", Status = OperationStatuses.Running,
|
||||
CreatedAtUtc = started.AddDays(2).UtcDateTime, AvailableAtUtc = started.UtcDateTime,
|
||||
});
|
||||
await seed.SaveChangesAsync();
|
||||
latestOperationId = latest.Id;
|
||||
}
|
||||
await using var read = Context(database);
|
||||
var users = TestHostFactory.CreateUserManager(owner);
|
||||
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(owner);
|
||||
var controller = new ProfileCvController(users.Object, Mock.Of<ISummarizerService>(), read, null!)
|
||||
{
|
||||
ControllerContext = new() { HttpContext = new DefaultHttpContext() },
|
||||
};
|
||||
var response = Assert.IsType<OkObjectResult>((await controller.GetRuns()).Result);
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var actual = Assert.IsAssignableFrom<IEnumerable<CvExtractionRunListItem>>(response.Value).ToArray();
|
||||
Assert.Equal(10, actual.Length);
|
||||
Assert.Equal(expected.Select(run => run.Id), actual.Select(run => run.Id));
|
||||
Assert.Equal(expected.Select(run => run.StartedAtUtc), actual.Select(run => run.StartedAtUtc));
|
||||
for (var index = 0; index < actual.Length; index++)
|
||||
{
|
||||
Assert.Equal(expected[index].Trigger, actual[index].Trigger);
|
||||
Assert.Equal(expected[index].Status, actual[index].Status);
|
||||
Assert.Equal(expected[index].Artifact!.OriginalFileName, actual[index].ArtifactFileName);
|
||||
Assert.Equal(expected[index].CompletedAtUtc, actual[index].CompletedAtUtc);
|
||||
Assert.Equal(expected[index].AppliedAtUtc, actual[index].AppliedAtUtc);
|
||||
Assert.Equal(expected[index].ParserVersion, actual[index].ParserVersion);
|
||||
Assert.Equal(expected[index].NormalizerVersion, actual[index].NormalizerVersion);
|
||||
Assert.Equal(expected[index].LlmPromptVersion, actual[index].LlmPromptVersion);
|
||||
Assert.Equal(expected[index].ErrorMessage, actual[index].ErrorMessage);
|
||||
}
|
||||
Assert.Equal(latestOperationId, actual[0].Operation!.Id);
|
||||
Assert.Equal(OperationStatuses.Succeeded, actual[0].Operation!.Status);
|
||||
Assert.All(actual.Skip(1), run => Assert.Null(run.Operation));
|
||||
Assert.Empty(read.ChangeTracker.Entries());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MySqlConnector;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.MariaDb.Tests;
|
||||
|
||||
public sealed class Rd21JobPersistenceTests
|
||||
{
|
||||
private static JobTrackerContext Context(MariaDbDatabase connection, string owner = "rd21-owner") => connection.Context(owner);
|
||||
|
||||
private static JobApplicationsController Controller(JobTrackerContext db) => new(db,
|
||||
Mock.Of<ISummarizerService>(), TestHostFactory.CreateUserManager(new ApplicationUser { Id = "rd21-owner" }).Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "rd21-owner") }, "test")),
|
||||
} },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Write_that_overlaps_a_delayed_save_wins_without_partial_events_or_opportunity_changes()
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
int id, companyId;
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
var company = new Company { OwnerUserId = "rd21-owner", Name = "RD21 overlap" };
|
||||
seed.Add(company);
|
||||
await seed.SaveChangesAsync();
|
||||
var job = new JobApplication { OwnerUserId = "rd21-owner", CompanyId = company.Id, JobTitle = "Original job title", Status = "Prospect" };
|
||||
job.Job = JobOpportunitySync.Create(job);
|
||||
seed.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id; companyId = company.Id;
|
||||
}
|
||||
|
||||
var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
summarizer.Setup(x => x.SummarizeAsync(It.IsAny<string>(), 160, 60)).Returns(() =>
|
||||
{
|
||||
reached.TrySetResult();
|
||||
return release.Task;
|
||||
});
|
||||
var user = new ApplicationUser { Id = "rd21-owner", AiEnabled = true };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Premium" });
|
||||
await using var delayed = Context(connection);
|
||||
var controller = new JobApplicationsController(delayed, summarizer.Object, users.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "test")),
|
||||
} },
|
||||
};
|
||||
var pending = controller.Update(id, Request(companyId, "Delayed title", null, 1) with { Status = "Offer" }, default);
|
||||
await reached.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
await using var winner = Context(connection);
|
||||
Assert.IsType<NoContentResult>(await Controller(winner).Update(id,
|
||||
Request(companyId, "Original job title", "Acknowledged concurrent notes", 1), default));
|
||||
}
|
||||
finally { release.TrySetResult("Delayed summary"); }
|
||||
Assert.IsType<ConflictObjectResult>(await pending);
|
||||
|
||||
await using var verify = Context(connection);
|
||||
var saved = await verify.JobApplications.SingleAsync();
|
||||
Assert.Equal(2, saved.Version);
|
||||
Assert.Equal("Original job title", saved.JobTitle);
|
||||
Assert.Equal("Acknowledged concurrent notes", saved.Notes);
|
||||
Assert.Equal("Prospect", saved.Status);
|
||||
Assert.Equal("Original job title", (await verify.Jobs.SingleAsync()).JobTitle);
|
||||
Assert.Empty(await verify.JobEvents.ToListAsync());
|
||||
Assert.Empty(delayed.ChangeTracker.Entries());
|
||||
|
||||
// A deliberate reload/review uses the winner's content and new revision, never a blind retry.
|
||||
await using var reviewed = Context(connection);
|
||||
Assert.IsType<NoContentResult>(await Controller(reviewed).Update(id,
|
||||
Request(companyId, "Reviewed final title", saved.Notes, saved.Version), default));
|
||||
await using var finalSession = Context(connection);
|
||||
var final = await finalSession.JobApplications.SingleAsync();
|
||||
Assert.Equal(3, final.Version);
|
||||
Assert.Equal("Reviewed final title", final.JobTitle);
|
||||
Assert.Equal("Acknowledged concurrent notes", final.Notes);
|
||||
}
|
||||
|
||||
private static UpdateJobApplicationRequest Request(int companyId, string title, string? notes, int? expectedVersion) =>
|
||||
JsonSerializer.Deserialize<UpdateJobApplicationRequest>(JsonSerializer.Serialize(new
|
||||
{
|
||||
jobTitle = title, companyId, status = "Prospect", notes, expectedVersion,
|
||||
location = "Oslo", salary = "unchanged salary", description = "Original description",
|
||||
}), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
|
||||
[Fact]
|
||||
public async Task Structured_tailored_cv_snapshot_cannot_replace_a_newer_approved_document()
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
int id;
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
var job = new JobApplication { OwnerUserId = "rd21-owner", Company = new Company { Name = "RD21 CV", OwnerUserId = "rd21-owner" }, JobTitle = "Role" };
|
||||
seed.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id;
|
||||
}
|
||||
SaveTailoredCvDraftRequest Draft(string text) => JsonSerializer.Deserialize<SaveTailoredCvDraftRequest>(
|
||||
JsonSerializer.Serialize(new { summary = new[] { text }, expectedVersion = 1 }),
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
await using var first = Context(connection);
|
||||
Assert.IsType<NoContentResult>(await Controller(first).SaveTailoredCvDraft(id, Draft("Approved CV evidence"), default));
|
||||
await using var stale = Context(connection);
|
||||
var rejected = await Controller(stale).SaveTailoredCvDraft(id, Draft("Stale CV evidence"), default);
|
||||
await using var verify = Context(connection);
|
||||
Assert.Contains("Approved CV evidence", (await verify.JobApplications.SingleAsync()).TailoredCvText);
|
||||
Assert.Contains("Approved CV evidence", (await verify.TailoredCvDrafts.SingleAsync()).SummaryJson);
|
||||
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(rejected).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_job_transaction_cannot_acknowledge_or_leave_partial_data_for_a_later_save()
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = Context(connection);
|
||||
var company = new Company { OwnerUserId = "rd21-owner", Name = "RD21 rollback" };
|
||||
db.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
var job = new JobApplication { OwnerUserId = "rd21-owner", CompanyId = company.Id, JobTitle = "Approved title", Notes = "Approved notes", Status = "Prospect" };
|
||||
job.Job = JobOpportunitySync.Create(job);
|
||||
db.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TRIGGER rd21_reject_event BEFORE INSERT ON JobEvents
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'synthetic RD21 event failure';
|
||||
""");
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() => Controller(db).Update(job.Id,
|
||||
Request(company.Id, "Uncommitted title", "Uncommitted notes", 1) with { Status = "Offer" }, default));
|
||||
Assert.Empty(db.ChangeTracker.Entries());
|
||||
await db.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
var actual = await verify.JobApplications.SingleAsync();
|
||||
Assert.Equal("Approved title", actual.JobTitle);
|
||||
Assert.Equal("Approved notes", actual.Notes);
|
||||
Assert.Equal("Prospect", actual.Status);
|
||||
Assert.Equal(1, actual.Version);
|
||||
Assert.Equal("Approved title", (await verify.Jobs.SingleAsync()).JobTitle);
|
||||
Assert.Empty(await verify.JobEvents.ToListAsync());
|
||||
await db.Database.ExecuteSqlRawAsync("DROP TRIGGER rd21_reject_event;");
|
||||
Assert.IsType<NoContentResult>(await Controller(db).Update(job.Id,
|
||||
Request(company.Id, "Recovered title", "Recovered notes", 1) with { Status = "Offer" }, default));
|
||||
await using var recovered = Context(connection);
|
||||
Assert.Equal("Recovered title", (await recovered.JobApplications.SingleAsync()).JobTitle);
|
||||
Assert.Equal(2, (await recovered.JobApplications.SingleAsync()).Version);
|
||||
Assert.Equal(new[] { "OfferReceived", "StatusChanged" },
|
||||
await recovered.JobEvents.OrderBy(x => x.Type).Select(x => x.Type).ToArrayAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Versioned_job_save_cannot_cross_tenant_job_or_company_ownership()
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = Context(connection);
|
||||
var mine = new Company { OwnerUserId = "rd21-owner", Name = "Owned company" };
|
||||
var other = new Company { OwnerUserId = "other-owner", Name = "Private company" };
|
||||
var job = new JobApplication { OwnerUserId = "rd21-owner", Company = mine, JobTitle = "Owned job", Status = "Prospect" };
|
||||
var foreign = new JobApplication { OwnerUserId = "other-owner", Company = other, JobTitle = "Private job", Status = "Prospect" };
|
||||
db.AddRange(job, foreign);
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
Assert.IsType<NotFoundResult>(await Controller(db).Update(foreign.Id, Request(other.Id, "Stolen", null, 1), default));
|
||||
Assert.IsType<BadRequestObjectResult>(await Controller(db).Update(job.Id, Request(other.Id, "Changed", null, 1), default));
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal("Owned job", (await verify.JobApplications.SingleAsync()).JobTitle);
|
||||
Assert.Equal(mine.Id, (await verify.JobApplications.SingleAsync()).CompanyId);
|
||||
await using var foreignSession = Context(connection, "other-owner");
|
||||
Assert.Equal("Private job", (await foreignSession.JobApplications.SingleAsync()).JobTitle);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Related_text_save_cannot_silently_replace_a_newer_acknowledged_draft(bool coverLetter)
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
int id;
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = "rd21-owner", Company = new Company { OwnerUserId = "rd21-owner", Name = "RD21" },
|
||||
JobTitle = "Role", CoverLetterText = "Original letter", Notes = "Original notes",
|
||||
};
|
||||
seed.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id;
|
||||
}
|
||||
|
||||
async Task<IActionResult> Save(JobTrackerContext db, string text)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new { text, notes = text, expectedVersion = 1 });
|
||||
if (!coverLetter)
|
||||
return await Controller(db).SaveApplicationDrafts(id,
|
||||
JsonSerializer.Deserialize<SaveApplicationDraftsRequest>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!, default);
|
||||
var user = new ApplicationUser { Id = "rd21-owner" };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var controller = new ApplicationAssetsController(users.Object,
|
||||
new ApplicationAssetsService(db, Mock.Of<ICvVariantService>(), Mock.Of<IApplicationIntelligenceService>()),
|
||||
Mock.Of<ISubmittedApplicationPackageService>())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "test")),
|
||||
} },
|
||||
};
|
||||
return (await controller.SaveCoverLetter(id,
|
||||
JsonSerializer.Deserialize<ApplicationAssetsController.SaveCoverLetterRequest>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!, default)).Result!;
|
||||
}
|
||||
|
||||
await using var first = Context(connection);
|
||||
var acknowledged = await Save(first, "First approved text");
|
||||
Assert.True(acknowledged is OkObjectResult or NoContentResult);
|
||||
await using (var immediate = Context(connection))
|
||||
Assert.Equal("First approved text", coverLetter ? (await immediate.JobApplications.SingleAsync()).CoverLetterText : (await immediate.JobApplications.SingleAsync()).Notes);
|
||||
await using var stale = Context(connection);
|
||||
if (coverLetter)
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => Save(stale, "Stale snapshot text"));
|
||||
else
|
||||
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(await Save(stale, "Stale snapshot text")).StatusCode);
|
||||
await using var verify = Context(connection);
|
||||
var saved = await verify.JobApplications.SingleAsync();
|
||||
Assert.Equal("First approved text", coverLetter ? saved.CoverLetterText : saved.Notes);
|
||||
if (coverLetter) Assert.Single(await verify.CoverLetterVersions.ToListAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Stale_or_unversioned_snapshot_cannot_erase_an_acknowledged_job_edit(bool omitRevision)
|
||||
{
|
||||
await using var connection = await MariaDbDatabase.CreateAsync();
|
||||
int id, companyId;
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
var company = new Company { OwnerUserId = "rd21-owner", Name = "Synthetic RD21" };
|
||||
seed.Companies.Add(company);
|
||||
await seed.SaveChangesAsync();
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = "rd21-owner", Company = company, CompanyId = company.Id, JobTitle = "Original job title",
|
||||
Status = "Prospect", Location = "Oslo", Salary = "unchanged salary", Description = "Original description",
|
||||
};
|
||||
job.Job = JobOpportunitySync.Create(job);
|
||||
seed.JobApplications.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id; companyId = company.Id;
|
||||
}
|
||||
|
||||
// These are the two snapshots actually read by independent editors.
|
||||
await using var editorA = Context(connection);
|
||||
await using var editorBRead = Context(connection);
|
||||
var original = await editorBRead.JobApplications.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("Original job title", original.JobTitle);
|
||||
Assert.IsType<NoContentResult>(await Controller(editorA).Update(id,
|
||||
Request(companyId, "First editor committed title", null, 1), default));
|
||||
|
||||
await using (var immediatelyAfterAcknowledgement = Context(connection))
|
||||
{
|
||||
Assert.Equal("First editor committed title", (await immediatelyAfterAcknowledgement.JobApplications.SingleAsync()).JobTitle);
|
||||
Assert.Equal("First editor committed title", (await immediatelyAfterAcknowledgement.Jobs.SingleAsync()).JobTitle);
|
||||
}
|
||||
|
||||
await using var editorBWrite = Context(connection);
|
||||
var rejected = await Controller(editorBWrite).Update(id,
|
||||
Request(companyId, original.JobTitle, "Second editor stale notes", omitRevision ? null : 1), default);
|
||||
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(rejected).StatusCode);
|
||||
|
||||
await using var independentSession = Context(connection);
|
||||
var persisted = await independentSession.JobApplications.SingleAsync();
|
||||
Assert.Equal("First editor committed title", persisted.JobTitle);
|
||||
Assert.Null(persisted.Notes);
|
||||
Assert.Equal("Oslo", persisted.Location);
|
||||
Assert.Equal("unchanged salary", persisted.Salary);
|
||||
Assert.Equal("First editor committed title", (await independentSession.Jobs.SingleAsync()).JobTitle);
|
||||
Assert.Empty(await independentSession.JobEvents.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Held_writer_timeout_rolls_back_and_a_reviewed_save_recovers_after_release()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
int id, companyId;
|
||||
await using (var seed = Context(database))
|
||||
{
|
||||
var company = new Company { OwnerUserId = "rd21-owner", Name = "Writer lock" };
|
||||
seed.Add(company);
|
||||
await seed.SaveChangesAsync();
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = "rd21-owner", CompanyId = company.Id,
|
||||
JobTitle = "Approved title", Notes = "Approved notes", Status = "Prospect",
|
||||
};
|
||||
job.Job = JobOpportunitySync.Create(job);
|
||||
seed.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id; companyId = job.CompanyId;
|
||||
}
|
||||
await using var holder = Context(database);
|
||||
await using var transaction = await holder.Database.BeginTransactionAsync();
|
||||
await holder.Database.ExecuteSqlInterpolatedAsync($"UPDATE JobApplications SET Notes = 'Uncommitted lock holder' WHERE Id = {id}");
|
||||
await using var waiting = Context(database);
|
||||
await waiting.Database.OpenConnectionAsync();
|
||||
await waiting.Database.ExecuteSqlRawAsync("SET SESSION innodb_lock_wait_timeout = 1;");
|
||||
try
|
||||
{
|
||||
// The server's lock timeout supplies the synchronization; no sleep or retry hides lock failures.
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(waiting).Update(id,
|
||||
Request(companyId, "Timed out title", "Timed out notes", 1) with { Status = "Offer" }, default));
|
||||
var updateFailure = Assert.IsType<DbUpdateException>(exception.InnerException);
|
||||
Assert.Equal(1205, Assert.IsType<MySqlException>(updateFailure.InnerException).Number);
|
||||
Assert.Empty(waiting.ChangeTracker.Entries());
|
||||
await waiting.SaveChangesAsync();
|
||||
}
|
||||
finally { await transaction.RollbackAsync(); }
|
||||
await using (var verify = Context(database))
|
||||
{
|
||||
var saved = await verify.JobApplications.SingleAsync();
|
||||
Assert.Equal("Approved title", saved.JobTitle);
|
||||
Assert.Equal("Approved notes", saved.Notes);
|
||||
Assert.Equal("Prospect", saved.Status);
|
||||
Assert.Equal(1, saved.Version);
|
||||
Assert.Equal("Approved title", (await verify.Jobs.SingleAsync()).JobTitle);
|
||||
Assert.Empty(await verify.JobEvents.ToListAsync());
|
||||
}
|
||||
Assert.IsType<NoContentResult>(await Controller(waiting).Update(id,
|
||||
Request(companyId, "Reviewed recovery", "Approved notes", 1), default));
|
||||
await using var recovered = Context(database);
|
||||
Assert.Equal("Reviewed recovery", (await recovered.JobApplications.SingleAsync()).JobTitle);
|
||||
Assert.Equal(2, (await recovered.JobApplications.SingleAsync()).Version);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using MySqlConnector;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace JobTrackerApi.MariaDb.Tests;
|
||||
|
||||
public sealed class StartupAndLockTests(ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Full_startup_creates_every_table_and_column_required_by_the_persisted_EF_model()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = database.Context();
|
||||
await using var connection = new MySqlConnection(database.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE();";
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
var actual = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
while (await reader.ReadAsync()) actual.Add($"{reader.GetString(0)}.{reader.GetString(1)}");
|
||||
var missing = db.Model.GetRelationalModel().Tables
|
||||
.SelectMany(table => table.Columns.Select(column => $"{table.Name}.{column.Name}"))
|
||||
.Where(column => !actual.Contains(column)).Order().ToArray();
|
||||
output.WriteLine($"Server: {database.ActualVersion}; actual columns: {actual.Count}; missing ({missing.Length}): {string.Join(", ", missing)}");
|
||||
Assert.True(missing.Length == 0, $"Startup omitted persisted EF columns: {string.Join(", ", missing)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fresh_repeated_and_populated_startup_preserve_owned_data_and_complete_migrations()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await using (var fresh = database.Context())
|
||||
{
|
||||
Assert.Equal("Pomelo.EntityFrameworkCore.MySql", fresh.Database.ProviderName);
|
||||
Assert.Empty(await fresh.Database.GetPendingMigrationsAsync());
|
||||
var applied = (await fresh.Database.GetAppliedMigrationsAsync()).ToArray();
|
||||
Assert.Contains("20261023090000_AddJobRevision", applied);
|
||||
output.WriteLine($"MariaDB server: {database.ActualVersion}; EF: {typeof(DbContext).Assembly.GetName().Version}; Pomelo: {typeof(MariaDbServerVersion).Assembly.GetName().Version}");
|
||||
output.WriteLine($"Applied migrations ({applied.Length}): {string.Join(", ", applied)}");
|
||||
fresh.Users.AddRange(
|
||||
new ApplicationUser { Id = "rd21-owner", UserName = "owner@example.test", UiLanguage = "nb-NO" },
|
||||
new ApplicationUser { Id = "other", UserName = "other@example.test", UiLanguage = "en" });
|
||||
fresh.Companies.AddRange(new Company { OwnerUserId = "rd21-owner", Name = "Owned" },
|
||||
new Company { OwnerUserId = "other", Name = "Private" });
|
||||
await fresh.SaveChangesAsync();
|
||||
}
|
||||
await database.App.InitializeJobTrackerAsync();
|
||||
await database.App.InitializeJobTrackerAsync();
|
||||
await using var verify = database.Context();
|
||||
Assert.Equal(new[] { "other", "rd21-owner" }, await verify.Users.AsNoTracking().OrderBy(x => x.Id).Select(x => x.Id).ToArrayAsync());
|
||||
Assert.Equal("nb-NO", (await verify.Users.AsNoTracking().SingleAsync(x => x.Id == "rd21-owner")).UiLanguage);
|
||||
Assert.Equal("Owned", (await verify.Companies.AsNoTracking().SingleAsync()).Name);
|
||||
Assert.Equal(2, await verify.Companies.IgnoreQueryFilters().CountAsync());
|
||||
Assert.Empty(await verify.Database.GetPendingMigrationsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rd21_additive_migration_preserves_existing_job_fields_and_backfills_revision_one()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = database.Context();
|
||||
var company = new Company { OwnerUserId = "rd21-owner", Name = "Preserve me" };
|
||||
db.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = "rd21-owner", CompanyId = company.Id,
|
||||
JobTitle = "Historical title", Notes = "Historical notes", Status = "Prospect",
|
||||
CoverLetterText = "Historical letter", TailoredCvText = "Historical CV",
|
||||
};
|
||||
job.Job = JobOpportunitySync.Create(job);
|
||||
db.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
var migrations = (await db.Database.GetAppliedMigrationsAsync()).ToArray();
|
||||
Assert.Equal("20261023090000_AddJobRevision", migrations[^1]);
|
||||
// Only this test's newly-created schema is downgraded; no existing database is selected.
|
||||
await db.GetService<IMigrator>().MigrateAsync(migrations[^2]);
|
||||
Assert.Equal(new[] { migrations[^1] }, await db.Database.GetPendingMigrationsAsync());
|
||||
db.ChangeTracker.Clear();
|
||||
await database.App.InitializeJobTrackerAsync();
|
||||
await using var verify = database.Context();
|
||||
var actual = await verify.JobApplications.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(job.Id, actual.Id);
|
||||
Assert.Equal(job.CompanyId, actual.CompanyId);
|
||||
Assert.Equal("Historical title", actual.JobTitle);
|
||||
Assert.Equal("Historical notes", actual.Notes);
|
||||
Assert.Equal("Prospect", actual.Status);
|
||||
Assert.Equal("Historical letter", actual.CoverLetterText);
|
||||
Assert.Equal("Historical CV", actual.TailoredCvText);
|
||||
Assert.Equal("rd21-owner", actual.OwnerUserId);
|
||||
Assert.Equal(1, actual.Version);
|
||||
Assert.Equal(migrations, await verify.Database.GetAppliedMigrationsAsync());
|
||||
Assert.Empty(await verify.Database.GetPendingMigrationsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Open_reader_does_not_block_independent_usage_and_identity_writes()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await using (var seed = database.Context())
|
||||
{
|
||||
seed.Users.AddRange(new ApplicationUser { Id = "rd21-owner", UserName = "owner@example.test", UiLanguage = "en" },
|
||||
new ApplicationUser { Id = "other", UserName = "other@example.test", UiLanguage = "en" });
|
||||
seed.Companies.AddRange(new Company { OwnerUserId = "rd21-owner", Name = "Owned" },
|
||||
new Company { OwnerUserId = "other", Name = "Other" });
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
await using var connection = new MySqlConnection(database.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var snapshot = await connection.BeginTransactionAsync(System.Data.IsolationLevel.RepeatableRead);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = snapshot;
|
||||
command.CommandText = "SELECT Id FROM Companies ORDER BY Id;";
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync());
|
||||
await using var scope = database.App.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
var reservation = await new AiUsageMeter(db, TimeProvider.System).ReserveAsync("rd21-owner",
|
||||
new AccountEntitlements(true, true, 1_000_000, 10, 10_000), "synchronous", "held-reader", "synchronous.summary", 40, 10, default);
|
||||
Assert.True(reservation.Created);
|
||||
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var owner = (await users.FindByIdAsync("rd21-owner"))!;
|
||||
owner.UiLanguage = "nb-NO";
|
||||
Assert.True((await users.UpdateAsync(owner)).Succeeded);
|
||||
await using var verify = database.Context();
|
||||
var usage = await verify.AiUsageRecords.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(reservation.Record.Id, usage.Id);
|
||||
Assert.Equal("held-reader", usage.SourceId);
|
||||
Assert.Equal(1, usage.CallCount);
|
||||
Assert.Equal("nb-NO", (await verify.Users.AsNoTracking().SingleAsync(x => x.Id == "rd21-owner")).UiLanguage);
|
||||
Assert.Equal("en", (await verify.Users.AsNoTracking().SingleAsync(x => x.Id == "other")).UiLanguage);
|
||||
Assert.True(await reader.ReadAsync());
|
||||
Assert.False(await reader.ReadAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("Server=database.example.test;User ID=test;Password=synthetic")]
|
||||
[InlineData("Server=192.0.2.1;Database=development;User ID=test")]
|
||||
public void Unsafe_or_missing_admin_configuration_fails_closed(string? connection)
|
||||
=> Assert.Throws<InvalidOperationException>(() => MariaDbDatabase.ValidateAdminConnection(connection));
|
||||
|
||||
[Fact]
|
||||
public void Admin_database_name_is_never_used_as_the_test_database()
|
||||
{
|
||||
var connection = MariaDbDatabase.ValidateAdminConnection("Server=127.0.0.1;Database=development;User ID=test");
|
||||
Assert.Equal("", connection.Database);
|
||||
Assert.False(connection.Pooling);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace JobTrackerApi.MariaDb.Tests;
|
||||
|
||||
internal static class TestHostFactory
|
||||
{
|
||||
public static Mock<UserManager<ApplicationUser>> CreateUserManager(ApplicationUser? lookupUser = null)
|
||||
{
|
||||
var manager = new Mock<UserManager<ApplicationUser>>(
|
||||
Mock.Of<IUserStore<ApplicationUser>>(), Options.Create(new IdentityOptions()),
|
||||
new PasswordHasher<ApplicationUser>(), Array.Empty<IUserValidator<ApplicationUser>>(),
|
||||
Array.Empty<IPasswordValidator<ApplicationUser>>(), new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(), null!, NullLogger<UserManager<ApplicationUser>>.Instance);
|
||||
if (lookupUser is not null)
|
||||
manager.Setup(x => x.FindByIdAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string id) => lookupUser.Id == id ? lookupUser : null);
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
{
|
||||
"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.2",
|
||||
"contentHash": "VWACYq0JGxi9sw3TonSSV4B2MfhYk5nhgOtp9wbrt5NFOtUetFqHkMZylcCwPX+117cBJH5EnIzIy6X4OKn/kA=="
|
||||
},
|
||||
"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, )",
|
||||
"System.Security.Cryptography.Pkcs": "[8.0.2, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,7 @@ public sealed class ApplicationAssetsTests
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Dear team", CoverLetterSources.Manual, null, default);
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Dear team", CoverLetterSources.Manual, null, job.Version, default);
|
||||
|
||||
Assert.Equal("Dear team", result!.Text);
|
||||
Assert.Equal(1, result.CurrentVersion);
|
||||
@@ -217,9 +217,9 @@ public sealed class ApplicationAssetsTests
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "First draft", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Second draft", CoverLetterSources.Ai, "improve", default);
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Third draft", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "First draft", CoverLetterSources.Manual, null, job.Version, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Second draft", CoverLetterSources.Ai, "improve", job.Version, default);
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Third draft", CoverLetterSources.Manual, null, job.Version, default);
|
||||
|
||||
Assert.Equal(3, result!.CurrentVersion);
|
||||
Assert.Equal(3, result.Versions.Count);
|
||||
@@ -234,8 +234,8 @@ public sealed class ApplicationAssetsTests
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, default);
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, job.Version, default);
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, job.Version, default);
|
||||
|
||||
Assert.Single(result!.Versions);
|
||||
}
|
||||
@@ -246,10 +246,10 @@ public sealed class ApplicationAssetsTests
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "The good draft", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "The AI ruined it", CoverLetterSources.Ai, "shorten", default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "The good draft", CoverLetterSources.Manual, null, job.Version, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "The AI ruined it", CoverLetterSources.Ai, "shorten", job.Version, default);
|
||||
|
||||
var result = await assets.RestoreCoverLetterAsync("user-1", job.Id, 1, default);
|
||||
var result = await assets.RestoreCoverLetterAsync("user-1", job.Id, 1, job.Version, default);
|
||||
|
||||
Assert.Equal("The good draft", result!.Text);
|
||||
// Restore is additive: v1 and v2 both survive, and the restore is v3.
|
||||
@@ -265,9 +265,9 @@ public sealed class ApplicationAssetsTests
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Only draft", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Only draft", CoverLetterSources.Manual, null, job.Version, default);
|
||||
|
||||
Assert.Null(await assets.RestoreCoverLetterAsync("user-1", job.Id, 99, default));
|
||||
Assert.Null(await assets.RestoreCoverLetterAsync("user-1", job.Id, 99, job.Version, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -277,11 +277,11 @@ public sealed class ApplicationAssetsTests
|
||||
await using var _ = db;
|
||||
var mine = await SeedJobAsync(db, "user-1");
|
||||
var theirs = await SeedJobAsync(db, "user-2");
|
||||
await assets.SaveCoverLetterAsync("user-1", mine.Id, "Mine", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", mine.Id, "Mine", CoverLetterSources.Manual, null, mine.Version, default);
|
||||
|
||||
Assert.Null(await assets.GetCoverLetterAsync("user-1", theirs.Id, default));
|
||||
Assert.Null(await assets.SaveCoverLetterAsync("user-1", theirs.Id, "Sneak", CoverLetterSources.Manual, null, default));
|
||||
Assert.Null(await assets.RestoreCoverLetterAsync("user-1", theirs.Id, 1, default));
|
||||
Assert.Null(await assets.SaveCoverLetterAsync("user-1", theirs.Id, "Sneak", CoverLetterSources.Manual, null, theirs.Version, default));
|
||||
Assert.Null(await assets.RestoreCoverLetterAsync("user-1", theirs.Id, 1, theirs.Version, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -564,28 +564,23 @@ public sealed class AuthAndSystemControllerTests
|
||||
Assert.Equal("{\"jobs\":[]}", user.ProfileCvStructureJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Career_only_save_does_not_wipe_identity()
|
||||
[Theory]
|
||||
[InlineData("stale text", null)]
|
||||
[InlineData(null, "{\"summary\":[\"stale\"]}")]
|
||||
[InlineData("", "")]
|
||||
public async Task Legacy_profile_endpoint_cannot_bypass_canonical_revision_guards(string? text, string? structured)
|
||||
{
|
||||
// The mirror: /career saves the master profile and omits identity fields.
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Email = "ada@example.com",
|
||||
UserName = "ada",
|
||||
FirstName = "Ada",
|
||||
LastName = "Lovelace",
|
||||
DisplayName = "Ada L.",
|
||||
UserName = "ada", FirstName = "Ada", ProfileCvText = "Approved text", ProfileCvStructureJson = "Approved structure",
|
||||
};
|
||||
var controller = BuildProfileController(user);
|
||||
|
||||
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, null, null, null, "New CV text", "{\"jobs\":[1]}"));
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(
|
||||
null, null, "Stale identity", null, null, text, structured));
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
Assert.Equal("Ada", user.FirstName);
|
||||
Assert.Equal("Lovelace", user.LastName);
|
||||
Assert.Equal("Ada L.", user.DisplayName);
|
||||
Assert.Equal("New CV text", user.ProfileCvText);
|
||||
Assert.Equal("{\"jobs\":[1]}", user.ProfileCvStructureJson);
|
||||
Assert.Equal("Approved text", user.ProfileCvText);
|
||||
Assert.Equal("Approved structure", user.ProfileCvStructureJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -47,7 +47,7 @@ public sealed class CareerProfileControllerTests
|
||||
{
|
||||
var (controller, _, user) = Build();
|
||||
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(Sample(), null), CancellationToken.None);
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(Sample(), null, 0), CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(put.Result);
|
||||
|
||||
// The derived blob projection is kept in sync for legacy readers.
|
||||
@@ -78,7 +78,7 @@ public sealed class CareerProfileControllerTests
|
||||
Notes = "Used with distributed teams",
|
||||
});
|
||||
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(profile, null), CancellationToken.None);
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(profile, null, 0), CancellationToken.None);
|
||||
var saved = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(put.Result).Value).Profile;
|
||||
var get = await controller.Get(CancellationToken.None);
|
||||
var reloaded = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(get.Result).Value).Profile;
|
||||
@@ -104,7 +104,7 @@ public sealed class CareerProfileControllerTests
|
||||
public async Task Get_reports_completeness_with_missing_sections()
|
||||
{
|
||||
var (controller, _, _) = Build();
|
||||
await controller.Put(new CareerProfileSaveRequest(Sample(), null), CancellationToken.None); // has personal + experience + skills
|
||||
await controller.Put(new CareerProfileSaveRequest(Sample(), null, 0), CancellationToken.None); // has personal + experience + skills
|
||||
|
||||
var get = await controller.Get(CancellationToken.None);
|
||||
var dto = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(get.Result).Value);
|
||||
@@ -121,7 +121,7 @@ public sealed class CareerProfileControllerTests
|
||||
var huge = new StructuredCvProfile();
|
||||
for (var i = 0; i < 500; i++) huge.Skills.Add($"skill-{i}");
|
||||
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(huge, null), CancellationToken.None);
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(huge, null, 0), CancellationToken.None);
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(put.Result);
|
||||
}
|
||||
@@ -133,7 +133,7 @@ public sealed class CareerProfileControllerTests
|
||||
var profile = Sample();
|
||||
profile.Contact.Website = $"https://example.test/{new string('a', 600)}";
|
||||
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(profile, null), CancellationToken.None);
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(profile, null, 0), CancellationToken.None);
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(put.Result);
|
||||
Assert.Equal("A contact field exceeds the allowed length.", badRequest.Value);
|
||||
@@ -144,7 +144,7 @@ public sealed class CareerProfileControllerTests
|
||||
{
|
||||
var (controller, _, _) = Build();
|
||||
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(new StructuredCvProfile(), null), CancellationToken.None);
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(new StructuredCvProfile(), null, 0), CancellationToken.None);
|
||||
|
||||
// Completeness, not validation, is what flags an incomplete profile — an empty profile saves.
|
||||
Assert.IsType<OkObjectResult>(put.Result);
|
||||
|
||||
@@ -29,7 +29,7 @@ public sealed class CareerProfileServiceTests
|
||||
Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } },
|
||||
};
|
||||
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "upload", 0, default);
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(saved.Jobs[0].Id));
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public sealed class CareerProfileServiceTests
|
||||
Education = { new StructuredCvEducation { Id = "shared", Qualification = "BSc", Institution = "University" } },
|
||||
};
|
||||
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "manual", default);
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "manual", 0, default);
|
||||
|
||||
Assert.Equal("shared", saved.Jobs[0].Id);
|
||||
Assert.NotEqual(saved.Jobs[0].Id, saved.Education[0].Id);
|
||||
@@ -80,10 +80,10 @@ public sealed class CareerProfileServiceTests
|
||||
{
|
||||
Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } },
|
||||
};
|
||||
await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||
await service.SaveVersionAsync("user-1", profile, "upload", 0, default);
|
||||
var firstId = profile.Jobs[0].Id;
|
||||
|
||||
await service.SaveVersionAsync("user-1", profile, "rebuild", default);
|
||||
await service.SaveVersionAsync("user-1", profile, "rebuild", 1, default);
|
||||
|
||||
Assert.Equal(firstId, profile.Jobs[0].Id);
|
||||
}
|
||||
@@ -108,8 +108,8 @@ public sealed class CareerProfileServiceTests
|
||||
var service = new CareerProfileService(db);
|
||||
var profile = new StructuredCvProfile { Summary = { "First version" } };
|
||||
|
||||
await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||
await service.SaveVersionAsync("user-1", profile, "improve", default);
|
||||
await service.SaveVersionAsync("user-1", profile, "upload", 0, default);
|
||||
await service.SaveVersionAsync("user-1", profile, "improve", 1, default);
|
||||
|
||||
var snapshots = await db.CareerProfiles.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
|
||||
var history = await db.CareerProfileVersions.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
|
||||
@@ -130,7 +130,7 @@ public sealed class CareerProfileServiceTests
|
||||
Jobs = { new StructuredCvJob { Title = "Engineer", Start = "Jan 2020", End = "Present", IsCurrent = true } },
|
||||
};
|
||||
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "upload", 0, default);
|
||||
|
||||
Assert.Equal("2020-01", saved.Jobs[0].StartDate);
|
||||
Assert.Null(saved.Jobs[0].EndDate);
|
||||
@@ -165,7 +165,7 @@ public sealed class CareerProfileServiceTests
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
|
||||
await service.SaveVersionAsync("user-1", RichProfile(), "manual", default);
|
||||
await service.SaveVersionAsync("user-1", RichProfile(), "manual", 0, default);
|
||||
var loaded = await service.LoadStructuredAsync("user-1", default);
|
||||
|
||||
Assert.Equal("Ada Lovelace", loaded.Contact.FullName);
|
||||
@@ -196,7 +196,7 @@ public sealed class CareerProfileServiceTests
|
||||
var service = new CareerProfileService(db);
|
||||
var profile = RichProfile();
|
||||
|
||||
await service.SaveVersionAsync("user-1", profile, "manual", default);
|
||||
await service.SaveVersionAsync("user-1", profile, "manual", 0, default);
|
||||
var jobKey = profile.Jobs[0].Id;
|
||||
var loaded = await service.LoadStructuredAsync("user-1", default);
|
||||
|
||||
@@ -213,9 +213,9 @@ public sealed class CareerProfileServiceTests
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
|
||||
await service.SaveVersionAsync("user-1", RichProfile(), "manual", default); // 2 jobs
|
||||
await service.SaveVersionAsync("user-1", RichProfile(), "manual", 0, default); // 2 jobs
|
||||
var trimmed = new StructuredCvProfile { Jobs = { new StructuredCvJob { Title = "Only role", Company = "Solo" } } };
|
||||
await service.SaveVersionAsync("user-1", trimmed, "manual", default); // 1 job
|
||||
await service.SaveVersionAsync("user-1", trimmed, "manual", 1, default); // 1 job
|
||||
|
||||
var experiences = await db.CareerExperiences.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
|
||||
Assert.Single(experiences);
|
||||
@@ -225,7 +225,7 @@ public sealed class CareerProfileServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_backfills_relational_children_from_a_pre_phase3_blob()
|
||||
public async Task Load_reads_a_pre_phase3_blob_without_mutating_canonical_children()
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
// Simulate a profile that predates Phase 3: a CareerProfile with a ProfileJson blob but no
|
||||
@@ -239,8 +239,8 @@ public sealed class CareerProfileServiceTests
|
||||
var loaded = await service.LoadStructuredAsync("user-1", default);
|
||||
|
||||
Assert.Equal(2, loaded.Jobs.Count);
|
||||
// The backfill materialized the relational rows.
|
||||
Assert.Equal(2, await db.CareerExperiences.IgnoreQueryFilters().CountAsync(x => x.OwnerUserId == "user-1"));
|
||||
// Reads cannot materialize unversioned children alongside a concurrent replacement.
|
||||
Assert.Equal(0, await db.CareerExperiences.IgnoreQueryFilters().CountAsync(x => x.OwnerUserId == "user-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -260,8 +260,8 @@ public sealed class CareerProfileServiceTests
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v1" } }, "upload", default);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v2" } }, "improve", default);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v1" } }, "upload", 0, default);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v2" } }, "improve", 1, default);
|
||||
|
||||
var versions = await service.ListVersionsAsync("user-1", default);
|
||||
|
||||
@@ -276,10 +276,10 @@ public sealed class CareerProfileServiceTests
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "original" } }, "upload", default); // v1
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "edited" } }, "manual", default); // v2
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "original" } }, "upload", 0, default); // v1
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "edited" } }, "manual", 1, default); // v2
|
||||
|
||||
var restored = await service.RestoreVersionAsync("user-1", 1, default);
|
||||
var restored = await service.RestoreVersionAsync("user-1", 1, 2, default);
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal(new[] { "original" }, restored!.Summary);
|
||||
@@ -296,8 +296,8 @@ public sealed class CareerProfileServiceTests
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile(), "upload", default);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile(), "upload", 0, default);
|
||||
|
||||
Assert.Null(await service.RestoreVersionAsync("user-1", 99, default));
|
||||
Assert.Null(await service.RestoreVersionAsync("user-1", 99, 1, default));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CompanyCreateCompatibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Exact_Norwegian_name_reuses_owned_company_without_overwriting_existing_fields()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var existing = new Company { OwnerUserId = "owner-a", Name = "Ærlig Økonomi Ås", Location = "Original location", Source = "Finn", RecruiterEmail = "retained@example.test" };
|
||||
fixture.Db.Companies.Add(existing);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var result = await fixture.Controller.Create(new(" Ærlig Økonomi Ås ", "Imported location", "LinkedIn"), default);
|
||||
|
||||
var company = Assert.IsType<Company>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
||||
Assert.Equal(existing.Id, company.Id);
|
||||
Assert.Equal("owner-a", company.OwnerUserId);
|
||||
Assert.Equal("Ærlig Økonomi Ås", company.Name);
|
||||
Assert.Equal("Original location", company.Location);
|
||||
Assert.Equal("Finn", company.Source);
|
||||
Assert.Equal("retained@example.test", company.RecruiterEmail);
|
||||
Assert.Equal(1, await fixture.Db.Companies.IgnoreQueryFilters().CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ascii_case_insensitive_reuse_and_trimmed_input_remain_compatible()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var existing = new Company { OwnerUserId = "owner-a", Name = "Northwind AS", Location = "Original location", Source = "Finn" };
|
||||
fixture.Db.Companies.Add(existing);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var result = await fixture.Controller.Create(new(" nORTHWIND aS ", "Another location", "LinkedIn"), default);
|
||||
|
||||
var company = Assert.IsType<Company>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
||||
Assert.Equal(existing.Id, company.Id);
|
||||
Assert.Equal("Northwind AS", company.Name);
|
||||
Assert.Equal("Original location", company.Location);
|
||||
Assert.Equal("Finn", company.Source);
|
||||
Assert.Equal(1, await fixture.Db.Companies.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_identical_foreign_name_creates_a_separate_owned_company_and_preserves_source_normalization()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var foreign = new Company { OwnerUserId = "owner-b", Name = "Ærlig AS", Location = "Private location", Source = "Private source", RecruiterEmail = "private@example.test" };
|
||||
fixture.Db.Companies.Add(foreign);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var result = await fixture.Controller.Create(new(" Ærlig AS ", " Bergen ", "https://www.nav.no/jobs"), default);
|
||||
|
||||
var company = Assert.IsType<Company>(Assert.IsType<CreatedAtActionResult>(result.Result).Value);
|
||||
Assert.NotEqual(foreign.Id, company.Id);
|
||||
Assert.Equal("owner-a", company.OwnerUserId);
|
||||
Assert.Equal("Ærlig AS", company.Name);
|
||||
Assert.Equal("Bergen", company.Location);
|
||||
Assert.Equal("NAV", company.Source);
|
||||
Assert.Null(company.RecruiterEmail);
|
||||
Assert.Equal(1, await fixture.Db.Companies.CountAsync());
|
||||
Assert.Equal(2, await fixture.Db.Companies.IgnoreQueryFilters().CountAsync());
|
||||
var retained = await fixture.Db.Companies.IgnoreQueryFilters().AsNoTracking().SingleAsync(value => value.Id == foreign.Id);
|
||||
Assert.Equal("owner-b", retained.OwnerUserId);
|
||||
Assert.Equal("Private location", retained.Location);
|
||||
Assert.Equal("Private source", retained.Source);
|
||||
Assert.Equal("private@example.test", retained.RecruiterEmail);
|
||||
}
|
||||
|
||||
private sealed class Fixture(SqliteConnection connection, JobTrackerContext db) : IAsyncDisposable
|
||||
{
|
||||
public JobTrackerContext Db { get; } = db;
|
||||
public CompaniesController Controller { get; } = new(db)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "owner-a")], "test")),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public static async Task<Fixture> CreateAsync()
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(value => value.UserId).Returns("owner-a");
|
||||
var db = new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options, currentUser.Object);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
return new Fixture(connection, db);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Db.DisposeAsync();
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ public sealed class CvAiReadinessTests
|
||||
Db = new(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(Connection).Options, current.Object);
|
||||
await Db.Database.EnsureCreatedAsync();
|
||||
Career = new(Db);
|
||||
await Career.SaveVersionAsync("owner", Profile, "manual", default);
|
||||
await Career.SaveVersionAsync("owner", Profile, "manual", 0, default);
|
||||
Variants = new(Db, Career, new ThemedCvRenderer());
|
||||
Cv = await Variants.CreateAsync("owner", "Synthetic CV", null, new CvVariantSettings(), default);
|
||||
Suggestions = new(Db, Career, Variants, Ai.Object, Protection);
|
||||
@@ -150,7 +150,7 @@ public sealed class CvAiReadinessTests
|
||||
if (canonical)
|
||||
{
|
||||
fixture.Profile.Summary = ["Newer canonical user content"];
|
||||
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", default);
|
||||
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", 1, default);
|
||||
}
|
||||
else await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null,
|
||||
new CvVariantSettings { Headline = "Newer CV user content" }, "manual", 1, default);
|
||||
@@ -191,7 +191,7 @@ public sealed class CvAiReadinessTests
|
||||
.Returns(async () =>
|
||||
{
|
||||
fixture.Profile.Summary = ["Newer canonical content"];
|
||||
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", default);
|
||||
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", 1, default);
|
||||
return new AiGenerationResult(Assessment(shortened, Source));
|
||||
});
|
||||
var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id, proposal.ProposalToken, shortened, default));
|
||||
@@ -359,7 +359,7 @@ public sealed class CvAiReadinessTests
|
||||
{
|
||||
await using var fixture = new Fixture(); await fixture.Open();
|
||||
fixture.Profile.Jobs = [new() { Id = "experience-key", Title = "Engineer", Company = "Nordlys", Bullets = { Source } }];
|
||||
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", default);
|
||||
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", 1, default);
|
||||
await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null, new CvVariantSettings
|
||||
{ AccentColor = "#123456", PageMarginMm = 22, Sections = { new() { Key = "experience" }, new() { Key = "summary" } } }, "manual", 1, default);
|
||||
fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), AiGenerationOutputMode.CvBlocksJson, It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
|
||||
@@ -459,7 +459,7 @@ public sealed class CvBuilderTests
|
||||
{
|
||||
var (db, svc) = NewService("user-1");
|
||||
await using var _ = db;
|
||||
await new CareerProfileService(db).SaveVersionAsync("user-1", Rich(), "manual", default);
|
||||
await new CareerProfileService(db).SaveVersionAsync("user-1", Rich(), "manual", 0, default);
|
||||
var v = await svc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "modern" }, default);
|
||||
|
||||
var render = await svc.RenderAsync("user-1", v.Id, new CvRenderPerson("Fallback", null), default);
|
||||
@@ -473,7 +473,7 @@ public sealed class CvBuilderTests
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
var (ownerDb, ownerSvc) = NewService("user-1", dbName);
|
||||
await new CareerProfileService(ownerDb).SaveVersionAsync("user-1", Rich(), "manual", default);
|
||||
await new CareerProfileService(ownerDb).SaveVersionAsync("user-1", Rich(), "manual", 0, default);
|
||||
var v = await ownerSvc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "minimal" }, default);
|
||||
await ownerSvc.SetPublicAsync("user-1", v.Id, true, default);
|
||||
await ownerDb.DisposeAsync();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CvDocumentFontsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Embedded_fonts_use_static_outlines_for_the_CLI_renderer()
|
||||
{
|
||||
var assembly = typeof(ThemedCvRenderer).Assembly;
|
||||
var fonts = assembly.GetManifestResourceNames().Where(name => name.StartsWith("CvFonts.") && name.EndsWith(".ttf")).ToArray();
|
||||
Assert.NotEmpty(fonts);
|
||||
foreach (var name in fonts)
|
||||
{
|
||||
using var stream = assembly.GetManifestResourceStream(name)!;
|
||||
using var bytes = new MemoryStream();
|
||||
stream.CopyTo(bytes);
|
||||
var data = bytes.ToArray();
|
||||
var count = System.Buffers.Binary.BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(4, 2));
|
||||
var tables = Enumerable.Range(0, count).Select(i => System.Text.Encoding.ASCII.GetString(data, 12 + i * 16, 4));
|
||||
Assert.DoesNotContain("fvar", tables);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("modern")]
|
||||
[InlineData("minimal")]
|
||||
[InlineData("executive")]
|
||||
[InlineData("technical")]
|
||||
[InlineData("creative")]
|
||||
[InlineData("code")]
|
||||
public void Document_is_self_contained_and_does_not_depend_on_installed_fonts(string themeId)
|
||||
{
|
||||
var profile = new StructuredCvProfile { Contact = { FullName = "Åse æøå" }, Summary = { "Retained career content." } };
|
||||
var settings = new CvVariantSettings { ThemeId = themeId };
|
||||
var model = CvVariantResolver.Build(profile, settings, "Fallback", null);
|
||||
var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve(themeId), settings).Html;
|
||||
Assert.Contains("@font-face", html);
|
||||
Assert.Contains("data:font/ttf;base64,", html);
|
||||
Assert.DoesNotContain("src:local(", html);
|
||||
Assert.Contains("Retained career content.", html);
|
||||
Assert.Null(settings.BodyFont);
|
||||
Assert.Null(settings.HeadingFont);
|
||||
Assert.Equal("Åse æøå", profile.Contact.FullName);
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ public sealed class CvRevisionReadinessTests
|
||||
Jobs = { new() { Id = "source-entry", Title = "Canonical role", Company = "Nordlys", Bullets = { "Canonical evidence" } } },
|
||||
Skills = { "C#", "Norsk" },
|
||||
};
|
||||
await career.SaveVersionAsync("revision-owner", profile, "manual", default);
|
||||
await career.SaveVersionAsync("revision-owner", profile, "manual", 0, default);
|
||||
var service = Service(db);
|
||||
var lens = new CvVariantSettings
|
||||
{
|
||||
@@ -97,7 +97,7 @@ public sealed class CvRevisionReadinessTests
|
||||
// History is the immutable lens; inherited source values intentionally remain live.
|
||||
profile.Contact.Headline = "Updated canonical source";
|
||||
profile.Jobs[0].Title = "Updated canonical role";
|
||||
await career.SaveVersionAsync("revision-owner", profile, "manual", default);
|
||||
await career.SaveVersionAsync("revision-owner", profile, "manual", 1, default);
|
||||
Assert.Equal(originalHistory, (await db.CvVariantVersions.SingleAsync()).SettingsJson);
|
||||
var reset = new CvVariantSettings { ThemeId = "minimal" };
|
||||
await service.SaveAsync("revision-owner", cv.Id, null, reset, "manual", 1, default);
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Data.Common;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class DashboardPreferencesTests
|
||||
{
|
||||
private const string Route = "/api/auth/preferences/dashboard";
|
||||
private const string Layout = "{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"cards\",\"visible\":true,\"size\":\"full\"}]}";
|
||||
|
||||
[Fact]
|
||||
public async Task Http_roundtrip_is_owner_scoped_and_preserves_other_preferences()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var owner = host.Client("owner");
|
||||
using var other = host.Client("other");
|
||||
using var anonymous = host.Client(null);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync(Route)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.PutAsync(Route, Json(Layout))).StatusCode);
|
||||
var initial = await owner.GetFromJsonAsync<JsonElement>(Route);
|
||||
Assert.Equal(0, initial.GetProperty("revision").GetInt64());
|
||||
Assert.Empty(initial.GetProperty("widgets").EnumerateArray());
|
||||
var saved = await owner.PutAsync(Route + "?userId=other", Json(Layout));
|
||||
Assert.Equal(HttpStatusCode.OK, saved.StatusCode);
|
||||
Assert.Equal(1, (await saved.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("revision").GetInt64());
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await owner.PutAsync(Route, Json(Layout))).StatusCode);
|
||||
Assert.Equal(0, (await other.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
|
||||
await using var db = host.Db();
|
||||
var user = await db.Users.SingleAsync(x => x.Id == "owner");
|
||||
Assert.Equal("nb-NO", user.UiLanguage);
|
||||
Assert.False(user.EmailFollowUpRemindersEnabled);
|
||||
Assert.Equal("security", user.SecurityStamp);
|
||||
Assert.Equal("profile", user.DisplayName);
|
||||
Assert.Equal("concurrency", user.ConcurrencyStamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Simultaneous_first_writes_have_exactly_one_winner()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var first = host.Client("owner");
|
||||
using var second = host.Client("owner");
|
||||
var results = await Task.WhenAll(first.PutAsync(Route, Json(Layout)), second.PutAsync(Route, Json(Layout)));
|
||||
Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, results.Select(x => x.StatusCode).OrderBy(x => x));
|
||||
Assert.Equal(1, (await first.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Layout_write_succeeds_while_WAL_reader_remains_open()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
await using var db = host.Db();
|
||||
await db.Database.OpenConnectionAsync();
|
||||
await using var command = db.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = "SELECT Id FROM AspNetUsers ORDER BY Id";
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync());
|
||||
using var client = host.Client("owner");
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PutAsync(Route, Json(Layout))).StatusCode);
|
||||
Assert.True(await reader.ReadAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_authenticated_subject_cannot_access_or_write_preferences()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("missing-account");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync(Route)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.PutAsync(Route, Json(Layout))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_identity_profile_update_cannot_overwrite_new_layout()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
await using var staleDb = host.Db();
|
||||
var staleUser = await staleDb.Users.SingleAsync(x => x.Id == "owner");
|
||||
using var client = host.Client("owner");
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PutAsync(Route, Json(Layout))).StatusCode);
|
||||
// Identity's store marks every user property modified, even an unchanged stale layout.
|
||||
staleUser.UiLanguage = "en";
|
||||
staleDb.Update(staleUser);
|
||||
await staleDb.SaveChangesAsync();
|
||||
Assert.Equal(1, (await client.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_normalises_corrupt_unknown_and_invalid_stored_widgets_without_resetting_revision()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("owner");
|
||||
await using var db = host.Db();
|
||||
var stored = "{\"version\":1,\"widgets\":[{\"id\":\"future\",\"visible\":true,\"size\":\"full\"},{\"id\":\"cards\",\"visible\":false,\"size\":\"bad\"},{\"id\":\"funnel\",\"visible\":true,\"size\":\"bad\"}]}";
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={stored}, DashboardLayoutRevision=4 WHERE Id='owner'");
|
||||
var normalised = await client.GetFromJsonAsync<JsonElement>(Route);
|
||||
Assert.Equal(4, normalised.GetProperty("revision").GetInt64());
|
||||
var widgets = normalised.GetProperty("widgets").EnumerateArray().ToArray();
|
||||
Assert.Equal(2, widgets.Length);
|
||||
Assert.Equal("full", widgets[0].GetProperty("size").GetString());
|
||||
Assert.False(widgets[0].GetProperty("visible").GetBoolean());
|
||||
Assert.Equal("standard", widgets[1].GetProperty("size").GetString());
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={"{"} WHERE Id='owner'");
|
||||
var corrupt = await client.GetFromJsonAsync<JsonElement>(Route);
|
||||
Assert.Equal(4, corrupt.GetProperty("revision").GetInt64());
|
||||
Assert.Empty(corrupt.GetProperty("widgets").EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Future_schema_is_exposed_and_cannot_be_overwritten_by_version_one()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("owner");
|
||||
await using var db = host.Db();
|
||||
const string future = "{\"version\":2,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}";
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={future}, DashboardLayoutRevision=4 WHERE Id='owner'");
|
||||
var response = await client.GetFromJsonAsync<JsonElement>(Route);
|
||||
Assert.Equal(2, response.GetProperty("version").GetInt32());
|
||||
Assert.Equal(4, response.GetProperty("revision").GetInt64());
|
||||
Assert.Empty(response.GetProperty("widgets").EnumerateArray());
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await client.PutAsync(Route, Json("{\"version\":1,\"revision\":4,\"widgets\":[]}"))).StatusCode);
|
||||
var stored = await db.Users.AsNoTracking().Where(x => x.Id == "owner").Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleAsync();
|
||||
Assert.Equal(future, stored.DashboardLayoutJson);
|
||||
Assert.Equal(4, stored.DashboardLayoutRevision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Future_schema_installed_while_body_is_paused_cannot_be_overwritten_with_guessed_revision()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("owner");
|
||||
var preRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseBody = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
host.ReadObserver.OnLayoutRead = () => preRead.TrySetResult();
|
||||
using var body = new PausedJson("{\"version\":1,\"revision\":1,\"widgets\":[]}", releaseBody.Task);
|
||||
var pending = client.PutAsync(Route, body);
|
||||
try
|
||||
{
|
||||
await preRead.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await using var db = host.Db();
|
||||
const string future = "{\"version\":2,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}";
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={future}, DashboardLayoutRevision=1 WHERE Id='owner'");
|
||||
releaseBody.TrySetResult();
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await pending).StatusCode);
|
||||
var stored = await db.Users.AsNoTracking().Where(x => x.Id == "owner").Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleAsync();
|
||||
Assert.Equal(future, stored.DashboardLayoutJson);
|
||||
Assert.Equal(1, stored.DashboardLayoutRevision);
|
||||
}
|
||||
finally { releaseBody.TrySetResult(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task All_known_widgets_and_empty_reset_save_roundtrip_in_order()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("owner");
|
||||
var ids = new[] { "cards", "attention", "funnel", "activity", "stageAge", "sources", "salary", "companies", "skills" };
|
||||
var body = new { version = 1, revision = 0, widgets = ids.Select(id => new { id, visible = false, size = "full" }) };
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PutAsJsonAsync(Route, body)).StatusCode);
|
||||
var saved = await client.GetFromJsonAsync<JsonElement>(Route);
|
||||
Assert.Equal(ids, saved.GetProperty("widgets").EnumerateArray().Select(x => x.GetProperty("id").GetString()));
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PutAsync(Route, Json("{\"version\":1,\"revision\":1,\"widgets\":[]}"))).StatusCode);
|
||||
var reset = await client.GetFromJsonAsync<JsonElement>(Route);
|
||||
Assert.Equal(2, reset.GetProperty("revision").GetInt64());
|
||||
Assert.Empty(reset.GetProperty("widgets").EnumerateArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{", 400)]
|
||||
[InlineData("{\"version\":\"1\",\"revision\":0,\"widgets\":[]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":\"0\",\"widgets\":[]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":9223372036854775807,\"widgets\":[]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":9007199254740991,\"widgets\":[]}", 400)]
|
||||
[InlineData("{\"version\":2,\"revision\":0,\"widgets\":[]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":-1,\"widgets\":[]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":null}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"unknown\",\"visible\":true,\"size\":\"full\"}]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"cards\",\"visible\":true,\"size\":\"standard\"}]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"funnel\",\"visible\":true,\"size\":\"huge\"}]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"funnel\",\"size\":\"full\"}]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"funnel\",\"visible\":\"true\",\"size\":\"full\"}]}", 400)]
|
||||
public async Task Invalid_payload_does_not_save(string payload, int expected)
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("owner");
|
||||
Assert.Equal(expected, (int)(await client.PutAsync(Route, Json(payload))).StatusCode);
|
||||
Assert.Equal(0, (await client.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_excessive_and_oversized_payloads_are_bounded()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("owner");
|
||||
const string widget = "{\"id\":\"cards\",\"visible\":true,\"size\":\"full\"}";
|
||||
foreach (var count in new[] { 2, 33 })
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.PutAsync(Route, Json("{\"version\":1,\"revision\":0,\"widgets\":[" + string.Join(',', Enumerable.Repeat(widget, count)) + "]}"))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, (await client.PutAsync(Route, Json(Layout + new string(' ', 16384)))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, (await client.PutAsync(Route, new ChunkedJson(Layout + new string(' ', 16384)))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Additive_migration_preserves_legacy_account_and_defaults_and_generates_MariaDb_SQL()
|
||||
{
|
||||
await using var host = await Host.Start(legacy: true);
|
||||
await using var db = host.Db();
|
||||
var user = await db.Users.SingleAsync(x => x.Id == "owner");
|
||||
Assert.Null(db.Entry(user).Property("DashboardLayoutJson").CurrentValue);
|
||||
Assert.Equal(0L, db.Entry(user).Property("DashboardLayoutRevision").CurrentValue);
|
||||
Assert.Equal("nb-NO", user.UiLanguage);
|
||||
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseMySql("Server=127.0.0.1;Database=script_only;User=none;Password=none;", new MariaDbServerVersion(new Version(11, 0, 0))).Options;
|
||||
await using var maria = new JobTrackerContext(options, Mock.Of<ICurrentUserService>());
|
||||
var script = maria.GetService<IMigrator>().GenerateScript("20260830137000_AdoptSavedJobSearchSchema");
|
||||
Assert.Contains("`DashboardLayoutJson` longtext NULL", script);
|
||||
Assert.Contains("`DashboardLayoutRevision` bigint NOT NULL DEFAULT 0", script);
|
||||
}
|
||||
|
||||
private static StringContent Json(string value) => new(value, Encoding.UTF8, "application/json");
|
||||
|
||||
private sealed class ChunkedJson(string value) : HttpContent
|
||||
{
|
||||
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => stream.WriteAsync(Encoding.UTF8.GetBytes(value)).AsTask();
|
||||
protected override bool TryComputeLength(out long length) { length = 0; return false; }
|
||||
}
|
||||
|
||||
private sealed class PausedJson(string value, Task release) : HttpContent
|
||||
{
|
||||
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
|
||||
{
|
||||
await stream.WriteAsync(new byte[] { (byte)' ' });
|
||||
await stream.FlushAsync();
|
||||
await release;
|
||||
await stream.WriteAsync(Encoding.UTF8.GetBytes(value));
|
||||
}
|
||||
protected override bool TryComputeLength(out long length) { length = 0; return false; }
|
||||
}
|
||||
|
||||
private sealed class LayoutReadObserver : DbCommandInterceptor
|
||||
{
|
||||
public Action? OnLayoutRead { get; set; }
|
||||
public override InterceptionResult DataReaderDisposing(DbCommand command, DataReaderDisposingEventData eventData, InterceptionResult result)
|
||||
{
|
||||
if (command.CommandText.StartsWith("SELECT", StringComparison.Ordinal) && command.CommandText.Contains("DashboardLayoutJson", StringComparison.Ordinal)) OnLayoutRead?.Invoke();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Host : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication app;
|
||||
private readonly string directory;
|
||||
private readonly string connection;
|
||||
public LayoutReadObserver ReadObserver { get; }
|
||||
private Host(WebApplication app, string directory, string connection, LayoutReadObserver readObserver) => (this.app, this.directory, this.connection, ReadObserver) = (app, directory, connection, readObserver);
|
||||
public JobTrackerContext Db() => new(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options, Mock.Of<ICurrentUserService>());
|
||||
public HttpClient Client(string? user)
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = new Uri(app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single()) };
|
||||
if (user is not null) client.DefaultRequestHeaders.Add("X-Test-User", user);
|
||||
return client;
|
||||
}
|
||||
public static async Task<Host> Start(bool legacy = false)
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "dashboard-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
var connection = $"Data Source={Path.Combine(directory, "test.db")};Pooling=False;Default Timeout=5";
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = directory, ["Database:Provider"] = "sqlite", ["ConnectionStrings:JobTracker"] = connection, ["Auth:AdminEmail"] = "", ["Auth:AdminPassword"] = "" });
|
||||
builder.Logging.ClearProviders();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Services.AddControllers().AddApplicationPart(typeof(AuthController).Assembly);
|
||||
builder.Services.AddAuthentication("local").AddScheme<AuthenticationSchemeOptions, TestAuthentication>("local", _ => { });
|
||||
builder.Services.AddAuthorization();
|
||||
var readObserver = new LayoutReadObserver();
|
||||
builder.Services.AddDbContext<JobTrackerContext>(x => x.UseSqlite(connection).AddInterceptors(readObserver));
|
||||
builder.Services.AddSingleton(Mock.Of<ICurrentUserService>());
|
||||
builder.Services.AddSingleton<AppPaths>();
|
||||
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
|
||||
builder.Services.AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>().AddEntityFrameworkStores<JobTrackerContext>();
|
||||
builder.Services.AddSingleton(Mock.Of<ITokenService>());
|
||||
builder.Services.AddSingleton(Mock.Of<IAppEmailSender>());
|
||||
builder.Services.AddSingleton(Mock.Of<IGoogleTokenValidator>());
|
||||
builder.Services.AddSingleton(Mock.Of<IMicrosoftTokenValidator>());
|
||||
builder.Services.AddSingleton(Mock.Of<ITwoFactorPendingTokenService>());
|
||||
var app = builder.Build();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
var host = new Host(app, directory, connection, readObserver);
|
||||
await using (var db = host.Db())
|
||||
{
|
||||
if (legacy)
|
||||
{
|
||||
await db.GetService<IMigrator>().MigrateAsync("20260830137000_AdoptSavedJobSearchSchema");
|
||||
await db.Database.ExecuteSqlRawAsync("INSERT INTO AspNetUsers (Id, UiLanguage, EmailConfirmed, PhoneNumberConfirmed, TwoFactorEnabled, LockoutEnabled, AccessFailedCount) VALUES ('owner', 'nb-NO', 0, 0, 0, 0, 0)");
|
||||
}
|
||||
await app.InitializeJobTrackerAsync();
|
||||
if (!legacy)
|
||||
{
|
||||
db.Users.AddRange(new ApplicationUser { Id = "owner", UiLanguage = "nb-NO", EmailFollowUpRemindersEnabled = false, SecurityStamp = "security", ConcurrencyStamp = "concurrency", DisplayName = "profile" }, new ApplicationUser { Id = "other" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
await app.StartAsync();
|
||||
return host;
|
||||
}
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await app.DisposeAsync();
|
||||
Directory.Delete(directory, true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAuthentication(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync() => Task.FromResult(Request.Headers.TryGetValue("X-Test-User", out var id)
|
||||
? AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, id.ToString()) }, "local")), "local"))
|
||||
: AuthenticateResult.NoResult());
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,39 @@ public sealed class DatabaseBackupRunnerTests : IDisposable
|
||||
Assert.Equal(2L, (long)(await count.ExecuteScalarAsync())!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOnce_includes_committed_WAL_rows_in_an_independent_restorable_snapshot()
|
||||
{
|
||||
CreateSourceDb(out var connectionString);
|
||||
await using var source = new SqliteConnection(connectionString);
|
||||
await source.OpenAsync();
|
||||
await using var write = source.CreateCommand();
|
||||
write.CommandText = "PRAGMA journal_mode=WAL;";
|
||||
Assert.Equal("wal", await write.ExecuteScalarAsync());
|
||||
write.CommandText = "INSERT INTO Sample (Name) VALUES ('committed-in-wal');";
|
||||
await write.ExecuteNonQueryAsync();
|
||||
Assert.True(new FileInfo(Path.Combine(_root, "source.db-wal")).Length > 0);
|
||||
|
||||
var backupPath = await CreateRunner(connectionString).RunOnceAsync(CancellationToken.None);
|
||||
Assert.NotNull(backupPath);
|
||||
await using var backup = new SqliteConnection($"Data Source={backupPath};Mode=ReadOnly;Pooling=False");
|
||||
await backup.OpenAsync();
|
||||
await using var query = backup.CreateCommand();
|
||||
query.CommandText = "PRAGMA integrity_check;";
|
||||
Assert.Equal("ok", await query.ExecuteScalarAsync());
|
||||
query.CommandText = "SELECT COUNT(*) FROM Sample;";
|
||||
Assert.Equal(3L, await query.ExecuteScalarAsync());
|
||||
query.CommandText = "SELECT Name FROM Sample WHERE Id=3;";
|
||||
Assert.Equal("committed-in-wal", await query.ExecuteScalarAsync());
|
||||
|
||||
write.CommandText = "INSERT INTO Sample (Name) VALUES ('after-snapshot');";
|
||||
await write.ExecuteNonQueryAsync();
|
||||
query.CommandText = "SELECT COUNT(*) FROM Sample;";
|
||||
Assert.Equal(3L, await query.ExecuteScalarAsync());
|
||||
write.CommandText = "SELECT COUNT(*) FROM Sample;";
|
||||
Assert.Equal(4L, await write.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOnce_prunes_backups_beyond_retention()
|
||||
{
|
||||
|
||||
@@ -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<string, string?>
|
||||
{
|
||||
["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<JobTrackerContext>();
|
||||
DatabaseConfiguration.Configure(options, configuration, "unused.db");
|
||||
Assert.Equal("mariadb", DatabaseConfiguration.GetProvider(configuration));
|
||||
var relational = Assert.Single(options.Options.Extensions.OfType<RelationalOptionsExtension>());
|
||||
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<MySqlOptionsExtension>(relational);
|
||||
Assert.IsType<MariaDbServerVersion>(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<JobTrackerContext>();
|
||||
Assert.Throws<InvalidOperationException>(() => DatabaseConfiguration.Configure(options, Config(provider), "must-not-create.db"));
|
||||
Assert.Empty(options.Options.Extensions.OfType<RelationalOptionsExtension>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("postgres")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("maria-db")]
|
||||
public void Unknown_provider_is_rejected_even_when_a_connection_is_configured(string provider)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => DatabaseConfiguration.Configure(
|
||||
new DbContextOptionsBuilder<JobTrackerContext>(), Config(provider, "Data Source=must-not-create.db"), "unused.db"));
|
||||
Assert.Throws<InvalidOperationException>(() => 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<JobTrackerContext>();
|
||||
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<MySqlOptionsExtension>().Single().ServerVersion!.Version);
|
||||
#pragma warning restore EF1001
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_server_version_fails_during_configuration()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => DatabaseConfiguration.Configure(
|
||||
new DbContextOptionsBuilder<JobTrackerContext>(), 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<JobTrackerContext>();
|
||||
DatabaseConfiguration.Configure(options, Config(" SQLITE ", connection), "preserved.db");
|
||||
var relational = Assert.Single(options.Options.Extensions.OfType<RelationalOptionsExtension>());
|
||||
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<string, string?> { ["Data:Root"] = root }).Build();
|
||||
var environment = new Mock<IHostEnvironment>();
|
||||
environment.SetupGet(x => x.ContentRootPath).Returns(root);
|
||||
try
|
||||
{
|
||||
var runner = new SqliteDatabaseBackupRunner(configuration, new AppPaths(configuration, environment.Object), NullLogger<SqliteDatabaseBackupRunner>.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); }
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, "Updated notes block", null), CancellationToken.None);
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, "Updated notes block", null, ExpectedVersion: job.Version), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
@@ -68,7 +68,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
|
||||
var updated = await controller.SaveApplicationDrafts(
|
||||
job.Id,
|
||||
new SaveApplicationDraftsRequest(null, null, " New recruiter message ", " New answer "),
|
||||
new SaveApplicationDraftsRequest(null, null, " New recruiter message ", " New answer ", ExpectedVersion: job.Version),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(updated);
|
||||
@@ -79,7 +79,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
job.Notes += "\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nDuplicate stale answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
||||
var cleared = await controller.SaveApplicationDrafts(
|
||||
job.Id,
|
||||
new SaveApplicationDraftsRequest(null, null, "", ""),
|
||||
new SaveApplicationDraftsRequest(null, null, "", "", ExpectedVersion: job.Version),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(cleared);
|
||||
@@ -109,7 +109,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
|
||||
var result = await controller.SaveApplicationDrafts(
|
||||
otherUsersJob.Id,
|
||||
new SaveApplicationDraftsRequest(null, null, "Changed", "Changed"),
|
||||
new SaveApplicationDraftsRequest(null, null, "Changed", "Changed", ExpectedVersion: otherUsersJob.Version),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
@@ -424,7 +424,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
generated.Education,
|
||||
generated.CustomSections,
|
||||
generated.RenderOptions,
|
||||
"edited"), CancellationToken.None);
|
||||
"edited", ExpectedVersion: generated.Revision), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(saveResult);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(" Cover letter body ", " Notes body ", null), CancellationToken.None);
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(" Cover letter body ", " Notes body ", null, ExpectedVersion: job.Version), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
@@ -272,7 +272,8 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
JobUrl: null,
|
||||
DateApplied: null,
|
||||
FeedbackRequestedAt: null,
|
||||
StatusChangedAt: null);
|
||||
StatusChangedAt: null,
|
||||
ExpectedVersion: job.Version);
|
||||
|
||||
var result = await controller.Update(job.Id, request, CancellationToken.None);
|
||||
|
||||
@@ -340,7 +341,8 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
JobUrl: null,
|
||||
DateApplied: null,
|
||||
FeedbackRequestedAt: null,
|
||||
StatusChangedAt: null);
|
||||
StatusChangedAt: null,
|
||||
ExpectedVersion: job.Version);
|
||||
|
||||
Assert.IsType<NoContentResult>(await controller.Update(job.Id, request, CancellationToken.None));
|
||||
|
||||
@@ -348,7 +350,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
summarizer.Verify(service => service.SummarizeAsync(It.IsAny<string>(), 160, 60), Times.Once);
|
||||
Assert.Contains(await db.JobEvents.ToListAsync(), item => item.Type == "AiRefreshed" && item.Note!.Contains("automatically"));
|
||||
|
||||
request = request with { Description = request.Description + " " };
|
||||
request = request with { Description = request.Description + " ", ExpectedVersion = job.Version };
|
||||
Assert.IsType<NoContentResult>(await controller.Update(job.Id, request, CancellationToken.None));
|
||||
summarizer.Verify(service => service.SummarizeAsync(It.IsAny<string>(), 160, 60), Times.Once);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class JobApplicationsMariaDraftTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, null, " Recruiter hello "), CancellationToken.None);
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, null, " Recruiter hello ", ExpectedVersion: job.Version), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
|
||||
@@ -15,6 +15,33 @@ public sealed class MigrationChainTests
|
||||
{
|
||||
private const string SnapshotMigration = "20260711181039_SyncModelSnapshot";
|
||||
|
||||
[Fact]
|
||||
public async Task Rd21_revision_upgrade_and_rollback_preserve_existing_job_content()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
var migrator = db.GetService<IMigrator>();
|
||||
const string previous = "20261021090000_AddDashboardLayoutPreference";
|
||||
await migrator.MigrateAsync(previous);
|
||||
await ExecuteAsync(connection, """
|
||||
INSERT INTO Companies (Name) VALUES ('RD21 migration fixture');
|
||||
INSERT INTO JobApplications
|
||||
(CompanyId, JobTitle, Status, ResponseReceived, OwnerUserId, SavedAt, Notes,
|
||||
HasResume, HasCoverLetter, HasPortfolio, HasOtherAttachment, IsDeleted)
|
||||
VALUES (1, 'Durable role', 'Saved', 0, 'rd21-owner', '2026-10-23', 'Keep these notes', 0, 0, 0, 0, 0);
|
||||
""");
|
||||
await migrator.MigrateAsync();
|
||||
Assert.Equal(1L, await ScalarAsync<long>(connection, "SELECT Version FROM JobApplications WHERE Id=1;"));
|
||||
Assert.Equal("Keep these notes", await ScalarAsync<string>(connection, "SELECT Notes FROM JobApplications WHERE Id=1;"));
|
||||
await migrator.MigrateAsync(previous);
|
||||
Assert.Equal("Durable role", await ScalarAsync<string>(connection, "SELECT JobTitle FROM JobApplications WHERE Id=1;"));
|
||||
Assert.Equal("Keep these notes", await ScalarAsync<string>(connection, "SELECT Notes FROM JobApplications WHERE Id=1;"));
|
||||
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM pragma_table_info('JobApplications') WHERE name='Version';"));
|
||||
await migrator.MigrateAsync();
|
||||
Assert.Equal(1L, await ScalarAsync<long>(connection, "SELECT Version FROM JobApplications WHERE Id=1;"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Blank_sqlite_chain_reaches_latest_and_is_idempotent()
|
||||
{
|
||||
|
||||
@@ -92,7 +92,7 @@ public sealed class ProfileCvControllerTests
|
||||
Assert.Null(user.CurrentCvExtractionRunId);
|
||||
|
||||
Assert.IsType<OkObjectResult>(await controller.GetRunDiff(run.Id));
|
||||
Assert.IsType<OkObjectResult>(await controller.AcceptRun(run.Id));
|
||||
Assert.IsType<OkObjectResult>(await controller.AcceptRun(run.Id, new(null, 0)));
|
||||
|
||||
var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
Assert.Equal("applied", run.Status);
|
||||
@@ -906,7 +906,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
|
||||
|
||||
var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText));
|
||||
var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText, 0));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -940,7 +940,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
|
||||
|
||||
var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText));
|
||||
var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText, 0));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -978,7 +978,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
|
||||
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -1018,7 +1018,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
|
||||
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -1054,7 +1054,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
|
||||
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -1237,7 +1237,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths(), null, normalizer.Object);
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
@@ -1276,7 +1276,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
|
||||
|
||||
var result = await controller.Parse(new ParseCvRequest(rawSource));
|
||||
var result = await controller.Parse(new ParseCvRequest(rawSource, 0));
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
Assert.NotNull(ok.Value);
|
||||
|
||||
@@ -1305,7 +1305,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
@@ -1336,7 +1336,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
@@ -1365,7 +1365,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
@@ -1393,7 +1393,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class Rd21CareerPersistenceTests
|
||||
{
|
||||
private static JobTrackerContext Context(SqliteConnection connection, IInterceptor? interceptor = null)
|
||||
{
|
||||
var current = new Mock<ICurrentUserService>();
|
||||
current.SetupGet(x => x.UserId).Returns("rd21-owner");
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection);
|
||||
if (interceptor is not null) options.AddInterceptors(interceptor);
|
||||
return new(options.Options, current.Object);
|
||||
}
|
||||
|
||||
private static CareerProfileController Controller(JobTrackerContext db, bool identitySucceeds = true)
|
||||
=> new(Users(db, identitySucceeds).Object, new CareerProfileService(db), db)
|
||||
{
|
||||
ControllerContext = new() { HttpContext = new DefaultHttpContext() },
|
||||
};
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> Users(JobTrackerContext db, bool identitySucceeds)
|
||||
{
|
||||
var user = db.Users.Single();
|
||||
var users = TestHostFactory.CreateUserManager();
|
||||
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
users.Setup(x => x.UpdateAsync(user)).Returns(async () =>
|
||||
{
|
||||
if (!identitySucceeds) return IdentityResult.Failed(new IdentityError { Description = "Synthetic Identity failure" });
|
||||
await db.SaveChangesAsync();
|
||||
return IdentityResult.Success;
|
||||
});
|
||||
return users;
|
||||
}
|
||||
|
||||
private static ProfileCvController ImportController(JobTrackerContext db, bool identitySucceeds = true) =>
|
||||
new(Users(db, identitySucceeds).Object, new Mock<ISummarizerService>().Object, db, null!)
|
||||
{ ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
|
||||
|
||||
private static CareerProfileSaveRequest Request(string title, int? expectedVersion) =>
|
||||
JsonSerializer.Deserialize<CareerProfileSaveRequest>(JsonSerializer.Serialize(new
|
||||
{
|
||||
profile = new StructuredCvProfile { Jobs = { new() { Id = "stable-role", Title = title } } },
|
||||
expectedVersion,
|
||||
}), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
|
||||
[Fact]
|
||||
public async Task Independent_stale_snapshot_cannot_replace_acknowledged_career_edit()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
seed.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
await seed.SaveChangesAsync();
|
||||
Assert.IsType<OkObjectResult>((await Controller(seed).Put(Request("Original", 0), default)).Result);
|
||||
}
|
||||
await using var first = Context(connection);
|
||||
await using var stale = Context(connection);
|
||||
var staleController = Controller(stale);
|
||||
var source = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>((await staleController.Get(default)).Result).Value);
|
||||
Assert.Equal(1, source.Revision);
|
||||
Assert.Equal("Original", source.Profile.Jobs.Single().Title);
|
||||
Assert.IsType<OkObjectResult>((await Controller(first).Put(Request("Acknowledged winner", 1), default)).Result);
|
||||
var rejected = await staleController.Put(Request("Stale draft", 1), default);
|
||||
Assert.IsType<ConflictObjectResult>(rejected.Result);
|
||||
await stale.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal("Acknowledged winner", (await verify.CareerExperiences.SingleAsync()).Title);
|
||||
Assert.Equal(2, (await verify.CareerProfiles.SingleAsync()).Version);
|
||||
Assert.Equal(2, await verify.CareerProfileVersions.CountAsync());
|
||||
Assert.Contains("Acknowledged winner", (await verify.Users.SingleAsync()).ProfileCvStructureJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Identity_failure_rolls_back_canonical_children_history_and_projection()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
await db.SaveChangesAsync();
|
||||
var result = await Controller(db, false).Put(Request("Must not persist", 0), default);
|
||||
Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||
await db.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
Assert.Empty(await verify.CareerProfiles.ToListAsync());
|
||||
Assert.Empty(await verify.CareerExperiences.ToListAsync());
|
||||
Assert.Empty(await verify.CareerProfileVersions.ToListAsync());
|
||||
Assert.Null((await verify.Users.SingleAsync()).ProfileCvStructureJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_tracked_entity_is_guarded_at_database_update_and_stale_restore_is_rejected()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var first = Context(connection);
|
||||
await first.Database.EnsureCreatedAsync();
|
||||
var a = new CareerProfileService(first);
|
||||
await a.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Original" } } }, "manual", 0, default);
|
||||
await using var stale = Context(connection);
|
||||
await stale.CareerProfiles.SingleAsync();
|
||||
await a.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Winner" } } }, "manual", 1, default);
|
||||
var b = new CareerProfileService(stale);
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => b.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Title = "Stale" } } }, "manual", 1, default));
|
||||
await stale.SaveChangesAsync();
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => b.RestoreVersionAsync("rd21-owner", 1, 1, default));
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal("Winner", (await verify.CareerExperiences.SingleAsync()).Title);
|
||||
Assert.Equal(2, await verify.CareerProfileVersions.CountAsync());
|
||||
Assert.Equal(0, (await b.LoadSnapshotAsync("another-owner", default)).Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Competing_first_save_sources_have_one_acknowledged_winner()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"rd21-first-save-{Guid.NewGuid():N}.db");
|
||||
try
|
||||
{
|
||||
await using (var setupConnection = new SqliteConnection($"Data Source={path};Pooling=False"))
|
||||
{
|
||||
await setupConnection.OpenAsync();
|
||||
await using var setup = Context(setupConnection);
|
||||
await setup.Database.EnsureCreatedAsync();
|
||||
setup.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
await setup.SaveChangesAsync();
|
||||
}
|
||||
using var ready = new Barrier(2);
|
||||
async Task<IActionResult?> Save(string title) => await Task.Run(async () =>
|
||||
{
|
||||
await using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
var controller = Controller(db);
|
||||
Assert.Equal(0, Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>((await controller.Get(default)).Result).Value).Revision);
|
||||
Assert.True(ready.SignalAndWait(TimeSpan.FromSeconds(10)));
|
||||
return (await controller.Put(Request(title, 0), default)).Result;
|
||||
});
|
||||
var outcomes = await Task.WhenAll(Save("Candidate A"), Save("Candidate B"));
|
||||
Assert.Single(outcomes.OfType<OkObjectResult>());
|
||||
Assert.Single(outcomes.OfType<ConflictObjectResult>());
|
||||
await using var verifyConnection = new SqliteConnection($"Data Source={path};Pooling=False");
|
||||
await verifyConnection.OpenAsync();
|
||||
await using var verify = Context(verifyConnection);
|
||||
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);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
private sealed class FailAfterChildDeletion : SaveChangesInterceptor
|
||||
{
|
||||
private int _saves;
|
||||
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (++_saves == 3) throw new InvalidOperationException("Synthetic child insert failure");
|
||||
return base.SavingChangesAsync(eventData, result, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Database_failure_after_deletion_restores_children_history_and_revision_and_clears_tracker()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
await new CareerProfileService(seed).SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Original" } } }, "manual", 0, default);
|
||||
}
|
||||
await using var failed = Context(connection, new FailAfterChildDeletion());
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => new CareerProfileService(failed).SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Title = "Rejected" } } }, "manual", 1, default));
|
||||
Assert.Empty(failed.ChangeTracker.Entries());
|
||||
await failed.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal("Original", (await verify.CareerExperiences.SingleAsync()).Title);
|
||||
Assert.Equal(1, (await verify.CareerProfiles.SingleAsync()).Version);
|
||||
Assert.Single(await verify.CareerProfileVersions.ToListAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData(-1)]
|
||||
public async Task Replacement_restore_parse_and_import_fail_closed_without_valid_revision(int? expectedVersion)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
await db.SaveChangesAsync();
|
||||
var controller = Controller(db);
|
||||
Assert.IsType<BadRequestObjectResult>((await controller.Put(Request("Rejected", expectedVersion), default)).Result);
|
||||
Assert.IsType<BadRequestObjectResult>((await controller.Restore(1, new(expectedVersion), default)).Result);
|
||||
var imports = ImportController(db);
|
||||
Assert.IsType<BadRequestObjectResult>((await imports.Parse(new("# Synthetic", expectedVersion))).Result);
|
||||
Assert.IsType<BadRequestObjectResult>(await imports.AcceptRun(1, new(null, expectedVersion)));
|
||||
Assert.Empty(await db.CareerProfiles.ToListAsync());
|
||||
Assert.Empty(await db.CvExtractionRuns.ToListAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Reviewed_import_commits_or_rolls_back_canonical_projection_and_application_metadata(bool identitySucceeds)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
var run = new CvExtractionRun
|
||||
{
|
||||
OwnerUserId = "rd21-owner", Trigger = "upload", Status = "pending_review",
|
||||
StructuredProfileJson = StructuredCvProfileJson.Serialize(new() { Jobs = { new() { Id = "imported-role", Title = "Reviewed role" } } }),
|
||||
};
|
||||
db.CvExtractionRuns.Add(run);
|
||||
await db.SaveChangesAsync();
|
||||
var result = await ImportController(db, identitySucceeds).AcceptRun(run.Id, new(null, 0));
|
||||
if (identitySucceeds) Assert.IsType<OkObjectResult>(result);
|
||||
else Assert.IsType<BadRequestObjectResult>(result);
|
||||
await db.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
var savedRun = await verify.CvExtractionRuns.SingleAsync();
|
||||
var user = await verify.Users.SingleAsync();
|
||||
Assert.Equal(identitySucceeds ? "applied" : "pending_review", savedRun.Status);
|
||||
Assert.Equal(identitySucceeds, savedRun.AppliedAtUtc.HasValue);
|
||||
Assert.Equal(identitySucceeds, user.CurrentCvExtractionRunId.HasValue);
|
||||
Assert.Equal(identitySucceeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync());
|
||||
Assert.Equal(identitySucceeds ? 1 : 0, await verify.CareerExperiences.CountAsync());
|
||||
Assert.Equal(identitySucceeds, user.ProfileCvStructureJson is not null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_acceptance_requires_the_revision_shown_in_its_diff()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
var run = new CvExtractionRun { OwnerUserId = "rd21-owner", Trigger = "upload", Status = "pending_review", StructuredProfileJson = "{}" };
|
||||
db.CvExtractionRuns.Add(run);
|
||||
await db.SaveChangesAsync();
|
||||
var imports = ImportController(db);
|
||||
var diff = Assert.IsType<OkObjectResult>(await imports.GetRunDiff(run.Id));
|
||||
Assert.Equal(0, JsonSerializer.SerializeToElement(diff.Value).GetProperty("revision").GetInt32());
|
||||
Assert.IsType<OkObjectResult>((await Controller(db).Put(Request("Winner", 0), default)).Result);
|
||||
Assert.IsType<ConflictObjectResult>(await imports.AcceptRun(run.Id, new(null, 0)));
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal("pending_review", (await verify.CvExtractionRuns.SingleAsync()).Status);
|
||||
Assert.Equal("Winner", (await verify.CareerExperiences.SingleAsync()).Title);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Parsing_commits_or_rolls_back_its_entire_import_application(bool identitySucceeds)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" });
|
||||
await db.SaveChangesAsync();
|
||||
const string text = "# Contact\nSynthetic Person\n\n# Professional Summary\nBuilt APIs.\n\n# Skills\nC#\nSQL";
|
||||
var result = await ImportController(db, identitySucceeds).Parse(new(text, 0));
|
||||
if (identitySucceeds) Assert.IsType<OkObjectResult>(result.Result);
|
||||
else Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||
await db.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal(identitySucceeds ? 1 : 0, await verify.CvExtractionRuns.CountAsync());
|
||||
Assert.Equal(identitySucceeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync());
|
||||
var user = await verify.Users.SingleAsync();
|
||||
Assert.Equal(identitySucceeds, user.CurrentCvExtractionRunId.HasValue);
|
||||
Assert.Equal(identitySucceeds, user.ProfileCvStructureJson is not null);
|
||||
if (identitySucceeds)
|
||||
{
|
||||
var profile = await verify.CareerProfiles.SingleAsync();
|
||||
Assert.Equal(profile.ProfileJson, user.ProfileCvStructureJson);
|
||||
Assert.Equal(profile.ProfileJson, (await verify.CvExtractionRuns.SingleAsync()).StructuredProfileJson);
|
||||
Assert.Equal(user.ProfileCvText, (await new CareerProfileService(verify).LoadSnapshotAsync("rd21-owner", default)).CvText);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editor_snapshot_uses_relational_canonical_source_and_never_writes_during_read()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
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 = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic", ProfileCvText = "Legacy raw CV text" });
|
||||
await db.SaveChangesAsync();
|
||||
var response = await Controller(db).Get(default);
|
||||
var dto = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(response.Result).Value);
|
||||
Assert.Equal(0, dto.Revision);
|
||||
Assert.Empty(dto.Profile.Jobs);
|
||||
Assert.Equal("Legacy raw CV text", dto.CvText);
|
||||
Assert.Empty(await db.CareerProfiles.ToListAsync());
|
||||
Assert.Empty(await db.CareerProfileVersions.ToListAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class Rd21JobPersistenceTests
|
||||
{
|
||||
private static JobTrackerContext Context(SqliteConnection connection, string owner = "rd21-owner")
|
||||
{
|
||||
var current = new Mock<ICurrentUserService>();
|
||||
current.SetupGet(x => x.UserId).Returns(owner);
|
||||
return new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options, current.Object);
|
||||
}
|
||||
|
||||
private static JobApplicationsController Controller(JobTrackerContext db) => new(db,
|
||||
Mock.Of<ISummarizerService>(), TestHostFactory.CreateUserManager(new ApplicationUser { Id = "rd21-owner" }).Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "rd21-owner") }, "test")),
|
||||
} },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Write_that_overlaps_a_delayed_save_wins_without_partial_events_or_opportunity_changes()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
int id, companyId;
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
var company = new Company { OwnerUserId = "rd21-owner", Name = "RD21 overlap" };
|
||||
seed.Add(company);
|
||||
await seed.SaveChangesAsync();
|
||||
var job = new JobApplication { OwnerUserId = "rd21-owner", CompanyId = company.Id, JobTitle = "Original job title", Status = "Prospect" };
|
||||
job.Job = JobOpportunitySync.Create(job);
|
||||
seed.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id; companyId = company.Id;
|
||||
}
|
||||
|
||||
var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
summarizer.Setup(x => x.SummarizeAsync(It.IsAny<string>(), 160, 60)).Returns(() =>
|
||||
{
|
||||
reached.TrySetResult();
|
||||
return release.Task;
|
||||
});
|
||||
var user = new ApplicationUser { Id = "rd21-owner", AiEnabled = true };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Premium" });
|
||||
await using var delayed = Context(connection);
|
||||
var controller = new JobApplicationsController(delayed, summarizer.Object, users.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "test")),
|
||||
} },
|
||||
};
|
||||
var pending = controller.Update(id, Request(companyId, "Delayed title", null, 1) with { Status = "Offer" }, default);
|
||||
await reached.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
await using var winner = Context(connection);
|
||||
Assert.IsType<NoContentResult>(await Controller(winner).Update(id,
|
||||
Request(companyId, "Original job title", "Acknowledged concurrent notes", 1), default));
|
||||
}
|
||||
finally { release.TrySetResult("Delayed summary"); }
|
||||
Assert.IsType<ConflictObjectResult>(await pending);
|
||||
|
||||
await using var verify = Context(connection);
|
||||
var saved = await verify.JobApplications.SingleAsync();
|
||||
Assert.Equal(2, saved.Version);
|
||||
Assert.Equal("Original job title", saved.JobTitle);
|
||||
Assert.Equal("Acknowledged concurrent notes", saved.Notes);
|
||||
Assert.Equal("Prospect", saved.Status);
|
||||
Assert.Equal("Original job title", (await verify.Jobs.SingleAsync()).JobTitle);
|
||||
Assert.Empty(await verify.JobEvents.ToListAsync());
|
||||
Assert.Empty(delayed.ChangeTracker.Entries());
|
||||
|
||||
// A deliberate reload/review uses the winner's content and new revision, never a blind retry.
|
||||
await using var reviewed = Context(connection);
|
||||
Assert.IsType<NoContentResult>(await Controller(reviewed).Update(id,
|
||||
Request(companyId, "Reviewed final title", saved.Notes, saved.Version), default));
|
||||
await using var finalSession = Context(connection);
|
||||
var final = await finalSession.JobApplications.SingleAsync();
|
||||
Assert.Equal(3, final.Version);
|
||||
Assert.Equal("Reviewed final title", final.JobTitle);
|
||||
Assert.Equal("Acknowledged concurrent notes", final.Notes);
|
||||
}
|
||||
|
||||
private static UpdateJobApplicationRequest Request(int companyId, string title, string? notes, int? expectedVersion) =>
|
||||
JsonSerializer.Deserialize<UpdateJobApplicationRequest>(JsonSerializer.Serialize(new
|
||||
{
|
||||
jobTitle = title, companyId, status = "Prospect", notes, expectedVersion,
|
||||
location = "Oslo", salary = "unchanged salary", description = "Original description",
|
||||
}), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
|
||||
[Fact]
|
||||
public async Task Structured_tailored_cv_snapshot_cannot_replace_a_newer_approved_document()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
int id;
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
var job = new JobApplication { OwnerUserId = "rd21-owner", Company = new Company { Name = "RD21 CV", OwnerUserId = "rd21-owner" }, JobTitle = "Role" };
|
||||
seed.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id;
|
||||
}
|
||||
SaveTailoredCvDraftRequest Draft(string text) => JsonSerializer.Deserialize<SaveTailoredCvDraftRequest>(
|
||||
JsonSerializer.Serialize(new { summary = new[] { text }, expectedVersion = 1 }),
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
await using var first = Context(connection);
|
||||
Assert.IsType<NoContentResult>(await Controller(first).SaveTailoredCvDraft(id, Draft("Approved CV evidence"), default));
|
||||
await using var stale = Context(connection);
|
||||
var rejected = await Controller(stale).SaveTailoredCvDraft(id, Draft("Stale CV evidence"), default);
|
||||
await using var verify = Context(connection);
|
||||
Assert.Contains("Approved CV evidence", (await verify.JobApplications.SingleAsync()).TailoredCvText);
|
||||
Assert.Contains("Approved CV evidence", (await verify.TailoredCvDrafts.SingleAsync()).SummaryJson);
|
||||
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(rejected).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_job_transaction_cannot_acknowledge_or_leave_partial_data_for_a_later_save()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
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
|
||||
BEGIN SELECT RAISE(ABORT, 'synthetic RD21 event failure'); END;
|
||||
""");
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() => Controller(db).Update(job.Id,
|
||||
Request(company.Id, "Uncommitted title", "Uncommitted notes", 1) with { Status = "Offer" }, default));
|
||||
Assert.Empty(db.ChangeTracker.Entries());
|
||||
await db.SaveChangesAsync();
|
||||
await using var verify = Context(connection);
|
||||
var actual = await verify.JobApplications.SingleAsync();
|
||||
Assert.Equal("Approved title", actual.JobTitle);
|
||||
Assert.Equal("Approved notes", actual.Notes);
|
||||
Assert.Equal("Prospect", actual.Status);
|
||||
Assert.Equal(1, actual.Version);
|
||||
Assert.Equal("Approved title", (await verify.Jobs.SingleAsync()).JobTitle);
|
||||
Assert.Empty(await verify.JobEvents.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Versioned_job_save_cannot_cross_tenant_job_or_company_ownership()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var mine = new Company { OwnerUserId = "rd21-owner", Name = "Owned company" };
|
||||
var other = new Company { OwnerUserId = "other-owner", Name = "Private company" };
|
||||
var job = new JobApplication { OwnerUserId = "rd21-owner", Company = mine, JobTitle = "Owned job", Status = "Prospect" };
|
||||
var foreign = new JobApplication { OwnerUserId = "other-owner", Company = other, JobTitle = "Private job", Status = "Prospect" };
|
||||
db.AddRange(job, foreign);
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
Assert.IsType<NotFoundResult>(await Controller(db).Update(foreign.Id, Request(other.Id, "Stolen", null, 1), default));
|
||||
Assert.IsType<BadRequestObjectResult>(await Controller(db).Update(job.Id, Request(other.Id, "Changed", null, 1), default));
|
||||
await using var verify = Context(connection);
|
||||
Assert.Equal("Owned job", (await verify.JobApplications.SingleAsync()).JobTitle);
|
||||
Assert.Equal(mine.Id, (await verify.JobApplications.SingleAsync()).CompanyId);
|
||||
await using var foreignSession = Context(connection, "other-owner");
|
||||
Assert.Equal("Private job", (await foreignSession.JobApplications.SingleAsync()).JobTitle);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Related_text_save_cannot_silently_replace_a_newer_acknowledged_draft(bool coverLetter)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
int id;
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = "rd21-owner", Company = new Company { OwnerUserId = "rd21-owner", Name = "RD21" },
|
||||
JobTitle = "Role", CoverLetterText = "Original letter", Notes = "Original notes",
|
||||
};
|
||||
seed.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id;
|
||||
}
|
||||
|
||||
async Task<IActionResult> Save(JobTrackerContext db, string text)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new { text, notes = text, expectedVersion = 1 });
|
||||
if (!coverLetter)
|
||||
return await Controller(db).SaveApplicationDrafts(id,
|
||||
JsonSerializer.Deserialize<SaveApplicationDraftsRequest>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!, default);
|
||||
var user = new ApplicationUser { Id = "rd21-owner" };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var controller = new ApplicationAssetsController(users.Object,
|
||||
new ApplicationAssetsService(db, Mock.Of<ICvVariantService>(), Mock.Of<IApplicationIntelligenceService>()),
|
||||
Mock.Of<ISubmittedApplicationPackageService>())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "test")),
|
||||
} },
|
||||
};
|
||||
return (await controller.SaveCoverLetter(id,
|
||||
JsonSerializer.Deserialize<ApplicationAssetsController.SaveCoverLetterRequest>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!, default)).Result!;
|
||||
}
|
||||
|
||||
await using var first = Context(connection);
|
||||
var acknowledged = await Save(first, "First approved text");
|
||||
Assert.True(acknowledged is OkObjectResult or NoContentResult);
|
||||
await using (var immediate = Context(connection))
|
||||
Assert.Equal("First approved text", coverLetter ? (await immediate.JobApplications.SingleAsync()).CoverLetterText : (await immediate.JobApplications.SingleAsync()).Notes);
|
||||
await using var stale = Context(connection);
|
||||
IActionResult rejected;
|
||||
try { rejected = await Save(stale, "Stale snapshot text"); }
|
||||
catch (DbUpdateConcurrencyException) { rejected = new ConflictObjectResult("revision_conflict"); }
|
||||
await using var verify = Context(connection);
|
||||
var saved = await verify.JobApplications.SingleAsync();
|
||||
Assert.Equal("First approved text", coverLetter ? saved.CoverLetterText : saved.Notes);
|
||||
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(rejected).StatusCode);
|
||||
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 = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
int id, companyId;
|
||||
await using (var seed = Context(connection))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
var company = new Company { OwnerUserId = "rd21-owner", Name = "Synthetic RD21" };
|
||||
seed.Companies.Add(company);
|
||||
await seed.SaveChangesAsync();
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = "rd21-owner", Company = company, CompanyId = company.Id, JobTitle = "Original job title",
|
||||
Status = "Prospect", Location = "Oslo", Salary = "unchanged salary", Description = "Original description",
|
||||
};
|
||||
job.Job = JobOpportunitySync.Create(job);
|
||||
seed.JobApplications.Add(job);
|
||||
await seed.SaveChangesAsync();
|
||||
id = job.Id; companyId = company.Id;
|
||||
}
|
||||
|
||||
// These are the two snapshots actually read by independent editors.
|
||||
await using var editorA = Context(connection);
|
||||
await using var editorBRead = Context(connection);
|
||||
var original = await editorBRead.JobApplications.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("Original job title", original.JobTitle);
|
||||
Assert.IsType<NoContentResult>(await Controller(editorA).Update(id,
|
||||
Request(companyId, "First editor committed title", null, 1), default));
|
||||
|
||||
await using (var immediatelyAfterAcknowledgement = Context(connection))
|
||||
{
|
||||
Assert.Equal("First editor committed title", (await immediatelyAfterAcknowledgement.JobApplications.SingleAsync()).JobTitle);
|
||||
Assert.Equal("First editor committed title", (await immediatelyAfterAcknowledgement.Jobs.SingleAsync()).JobTitle);
|
||||
}
|
||||
|
||||
await using var editorBWrite = Context(connection);
|
||||
var rejected = await Controller(editorBWrite).Update(id,
|
||||
Request(companyId, original.JobTitle, "Second editor stale notes", omitRevision ? null : 1), default);
|
||||
Assert.Equal(409, Assert.IsAssignableFrom<ObjectResult>(rejected).StatusCode);
|
||||
|
||||
await using var independentSession = Context(connection);
|
||||
var persisted = await independentSession.JobApplications.SingleAsync();
|
||||
Assert.Equal("First editor committed title", persisted.JobTitle);
|
||||
Assert.Null(persisted.Notes);
|
||||
Assert.Equal("Oslo", persisted.Location);
|
||||
Assert.Equal("unchanged salary", persisted.Salary);
|
||||
Assert.Equal("First editor committed title", (await independentSession.Jobs.SingleAsync()).JobTitle);
|
||||
Assert.Empty(await independentSession.JobEvents.ToListAsync());
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public sealed class SqliteDateTimeOffsetCompatibilityTests
|
||||
db.Attachments.Add(attachment);
|
||||
await db.SaveChangesAsync();
|
||||
var career = new CareerProfileService(db);
|
||||
await career.SaveVersionAsync("user-1", JsonSerializer.Deserialize<StructuredCvProfile>("{\"version\":\"1\",\"contact\":{\"fullName\":\"Åse\",\"headline\":\"Original profile\"}}", new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!, "manual", default);
|
||||
await career.SaveVersionAsync("user-1", JsonSerializer.Deserialize<StructuredCvProfile>("{\"version\":\"1\",\"contact\":{\"fullName\":\"Åse\",\"headline\":\"Original profile\"}}", new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!, "manual", 0, default);
|
||||
var variants = new CvVariantService(db, career, new ThemedCvRenderer());
|
||||
var assets = new ApplicationAssetsService(db, variants, new ApplicationIntelligenceService(db, new JobCvMatchService()));
|
||||
Assert.NotNull(await assets.GetTailoringPlanAsync("user-1", job.Id, default));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class SqliteStartupConcurrencyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Fresh_and_populated_startup_enable_WAL_without_changing_owned_rows()
|
||||
{
|
||||
await using var database = await Database.CreateAsync();
|
||||
await using var connection = new SqliteConnection(database.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var journal = connection.CreateCommand();
|
||||
journal.CommandText = "PRAGMA journal_mode;";
|
||||
Assert.Equal("wal", await journal.ExecuteScalarAsync());
|
||||
|
||||
journal.CommandText = "PRAGMA journal_mode=DELETE;";
|
||||
Assert.Equal("delete", await journal.ExecuteScalarAsync());
|
||||
await connection.CloseAsync();
|
||||
await database.App.InitializeJobTrackerAsync();
|
||||
|
||||
await connection.OpenAsync();
|
||||
journal.CommandText = "PRAGMA journal_mode;";
|
||||
Assert.Equal("wal", await journal.ExecuteScalarAsync());
|
||||
await using var scope = database.App.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
Assert.Equal(new[] { "other", "owner" }, await db.Users.OrderBy(user => user.Id).Select(user => user.Id).ToArrayAsync());
|
||||
Assert.Equal(new[] { "Other company", "Owned company" }, await db.Companies.IgnoreQueryFilters().OrderBy(company => company.Name).Select(company => company.Name).ToArrayAsync());
|
||||
Assert.All(await db.Users.ToListAsync(), user => Assert.Equal("en", user.UiLanguage));
|
||||
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_open_read_cursor_does_not_block_usage_reservation_or_Identity_preference_save()
|
||||
{
|
||||
await using var database = await Database.CreateAsync();
|
||||
await using var connection = new SqliteConnection(database.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT Id FROM Companies ORDER BY Id;";
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync());
|
||||
|
||||
await using var scope = database.App.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
var reservation = await new AiUsageMeter(db, TimeProvider.System).ReserveAsync(
|
||||
"owner", new AccountEntitlements(true, true, 1_000_000, 10, 10_000),
|
||||
"synchronous", "held-reader", "synchronous.summary", 40, 10, default);
|
||||
Assert.True(reservation.Created);
|
||||
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var owner = await users.FindByIdAsync("owner");
|
||||
Assert.NotNull(owner);
|
||||
owner.UiLanguage = "nb-NO";
|
||||
Assert.True((await users.UpdateAsync(owner)).Succeeded);
|
||||
|
||||
await using var verifyScope = database.App.Services.CreateAsyncScope();
|
||||
var verify = verifyScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
var usage = Assert.Single(await verify.AiUsageRecords.AsNoTracking().ToListAsync());
|
||||
Assert.Equal(reservation.Record.Id, usage.Id);
|
||||
Assert.Equal("owner", usage.OwnerUserId);
|
||||
Assert.Equal("held-reader", usage.SourceId);
|
||||
Assert.Equal(1, usage.CallCount);
|
||||
Assert.Equal("nb-NO", (await verify.Users.AsNoTracking().SingleAsync(user => user.Id == "owner")).UiLanguage);
|
||||
Assert.Equal("en", (await verify.Users.AsNoTracking().SingleAsync(user => user.Id == "other")).UiLanguage);
|
||||
Assert.Equal(2, await verify.Companies.IgnoreQueryFilters().CountAsync());
|
||||
Assert.True(await reader.ReadAsync());
|
||||
Assert.False(await reader.ReadAsync());
|
||||
}
|
||||
|
||||
private sealed class Database(WebApplication app, string root, string connectionString) : IAsyncDisposable
|
||||
{
|
||||
public WebApplication App { get; } = app;
|
||||
public string ConnectionString { get; } = connectionString;
|
||||
|
||||
public static async Task<Database> CreateAsync()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"jobtracker-startup-lock-{Guid.NewGuid():N}");
|
||||
var connectionString = $"Data Source={Path.Combine(root, "jobtracker.db")};Default Timeout=1;Pooling=False";
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
EnvironmentName = "Production",
|
||||
ApplicationName = typeof(StartupInitializationExtensions).Assembly.GetName().Name,
|
||||
});
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Data:Root"] = root,
|
||||
["Database:Provider"] = "sqlite",
|
||||
["ConnectionStrings:JobTracker"] = connectionString,
|
||||
["Auth:AdminEmail"] = "",
|
||||
["Auth:AdminPassword"] = "",
|
||||
});
|
||||
builder.Logging.ClearProviders();
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns("owner");
|
||||
builder.Services.AddSingleton(currentUser.Object);
|
||||
builder.Services.AddSingleton<AppPaths>();
|
||||
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
|
||||
builder.Services.AddDbContext<JobTrackerContext>(options => options.UseSqlite(connectionString)
|
||||
.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)));
|
||||
builder.Services.AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>().AddEntityFrameworkStores<JobTrackerContext>();
|
||||
var app = builder.Build();
|
||||
var database = new Database(app, root, connectionString);
|
||||
try
|
||||
{
|
||||
await app.InitializeJobTrackerAsync();
|
||||
await using var scope = app.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
db.Users.AddRange(
|
||||
new ApplicationUser { Id = "owner", UserName = "owner@example.test", UiLanguage = "en" },
|
||||
new ApplicationUser { Id = "other", UserName = "other@example.test", UiLanguage = "en" });
|
||||
db.Companies.AddRange(
|
||||
new Company { Name = "Owned company", OwnerUserId = "owner" },
|
||||
new Company { Name = "Other company", OwnerUserId = "other" });
|
||||
await db.SaveChangesAsync();
|
||||
return database;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await database.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await App.DisposeAsync();
|
||||
var fullRoot = Path.GetFullPath(root);
|
||||
Assert.StartsWith(Path.GetFullPath(Path.GetTempPath()), fullRoot, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.StartsWith("jobtracker-startup-lock-", Path.GetFileName(fullRoot), StringComparison.Ordinal);
|
||||
if (Directory.Exists(fullRoot)) Directory.Delete(fullRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,8 +505,8 @@
|
||||
},
|
||||
"System.Security.Cryptography.Pkcs": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.1",
|
||||
"contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA=="
|
||||
"resolved": "8.0.2",
|
||||
"contentHash": "VWACYq0JGxi9sw3TonSSV4B2MfhYk5nhgOtp9wbrt5NFOtUetFqHkMZylcCwPX+117cBJH5EnIzIy6X4OKn/kA=="
|
||||
},
|
||||
"System.Security.Cryptography.ProtectedData": {
|
||||
"type": "Transitive",
|
||||
@@ -567,7 +567,8 @@
|
||||
"QRCoder": "[1.8.0, )",
|
||||
"SQLitePCLRaw.lib.e_sqlite3": "[2.1.12, )",
|
||||
"Stripe.net": "[52.2.0, )",
|
||||
"System.IdentityModel.Tokens.Jwt": "[8.14.0, )"
|
||||
"System.IdentityModel.Tokens.Jwt": "[8.14.0, )",
|
||||
"System.Security.Cryptography.Pkcs": "[8.0.2, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ namespace JobTrackerApi.Controllers;
|
||||
public sealed class ApplicationAssetsController : ControllerBase
|
||||
{
|
||||
public sealed record AttachVariantRequest(int? VariantId);
|
||||
public sealed record SaveCoverLetterRequest(string? Text, string? Source, string? AiAction);
|
||||
public sealed record SaveCoverLetterRequest(string? Text, string? Source, string? AiAction, int? ExpectedVersion = null);
|
||||
public sealed record RestoreCoverLetterRequest(int? ExpectedVersion);
|
||||
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly IApplicationAssetsService _assets;
|
||||
@@ -74,16 +75,16 @@ public sealed class ApplicationAssetsController : ControllerBase
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _assets.SaveCoverLetterAsync(
|
||||
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, ct);
|
||||
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, request?.ExpectedVersion, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("cover-letter/versions/{version:int}/restore")]
|
||||
public async Task<ActionResult<CoverLetterDto>> RestoreCoverLetter(int jobId, int version, CancellationToken ct)
|
||||
public async Task<ActionResult<CoverLetterDto>> RestoreCoverLetter(int jobId, int version, [FromBody] RestoreCoverLetterRequest? request, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, ct);
|
||||
var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, request?.ExpectedVersion, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -516,12 +516,14 @@ public sealed class AuthController : ControllerBase
|
||||
return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
|
||||
}
|
||||
|
||||
if (request.ProfileCvText is not null || request.ProfileCvStructureJson is not null)
|
||||
return BadRequest("Career data must be saved through /api/career/profile with its source revision.");
|
||||
|
||||
// Partial update. A field is only touched when the request actually carries it:
|
||||
// - null / omitted -> leave unchanged (the caller isn't editing this field)
|
||||
// - "" -> explicitly clear
|
||||
// - "value" -> set (trimmed)
|
||||
// This lets /profile save identity fields and /career save the master-profile fields
|
||||
// through the same endpoint without one wiping the other. Email and UserName are the
|
||||
// Career data uses its own atomic, revision-guarded endpoint. Email and UserName are the
|
||||
// login identifiers and are never cleared to empty. Email ownership changes use the
|
||||
// separate, token-confirmed email-change flow below.
|
||||
if (request.UserName is not null)
|
||||
@@ -532,8 +534,6 @@ public sealed class AuthController : ControllerBase
|
||||
if (request.FirstName is not null) user.FirstName = TrimOrNull(request.FirstName);
|
||||
if (request.LastName is not null) user.LastName = TrimOrNull(request.LastName);
|
||||
if (request.DisplayName is not null) user.DisplayName = TrimOrNull(request.DisplayName);
|
||||
if (request.ProfileCvText is not null) user.ProfileCvText = TrimOrNull(request.ProfileCvText);
|
||||
if (request.ProfileCvStructureJson is not null) user.ProfileCvStructureJson = TrimOrNull(request.ProfileCvStructureJson);
|
||||
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded)
|
||||
@@ -544,6 +544,56 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
public sealed record UpdateLanguagePreferenceRequest(string? Language);
|
||||
|
||||
[HttpGet("preferences/dashboard")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> GetDashboardPreference(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = LocalAuthIdentity.GetRequiredUserId(User);
|
||||
if (userId is null) return Unauthorized();
|
||||
var user = await _db.Users.AsNoTracking().Where(x => x.Id == userId)
|
||||
.Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleOrDefaultAsync(cancellationToken);
|
||||
return user is null ? Unauthorized() : Ok(DashboardLayout.Read(user.DashboardLayoutJson, user.DashboardLayoutRevision));
|
||||
}
|
||||
|
||||
[HttpPut("preferences/dashboard")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> UpdateDashboardPreference(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = LocalAuthIdentity.GetRequiredUserId(User);
|
||||
if (userId is null) return Unauthorized();
|
||||
var existing = await _db.Users.AsNoTracking().Where(x => x.Id == userId)
|
||||
.Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleOrDefaultAsync(cancellationToken);
|
||||
if (existing is null) return Unauthorized();
|
||||
if (DashboardLayout.Read(existing.DashboardLayoutJson, 0).Version > 1)
|
||||
return Conflict(new { error = "dashboard_version_unsupported" });
|
||||
if (Request.ContentLength > DashboardLayout.MaxBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
// Bound actual bytes as well as Content-Length, including chunked bodies.
|
||||
var bytes = new byte[DashboardLayout.MaxBytes + 1];
|
||||
var length = 0;
|
||||
while (length < bytes.Length)
|
||||
{
|
||||
var count = await Request.Body.ReadAsync(bytes.AsMemory(length), cancellationToken);
|
||||
if (count == 0) break;
|
||||
length += count;
|
||||
}
|
||||
if (length > DashboardLayout.MaxBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
DashboardLayout? layout;
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(bytes.AsMemory(0, length));
|
||||
layout = DashboardLayout.Parse(document.RootElement);
|
||||
}
|
||||
catch (JsonException) { return BadRequest("Invalid dashboard layout JSON."); }
|
||||
if (layout is null) return BadRequest("Invalid dashboard layout version, revision or widgets.");
|
||||
if (layout.Revision != existing.DashboardLayoutRevision)
|
||||
return Conflict(new { error = "dashboard_revision_conflict" });
|
||||
var json = JsonSerializer.Serialize(layout, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
var updated = await _db.Users.Where(x => x.Id == userId && x.DashboardLayoutRevision == layout.Revision)
|
||||
.ExecuteUpdateAsync(set => set.SetProperty(x => x.DashboardLayoutJson, json)
|
||||
.SetProperty(x => x.DashboardLayoutRevision, x => x.DashboardLayoutRevision + 1), cancellationToken);
|
||||
return updated == 0 ? Conflict(new { error = "dashboard_revision_conflict" }) : Ok(layout with { Revision = layout.Revision + 1 });
|
||||
}
|
||||
|
||||
[HttpPut("preferences/language")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> UpdateLanguagePreference([FromBody] UpdateLanguagePreferenceRequest request)
|
||||
|
||||
@@ -4,6 +4,7 @@ using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
@@ -35,8 +36,8 @@ public sealed class CareerProfileController : ControllerBase
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken);
|
||||
return Ok(new CareerProfileDto(profile, CareerCompleteness.Evaluate(profile), user.ProfileCvText));
|
||||
var snapshot = await _career.LoadSnapshotAsync(user.Id, cancellationToken);
|
||||
return Ok(new CareerProfileDto(snapshot.Profile, CareerCompleteness.Evaluate(snapshot.Profile), snapshot.CvText, snapshot.Revision));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -50,21 +51,27 @@ public sealed class CareerProfileController : ControllerBase
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts.");
|
||||
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
|
||||
|
||||
var profile = StructuredCvProfileJson.NormalizeForPersistence(request?.Profile);
|
||||
var profile = StructuredCvProfileJson.NormalizeForPersistence(request.Profile);
|
||||
var error = CareerProfileValidator.Validate(profile);
|
||||
if (error is not null) return BadRequest(error);
|
||||
|
||||
var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", cancellationToken);
|
||||
|
||||
// Keep the derived projection in sync for legacy readers. CvText (the raw imported text) is
|
||||
// part of the career profile and is set here too; identity fields are never touched.
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(saved);
|
||||
if (request?.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText;
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
|
||||
return Ok(new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved), user.ProfileCvText));
|
||||
try
|
||||
{
|
||||
var dto = await _career.ExecuteAtomicAsync(async () =>
|
||||
{
|
||||
var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", request.ExpectedVersion.Value, cancellationToken);
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(saved);
|
||||
if (request.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText;
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded) throw new CareerProjectionException(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
return new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved), user.ProfileCvText, request.ExpectedVersion.Value + 1);
|
||||
}, cancellationToken);
|
||||
return Ok(dto);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload and review your draft before saving." }); }
|
||||
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
|
||||
}
|
||||
|
||||
/// <summary>Just the completeness scorecard, for the /career overview.</summary>
|
||||
@@ -92,25 +99,34 @@ public sealed class CareerProfileController : ControllerBase
|
||||
/// </summary>
|
||||
[HttpPost("versions/{version:int}/restore")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<ActionResult<CareerProfileDto>> Restore([FromRoute] int version, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<CareerProfileDto>> Restore([FromRoute] int version, [FromBody] CareerProfileRestoreRequest? request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts.");
|
||||
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
|
||||
|
||||
var restored = await _career.RestoreVersionAsync(user.Id, version, cancellationToken);
|
||||
if (restored is null) return NotFound($"Version {version} was not found.");
|
||||
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(restored);
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
|
||||
return Ok(new CareerProfileDto(restored, CareerCompleteness.Evaluate(restored), user.ProfileCvText));
|
||||
try
|
||||
{
|
||||
var dto = await _career.ExecuteAtomicAsync(async () =>
|
||||
{
|
||||
var restored = await _career.RestoreVersionAsync(user.Id, version, request.ExpectedVersion.Value, cancellationToken);
|
||||
if (restored is null) return null;
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(restored);
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded) throw new CareerProjectionException(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
return new CareerProfileDto(restored, CareerCompleteness.Evaluate(restored), user.ProfileCvText, request.ExpectedVersion.Value + 1);
|
||||
}, cancellationToken);
|
||||
return dto is null ? NotFound($"Version {version} was not found.") : Ok(dto);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload before restoring a version." }); }
|
||||
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CareerProfileSaveRequest(StructuredCvProfile? Profile, string? CvText);
|
||||
public sealed record CareerProfileSaveRequest(StructuredCvProfile? Profile, string? CvText, int? ExpectedVersion = null);
|
||||
public sealed record CareerProfileRestoreRequest(int? ExpectedVersion);
|
||||
|
||||
public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness, string? CvText);
|
||||
public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness, string? CvText, int Revision);
|
||||
|
||||
public sealed record CareerCompletenessDto(int Percent, List<string> Missing, List<CareerSectionStatusDto> Sections);
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ namespace JobTrackerApi.Controllers
|
||||
existingQuery = existingQuery.Where(c => c.OwnerUserId == userId);
|
||||
|
||||
var existing = await existingQuery
|
||||
.FirstOrDefaultAsync(c => c.Name.ToLower() == name.ToLower(), cancellationToken);
|
||||
.FirstOrDefaultAsync(c => c.Name == name || c.Name.ToLower() == name.ToLower(), cancellationToken);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
|
||||
@@ -79,7 +79,8 @@ namespace JobTrackerApi.Controllers
|
||||
string? TailoredCvText,
|
||||
WorkflowSignalDto WorkflowSignal,
|
||||
string? ShortSummary,
|
||||
string? FullSummary
|
||||
string? FullSummary,
|
||||
int Version = 1
|
||||
);
|
||||
|
||||
public sealed record CreateJobApplicationRequest(
|
||||
@@ -132,7 +133,8 @@ namespace JobTrackerApi.Controllers
|
||||
string? JobUrl,
|
||||
DateTime? DateApplied,
|
||||
DateTime? FeedbackRequestedAt,
|
||||
DateTime? StatusChangedAt
|
||||
DateTime? StatusChangedAt,
|
||||
int? ExpectedVersion = null
|
||||
);
|
||||
|
||||
public sealed record UpdateStatusRequest(string Status);
|
||||
@@ -189,7 +191,7 @@ namespace JobTrackerApi.Controllers
|
||||
CandidateFitChannelGuidanceDto Guidance,
|
||||
string? CoverLetterDraft,
|
||||
string? RecruiterMessageDraft);
|
||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText, int? ExpectedVersion = null);
|
||||
public sealed record TailoredCvDraftDto(
|
||||
int? Id,
|
||||
int? CanonicalProfileVersion,
|
||||
@@ -206,7 +208,8 @@ namespace JobTrackerApi.Controllers
|
||||
DateTimeOffset? LastEditedAtUtc,
|
||||
string Status,
|
||||
string RenderedText,
|
||||
bool IsLegacyFallback);
|
||||
bool IsLegacyFallback,
|
||||
int Revision = 1);
|
||||
public sealed record SaveTailoredCvDraftRequest(
|
||||
string? TemplateId,
|
||||
string? Headline,
|
||||
@@ -216,13 +219,15 @@ namespace JobTrackerApi.Controllers
|
||||
List<TailoredCvEducationItem>? Education,
|
||||
List<TailoredCvCustomSection>? CustomSections,
|
||||
TailoredCvRenderOptions? RenderOptions,
|
||||
string? Status);
|
||||
string? Status,
|
||||
int? ExpectedVersion = null);
|
||||
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
|
||||
public sealed record SaveApplicationDraftsRequest(
|
||||
string? CoverLetterText,
|
||||
string? Notes,
|
||||
string? RecruiterMessageDraft,
|
||||
string? ApplicationAnswerDraft = null);
|
||||
string? ApplicationAnswerDraft = null,
|
||||
int? ExpectedVersion = null);
|
||||
public sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
|
||||
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
|
||||
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders, WorkflowSignalDto WorkflowSignal);
|
||||
|
||||
@@ -459,7 +459,8 @@ Canonical profile:
|
||||
TailoredCvText: job.TailoredCvText,
|
||||
WorkflowSignal: workflowSignal,
|
||||
ShortSummary: job.ShortSummary,
|
||||
FullSummary: fullSummary);
|
||||
FullSummary: fullSummary,
|
||||
Version: job.Version);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -840,6 +841,9 @@ Canonical profile:
|
||||
var job = await _db.JobApplications.Include(j => j.Job).FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version)
|
||||
return Conflict(new { code = "revision_conflict", message = "This job changed or its revision is missing. Your draft was not saved. Reload and review before saving again." });
|
||||
|
||||
var oldStatus = job.Status;
|
||||
var oldResponseReceived = job.ResponseReceived;
|
||||
var oldResponseDate = job.ResponseDate;
|
||||
@@ -850,6 +854,8 @@ Canonical profile:
|
||||
var title = (request.JobTitle ?? "").Trim();
|
||||
if (title.Length == 0) return BadRequest("Job title is required.");
|
||||
if (request.CompanyId <= 0) return BadRequest("Valid companyId is required.");
|
||||
if (!await _db.Companies.AnyAsync(x => x.Id == request.CompanyId, cancellationToken))
|
||||
return BadRequest("Company was not found.");
|
||||
|
||||
job.JobTitle = title;
|
||||
job.CompanyId = request.CompanyId;
|
||||
@@ -926,7 +932,19 @@ Canonical profile:
|
||||
JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, request.StatusChangedAt ?? DateTime.Now);
|
||||
|
||||
if (job.Job is not null) JobOpportunitySync.Apply(job, job.Job);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
_db.Entry(job).Property(x => x.Version).IsModified = true;
|
||||
try { await _db.SaveChangesAsync(cancellationToken); }
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_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
|
||||
{
|
||||
_db.ChangeTracker.Clear();
|
||||
throw;
|
||||
}
|
||||
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -1397,7 +1415,7 @@ Candidate CV/profile:
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken);
|
||||
|
||||
return Ok(draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft));
|
||||
return Ok((draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft)) with { Revision = job.Version });
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/tailored-cv-preview")]
|
||||
@@ -1486,7 +1504,7 @@ Candidate CV/profile:
|
||||
}
|
||||
|
||||
var draft = await UpsertGeneratedTailoredCvDraftAsync(job, user, mode, cancellationToken);
|
||||
return Ok(ToTailoredCvDraftDto(draft));
|
||||
return Ok(ToTailoredCvDraftDto(draft) with { Revision = job.Version });
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/tailored-cv-draft")]
|
||||
@@ -1494,6 +1512,8 @@ Candidate CV/profile:
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version)
|
||||
return Conflict(new { code = "revision_conflict", message = "The application changed. Your CV draft was not saved. Reload and review before saving again." });
|
||||
|
||||
var user = await GetCurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
@@ -1531,7 +1551,9 @@ Candidate CV/profile:
|
||||
|
||||
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
|
||||
job.TailoredCvUpdatedAt = DateTime.UtcNow;
|
||||
_db.Entry(job).Property(x => x.Version).IsModified = true;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -1540,10 +1562,14 @@ Candidate CV/profile:
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version)
|
||||
return Conflict(new { code = "revision_conflict", message = "The application changed. Your draft was not saved. Reload and review before saving again." });
|
||||
|
||||
job.TailoredCvText = string.IsNullOrWhiteSpace(request.TailoredCvText) ? null : request.TailoredCvText.Trim();
|
||||
job.TailoredCvUpdatedAt = job.TailoredCvText is null ? null : DateTime.UtcNow;
|
||||
_db.Entry(job).Property(x => x.Version).IsModified = true;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -1552,6 +1578,8 @@ Candidate CV/profile:
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version)
|
||||
return Conflict(new { code = "revision_conflict", message = "The application changed. Your drafts were not saved. Reload and review before saving again." });
|
||||
|
||||
if (request.CoverLetterText is not null)
|
||||
{
|
||||
@@ -1575,7 +1603,9 @@ Candidate CV/profile:
|
||||
: request.RecruiterMessageDraft.Trim();
|
||||
}
|
||||
|
||||
_db.Entry(job).Property(x => x.Version).IsModified = true;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -248,53 +248,60 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
return new ExtractionPipelineResult(text, normalizedText, structuredCv);
|
||||
}
|
||||
|
||||
private async Task ApplyTextExtractionRunAsync(ApplicationUser user, string trigger, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, CancellationToken cancellationToken)
|
||||
private async Task ApplyTextExtractionRunAsync(ApplicationUser user, string trigger, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, int expectedVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
var run = new CvExtractionRun
|
||||
await _careerProfileService.ExecuteAtomicAsync(async () =>
|
||||
{
|
||||
OwnerUserId = user.Id,
|
||||
ArtifactId = artifactId,
|
||||
Trigger = trigger,
|
||||
ParserVersion = ParserVersion,
|
||||
NormalizerVersion = NormalizerVersion,
|
||||
LlmPromptVersion = LlmPromptVersion,
|
||||
Status = "applied",
|
||||
RawExtractedText = rawText,
|
||||
NormalizedText = normalizedText,
|
||||
StartedAtUtc = DateTimeOffset.UtcNow,
|
||||
CompletedAtUtc = DateTimeOffset.UtcNow,
|
||||
AppliedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.CvExtractionRuns.Add(run);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
||||
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, cancellationToken);
|
||||
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
|
||||
run.StructuredProfileJson = structuredJson;
|
||||
|
||||
user.ProfileCvText = normalizedText;
|
||||
user.ProfileCvStructureJson = structuredJson;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
|
||||
if (artifactId.HasValue)
|
||||
{
|
||||
user.CurrentCvUploadArtifactId = artifactId.Value;
|
||||
}
|
||||
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded)
|
||||
{
|
||||
run.Status = "failed";
|
||||
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
|
||||
var run = new CvExtractionRun
|
||||
{
|
||||
OwnerUserId = user.Id,
|
||||
ArtifactId = artifactId,
|
||||
Trigger = trigger,
|
||||
ParserVersion = ParserVersion,
|
||||
NormalizerVersion = NormalizerVersion,
|
||||
LlmPromptVersion = LlmPromptVersion,
|
||||
Status = "applied",
|
||||
RawExtractedText = rawText,
|
||||
NormalizedText = normalizedText,
|
||||
StartedAtUtc = DateTimeOffset.UtcNow,
|
||||
CompletedAtUtc = DateTimeOffset.UtcNow,
|
||||
AppliedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.CvExtractionRuns.Add(run);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
throw new InvalidOperationException(run.ErrorMessage);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await PruneExtractionRunsAsync(user.Id, cancellationToken);
|
||||
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
||||
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, expectedVersion, cancellationToken);
|
||||
var structuredJson = StructuredCvProfileJson.SerializePersisted(structuredCv);
|
||||
run.StructuredProfileJson = structuredJson;
|
||||
|
||||
user.ProfileCvText = normalizedText;
|
||||
user.ProfileCvStructureJson = structuredJson;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
|
||||
if (artifactId.HasValue)
|
||||
{
|
||||
user.CurrentCvUploadArtifactId = artifactId.Value;
|
||||
}
|
||||
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded)
|
||||
{
|
||||
throw new CareerProjectionException(string.Join("; ", update.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return true;
|
||||
}, cancellationToken);
|
||||
|
||||
// Retention is maintenance; a failure cannot turn a committed profile into a rejected save.
|
||||
try { await PruneExtractionRunsAsync(user.Id, cancellationToken); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_db.ChangeTracker.Clear();
|
||||
_logger.LogWarning(ex, "Could not prune CV extraction history after the profile commit for user {UserId}", user.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CvExtractionRun> CreateQueuedRunAsync(string ownerUserId, int? artifactId, string trigger, CancellationToken cancellationToken)
|
||||
|
||||
@@ -162,7 +162,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
// in-file copies are dropped here to avoid duplicate definitions. The LayoutFamily/AtsRating
|
||||
// fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work
|
||||
// (Phase 4), not this foundation integration.
|
||||
public sealed record AcceptCvRunRequest(List<string>? AcceptedLowConfidenceIds);
|
||||
public sealed record AcceptCvRunRequest(List<string>? AcceptedLowConfidenceIds, int? ExpectedVersion = null);
|
||||
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
|
||||
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
||||
|
||||
@@ -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()
|
||||
@@ -258,9 +260,10 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
if (run is null) return NotFound();
|
||||
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
|
||||
|
||||
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
|
||||
var snapshot = await _careerProfileService.LoadSnapshotAsync(user.Id, HttpContext.RequestAborted);
|
||||
var current = snapshot.Profile;
|
||||
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
|
||||
return Ok(new { runId = run.Id, run.Status, diff = _cvProfileDiffService.Diff(current, extracted) });
|
||||
return Ok(new { runId = run.Id, run.Status, revision = snapshot.Revision, diff = _cvProfileDiffService.Diff(current, extracted) });
|
||||
}
|
||||
|
||||
[HttpPost("runs/{id:int}/accept")]
|
||||
@@ -268,34 +271,45 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
|
||||
|
||||
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
|
||||
if (run is null) return NotFound();
|
||||
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
|
||||
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
|
||||
try
|
||||
{
|
||||
return await _careerProfileService.ExecuteAtomicAsync<IActionResult>(async () =>
|
||||
{
|
||||
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
|
||||
if (run is null) return NotFound();
|
||||
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
|
||||
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
|
||||
|
||||
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
|
||||
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
|
||||
var diff = _cvProfileDiffService.Diff(current, extracted);
|
||||
var acceptedLowConfidenceIds = (request?.AcceptedLowConfidenceIds ?? new List<string>()).ToHashSet(StringComparer.Ordinal);
|
||||
var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds);
|
||||
merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
merged.Metadata.AppliedExtractionRunId = run.Id;
|
||||
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", HttpContext.RequestAborted);
|
||||
var snapshot = await _careerProfileService.LoadSnapshotAsync(user.Id, HttpContext.RequestAborted);
|
||||
if (snapshot.Revision != request.ExpectedVersion.Value)
|
||||
throw new DbUpdateConcurrencyException("The career profile changed after import review.");
|
||||
var current = snapshot.Profile;
|
||||
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
|
||||
var diff = _cvProfileDiffService.Diff(current, extracted);
|
||||
var acceptedLowConfidenceIds = (request.AcceptedLowConfidenceIds ?? new List<string>()).ToHashSet(StringComparer.Ordinal);
|
||||
var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds);
|
||||
merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
merged.Metadata.AppliedExtractionRunId = run.Id;
|
||||
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", request.ExpectedVersion.Value, HttpContext.RequestAborted);
|
||||
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(merged);
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
|
||||
if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value;
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(merged);
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
|
||||
if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value;
|
||||
|
||||
run.Status = "applied";
|
||||
run.AppliedAtUtc = DateTimeOffset.UtcNow;
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(e => e.Description)));
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged });
|
||||
run.Status = "applied";
|
||||
run.AppliedAtUtc = DateTimeOffset.UtcNow;
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded) throw new CareerProjectionException(string.Join("; ", update.Errors.Select(e => e.Description)));
|
||||
return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged, revision = request.ExpectedVersion.Value + 1 });
|
||||
}, HttpContext.RequestAborted);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Review the import again before accepting." }); }
|
||||
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
|
||||
}
|
||||
|
||||
[HttpPost("runs/{id:int}/discard")]
|
||||
@@ -522,19 +536,20 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
|
||||
|
||||
var source = string.IsNullOrWhiteSpace(request?.Text) ? user.ProfileCvText : request!.Text;
|
||||
var source = string.IsNullOrWhiteSpace(request.Text) ? user.ProfileCvText : request.Text;
|
||||
if (string.IsNullOrWhiteSpace(source)) return BadRequest("Add or import CV text before parsing sections.");
|
||||
|
||||
var normalizedSource = await MaybeReconstructStructuredCvAsync(source, HttpContext.RequestAborted);
|
||||
var structuredCv = await BuildStructuredCvAsync(normalizedSource, HttpContext.RequestAborted);
|
||||
if (string.IsNullOrWhiteSpace(request?.Text))
|
||||
try
|
||||
{
|
||||
user.ProfileCvText = normalizedSource;
|
||||
await ApplyTextExtractionRunAsync(user, "parse", source, normalizedSource, structuredCv, user.CurrentCvUploadArtifactId, request.ExpectedVersion.Value, HttpContext.RequestAborted);
|
||||
return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(normalizedSource), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion, revision = request.ExpectedVersion.Value + 1 });
|
||||
}
|
||||
await ApplyTextExtractionRunAsync(user, "parse", source, normalizedSource, structuredCv, user.CurrentCvUploadArtifactId, HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(normalizedSource), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion });
|
||||
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload before parsing." }); }
|
||||
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
|
||||
}
|
||||
|
||||
[HttpPost("improve")]
|
||||
|
||||
@@ -2,7 +2,7 @@ using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
public sealed record ParseCvRequest(string? Text);
|
||||
public sealed record ParseCvRequest(string? Text, int? ExpectedVersion = null);
|
||||
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets);
|
||||
public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId);
|
||||
public sealed record CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -72,10 +72,35 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<AccountDeletionRequest> AccountDeletionRequests => Set<AccountDeletionRequest>();
|
||||
public DbSet<AccountDeletionFile> AccountDeletionFiles => Set<AccountDeletionFile>();
|
||||
|
||||
private void AdvanceJobRevisions()
|
||||
{
|
||||
ChangeTracker.DetectChanges();
|
||||
foreach (var entry in ChangeTracker.Entries<JobApplication>().Where(x => x.State == EntityState.Modified))
|
||||
{
|
||||
// All writers share this guard, including partial updates and background workers.
|
||||
entry.Entity.Version = checked(entry.Property(x => x.Version).OriginalValue + 1);
|
||||
}
|
||||
}
|
||||
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
AdvanceJobRevisions();
|
||||
return base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
|
||||
{
|
||||
AdvanceJobRevisions();
|
||||
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<JobApplication>().Property(x => x.Version).IsConcurrencyToken().HasDefaultValue(1);
|
||||
modelBuilder.Entity<CareerProfile>().Property(x => x.Version).IsConcurrencyToken();
|
||||
|
||||
modelBuilder.Entity<ApplicationUser>()
|
||||
.Property(x => x.PendingEmail)
|
||||
.HasMaxLength(320);
|
||||
@@ -101,6 +126,15 @@ namespace JobTrackerApi.Data
|
||||
.Property(x => x.UiLanguage)
|
||||
.HasMaxLength(16);
|
||||
|
||||
modelBuilder.Entity<ApplicationUser>()
|
||||
.Property(x => x.DashboardLayoutRevision)
|
||||
.HasDefaultValue(0L);
|
||||
// Layout writes use the revision-guarded endpoint; Identity's whole-user update must not replay stale values.
|
||||
modelBuilder.Entity<ApplicationUser>().Property(x => x.DashboardLayoutJson)
|
||||
.Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
|
||||
modelBuilder.Entity<ApplicationUser>().Property(x => x.DashboardLayoutRevision)
|
||||
.Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
|
||||
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerKey).HasMaxLength(64);
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.RequestedByUserId).HasMaxLength(255);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2026 The Arimo Project Authors (https://github.com/googlefonts/Arimo)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Gelasio Project Authors (https://github.com/SorkinType/Gelasio)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,203 @@
|
||||
{
|
||||
"googleFontsCommit": "e7db6ca603fd4862010383ff17ffd6cc4ffac1c0",
|
||||
"selawikRelease": "https://github.com/microsoft/Selawik/releases/tag/1.01",
|
||||
"sourceEvidence": ".artifacts/p9-remed-02/fonts/provenance.json",
|
||||
"fontFilesUnmodified": true,
|
||||
"files": [
|
||||
{
|
||||
"file": "Arimo-Regular.ttf",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/Arimo/4a6255f269916ae7ad3fc2706b0935e7621396b8/fonts/ttf/Arimo-Regular.ttf",
|
||||
"sha256": "41b22bc8f0b51f932825d37bc55b5eb6ba67dfe599a626e4aff2b43b624f9f8c"
|
||||
},
|
||||
{
|
||||
"file": "Arimo-Italic.ttf",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/Arimo/4a6255f269916ae7ad3fc2706b0935e7621396b8/fonts/ttf/Arimo-Italic.ttf",
|
||||
"sha256": "ffdf690e4357a392e4edcc3df0b3adb0302920f75e54f2e6eec355afbe7edcba"
|
||||
},
|
||||
{
|
||||
"file": "Arimo-Bold.ttf",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/Arimo/4a6255f269916ae7ad3fc2706b0935e7621396b8/fonts/ttf/Arimo-Bold.ttf",
|
||||
"sha256": "d7a8b187cf8444d4cfee102e8eae9e3043682fd5106d5d33ed677fe268a0e2ba"
|
||||
},
|
||||
{
|
||||
"file": "Arimo-BoldItalic.ttf",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/Arimo/4a6255f269916ae7ad3fc2706b0935e7621396b8/fonts/ttf/Arimo-BoldItalic.ttf",
|
||||
"sha256": "77c967d49d4ef4a63c39fbcd1e27892102e503e35a20983cb058430c4f268b34"
|
||||
},
|
||||
{
|
||||
"file": "arimo.OFL.txt",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/Arimo/4a6255f269916ae7ad3fc2706b0935e7621396b8/OFL.txt",
|
||||
"sha256": "c46fda6859efb80c0a3c1faa868908a3d2fcb26eb7fd9602d73bebeec21bca3a"
|
||||
},
|
||||
{
|
||||
"file": "Gelasio-Regular.ttf",
|
||||
"source": "https://raw.githubusercontent.com/SorkinType/Gelasio/7ab20e7e5c42791e603b9ee3201a0b49849cfdb2/fonts/ttf/Gelasio-Regular.ttf",
|
||||
"sha256": "48c797fbe0e07c48a18cb962e7bdfa23f19618327dddf54093265328dc9eb39d"
|
||||
},
|
||||
{
|
||||
"file": "Gelasio-Italic.ttf",
|
||||
"source": "https://raw.githubusercontent.com/SorkinType/Gelasio/7ab20e7e5c42791e603b9ee3201a0b49849cfdb2/fonts/ttf/Gelasio-Italic.ttf",
|
||||
"sha256": "1d8bda4ff258bb8251eae4f7d7500c8d97d084136192bb264bc2305168765d1b"
|
||||
},
|
||||
{
|
||||
"file": "Gelasio-SemiBold.ttf",
|
||||
"source": "https://raw.githubusercontent.com/SorkinType/Gelasio/7ab20e7e5c42791e603b9ee3201a0b49849cfdb2/fonts/ttf/Gelasio-SemiBold.ttf",
|
||||
"sha256": "f5eb3f3e395d1958bef5449bacda5e815aeab40cf40a24f71c2ad163c196a6ba"
|
||||
},
|
||||
{
|
||||
"file": "Gelasio-SemiBoldItalic.ttf",
|
||||
"source": "https://raw.githubusercontent.com/SorkinType/Gelasio/7ab20e7e5c42791e603b9ee3201a0b49849cfdb2/fonts/ttf/Gelasio-SemiBoldItalic.ttf",
|
||||
"sha256": "9ab9c9363546116bea8296a66e0bde426cdcecebafc406c417cd4ade4188f48c"
|
||||
},
|
||||
{
|
||||
"file": "Gelasio-Bold.ttf",
|
||||
"source": "https://raw.githubusercontent.com/SorkinType/Gelasio/7ab20e7e5c42791e603b9ee3201a0b49849cfdb2/fonts/ttf/Gelasio-Bold.ttf",
|
||||
"sha256": "e0ef3addf1acf35f5c6aef2be00d0a2c01363bf70a5950e16650976051a0c462"
|
||||
},
|
||||
{
|
||||
"file": "Gelasio-BoldItalic.ttf",
|
||||
"source": "https://raw.githubusercontent.com/SorkinType/Gelasio/7ab20e7e5c42791e603b9ee3201a0b49849cfdb2/fonts/ttf/Gelasio-BoldItalic.ttf",
|
||||
"sha256": "aa87e21b46aef3dcb50a68dc6e1946f0c0a9819d067e4be6a98a6daa8f5b3c16"
|
||||
},
|
||||
{
|
||||
"file": "gelasio.OFL.txt",
|
||||
"source": "https://raw.githubusercontent.com/SorkinType/Gelasio/7ab20e7e5c42791e603b9ee3201a0b49849cfdb2/OFL.txt",
|
||||
"sha256": "b393cb01867c919b44381512120dc3e4c954c7b47e2035c405f3a324799a4d29"
|
||||
},
|
||||
{
|
||||
"file": "RobotoMono-Regular.ttf",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/RobotoMono/895ec691990d041dd727c7b5afa3ce56525d98e6/fonts/ttf/RobotoMono-Regular.ttf",
|
||||
"sha256": "af0bff7599c3df3831755c16e39b3c496df74b8c8d8a1161b14dc8461be17cb4"
|
||||
},
|
||||
{
|
||||
"file": "RobotoMono-Italic.ttf",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/RobotoMono/895ec691990d041dd727c7b5afa3ce56525d98e6/fonts/ttf/RobotoMono-Italic.ttf",
|
||||
"sha256": "4549325cd2d10938d37d63eba2aaca7c2e16e48322dc767576eab45e512b6ad2"
|
||||
},
|
||||
{
|
||||
"file": "RobotoMono-Medium.ttf",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/RobotoMono/895ec691990d041dd727c7b5afa3ce56525d98e6/fonts/ttf/RobotoMono-Medium.ttf",
|
||||
"sha256": "4acbb2725a260eafff1bcaade3501204ca774e56346e259ee1b61d56996d2611"
|
||||
},
|
||||
{
|
||||
"file": "RobotoMono-MediumItalic.ttf",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/RobotoMono/895ec691990d041dd727c7b5afa3ce56525d98e6/fonts/ttf/RobotoMono-MediumItalic.ttf",
|
||||
"sha256": "e01593c1cf8034729deb4ebe412d19884907ca1156962169bca624ef139d827a"
|
||||
},
|
||||
{
|
||||
"file": "RobotoMono-Bold.ttf",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/RobotoMono/895ec691990d041dd727c7b5afa3ce56525d98e6/fonts/ttf/RobotoMono-Bold.ttf",
|
||||
"sha256": "3ecf35e5e87accc7578b605d1f5f0bc30d88b195d6807bec8a0c57f6aa95c4db"
|
||||
},
|
||||
{
|
||||
"file": "RobotoMono-BoldItalic.ttf",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/RobotoMono/895ec691990d041dd727c7b5afa3ce56525d98e6/fonts/ttf/RobotoMono-BoldItalic.ttf",
|
||||
"sha256": "a0f16567447311eaf42a35f6c50eb64b911694b42f1b01038e3b7e92c20f131d"
|
||||
},
|
||||
{
|
||||
"file": "robotomono.OFL.txt",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/RobotoMono/895ec691990d041dd727c7b5afa3ce56525d98e6/OFL.txt",
|
||||
"sha256": "50ab8dd54680d3473f649c9db86fece88434d097c7834475c1c72d2f8c429215"
|
||||
},
|
||||
{
|
||||
"file": "Roboto-Regular.ttf",
|
||||
"source": "https://github.com/googlefonts/roboto-3-classic/releases/download/v3.016/Roboto_v3.016.zip#android/static/Roboto-Regular.ttf",
|
||||
"sha256": "e688a215e0841b6e4edb1207c93f88f4c609f82e870884349a7257e449eb9355"
|
||||
},
|
||||
{
|
||||
"file": "Roboto-Italic.ttf",
|
||||
"source": "https://github.com/googlefonts/roboto-3-classic/releases/download/v3.016/Roboto_v3.016.zip#android/static/Roboto-Italic.ttf",
|
||||
"sha256": "1f4b29f2e9c648707620a0768f4030df7abb15c25383fb73d088fe2624724f46"
|
||||
},
|
||||
{
|
||||
"file": "Roboto-Medium.ttf",
|
||||
"source": "https://github.com/googlefonts/roboto-3-classic/releases/download/v3.016/Roboto_v3.016.zip#android/static/Roboto-Medium.ttf",
|
||||
"sha256": "3e4dca65eead6ff95f6963ab1f489d055086bcc5a62b2afed5d72e62b0c04ba5"
|
||||
},
|
||||
{
|
||||
"file": "Roboto-MediumItalic.ttf",
|
||||
"source": "https://github.com/googlefonts/roboto-3-classic/releases/download/v3.016/Roboto_v3.016.zip#android/static/Roboto-MediumItalic.ttf",
|
||||
"sha256": "bf74cbbe08685994cb28404dc50ecdb38816b8e7062c52f3a7af5e4f0c7042dc"
|
||||
},
|
||||
{
|
||||
"file": "Roboto-Bold.ttf",
|
||||
"source": "https://github.com/googlefonts/roboto-3-classic/releases/download/v3.016/Roboto_v3.016.zip#android/static/Roboto-Bold.ttf",
|
||||
"sha256": "9aa793ad5e12c3b2486464ec6f047f16ac6d30bb06c8b4d162ba313459794968"
|
||||
},
|
||||
{
|
||||
"file": "Roboto-BoldItalic.ttf",
|
||||
"source": "https://github.com/googlefonts/roboto-3-classic/releases/download/v3.016/Roboto_v3.016.zip#android/static/Roboto-BoldItalic.ttf",
|
||||
"sha256": "8942bdf86849104971ccc41f038a91cc7e84a6b04a0df718fca6cac0a8c4e44a"
|
||||
},
|
||||
{
|
||||
"file": "roboto.OFL.txt",
|
||||
"source": "https://raw.githubusercontent.com/googlefonts/roboto-3-classic/5166f3d07889bf7d3732fb72e09623d7e52f862b/OFL.txt",
|
||||
"sha256": "061402327a96aadb0bfb694a960ed289ecd38d383e396243831ab81feb109c41"
|
||||
},
|
||||
{
|
||||
"family": "poppins",
|
||||
"file": "Poppins-Bold.ttf",
|
||||
"sha256": "983676516167748b74de6f4771fb384c664fd913acb8b471122ecacf5da5ea6c",
|
||||
"source": "https://raw.githubusercontent.com/google/fonts/e7db6ca603fd4862010383ff17ffd6cc4ffac1c0/ofl/poppins/Poppins-Bold.ttf"
|
||||
},
|
||||
{
|
||||
"family": "poppins",
|
||||
"file": "Poppins-BoldItalic.ttf",
|
||||
"sha256": "3572ac8116a0ac7317d342262b29937bcbaf94d8f03f90df6fe666fa7e2fb43a",
|
||||
"source": "https://raw.githubusercontent.com/google/fonts/e7db6ca603fd4862010383ff17ffd6cc4ffac1c0/ofl/poppins/Poppins-BoldItalic.ttf"
|
||||
},
|
||||
{
|
||||
"family": "poppins",
|
||||
"file": "Poppins-Italic.ttf",
|
||||
"sha256": "4fa76ae75b40f926420514044722cb97f32186cafd3b38263cc34dad7174d46d",
|
||||
"source": "https://raw.githubusercontent.com/google/fonts/e7db6ca603fd4862010383ff17ffd6cc4ffac1c0/ofl/poppins/Poppins-Italic.ttf"
|
||||
},
|
||||
{
|
||||
"family": "poppins",
|
||||
"file": "Poppins-Regular.ttf",
|
||||
"sha256": "7e65201e9b79159e2300267cc885e16c8dcef2424cdfa09a29bfb0980a94a7ba",
|
||||
"source": "https://raw.githubusercontent.com/google/fonts/e7db6ca603fd4862010383ff17ffd6cc4ffac1c0/ofl/poppins/Poppins-Regular.ttf"
|
||||
},
|
||||
{
|
||||
"family": "poppins",
|
||||
"file": "Poppins-SemiBold.ttf",
|
||||
"sha256": "d3bf1bdaf0550e83da9ac0b1d1d9fe6db086835a83aa28578e609a394b9a0286",
|
||||
"source": "https://raw.githubusercontent.com/google/fonts/e7db6ca603fd4862010383ff17ffd6cc4ffac1c0/ofl/poppins/Poppins-SemiBold.ttf"
|
||||
},
|
||||
{
|
||||
"family": "poppins",
|
||||
"file": "Poppins-SemiBoldItalic.ttf",
|
||||
"sha256": "16bb118aa232c9a13fa238027d24d7854dd1a1d9cbaf99b17fec4388d56b432c",
|
||||
"source": "https://raw.githubusercontent.com/google/fonts/e7db6ca603fd4862010383ff17ffd6cc4ffac1c0/ofl/poppins/Poppins-SemiBoldItalic.ttf"
|
||||
},
|
||||
{
|
||||
"family": "selawik",
|
||||
"file": "selawk.ttf",
|
||||
"sha256": "e9d98518d8ac2817782a9a382430463a2e0793ea68350b695bb727d9a830ee1c",
|
||||
"source": "https://github.com/microsoft/Selawik/releases/download/1.01/Selawik_Release.zip#selawk.ttf"
|
||||
},
|
||||
{
|
||||
"family": "selawik",
|
||||
"file": "selawkb.ttf",
|
||||
"sha256": "f0db5e174a90e0956ad7d2844bdca1d5e6da92ec65b2c04e57ba9b180668c904",
|
||||
"source": "https://github.com/microsoft/Selawik/releases/download/1.01/Selawik_Release.zip#selawkb.ttf"
|
||||
},
|
||||
{
|
||||
"family": "selawik",
|
||||
"file": "selawksb.ttf",
|
||||
"sha256": "0a9e9d0549a10f24bef9b3a29e06fe6e0b5c21e7a784c503b048a307841a7783",
|
||||
"source": "https://github.com/microsoft/Selawik/releases/download/1.01/Selawik_Release.zip#selawksb.ttf"
|
||||
},
|
||||
{
|
||||
"file": "poppins.OFL.txt",
|
||||
"source": "https://raw.githubusercontent.com/google/fonts/e7db6ca603fd4862010383ff17ffd6cc4ffac1c0/ofl/poppins/OFL.txt",
|
||||
"sha256": "6be04893d770899a015649c7aa3b582f871b272f8747a92b78b17c3e5c8b2573"
|
||||
},
|
||||
{
|
||||
"file": "selawik.OFL.txt",
|
||||
"source": "https://raw.githubusercontent.com/microsoft/Selawik/89362e84731d1f5777fa078fd4d5ebbd339e4378/LICENSE.txt",
|
||||
"sha256": "77b7c2506d4efb22e09c8ccf10159f4956eab3ef7c007fef95de136bcf45300c"
|
||||
}
|
||||
],
|
||||
"staticFontReason": "Chromium156 emits variable-font Type3 descriptors without FontBBox/Ascent/Descent; upstream static originals preserve extractable PDF geometry. No local conversion or font modification."
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 The Roboto Mono Project Authors (https://github.com/googlefonts/robotomono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,91 @@
|
||||
Copyright 2015, Microsoft Corporation (www.microsoft.com), with Reserved Font Name Selawik. All Rights Reserved. Selawik is a trademark of Microsoft Corporation in the United States and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,12 @@
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="DocumentFonts/*.ttf;DocumentFonts/*.txt" LogicalName="CvFonts.%(Filename)%(Extension)" />
|
||||
<Content Include="DocumentFonts/*.txt" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Update="DocumentFonts/provenance.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.14" />
|
||||
@@ -25,6 +31,8 @@
|
||||
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
|
||||
<PackageReference Include="Stripe.net" Version="52.2.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
<!-- Override MimeKit's vulnerable transitive floor; retain its supported 8.x API. -->
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="8.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using JobTrackerApi.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobTrackerApi.Migrations;
|
||||
|
||||
[DbContext(typeof(JobTrackerContext))]
|
||||
[Migration("20261021090000_AddDashboardLayoutPreference")]
|
||||
public partial class AddDashboardLayoutPreference : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
var mysql = ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase);
|
||||
migrationBuilder.AddColumn<string>(name: "DashboardLayoutJson", table: "AspNetUsers", type: mysql ? "longtext" : "TEXT", nullable: true);
|
||||
migrationBuilder.AddColumn<long>(name: "DashboardLayoutRevision", table: "AspNetUsers", type: mysql ? "bigint" : "INTEGER", nullable: false, defaultValue: 0L);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
if (ActiveProvider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// These independent columns need no table rebuild (SQLite 3.35+ supports DROP COLUMN).
|
||||
migrationBuilder.Sql("ALTER TABLE \"AspNetUsers\" DROP COLUMN \"DashboardLayoutJson\";");
|
||||
migrationBuilder.Sql("ALTER TABLE \"AspNetUsers\" DROP COLUMN \"DashboardLayoutRevision\";");
|
||||
return;
|
||||
}
|
||||
migrationBuilder.DropColumn(name: "DashboardLayoutJson", table: "AspNetUsers");
|
||||
migrationBuilder.DropColumn(name: "DashboardLayoutRevision", table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using JobTrackerApi.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobTrackerApi.Migrations;
|
||||
|
||||
[DbContext(typeof(JobTrackerContext))]
|
||||
[Migration("20261023090000_AddJobRevision")]
|
||||
public partial class AddJobRevision : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder) => migrationBuilder.AddColumn<int>(
|
||||
name: "Version", table: "JobApplications",
|
||||
type: ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? "int" : "INTEGER",
|
||||
nullable: false, defaultValue: 1);
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
if (ActiveProvider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
|
||||
migrationBuilder.Sql("ALTER TABLE \"JobApplications\" DROP COLUMN \"Version\";");
|
||||
else
|
||||
migrationBuilder.DropColumn(name: "Version", table: "JobApplications");
|
||||
}
|
||||
}
|
||||
@@ -485,6 +485,16 @@ namespace JobTrackerApi.Migrations
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DashboardLayoutJson")
|
||||
.HasColumnType("TEXT")
|
||||
.Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
|
||||
|
||||
b.Property<long>("DashboardLayoutRevision")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0L)
|
||||
.Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
|
||||
|
||||
b.Property<DateTimeOffset?>("TotpEnabledAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -793,6 +803,7 @@ namespace JobTrackerApi.Migrations
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
@@ -1928,6 +1939,12 @@ namespace JobTrackerApi.Migrations
|
||||
b.Property<string>("TranslatedDescription")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompanyId");
|
||||
|
||||
@@ -35,6 +35,8 @@ public sealed class ApplicationUser : IdentityUser
|
||||
public bool EmailFollowUpRemindersEnabled { get; set; } = true;
|
||||
// BCP 47 application UI preference. Null/legacy values safely fall back to English.
|
||||
public string? UiLanguage { get; set; }
|
||||
public string? DashboardLayoutJson { get; set; }
|
||||
public long DashboardLayoutRevision { get; set; }
|
||||
public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active;
|
||||
public DateTimeOffset? DeletionRequestedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed record DashboardWidget(string Id, bool Visible, string Size);
|
||||
public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList<DashboardWidget> Widgets)
|
||||
{
|
||||
public const int MaxBytes = 16 * 1024;
|
||||
// Responses cross a JavaScript client; the incremented revision must remain an exact integer there.
|
||||
private const long MaxRevision = 9_007_199_254_740_991L;
|
||||
private static readonly HashSet<string> KnownIds = new(StringComparer.Ordinal)
|
||||
{ "cards", "attention", "funnel", "activity", "stageAge", "sources", "salary", "companies", "skills" };
|
||||
|
||||
public static DashboardLayout? Parse(JsonElement root, bool reading = false, long revision = 0)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var v) || v != 1
|
||||
|| !root.TryGetProperty("widgets", out var widgets) || widgets.ValueKind != JsonValueKind.Array || widgets.GetArrayLength() > 32) return null;
|
||||
if (!reading && (!root.TryGetProperty("revision", out var rev) || rev.ValueKind != JsonValueKind.Number || !rev.TryGetInt64(out revision) || revision < 0 || revision >= MaxRevision)) return null;
|
||||
var result = new List<DashboardWidget>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var widget in widgets.EnumerateArray())
|
||||
{
|
||||
if (widget.ValueKind != JsonValueKind.Object || !widget.TryGetProperty("id", out var idValue) || idValue.ValueKind != JsonValueKind.String
|
||||
|| !KnownIds.Contains(idValue.GetString()!) || !seen.Add(idValue.GetString()!)
|
||||
|| !widget.TryGetProperty("visible", out var visible) || (visible.ValueKind != JsonValueKind.True && visible.ValueKind != JsonValueKind.False))
|
||||
{
|
||||
if (reading) continue;
|
||||
return null;
|
||||
}
|
||||
var id = idValue.GetString()!;
|
||||
var size = widget.TryGetProperty("size", out var sizeValue) && sizeValue.ValueKind == JsonValueKind.String ? sizeValue.GetString() : null;
|
||||
if (size is not ("standard" or "full") || (id == "cards" && size != "full"))
|
||||
{
|
||||
if (!reading) return null;
|
||||
size = id == "cards" ? "full" : "standard";
|
||||
}
|
||||
result.Add(new DashboardWidget(id, visible.GetBoolean(), size));
|
||||
}
|
||||
return new DashboardLayout(1, revision, result);
|
||||
}
|
||||
|
||||
public static DashboardLayout Read(string? json, long revision)
|
||||
{
|
||||
if (json is not null && Encoding.UTF8.GetByteCount(json) <= MaxBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& document.RootElement.TryGetProperty("version", out var version) && version.ValueKind == JsonValueKind.Number
|
||||
&& version.TryGetInt32(out var futureVersion) && futureVersion > 1)
|
||||
return new(futureVersion, revision, []);
|
||||
return Parse(document.RootElement, reading: true, revision) ?? new(1, revision, []);
|
||||
}
|
||||
catch (JsonException) { }
|
||||
}
|
||||
return new(1, revision, []);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace JobTrackerApi.Models
|
||||
public class JobApplication
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Version { get; set; } = 1;
|
||||
public string? OwnerUserId { get; set; }
|
||||
public string JobTitle { get; set; } = "";
|
||||
public int CompanyId { get; set; }
|
||||
|
||||
@@ -97,31 +97,7 @@ builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
|
||||
var cfg = sp.GetRequiredService<IConfiguration>();
|
||||
var paths = sp.GetRequiredService<AppPaths>();
|
||||
|
||||
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.
|
||||
@@ -148,6 +124,7 @@ builder.Services.AddCors(options =>
|
||||
policy.WithOrigins(corsOrigins.Select(x => x.Trim().TrimEnd('/')).Where(x => x.Length > 0).ToArray())
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.WithExposedHeaders("X-Job-Version")
|
||||
.AllowCredentials();
|
||||
});
|
||||
});
|
||||
@@ -160,6 +137,7 @@ builder.Services.AddProblemDetails(options =>
|
||||
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
|
||||
});
|
||||
builder.Services.AddExceptionHandler<AiUsageLimitExceptionHandler>();
|
||||
builder.Services.AddExceptionHandler<RevisionConflictExceptionHandler>();
|
||||
builder.Services.AddExceptionHandler<PdfRenderExceptionHandler>();
|
||||
builder.Services.AddOpenApi();
|
||||
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
|
||||
|
||||
@@ -41,7 +41,8 @@ public sealed record CoverLetterDto(
|
||||
string? Text,
|
||||
int CurrentVersion,
|
||||
IReadOnlyList<CoverLetterVersionDto> Versions,
|
||||
int AiSuggestionCount);
|
||||
int AiSuggestionCount,
|
||||
int Revision = 1);
|
||||
|
||||
public interface IApplicationAssetsService
|
||||
{
|
||||
@@ -50,8 +51,8 @@ public interface IApplicationAssetsService
|
||||
Task<TailoringPlanDto?> GetTailoringPlanAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
|
||||
|
||||
Task<CoverLetterDto?> GetCoverLetterAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
|
||||
Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct);
|
||||
Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct);
|
||||
Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct);
|
||||
Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
@@ -237,7 +238,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
// Every save snapshots the PREVIOUS text first, so an AI rewrite can always be undone. The user's
|
||||
// text is what gets stored — an AI suggestion only becomes a version once the user saves it, which
|
||||
// is what "requires approval" means here.
|
||||
public async Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct)
|
||||
public async Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||||
@@ -246,9 +247,15 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
var next = text?.Trim() ?? string.Empty;
|
||||
var current = job.CoverLetterText?.Trim() ?? string.Empty;
|
||||
|
||||
if (expectedVersion is null or < 1 || expectedVersion != job.Version)
|
||||
throw new DbUpdateConcurrencyException("The application changed after this draft was loaded.");
|
||||
|
||||
// Nothing changed: do not spend a version on a no-op save (autosave calls this often).
|
||||
if (string.Equals(next, current, StringComparison.Ordinal))
|
||||
{
|
||||
// A no-op still consumes the source revision so an overlapping write cannot be acknowledged as this draft.
|
||||
_db.Entry(job).Property(x => x.Version).IsModified = true;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await BuildCoverLetterAsync(ownerUserId, job, ct);
|
||||
}
|
||||
|
||||
@@ -274,7 +281,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
|
||||
// Restore is non-destructive: the old text comes back as a NEW version, so the thing you restored
|
||||
// from is still in the history.
|
||||
public async Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct)
|
||||
public async Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||||
@@ -284,7 +291,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
.FirstOrDefaultAsync(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId && v.Version == version, ct);
|
||||
if (snapshot is null) return null;
|
||||
|
||||
return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, ct);
|
||||
return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, expectedVersion, ct);
|
||||
}
|
||||
|
||||
private async Task<CoverLetterDto> BuildCoverLetterAsync(string ownerUserId, JobApplication job, CancellationToken ct)
|
||||
@@ -304,7 +311,8 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
current,
|
||||
versions.Select(v => new CoverLetterVersionDto(
|
||||
v.Version, v.Source, v.AiAction, v.Text.Length, v.CreatedAtUtc, v.Version == current)).ToList(),
|
||||
aiCount);
|
||||
aiCount,
|
||||
job.Version);
|
||||
}
|
||||
|
||||
private async Task<int> NextVersionAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
|
||||
|
||||
@@ -47,7 +47,8 @@ public sealed record WorkspaceOverviewDto(
|
||||
DateTimeOffset? LastAiAtUtc,
|
||||
IReadOnlyList<WorkspaceActivityDto> RecentActivity,
|
||||
WorkspaceNextStepDto? NextStep,
|
||||
ChecklistProgressDto? ChecklistProgress);
|
||||
ChecklistProgressDto? ChecklistProgress,
|
||||
int Revision = 1);
|
||||
|
||||
public interface IApplicationWorkspaceService
|
||||
{
|
||||
@@ -147,7 +148,8 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService
|
||||
lastAi,
|
||||
activity,
|
||||
NextStep(checklist),
|
||||
checklist?.Progress);
|
||||
checklist?.Progress,
|
||||
job.Version);
|
||||
}
|
||||
|
||||
// "The user should never ask what to do next." Milestone 2 moved this onto the checklist: the first
|
||||
|
||||
@@ -15,12 +15,17 @@ public interface ICareerProfileService
|
||||
// Mutates the given profile in place (assigns missing item IDs + normalized dates), persists
|
||||
// it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, syncs
|
||||
// the relational children (Experience/Education/Skill/Project/Certification/Language), and
|
||||
// returns the same profile so the caller can go on to serialize it into the legacy column.
|
||||
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken);
|
||||
// returns the same profile. Controller writes include the legacy projection in ExecuteAtomicAsync.
|
||||
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken);
|
||||
|
||||
Task<CareerProfileSnapshot> LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
|
||||
// Includes Identity projection and import metadata in the canonical commit.
|
||||
Task<T> ExecuteAtomicAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken);
|
||||
|
||||
// Reads the master profile from the relational children (the source of truth). If the relational
|
||||
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), it is
|
||||
// backfilled from the blob first. Returns an empty profile if the user has none.
|
||||
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), reads fall
|
||||
// back to that blob without writing. Returns an empty profile if the user has none.
|
||||
Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
|
||||
// The append-only version history, newest first.
|
||||
@@ -29,7 +34,7 @@ public interface ICareerProfileService
|
||||
// Restores a past version by re-saving its snapshot as a NEW version (non-destructive: history
|
||||
// and the current state are both preserved; the restore is itself reversible). Returns the
|
||||
// restored profile, or null if the version does not exist.
|
||||
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken);
|
||||
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken);
|
||||
|
||||
// Read-only structured load for a specific owner, bypassing the tenant query filter. For public
|
||||
// CV rendering (/cv/{slug}) where there is no authenticated current user. Never writes, never
|
||||
@@ -38,6 +43,8 @@ public interface ICareerProfileService
|
||||
}
|
||||
|
||||
public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
|
||||
public sealed record CareerProfileSnapshot(StructuredCvProfile Profile, int Revision, string? CvText = null);
|
||||
public sealed class CareerProjectionException(string message) : Exception(message);
|
||||
|
||||
public sealed class CareerProfileService : ICareerProfileService
|
||||
{
|
||||
@@ -48,63 +55,119 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken)
|
||||
public async Task<T> ExecuteAtomicAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken)
|
||||
{
|
||||
AssignStableIds(profile);
|
||||
NormalizeDates(profile);
|
||||
|
||||
// Publish the profile row, relational children, and history revision atomically. Without
|
||||
// this boundary a concurrent outline/read can observe the new profile before its children,
|
||||
// enter the legacy backfill path, and insert a second copy of every child row.
|
||||
await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
|
||||
? await _db.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var json = StructuredCvProfileJson.SerializePersisted(profile);
|
||||
|
||||
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null)
|
||||
try
|
||||
{
|
||||
existing = new CareerProfile
|
||||
var result = await action();
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
if (transaction is not null) await transaction.RollbackAsync(CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A later SaveChanges must never publish an operation whose response failed.
|
||||
_db.ChangeTracker.Clear();
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CareerProfileSnapshot> LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Hold a read snapshot across the canonical row and relational children.
|
||||
await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
|
||||
? await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken)
|
||||
: null;
|
||||
var row = await _db.CareerProfiles.AsNoTracking().Where(x => x.OwnerUserId == ownerUserId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Version,
|
||||
CvText = _db.Users.Where(user => user.Id == ownerUserId).Select(user => user.ProfileCvText).FirstOrDefault(),
|
||||
}).FirstOrDefaultAsync(cancellationToken);
|
||||
var snapshot = row is null ? new CareerProfileSnapshot(new StructuredCvProfile(), 0,
|
||||
await _db.Users.Where(user => user.Id == ownerUserId).Select(user => user.ProfileCvText).FirstOrDefaultAsync(cancellationToken))
|
||||
: new(await LoadStructuredAsync(ownerUserId, cancellationToken), row.Version, row.CvText);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken) =>
|
||||
ExecuteAtomicAsync(async () =>
|
||||
{
|
||||
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (expectedVersion < 0 || (existing?.Version ?? 0) != expectedVersion)
|
||||
throw new DbUpdateConcurrencyException("The career profile changed after it was loaded.");
|
||||
|
||||
AssignStableIds(profile);
|
||||
NormalizeDates(profile);
|
||||
|
||||
var json = StructuredCvProfileJson.SerializePersisted(profile);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new CareerProfile
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
ProfileJson = json,
|
||||
Version = 1,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.CareerProfiles.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ProfileJson = json;
|
||||
existing.Version += 1;
|
||||
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (expectedVersion == 0 && IsDuplicateOwner(ex))
|
||||
{
|
||||
throw new DbUpdateConcurrencyException("The career profile was created by another request.", ex);
|
||||
}
|
||||
|
||||
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken);
|
||||
|
||||
_db.CareerProfileVersions.Add(new CareerProfileVersion
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
CareerProfileId = existing.Id,
|
||||
Version = existing.Version,
|
||||
ProfileJson = json,
|
||||
Version = 1,
|
||||
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.CareerProfiles.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ProfileJson = json;
|
||||
existing.Version += 1;
|
||||
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
});
|
||||
return profile;
|
||||
}, cancellationToken);
|
||||
|
||||
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken);
|
||||
|
||||
_db.CareerProfileVersions.Add(new CareerProfileVersion
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
CareerProfileId = existing.Id,
|
||||
Version = existing.Version,
|
||||
ProfileJson = json,
|
||||
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
return profile;
|
||||
}
|
||||
private static bool IsDuplicateOwner(DbUpdateException exception) => exception.InnerException switch
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteException sqlite => sqlite.SqliteErrorCode == 19
|
||||
&& sqlite.Message.Contains("UNIQUE constraint failed: CareerProfiles.OwnerUserId", StringComparison.Ordinal),
|
||||
MySqlConnector.MySqlException mysql => mysql.Number == 1062
|
||||
&& mysql.Message.Contains("IX_CareerProfiles_OwnerUserId", StringComparison.Ordinal),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
public async Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
var profile = await _db.CareerProfiles.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (profile is null) return new StructuredCvProfile();
|
||||
|
||||
var hasRelational = await _db.CareerExperiences.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|
||||
@@ -114,21 +177,19 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
|| await _db.CareerCertifications.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|
||||
|| await _db.CareerLanguages.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken);
|
||||
|
||||
// Backfill a pre-Phase-3 profile from its blob, once, before reading relationally.
|
||||
// A read cannot materialize unversioned children during a concurrent replacement.
|
||||
if (!hasRelational && !string.IsNullOrWhiteSpace(profile.ProfileJson))
|
||||
{
|
||||
var fromBlob = StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson);
|
||||
AssignStableIds(fromBlob);
|
||||
NormalizeDates(fromBlob);
|
||||
await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
|
||||
return fromBlob;
|
||||
}
|
||||
|
||||
var experiences = UniqueChildren(await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var education = UniqueChildren(await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var skills = UniqueChildren(await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var projects = UniqueChildren(await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var certifications = UniqueChildren(await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var languages = UniqueChildren(await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var experiences = UniqueChildren(await _db.CareerExperiences.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var education = UniqueChildren(await _db.CareerEducations.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var skills = UniqueChildren(await _db.CareerSkills.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var projects = UniqueChildren(await _db.CareerProjects.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var certifications = UniqueChildren(await _db.CareerCertifications.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var languages = UniqueChildren(await _db.CareerLanguages.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
|
||||
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
|
||||
}
|
||||
@@ -170,7 +231,7 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
return versions.Select(x => new CareerProfileVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == profile.Version)).ToList();
|
||||
}
|
||||
|
||||
public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken)
|
||||
public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (profile is null) return null;
|
||||
@@ -181,7 +242,7 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
// Re-save the old snapshot as a new version. Non-destructive: the current state stays in
|
||||
// history, so a restore can itself be undone by restoring the version before it.
|
||||
var restored = StructuredCvProfileJson.DeserializePersisted(target.ProfileJson);
|
||||
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", cancellationToken);
|
||||
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", expectedVersion, cancellationToken);
|
||||
}
|
||||
|
||||
// Replace-all: the profile is small and edited as a whole, so wiping and re-inserting the
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
// Self-contained document fonts keep local-file PDF export and srcDoc preview on the same metrics.
|
||||
internal static class CvDocumentFonts
|
||||
{
|
||||
private static readonly Dictionary<string, string> Styles = new()
|
||||
{
|
||||
["Selawik"] = Faces("selawik", ("selawk.ttf", "400", "normal"), ("selawksb.ttf", "600", "normal"), ("selawkb.ttf", "700", "normal")),
|
||||
["Arimo"] = Faces("arimo", ("Arimo-Regular.ttf", "400", "normal"), ("Arimo-Italic.ttf", "400", "italic"), ("Arimo-Bold.ttf", "700", "normal"), ("Arimo-BoldItalic.ttf", "700", "italic")),
|
||||
["Gelasio"] = Faces("gelasio", ("Gelasio-Regular.ttf", "400", "normal"), ("Gelasio-Italic.ttf", "400", "italic"), ("Gelasio-SemiBold.ttf", "600", "normal"), ("Gelasio-SemiBoldItalic.ttf", "600", "italic"), ("Gelasio-Bold.ttf", "700", "normal"), ("Gelasio-BoldItalic.ttf", "700", "italic")),
|
||||
["Roboto"] = Faces("roboto", ("Roboto-Regular.ttf", "400", "normal"), ("Roboto-Italic.ttf", "400", "italic"), ("Roboto-Medium.ttf", "500", "normal"), ("Roboto-MediumItalic.ttf", "500", "italic"), ("Roboto-Bold.ttf", "700", "normal"), ("Roboto-BoldItalic.ttf", "700", "italic")),
|
||||
["Poppins"] = Faces("poppins", ("Poppins-Regular.ttf", "400", "normal"), ("Poppins-Italic.ttf", "400", "italic"), ("Poppins-SemiBold.ttf", "600", "normal"), ("Poppins-SemiBoldItalic.ttf", "600", "italic"), ("Poppins-Bold.ttf", "700", "normal"), ("Poppins-BoldItalic.ttf", "700", "italic")),
|
||||
["Roboto Mono"] = Faces("robotomono", ("RobotoMono-Regular.ttf", "400", "normal"), ("RobotoMono-Italic.ttf", "400", "italic"), ("RobotoMono-Medium.ttf", "500", "normal"), ("RobotoMono-MediumItalic.ttf", "500", "italic"), ("RobotoMono-Bold.ttf", "700", "normal"), ("RobotoMono-BoldItalic.ttf", "700", "italic")),
|
||||
};
|
||||
|
||||
internal static string Family(string stack) => stack.TrimStart() switch
|
||||
{
|
||||
var s when s.StartsWith("Georgia", StringComparison.Ordinal) => "Gelasio",
|
||||
var s when s.StartsWith("'Roboto Mono'", StringComparison.Ordinal) || s.StartsWith("Consolas", StringComparison.Ordinal) => "Roboto Mono",
|
||||
var s when s.StartsWith("'Roboto'", StringComparison.Ordinal) => "Roboto",
|
||||
var s when s.StartsWith("'Poppins'", StringComparison.Ordinal) => "Poppins",
|
||||
var s when s.StartsWith("'Segoe UI'", StringComparison.Ordinal) => "Selawik",
|
||||
_ => "Arimo",
|
||||
};
|
||||
|
||||
internal static string Stack(string original) => $"'{Family(original)}', {original}";
|
||||
|
||||
internal static string Embed(params string[] stacks) => string.Concat(stacks.Select(Family).Distinct().Select(family => Styles[family]));
|
||||
|
||||
private static string Faces(string family, params (string File, string Weight, string Style)[] faces)
|
||||
{
|
||||
var names = new Dictionary<string, string> { ["selawik"] = "Selawik", ["arimo"] = "Arimo", ["gelasio"] = "Gelasio", ["roboto"] = "Roboto", ["poppins"] = "Poppins", ["robotomono"] = "Roboto Mono" };
|
||||
var css = new StringBuilder();
|
||||
using var license = new StreamReader(Resource(family + ".OFL.txt"));
|
||||
css.Append("/*\n").Append(license.ReadToEnd()).Append("\n*/\n");
|
||||
foreach (var face in faces)
|
||||
{
|
||||
using var stream = Resource(face.File);
|
||||
using var bytes = new MemoryStream();
|
||||
stream.CopyTo(bytes);
|
||||
css.Append($"@font-face{{font-family:'{names[family]}';font-style:{face.Style};font-weight:{face.Weight};font-display:block;src:url(data:font/ttf;base64,{Convert.ToBase64String(bytes.ToArray())}) format('truetype');}}\n");
|
||||
}
|
||||
return css.ToString();
|
||||
}
|
||||
|
||||
private static Stream Resource(string name) => typeof(CvDocumentFonts).Assembly.GetManifestResourceStream("CvFonts." + name)
|
||||
?? throw new InvalidOperationException("Required document font resource is missing: " + name);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -59,8 +59,10 @@ public sealed class DocumentsIndexService(JobTrackerContext db)
|
||||
var answers = HasAnswers(sqlite);
|
||||
var recruiter = HasContent("j.RecruiterMessageDraft");
|
||||
var tailored = HasContent("j.TailoredCvText");
|
||||
// Startup-created tables can have different collations; keep the index's folded, accent-sensitive names compatible.
|
||||
string Name(string value) => sqlite ? value : $"CONVERT({value} USING utf8mb4) COLLATE utf8mb4_bin";
|
||||
var sources = $$"""
|
||||
SELECT 'cv' AS Type, CAST(v.Id AS CHAR) AS ResourceId, v.Name AS Name, 'current' AS State,
|
||||
SELECT 'cv' AS Type, CAST(v.Id AS CHAR) AS ResourceId, {{Name("v.Name")}} AS Name, 'current' AS State,
|
||||
CASE WHEN v.IsPublic = 1 THEN 'public' ELSE 'private' END AS Visibility,
|
||||
j.Id AS JobApplicationId, j.JobTitle AS JobTitle, c.Name AS CompanyName, j.IsDeleted AS JobIsDeleted,
|
||||
CAST(v.UpdatedAtUtc AS CHAR) AS RawRecordedAt, 'updated-utc' AS DateKind,
|
||||
@@ -73,7 +75,7 @@ public sealed class DocumentsIndexService(JobTrackerContext db)
|
||||
LEFT JOIN `Companies` c ON c.Id = j.CompanyId AND c.OwnerUserId = {0}
|
||||
WHERE v.OwnerUserId = {0}
|
||||
UNION ALL
|
||||
SELECT 'cover-letter', CAST(j.Id AS CHAR), j.JobTitle, 'draft', NULL,
|
||||
SELECT 'cover-letter', CAST(j.Id AS CHAR), {{Name("j.JobTitle")}}, 'draft', NULL,
|
||||
j.Id, j.JobTitle, c.Name, j.IsDeleted, {{letterDate}},
|
||||
CASE WHEN {{letterDate}} IS NULL THEN 'unknown' ELSE 'history-recorded-utc' END,
|
||||
{{letterVersion}}, {{letterCount}}, NULL, NULL, NULL, NULL,
|
||||
@@ -82,14 +84,14 @@ public sealed class DocumentsIndexService(JobTrackerContext db)
|
||||
LEFT JOIN `Companies` c ON c.Id = j.CompanyId AND c.OwnerUserId = {0}
|
||||
WHERE j.OwnerUserId = {0} AND ({{letterContent}} OR {{letterCount}} > 0)
|
||||
UNION ALL
|
||||
SELECT 'application-material', CAST(j.Id AS CHAR), j.JobTitle, 'draft', NULL,
|
||||
SELECT 'application-material', CAST(j.Id AS CHAR), {{Name("j.JobTitle")}}, 'draft', NULL,
|
||||
j.Id, j.JobTitle, c.Name, j.IsDeleted, NULL, 'unknown',
|
||||
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL
|
||||
FROM `JobApplications` j
|
||||
LEFT JOIN `Companies` c ON c.Id = j.CompanyId AND c.OwnerUserId = {0}
|
||||
WHERE j.OwnerUserId = {0} AND ({{answers}} OR {{recruiter}})
|
||||
UNION ALL
|
||||
SELECT 'tailored-cv', CAST(j.Id AS CHAR), j.JobTitle, 'draft', NULL,
|
||||
SELECT 'tailored-cv', CAST(j.Id AS CHAR), {{Name("j.JobTitle")}}, 'draft', NULL,
|
||||
j.Id, j.JobTitle, c.Name, j.IsDeleted,
|
||||
CAST(COALESCE(d.LastEditedAtUtc, d.LastGeneratedAtUtc, j.TailoredCvUpdatedAt) AS CHAR),
|
||||
CASE WHEN COALESCE(d.LastEditedAtUtc, d.LastGeneratedAtUtc, j.TailoredCvUpdatedAt) IS NULL THEN 'unknown' ELSE 'updated-utc' END,
|
||||
@@ -99,14 +101,14 @@ public sealed class DocumentsIndexService(JobTrackerContext db)
|
||||
LEFT JOIN `TailoredCvDrafts` d ON d.JobApplicationId = j.Id AND d.OwnerUserId = {0}
|
||||
WHERE j.OwnerUserId = {0} AND (d.Id IS NOT NULL OR {{tailored}})
|
||||
UNION ALL
|
||||
SELECT 'attachment', CAST(a.Id AS CHAR), a.FileName, 'current', NULL,
|
||||
SELECT 'attachment', CAST(a.Id AS CHAR), {{Name("a.FileName")}}, 'current', NULL,
|
||||
j.Id, j.JobTitle, c.Name, j.IsDeleted, CAST(a.UploadDate AS CHAR), 'uploaded-local',
|
||||
NULL, NULL, NULL, NULL, NULL, NULL, a.FileType, a.FileSize, a.Purpose, a.UseForAi, NULL, NULL, NULL
|
||||
FROM `Attachments` a
|
||||
JOIN `JobApplications` j ON j.Id = a.JobApplicationId AND j.OwnerUserId = {0}
|
||||
LEFT JOIN `Companies` c ON c.Id = j.CompanyId AND c.OwnerUserId = {0}
|
||||
UNION ALL
|
||||
SELECT 'submitted-package', CAST(p.Id AS CHAR), COALESCE(p.CvVariantName, j.JobTitle), 'frozen', NULL,
|
||||
SELECT 'submitted-package', CAST(p.Id AS CHAR), {{Name("COALESCE(p.CvVariantName, j.JobTitle)")}}, 'frozen', NULL,
|
||||
j.Id, j.JobTitle, c.Name, j.IsDeleted, CAST(p.CreatedAtUtc AS CHAR), 'captured-utc',
|
||||
p.Version, NULL, (SELECT COUNT(*) FROM `SubmittedPackageAttachments` a WHERE a.SubmittedApplicationPackageId = p.Id),
|
||||
p.CvVariantId, p.CvVariantName, p.CvVariantVersion, NULL, NULL, NULL, NULL, NULL, NULL, NULL
|
||||
@@ -115,7 +117,7 @@ public sealed class DocumentsIndexService(JobTrackerContext db)
|
||||
LEFT JOIN `Companies` c ON c.Id = j.CompanyId AND c.OwnerUserId = {0}
|
||||
WHERE p.OwnerUserId = {0}
|
||||
UNION ALL
|
||||
SELECT 'email-draft', CAST(d.Id AS CHAR), d.Subject, 'draft', NULL,
|
||||
SELECT 'email-draft', CAST(d.Id AS CHAR), {{Name("d.Subject")}}, 'draft', NULL,
|
||||
j.Id, j.JobTitle, c.Name, j.IsDeleted, CAST(d.UpdatedAtUtc AS CHAR), 'updated-utc',
|
||||
d.Revision, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, d.Provider, NULL, NULL
|
||||
FROM `EmailDrafts` d
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed class RevisionConflictExceptionHandler : IExceptionHandler
|
||||
{
|
||||
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken)
|
||||
{
|
||||
if (exception is not DbUpdateConcurrencyException) return false;
|
||||
context.Response.StatusCode = StatusCodes.Status409Conflict;
|
||||
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status409Conflict,
|
||||
Title = "The record changed",
|
||||
Detail = "Your draft was not saved. Reload and review the current record before saving again.",
|
||||
Extensions = { ["code"] = "revision_conflict" },
|
||||
}, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -264,8 +264,11 @@ public static class StartupInitializationExtensions
|
||||
var paths = scope.ServiceProvider.GetRequiredService<AppPaths>();
|
||||
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||||
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;");
|
||||
|
||||
// The schema reconciler. Runs TWICE: once before Migrate() and once after.
|
||||
//
|
||||
@@ -317,6 +320,7 @@ public static class StartupInitializationExtensions
|
||||
EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;");
|
||||
// UiLanguage is migration-owned (AddUiLanguagePreference). Adding it here before
|
||||
// the per-migration loop makes a fresh database fail when that migration runs.
|
||||
// DashboardLayoutJson/Revision likewise belong to AddDashboardLayoutPreference.
|
||||
|
||||
static void ReconcileGmailConnectionColumns(DbConnection c)
|
||||
{
|
||||
@@ -515,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;");
|
||||
@@ -536,6 +543,7 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;");
|
||||
// UiLanguage is migration-owned; the final reconciliation pass observes it
|
||||
// after AddUiLanguagePreference instead of racing the migration.
|
||||
// DashboardLayoutJson/Revision are also migration-owned for both providers.
|
||||
|
||||
// RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler
|
||||
// used to create it too, which made a clean install fail with "Table 'RuleSettings'
|
||||
@@ -843,7 +851,7 @@ public static class StartupInitializationExtensions
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
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))
|
||||
|
||||
@@ -43,7 +43,8 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
? SplitColumns(model, theme, sidebarSections, settings)
|
||||
: (string.Empty, RenderSections(model.Sections, theme, settings));
|
||||
|
||||
var css = BuildCss(theme, settings, accent, headerInk, ink, muted, paper, headingColor, headingFont, bodyFont, headingStyle, density, pageDims, layout, twoColumn);
|
||||
var css = CvDocumentFonts.Embed(headingFont, bodyFont, theme.UtilityFont)
|
||||
+ BuildCss(theme, settings, accent, headerInk, ink, muted, paper, headingColor, CvDocumentFonts.Stack(headingFont), CvDocumentFonts.Stack(bodyFont), headingStyle, density, pageDims, layout, twoColumn);
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle);
|
||||
var body = layout switch
|
||||
{
|
||||
@@ -255,7 +256,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
.header-plain{{border-bottom:2px solid {accent};}}";
|
||||
|
||||
var numberedSections = t.NumberSections
|
||||
? $@".main{{counter-reset:cv-section;}} .main>.section>.section-title{{counter-increment:cv-section;font-family:{t.UtilityFont};font-weight:600;}} .main>.section>.section-title::before{{content:counter(cv-section,decimal-leading-zero) ' — ';color:var(--cv-accent-color);}}"
|
||||
? $@".main{{counter-reset:cv-section;}} .main>.section>.section-title{{counter-increment:cv-section;font-family:{CvDocumentFonts.Stack(t.UtilityFont)};font-weight:600;}} .main>.section>.section-title::before{{content:counter(cv-section,decimal-leading-zero) ' — ';color:var(--cv-accent-color);}}"
|
||||
: string.Empty;
|
||||
|
||||
return $@"
|
||||
@@ -304,7 +305,7 @@ h1,h2{{font-family:{headingFont};}}
|
||||
.paragraphs p{{margin:0 0 {F(1.8 * density)}mm;white-space:pre-line;}}
|
||||
.skill-groups{{display:grid;gap:{F(1.7 * density)}mm;}}
|
||||
.skill-group{{display:grid;grid-template-columns:minmax(24mm,.3fr) minmax(0,1fr);gap:3mm;break-inside:avoid-page;page-break-inside:avoid;}}
|
||||
.skill-group-name{{font-family:{t.UtilityFont};font-weight:700;color:var(--cv-accent-color);overflow-wrap:anywhere;}}
|
||||
.skill-group-name{{font-family:{CvDocumentFonts.Stack(t.UtilityFont)};font-weight:700;color:var(--cv-accent-color);overflow-wrap:anywhere;}}
|
||||
.skill-group-items{{display:flex;flex-wrap:wrap;gap:.8mm 3mm;list-style:none;margin:0;padding:0;}}
|
||||
.skill-group-items li{{overflow-wrap:anywhere;}}
|
||||
.entry{{margin-bottom:{entryGap}mm;}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user