feat(cv): qualify templates and reliable local import processing
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class AvatarRetentionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Hourly_retention_removes_old_orphans_but_preserves_current_recent_and_unrelated_files()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "avatar-retention-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var paths = Paths(root);
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var current = await AvatarStorage.StageAsync(root, "owner", new byte[] { 1 }, default);
|
||||
var orphan = await AvatarStorage.StageAsync(root, "owner", new byte[] { 2 }, default);
|
||||
var recent = await AvatarStorage.StageAsync(root, "owner", new byte[] { 3 }, default);
|
||||
var missingOwner = await AvatarStorage.StageAsync(root, "deleted-owner", new byte[] { 4 }, default);
|
||||
var legacyOrphan = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 5 }, "image/webp", default);
|
||||
var legacyCurrent = await AvatarStorage.StoreAsync(root, "legacy-owner", new byte[] { 6 }, "image/jpeg", default);
|
||||
var unrelated = Path.Combine(Path.GetDirectoryName(orphan[5..])!, "unrelated.png");
|
||||
await File.WriteAllTextAsync(unrelated, "keep");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var file in new[] { current, orphan, missingOwner, legacyOrphan, legacyCurrent }) File.SetLastWriteTimeUtc(file[5..], now.AddDays(-2).UtcDateTime);
|
||||
db.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner", AvatarImageDataUrl = current });
|
||||
db.Users.Add(new ApplicationUser { Id = "legacy-owner", UserName = "legacy-owner", AvatarImageDataUrl = legacyCurrent });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await CvImportRetentionHostedService.SweepAsync(db, paths, now, default);
|
||||
|
||||
Assert.True(File.Exists(current[5..]));
|
||||
Assert.True(File.Exists(recent[5..]));
|
||||
Assert.True(File.Exists(unrelated));
|
||||
Assert.False(File.Exists(orphan[5..]));
|
||||
Assert.False(File.Exists(missingOwner[5..]));
|
||||
Assert.False(File.Exists(legacyOrphan[5..]));
|
||||
Assert.True(File.Exists(legacyCurrent[5..]));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Account_inventory_includes_only_its_own_staged_portraits_even_without_a_current_avatar()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "avatar-inventory-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var paths = Paths(root);
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var own = await AvatarStorage.StageAsync(root, "owner", new byte[] { 1 }, default);
|
||||
var other = await AvatarStorage.StageAsync(root, "other", new byte[] { 2 }, default);
|
||||
db.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner" });
|
||||
await db.SaveChangesAsync();
|
||||
var inventory = await new AccountOwnedFileInventory(db, paths, Mock.Of<IAttachmentStorage>()).BuildAsync("owner", default);
|
||||
var avatar = Assert.Single(inventory.Files, file => file.Category == "avatar");
|
||||
Assert.Equal(own[5..], avatar.SourcePath);
|
||||
Assert.DoesNotContain(inventory.Files, file => file.SourcePath == other[5..]);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
private static AppPaths Paths(string root) => new(
|
||||
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = root }).Build(),
|
||||
Mock.Of<IHostEnvironment>(environment => environment.ContentRootPath == root));
|
||||
|
||||
[Theory]
|
||||
[InlineData("image/png")]
|
||||
[InlineData("image/jpeg")]
|
||||
[InlineData("image/webp")]
|
||||
public async Task Account_inventory_covers_exact_legacy_leftovers_after_portrait_replacement(string contentType)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "avatar-legacy-inventory-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var paths = Paths(root);
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var legacy = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1 }, contentType, default);
|
||||
var current = await AvatarStorage.StageAsync(root, "owner", new byte[] { 2 }, default);
|
||||
db.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner", AvatarImageDataUrl = current });
|
||||
await db.SaveChangesAsync();
|
||||
var inventory = await new AccountOwnedFileInventory(db, paths, Mock.Of<IAttachmentStorage>()).BuildAsync("owner", default);
|
||||
Assert.Contains(inventory.Files, file => file.SourcePath == legacy[5..]);
|
||||
Assert.Single(inventory.Files, file => file.SourcePath == current[5..]);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using SkiaSharp;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class AvatarUploadTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(SKEncodedImageFormat.Png)]
|
||||
[InlineData(SKEncodedImageFormat.Jpeg)]
|
||||
[InlineData(SKEncodedImageFormat.Webp)]
|
||||
public void Actual_supported_images_are_decoded_and_normalized_to_metadata_free_png(SKEncodedImageFormat format)
|
||||
{
|
||||
var source = Image(format, 12, 8).Concat(System.Text.Encoding.UTF8.GetBytes("private-camera-metadata")).ToArray();
|
||||
var normalized = AvatarImageProcessor.Normalize(source);
|
||||
Assert.NotNull(normalized);
|
||||
Assert.True(normalized.Length <= 1_000_000);
|
||||
Assert.DoesNotContain("private-camera-metadata", System.Text.Encoding.UTF8.GetString(normalized));
|
||||
using var codec = SKCodec.Create(new MemoryStream(normalized));
|
||||
Assert.Equal(SKEncodedImageFormat.Png, codec.EncodedFormat);
|
||||
Assert.Equal(12, codec.Info.Width);
|
||||
Assert.Equal(8, codec.Info.Height);
|
||||
using var decoded = SKBitmap.Decode(normalized);
|
||||
Assert.NotNull(decoded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2049, 1)]
|
||||
[InlineData(1, 2049)]
|
||||
[InlineData(2001, 2000)]
|
||||
public void Oversized_dimensions_are_rejected(int width, int height) =>
|
||||
Assert.Null(AvatarImageProcessor.Normalize(Image(SKEncodedImageFormat.Png, width, height)));
|
||||
|
||||
[Fact]
|
||||
public void Truncated_pixels_and_unsupported_formats_are_rejected()
|
||||
{
|
||||
var png = Image(SKEncodedImageFormat.Png, 12, 8);
|
||||
Assert.Null(AvatarImageProcessor.Normalize(png[..40]));
|
||||
Assert.Null(AvatarImageProcessor.Normalize(Convert.FromBase64String("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Animated_webp_is_rejected_instead_of_silently_selecting_a_frame()
|
||||
{
|
||||
using var first = new SKBitmap(12, 8);
|
||||
using var second = new SKBitmap(12, 8);
|
||||
first.Erase(SKColors.Red);
|
||||
second.Erase(SKColors.Blue);
|
||||
using var animated = SKWebpEncoder.EncodeAnimated(new SKWebpEncoderFrame[]
|
||||
{
|
||||
new(first, TimeSpan.FromMilliseconds(100)), new(second, TimeSpan.FromMilliseconds(100)),
|
||||
}, SKWebpEncoderOptions.Default);
|
||||
Assert.NotNull(animated);
|
||||
Assert.Null(AvatarImageProcessor.Normalize(animated.ToArray()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)]
|
||||
[InlineData(5)] [InlineData(6)] [InlineData(7)] [InlineData(8)]
|
||||
public void Exif_orientation_is_applied_before_metadata_is_removed(int orientation)
|
||||
{
|
||||
var jpeg = Image(SKEncodedImageFormat.Jpeg, 12, 8);
|
||||
using var original = SKBitmap.Decode(jpeg);
|
||||
var normalized = AvatarImageProcessor.Normalize(WithOrientation(jpeg, orientation));
|
||||
Assert.NotNull(normalized);
|
||||
using var result = SKBitmap.Decode(normalized);
|
||||
Assert.Equal(orientation >= 5 ? original.Height : original.Width, result.Width);
|
||||
Assert.Equal(orientation >= 5 ? original.Width : original.Height, result.Height);
|
||||
for (var y = 0; y < result.Height; y++)
|
||||
for (var x = 0; x < result.Width; x++)
|
||||
{
|
||||
var source = orientation switch
|
||||
{
|
||||
2 => (original.Width - 1 - x, y),
|
||||
3 => (original.Width - 1 - x, original.Height - 1 - y),
|
||||
4 => (x, original.Height - 1 - y),
|
||||
5 => (y, x),
|
||||
6 => (y, original.Height - 1 - x),
|
||||
7 => (original.Width - 1 - y, original.Height - 1 - x),
|
||||
8 => (original.Width - 1 - y, x),
|
||||
_ => (x, y),
|
||||
};
|
||||
Assert.Equal(original.GetPixel(source.Item1, source.Item2), result.GetPixel(x, y));
|
||||
}
|
||||
using var codec = SKCodec.Create(new MemoryStream(normalized));
|
||||
Assert.Equal(SKEncodedOrigin.TopLeft, codec.EncodedOrigin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Identity_failure_preserves_previous_file_and_removes_staged_image()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "avatar-identity-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default);
|
||||
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
|
||||
var users = TestHostFactory.CreateUserManager();
|
||||
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "private failure" }));
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var controller = Controller(root, user, users.Object, db);
|
||||
await using var stream = new MemoryStream(Image(SKEncodedImageFormat.Png, 12, 8));
|
||||
var result = Assert.IsType<BadRequestObjectResult>(await controller.UploadAvatar(new FormFile(stream, 0, stream.Length, "file", "portrait.png")));
|
||||
Assert.DoesNotContain("private failure", result.Value?.ToString() ?? string.Empty);
|
||||
Assert.Equal(stored, user.AvatarImageDataUrl);
|
||||
Assert.Equal("data:image/jpeg;base64,AQID", AvatarStorage.Resolve(stored));
|
||||
Assert.Single(Directory.GetFiles(root, "*", SearchOption.AllDirectories));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Actual_stream_bytes_are_bounded_even_when_declared_length_is_small()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "owner" };
|
||||
var users = TestHostFactory.CreateUserManager();
|
||||
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var file = new Mock<IFormFile>();
|
||||
file.SetupGet(value => value.Length).Returns(8);
|
||||
file.SetupGet(value => value.FileName).Returns("portrait.png");
|
||||
file.Setup(value => value.OpenReadStream()).Returns(new MemoryStream(new byte[1_000_001]));
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var result = Assert.IsType<BadRequestObjectResult>(await Controller(Path.GetTempPath(), user, users.Object, db).UploadAvatar(file.Object));
|
||||
Assert.Equal("Avatar image is too large.", result.Value);
|
||||
users.Verify(manager => manager.UpdateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_identity_ack_is_not_reported_as_failed_when_old_file_cleanup_is_blocked()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "avatar-cleanup-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default);
|
||||
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
|
||||
var users = TestHostFactory.CreateUserManager();
|
||||
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
await using var locked = new FileStream(stored[5..], FileMode.Open, FileAccess.Read, FileShare.None);
|
||||
await using var stream = new MemoryStream(Image(SKEncodedImageFormat.Png, 12, 8));
|
||||
var response = await Controller(root, user, users.Object, db).UploadAvatar(new FormFile(stream, 0, stream.Length, "file", "portrait.png"));
|
||||
Assert.IsType<OkObjectResult>(response);
|
||||
Assert.NotEqual(stored, user.AvatarImageDataUrl);
|
||||
Assert.NotNull(AvatarStorage.Resolve(user.AvatarImageDataUrl));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ambiguous_identity_exception_retains_staged_target_for_reference_aware_reconciliation()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "avatar-ambiguous-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default);
|
||||
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
|
||||
var users = TestHostFactory.CreateUserManager();
|
||||
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
string? committedTarget = null;
|
||||
users.Setup(manager => manager.UpdateAsync(user)).Callback(() => committedTarget = user.AvatarImageDataUrl)
|
||||
.ThrowsAsync(new IOException("Synthetic acknowledgement loss after commit"));
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
await using var stream = new MemoryStream(Image(SKEncodedImageFormat.Png, 12, 8));
|
||||
await Assert.ThrowsAsync<IOException>(() => Controller(root, user, users.Object, db)
|
||||
.UploadAvatar(new FormFile(stream, 0, stream.Length, "file", "portrait.png")));
|
||||
Assert.NotNull(committedTarget);
|
||||
Assert.NotEqual(stored, committedTarget);
|
||||
Assert.NotNull(AvatarStorage.Resolve(committedTarget));
|
||||
Assert.Equal(stored, user.AvatarImageDataUrl);
|
||||
Assert.Equal("data:image/jpeg;base64,AQID", AvatarStorage.Resolve(stored));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Removal_is_acknowledged_when_identity_succeeded_but_file_cleanup_is_blocked()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "avatar-remove-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default);
|
||||
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
|
||||
var users = TestHostFactory.CreateUserManager();
|
||||
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
await using var locked = new FileStream(stored[5..], FileMode.Open, FileAccess.Read, FileShare.None);
|
||||
Assert.IsType<NoContentResult>(await Controller(root, user, users.Object, db).DeleteAvatar());
|
||||
Assert.Null(user.AvatarImageDataUrl);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
private static byte[] Image(SKEncodedImageFormat format, int width, int height)
|
||||
{
|
||||
using var bitmap = new SKBitmap(width, height);
|
||||
using (var canvas = new SKCanvas(bitmap)) canvas.Clear(SKColors.Navy);
|
||||
bitmap.SetPixel(0, 0, SKColors.Red);
|
||||
bitmap.SetPixel(width - 1, 0, SKColors.Green);
|
||||
bitmap.SetPixel(0, height - 1, SKColors.Yellow);
|
||||
using var image = SKImage.FromBitmap(bitmap);
|
||||
using var data = image.Encode(format, 100);
|
||||
return data.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] WithOrientation(byte[] jpeg, int orientation)
|
||||
{
|
||||
using var payload = new MemoryStream();
|
||||
using (var writer = new BinaryWriter(payload, System.Text.Encoding.UTF8, true))
|
||||
{
|
||||
writer.Write(new byte[] { 69, 120, 105, 102, 0, 0, 73, 73 });
|
||||
writer.Write((ushort)42); writer.Write(8); writer.Write((ushort)1);
|
||||
writer.Write((ushort)0x112); writer.Write((ushort)3); writer.Write(1);
|
||||
writer.Write((ushort)orientation); writer.Write((ushort)0); writer.Write(0);
|
||||
}
|
||||
var bytes = payload.ToArray();
|
||||
return jpeg[..2].Concat(new byte[] { 0xff, 0xe1, (byte)((bytes.Length + 2) >> 8), (byte)(bytes.Length + 2) })
|
||||
.Concat(bytes).Concat(jpeg[2..]).ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signature_only_png_is_rejected_without_replacing_the_previous_portrait()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "avatar-validation-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var originalBytes = new byte[] { 1, 2, 3 };
|
||||
var stored = await AvatarStorage.StoreAsync(root, "owner", originalBytes, "image/png", default);
|
||||
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
|
||||
var users = TestHostFactory.CreateUserManager();
|
||||
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var controller = Controller(root, user, users.Object, db);
|
||||
await using var stream = new MemoryStream(new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a });
|
||||
var file = new FormFile(stream, 0, stream.Length, "file", "portrait.png");
|
||||
|
||||
var response = await controller.UploadAvatar(file);
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(response);
|
||||
Assert.Equal(stored, user.AvatarImageDataUrl);
|
||||
Assert.Equal("data:image/png;base64,AQID", AvatarStorage.Resolve(stored));
|
||||
users.Verify(manager => manager.UpdateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
private static AuthController Controller(string root, ApplicationUser user, UserManager<ApplicationUser> users, JobTrackerApi.Data.JobTrackerContext db) =>
|
||||
new(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = root }).Build(),
|
||||
users, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(),
|
||||
Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>(), Mock.Of<ITwoFactorPendingTokenService>(), db)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "local")) },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,25 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CoverLetterDocumentContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void Matching_letter_themes_use_the_CV_design_without_rewriting_legacy_output_or_body()
|
||||
{
|
||||
var renderer = new CoverLetterDocumentRenderer();
|
||||
foreach (var theme in CvThemeCatalog.Themes)
|
||||
{
|
||||
var legacy = new CoverLetterDocumentSettings { ThemeId = theme.Id, SenderName = "Reviewed sender" };
|
||||
var original = renderer.Render("Reviewed body", legacy).Html;
|
||||
var revised = CoverLetterDocumentSettingsJson.Deserialize("{\"themeId\":\"" + theme.Id + "\",\"designVersion\":2,\"senderName\":\"Reviewed sender\"}");
|
||||
var html = renderer.Render("Reviewed body", revised).Html;
|
||||
Assert.Contains("letter-theme-" + theme.Id, html);
|
||||
Assert.Contains("Reviewed sender", html);
|
||||
Assert.Contains("Reviewed body", html);
|
||||
Assert.Contains("\"designVersion\":2", CoverLetterDocumentSettingsJson.Serialize(revised));
|
||||
Assert.Contains(theme.Accent, html);
|
||||
Assert.Equal(original, renderer.Render("Reviewed body", CoverLetterDocumentSettingsJson.Deserialize(CoverLetterDocumentSettingsJson.Serialize(legacy))).Html);
|
||||
Assert.DoesNotContain("designVersion", CoverLetterDocumentSettingsJson.Serialize(legacy));
|
||||
}
|
||||
}
|
||||
[Theory]
|
||||
[InlineData("en", "Cover letter", "Cover letter text")]
|
||||
[InlineData("nb-NO", "Søknadsbrev", "Brevtekst")]
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
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.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CoverLetterThemeEntitlementTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("save")]
|
||||
[InlineData("preview")]
|
||||
[InlineData("restore")]
|
||||
public async Task Free_user_cannot_activate_a_premium_version_two_letter_through_any_design_writer(string action)
|
||||
{
|
||||
var (db, job, controller, _) = await Fixture();
|
||||
await using var dispose = db;
|
||||
var premium = new CoverLetterDocumentSettings { ThemeId = "executive", DesignVersion = 2 };
|
||||
db.CoverLetterVersions.Add(new CoverLetterVersion { OwnerUserId = "owner", JobApplicationId = job.Id, Version = 1,
|
||||
Text = "Historical premium", Source = CoverLetterSources.Manual, DocumentSettingsJson = CoverLetterDocumentSettingsJson.Serialize(premium) });
|
||||
await db.SaveChangesAsync();
|
||||
var response = action switch
|
||||
{
|
||||
"preview" => (await controller.PreviewCoverLetter(job.Id, new("Reviewed text", premium), default)).Result,
|
||||
"restore" => (await controller.RestoreCoverLetter(job.Id, 1, new(job.Version), default)).Result,
|
||||
_ => (await controller.SaveCoverLetter(job.Id, new("Reviewed text", "manual", null, job.Version, premium), default)).Result,
|
||||
};
|
||||
Assert.Equal(403, Assert.IsAssignableFrom<ObjectResult>(response).StatusCode);
|
||||
Assert.Null(job.CoverLetterDocumentSettingsJson);
|
||||
Assert.Null(job.CoverLetterText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shared_service_rejects_premium_design_before_changing_letter_or_history()
|
||||
{
|
||||
var (db, job, _, service) = await Fixture();
|
||||
await using var dispose = db;
|
||||
await Assert.ThrowsAsync<CoverLetterThemeAccessException>(() => service.SaveCoverLetterAsync("owner", job.Id, "Unsaved", "manual", null,
|
||||
job.Version, default, new() { DesignVersion = 2, ThemeId = "technical" }));
|
||||
Assert.Null(job.CoverLetterText);
|
||||
Assert.Null(job.CoverLetterDocumentSettingsJson);
|
||||
Assert.Empty(db.CoverLetterVersions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData(1)]
|
||||
public async Task Legacy_letter_metadata_remains_editable_and_previewable_for_free_users(int? designVersion)
|
||||
{
|
||||
var (db, job, controller, _) = await Fixture();
|
||||
await using var dispose = db;
|
||||
var legacy = new CoverLetterDocumentSettings { ThemeId = "executive", DesignVersion = designVersion };
|
||||
Assert.IsType<OkObjectResult>((await controller.PreviewCoverLetter(job.Id, new("Legacy text", legacy), default)).Result);
|
||||
Assert.IsType<OkObjectResult>((await controller.SaveCoverLetter(job.Id, new("Legacy text", "manual", null, job.Version, legacy), default)).Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Premium_account_can_save_and_preview_a_new_premium_letter_design()
|
||||
{
|
||||
var (db, job, controller, service) = await Fixture(premium: true);
|
||||
await using var dispose = db;
|
||||
var design = new CoverLetterDocumentSettings { ThemeId = "technical", DesignVersion = 2 };
|
||||
Assert.IsType<OkObjectResult>((await controller.PreviewCoverLetter(job.Id, new("Reviewed text", design), default)).Result);
|
||||
Assert.IsType<OkObjectResult>((await controller.SaveCoverLetter(job.Id, new("Reviewed text", "manual", null, job.Version, design), default)).Result);
|
||||
Assert.NotNull(await service.RenderSavedCoverLetterAsync("owner", job.Id, job.Version, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Downgraded_account_keeps_current_premium_design_but_cannot_switch_to_another_premium_design()
|
||||
{
|
||||
var (db, job, controller, service) = await Fixture();
|
||||
await using var dispose = db;
|
||||
var stored = new CoverLetterDocumentSettings { ThemeId = "executive", DesignVersion = 2 };
|
||||
job.CoverLetterDocumentSettingsJson = CoverLetterDocumentSettingsJson.Serialize(stored);
|
||||
job.CoverLetterText = "Stored premium text";
|
||||
await db.SaveChangesAsync();
|
||||
Assert.NotNull(await service.GetCoverLetterAsync("owner", job.Id, default));
|
||||
Assert.NotNull(await service.RenderSavedCoverLetterAsync("owner", job.Id, job.Version, default));
|
||||
Assert.IsType<OkObjectResult>((await controller.PreviewCoverLetter(job.Id, new("Text edit", stored), default)).Result);
|
||||
Assert.IsType<OkObjectResult>((await controller.SaveCoverLetter(job.Id, new("Text edit", "manual", null, job.Version, stored), default)).Result);
|
||||
var different = new CoverLetterDocumentSettings { ThemeId = "technical", DesignVersion = 2 };
|
||||
Assert.Equal(403, Assert.IsAssignableFrom<ObjectResult>((await controller.PreviewCoverLetter(job.Id, new("Text edit", different), default)).Result).StatusCode);
|
||||
Assert.Equal(403, Assert.IsAssignableFrom<ObjectResult>((await controller.SaveCoverLetter(job.Id, new("Text edit", "manual", null, job.Version, different), default)).Result).StatusCode);
|
||||
Assert.Equal("executive", CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson).ThemeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Foreign_application_remains_not_found_before_entitlement_checks()
|
||||
{
|
||||
var (db, job, controller, _) = await Fixture();
|
||||
await using var dispose = db;
|
||||
job.OwnerUserId = "other";
|
||||
await db.SaveChangesAsync();
|
||||
var design = new CoverLetterDocumentSettings { ThemeId = "executive", DesignVersion = 2 };
|
||||
Assert.IsType<NotFoundResult>((await controller.PreviewCoverLetter(job.Id, new("Foreign", design), default)).Result);
|
||||
Assert.IsType<NotFoundResult>((await controller.SaveCoverLetter(job.Id, new("Foreign", "manual", null, job.Version, design), default)).Result);
|
||||
}
|
||||
|
||||
private static async Task<(JobTrackerContext db, JobApplication job, ApplicationAssetsController controller, ApplicationAssetsService service)> Fixture(bool premium = false)
|
||||
{
|
||||
var db = TestHostFactory.CreateInMemoryDb("owner");
|
||||
var user = new ApplicationUser { Id = "owner", UserName = "owner" };
|
||||
db.Users.Add(user);
|
||||
if (premium)
|
||||
{
|
||||
db.Roles.Add(new IdentityRole("Premium") { Id = "premium-role" });
|
||||
db.UserRoles.Add(new IdentityUserRole<string> { UserId = user.Id, RoleId = "premium-role" });
|
||||
}
|
||||
var company = new Company { OwnerUserId = user.Id, Name = "Synthetic" };
|
||||
var job = new JobApplication { OwnerUserId = user.Id, Company = company, JobTitle = "Synthetic role" };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
var service = new ApplicationAssetsService(db, Mock.Of<ICvVariantService>(), Mock.Of<IApplicationIntelligenceService>());
|
||||
var users = TestHostFactory.CreateUserManager();
|
||||
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var controller = new ApplicationAssetsController(users.Object, service, Mock.Of<ISubmittedApplicationPackageService>())
|
||||
{ ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } };
|
||||
return (db, job, controller, service);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,94 @@ public sealed class CvBuilderTests
|
||||
|
||||
// ---- Resolver (the lens over the master profile) ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("modern")]
|
||||
[InlineData("ats-classic")]
|
||||
[InlineData("code")]
|
||||
public void Revised_design_has_space_below_the_main_title_and_header(string themeId)
|
||||
{
|
||||
var settings = new CvVariantSettings { ThemeId = themeId, DesignVersion = 2 };
|
||||
var html = new ThemedCvRenderer().Render(CvVariantResolver.Build(Rich(), settings, "", null), CvThemeCatalog.Resolve(themeId), settings).Html;
|
||||
Assert.Contains(".header .name{margin-bottom:3mm;}", html);
|
||||
Assert.Contains(".header + .main{padding-top:5mm;}", html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("technical", "left")]
|
||||
[InlineData("creative", "left")]
|
||||
[InlineData("nordic", "right")]
|
||||
public void Revised_sidebar_background_fills_each_physical_page_including_its_margins(string themeId, string edge)
|
||||
{
|
||||
var settings = new CvVariantSettings { ThemeId = themeId, DesignVersion = 2 };
|
||||
var html = new ThemedCvRenderer().Render(CvVariantResolver.Build(Rich(), settings, "", null), CvThemeCatalog.Resolve(themeId), settings).Html;
|
||||
Assert.Contains("@top-" + edge + "{content:", html);
|
||||
Assert.Contains("@bottom-" + edge + "{content:", html);
|
||||
Assert.Contains("body::before{content:", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Design_revision_missing_and_explicit_one_preserve_identical_legacy_html_for_every_theme()
|
||||
{
|
||||
foreach (var theme in CvThemeCatalog.Themes)
|
||||
{
|
||||
var legacy = new CvVariantSettings { ThemeId = theme.Id };
|
||||
var explicitOne = CvVariantSettingsJson.Deserialize("{\"designVersion\":1,\"themeId\":\"" + theme.Id + "\"}");
|
||||
var renderer = new ThemedCvRenderer();
|
||||
var model = CvVariantResolver.Build(Rich(), legacy, "Fallback", null);
|
||||
Assert.Equal(renderer.Render(model, theme, legacy).Html, renderer.Render(model, theme, explicitOne).Html);
|
||||
Assert.DoesNotContain("designVersion", CvVariantSettingsJson.Serialize(legacy));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Design_revision_two_is_persisted_and_changes_presentation_without_changing_content()
|
||||
{
|
||||
var settings = CvVariantSettingsJson.Deserialize("{\"designVersion\":2,\"themeId\":\"modern\"}");
|
||||
Assert.Contains("\"designVersion\":2", CvVariantSettingsJson.Serialize(settings));
|
||||
var model = CvVariantResolver.Build(Rich(), settings, "Fallback", null);
|
||||
var theme = CvThemeCatalog.Resolve("modern");
|
||||
var renderer = new ThemedCvRenderer();
|
||||
var revised = renderer.Render(model, theme, settings).Html;
|
||||
var legacy = renderer.Render(model, theme, new() { ThemeId = "modern" }).Html;
|
||||
Assert.NotEqual(legacy, revised);
|
||||
Assert.Contains("Ada Lovelace", revised);
|
||||
Assert.Contains("Builder of engines.", revised);
|
||||
Assert.Contains("Built X", revised);
|
||||
Assert.Contains("AZ-204", revised);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("technical", "#0f4c5c", "#ffffff")]
|
||||
[InlineData("nordic", "#eef3f8", "#1f2937")]
|
||||
[InlineData("creative", "#be185d", "#ffffff")]
|
||||
public void Design_revision_two_sidebar_uses_its_own_palette_and_room_for_identity(string themeId, string background, string ink)
|
||||
{
|
||||
var settings = CvVariantSettingsJson.Deserialize("{\"designVersion\":2,\"themeId\":\"" + themeId + "\"}");
|
||||
var model = CvVariantResolver.Build(Rich(), settings, "Fallback", null);
|
||||
var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve(themeId), settings).Html;
|
||||
Assert.Contains("--cv-sidebar-bg:" + background, html);
|
||||
Assert.Contains("--cv-sidebar-ink:" + ink, html);
|
||||
Assert.Contains("background:var(--cv-sidebar-bg)", html);
|
||||
Assert.Contains("color:var(--cv-sidebar-ink)", html);
|
||||
Assert.Contains("padding:8mm", html);
|
||||
Assert.Contains("68mm", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Design_revision_duplicate_and_restore_keep_their_stored_opt_in()
|
||||
{
|
||||
var (db, service) = NewService("user-1");
|
||||
await using var dispose = db;
|
||||
var created = await service.CreateAsync("user-1", "Legacy design", null, new(), default);
|
||||
var revisionTwo = CvVariantSettingsJson.Deserialize("{\"designVersion\":2,\"themeId\":\"technical\"}");
|
||||
await service.SaveAsync("user-1", created.Id, null, revisionTwo, "manual", 1, default);
|
||||
var copy = (await service.DuplicateAsync("user-1", created.Id, null, default))!;
|
||||
Assert.Contains("\"designVersion\":2", copy.SettingsJson);
|
||||
var restored = (await service.RestoreVersionAsync("user-1", created.Id, 1, 2, default))!;
|
||||
Assert.DoesNotContain("designVersion", restored.SettingsJson);
|
||||
Assert.Contains("\"designVersion\":2", (await db.CvVariantVersions.SingleAsync(row => row.CvVariantId == created.Id && row.Version == 2)).SettingsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_blank_roundtrips_without_inheriting_profile_content_or_header()
|
||||
{
|
||||
|
||||
@@ -19,6 +19,60 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CvProcessingOperationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Document_worker_processes_previously_queued_imports_while_generative_jobs_remain_disabled()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await fixture.SeedUserAsync();
|
||||
await using (var scope = fixture.Provider.CreateAsyncScope())
|
||||
{
|
||||
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-1");
|
||||
Assert.IsType<AcceptedResult>(await CreateController(scope.ServiceProvider).Upload(File()));
|
||||
var bytes = Encoding.UTF8.GetBytes("%PDF-1.4\nSynthetic document transport");
|
||||
Assert.IsType<AcceptedResult>(await CreateController(scope.ServiceProvider).Import(new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "synthetic.pdf") { Headers = new HeaderDictionary(), ContentType = "application/pdf" }));
|
||||
}
|
||||
var configuration = fixture.Provider.GetRequiredService<IConfiguration>();
|
||||
configuration["Workers:AiOperationsEnabled"] = "false";
|
||||
configuration["Workers:CvDocumentImportsEnabled"] = "true";
|
||||
using var hosted = new AiOperationHostedService(fixture.Provider.GetRequiredService<AiOperationWorker>(), configuration);
|
||||
await hosted.StartAsync(default);
|
||||
try
|
||||
{
|
||||
var ready = false;
|
||||
for (var attempt = 0; attempt < 50 && !ready; attempt++)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
await using var scope = fixture.Provider.CreateAsyncScope();
|
||||
var processingDb = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
ready = await processingDb.CvExtractionRuns.IgnoreQueryFilters()
|
||||
.AnyAsync(run => run.Trigger == "document_import" && run.Status == "pending_review") &&
|
||||
await processingDb.UserOperations.IgnoreQueryFilters().AnyAsync(operation => operation.Status == OperationStatuses.Succeeded);
|
||||
}
|
||||
Assert.True(ready, "Document-only processing must run even while generative AI workers are disabled.");
|
||||
}
|
||||
finally { await hosted.StopAsync(default); }
|
||||
await using var verify = fixture.Provider.CreateAsyncScope();
|
||||
var db = verify.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
Assert.Equal("queued", (await db.CvExtractionRuns.IgnoreQueryFilters().SingleAsync(run => run.Trigger != "document_import")).Status);
|
||||
Assert.Equal(1, await db.UserOperations.IgnoreQueryFilters().CountAsync(operation => operation.Status == OperationStatuses.Succeeded));
|
||||
Assert.Equal(1, await db.UserOperations.IgnoreQueryFilters().CountAsync(operation => operation.Status == OperationStatuses.Queued));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_document_and_AI_workers_reject_import_with_an_actionable_service_error()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await fixture.SeedUserAsync();
|
||||
fixture.Provider.GetRequiredService<IConfiguration>()["Workers:AiOperationsEnabled"] = "false";
|
||||
await using var scope = fixture.Provider.CreateAsyncScope();
|
||||
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-1");
|
||||
var bytes = Encoding.UTF8.GetBytes("%PDF-1.4\nSynthetic document transport");
|
||||
var response = await CreateController(scope.ServiceProvider).Import(new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "synthetic.pdf") { Headers = new HeaderDictionary(), ContentType = "application/pdf" });
|
||||
Assert.Equal(503, Assert.IsType<ObjectResult>(response).StatusCode);
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
Assert.Empty(await db.UserOperations.ToListAsync());
|
||||
Assert.Equal("failed", (await db.CvExtractionRuns.SingleAsync()).Status);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Worker_that_lost_its_lease_cannot_overwrite_a_recovered_successful_review()
|
||||
{
|
||||
@@ -317,6 +371,8 @@ public sealed class CvProcessingOperationTests
|
||||
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts"),
|
||||
["AiQueue:HeartbeatSeconds"] = "5",
|
||||
["Ai:ExternalProcessingEnabled"] = "false",
|
||||
["Workers:AiOperationsEnabled"] = "true",
|
||||
["Workers:CvDocumentImportsEnabled"] = "false",
|
||||
}).Build();
|
||||
var environment = new Mock<IHostEnvironment>();
|
||||
environment.SetupGet(item => item.ContentRootPath).Returns(tempRoot);
|
||||
|
||||
@@ -418,6 +418,30 @@
|
||||
"System.Drawing.Common": "6.0.0"
|
||||
}
|
||||
},
|
||||
"SkiaSharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.153.1",
|
||||
"contentHash": "TghYLOUaP3q6rTjQw7U9uQaaXh8lEs9rfA1U7/VtmyAptVDtj/SmjtcWzFWmcC/VMWxe3QgbV9uyvqDL7ySFtQ==",
|
||||
"dependencies": {
|
||||
"SkiaSharp.NativeAssets.Win32": "4.153.1",
|
||||
"SkiaSharp.NativeAssets.macOS": "4.153.1"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux.NoDependencies": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.153.1",
|
||||
"contentHash": "NrnPjcxjcRau4GMOY0BTpUdMey+MnmLU3jC5AuExEFHPS2NkX1tdkXW0n/c5nQMMHBTAMpHnt7qEOpXs2FNfIQ=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.153.1",
|
||||
"contentHash": "YTojWeWIhnpF7uC8vpMxB5wRONKkDB2t3r/nNPA3DL2pgj9fqMXRevA/rIanYazWnco4jo1ufgp3HkXuh1Tz8A=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.153.1",
|
||||
"contentHash": "Yqg6rChTVh0BUE8QQxtOA3uUBnihfgID4yex/AvDa0+os2bVcBjmI3Qj9tnOB+R1hd/Jry1Gwl8qw5JI9HZG8Q=="
|
||||
},
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.10",
|
||||
@@ -566,6 +590,8 @@
|
||||
"Pomelo.EntityFrameworkCore.MySql": "[9.0.0, )",
|
||||
"QRCoder": "[1.8.0, )",
|
||||
"SQLitePCLRaw.lib.e_sqlite3": "[2.1.12, )",
|
||||
"SkiaSharp": "[4.153.1, )",
|
||||
"SkiaSharp.NativeAssets.Linux.NoDependencies": "[4.153.1, )",
|
||||
"Stripe.net": "[52.2.0, )",
|
||||
"System.IdentityModel.Tokens.Jwt": "[8.14.0, )",
|
||||
"System.Security.Cryptography.Pkcs": "[8.0.2, )"
|
||||
|
||||
Reference in New Issue
Block a user