feat(applications): preserve submitted packages

This commit is contained in:
cesnimda
2026-08-31 16:54:41 +02:00
parent 47e8e3e50e
commit 5337ba3a5e
22 changed files with 903 additions and 11 deletions
@@ -275,10 +275,12 @@ public sealed class AttachmentConsistencyTests
public bool FailPromote { get; init; }
public bool FailDeletePurge { get; init; }
public string CreateFinalPath(int jobId, string storedFileName) => inner.CreateFinalPath(jobId, storedFileName);
public string CreatePackagePath(int jobId, int packageVersion, string storedFileName) => inner.CreatePackagePath(jobId, packageVersion, storedFileName);
public string StagePath(string finalPath) => inner.StagePath(finalPath);
public string DeletePath(string finalPath) => inner.DeletePath(finalPath);
public bool IsManagedPath(string path) => inner.IsManagedPath(path);
public Task StageAsync(IFormFile file, string stagePath, CancellationToken cancellationToken) => inner.StageAsync(file, stagePath, cancellationToken);
public Task<StoredSnapshotFile> SnapshotAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken) => inner.SnapshotAsync(sourcePath, destinationPath, cancellationToken);
public void Promote(string stagePath, string finalPath)
{
if (FailPromote) throw new IOException("Synthetic promotion failure.");
@@ -428,8 +428,8 @@ public sealed class BackgroundWorkerTenantTests
db.Companies.AddRange(companies);
await db.SaveChangesAsync();
db.JobApplications.AddRange(
new JobApplication { OwnerUserId = "user-1", CompanyId = companies[0].Id, JobTitle = "One", Status = "Applied", DateApplied = appliedAt ?? DateTime.Now.AddDays(-30), Description = "description-user-1" },
new JobApplication { OwnerUserId = "user-2", CompanyId = companies[1].Id, JobTitle = "Two", Status = "Applied", DateApplied = appliedAt ?? DateTime.Now.AddDays(-30), Description = "description-user-2" });
new JobApplication { OwnerUserId = "user-1", CompanyId = companies[0].Id, JobTitle = "One", Status = "Applied", DateApplied = appliedAt ?? FixedNow.DateTime.AddDays(-30), Description = "description-user-1" },
new JobApplication { OwnerUserId = "user-2", CompanyId = companies[1].Id, JobTitle = "Two", Status = "Applied", DateApplied = appliedAt ?? FixedNow.DateTime.AddDays(-30), Description = "description-user-2" });
await db.SaveChangesAsync();
}
@@ -30,6 +30,10 @@ public sealed class MigrationChainTests
SELECT COUNT(*) FROM sqlite_master
WHERE type = 'table' AND name IN ('AspNetUsers', 'AiInteractions', 'AiUsageRecords');
"""));
Assert.Equal(2, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM sqlite_master
WHERE type = 'table' AND name IN ('SubmittedApplicationPackages', 'SubmittedPackageAttachments');
"""));
Assert.Equal(10, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM pragma_table_info('JobApplications')
WHERE name IN ('OwnerUserId', 'ShortSummary', 'TailoredCvText', 'TailoredCvUpdatedAt',
@@ -117,6 +121,8 @@ public sealed class MigrationChainTests
Assert.Contains("CREATE TABLE IF NOT EXISTS `CvVariantVersions`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `ApplicationChecklistItems`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CoverLetterVersions`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `SubmittedApplicationPackages`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `SubmittedPackageAttachments`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `InterviewPrepItems`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProfiles`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProfileVersions`", script, StringComparison.Ordinal);
@@ -0,0 +1,156 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class SubmittedApplicationPackageTests
{
[Fact]
public async Task Storage_snapshot_copies_exact_bytes_and_calculates_sha256()
{
var root = Path.Combine(Path.GetTempPath(), $"jobtracker-package-{Guid.NewGuid():N}");
try
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = root }).Build();
var environment = new Mock<IHostEnvironment>();
environment.SetupGet(x => x.ContentRootPath).Returns(root);
var storage = new AttachmentStorage(new AppPaths(config, environment.Object));
var source = storage.CreateFinalPath(7, "source.txt");
await File.WriteAllTextAsync(source, "exact submitted bytes");
var destination = storage.CreatePackagePath(7, 1, "source.txt");
var result = await storage.SnapshotAsync(source, destination, default);
Assert.Equal("exact submitted bytes", await File.ReadAllTextAsync(destination));
Assert.Equal("32283fd0838392ef9d8c707f521096966432939d5d83624bbfce150cde3ce26c", result.Sha256);
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Fact]
public async Task Capture_freezes_documents_and_attachment_hashes()
{
var temp = Path.GetTempFileName();
await File.WriteAllTextAsync(temp, "exact submitted bytes");
try
{
var (db, service, storage) = New("owner-1");
await using var _ = db;
var job = await SeedAsync(db, "owner-1", temp);
var result = await service.CaptureAsync("owner-1", job.Id, new CvRenderPerson("Ada Example", null), default);
Assert.NotNull(result);
Assert.Equal(1, result.Version);
Assert.True(result.HasCv);
Assert.True(result.HasCoverLetter);
Assert.Single(result.Attachments);
Assert.Equal(new string('a', 64), result.Attachments[0].Sha256);
var stored = await db.SubmittedApplicationPackages.Include(x => x.Attachments).SingleAsync();
Assert.Contains("Frozen CV", stored.RenderedCvHtml);
Assert.Contains("Original profile", stored.CareerProfileJson);
Assert.Contains("Application answer", stored.ApplicationMaterialJson);
storage.Verify(x => x.SnapshotAsync(temp, It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
}
finally
{
File.Delete(temp);
}
}
[Fact]
public async Task Capture_is_tenant_scoped()
{
var (db, service, _) = New("owner-1");
await using var dispose = db;
var job = await SeedAsync(db, "owner-1", null);
Assert.Null(await service.CaptureAsync("owner-2", job.Id, new CvRenderPerson("Other", null), default));
Assert.Empty(db.SubmittedApplicationPackages);
}
[Fact]
public async Task Missing_attachment_aborts_the_whole_snapshot()
{
var (db, service, storage) = New("owner-1");
await using var dispose = db;
var job = await SeedAsync(db, "owner-1", "missing-file.pdf");
storage.Setup(x => x.IsManagedPath(It.IsAny<string>())).Returns(true);
await Assert.ThrowsAsync<IOException>(() => service.CaptureAsync("owner-1", job.Id, new CvRenderPerson("Ada", null), default));
Assert.Empty(db.SubmittedApplicationPackages);
}
private static (JobTrackerContext Db, SubmittedApplicationPackageService Service, Mock<IAttachmentStorage> Storage) New(string userId)
{
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns(userId);
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
var db = new JobTrackerContext(options, currentUser.Object);
var variants = new Mock<ICvVariantService>();
variants.Setup(x => x.RenderAsync(userId, It.IsAny<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ThemedCvRenderResult("nordic", "cv.pdf", "<html>Frozen CV</html>"));
var storage = new Mock<IAttachmentStorage>();
storage.Setup(x => x.IsManagedPath(It.IsAny<string>())).Returns(true);
storage.Setup(x => x.CreatePackagePath(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>())).Returns("snapshot.pdf");
storage.Setup(x => x.SnapshotAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new StoredSnapshotFile("snapshot.pdf", 21, new string('a', 64)));
return (db, new SubmittedApplicationPackageService(db, variants.Object, storage.Object), storage);
}
private static async Task<JobApplication> SeedAsync(JobTrackerContext db, string owner, string? attachmentPath)
{
var company = new Company { OwnerUserId = owner, Name = "Example AS" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication
{
OwnerUserId = owner,
CompanyId = company.Id,
JobTitle = "Engineer",
Status = "Applied",
CoverLetterText = "Exact cover letter",
Notes = "Application answer",
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
db.CareerProfiles.Add(new CareerProfile { OwnerUserId = owner, ProfileJson = "{\"summary\":\"Original profile\"}" });
db.CvVariants.Add(new CvVariant
{
OwnerUserId = owner,
JobApplicationId = job.Id,
Name = "Backend CV",
PublicSlug = Guid.NewGuid().ToString("N"),
Version = 4,
SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings { ThemeId = "nordic" }),
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
});
if (attachmentPath is not null)
{
db.Attachments.Add(new Attachment
{
JobApplicationId = job.Id,
FileName = "evidence.pdf",
FilePath = attachmentPath,
FileType = "application/pdf",
FileSize = 21,
Purpose = "portfolio",
});
}
await db.SaveChangesAsync();
return job;
}
}