feat: complete release readiness work
- consolidate API ownership and remove dead vendor code - add Stripe billing, learning paths, and public CV hardening - add migration, recovery, security, audit, and browser gates
This commit is contained in:
@@ -5,6 +5,17 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class AccountPlansTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("active", true)]
|
||||
[InlineData("trialing", true)]
|
||||
[InlineData("past_due", false)]
|
||||
[InlineData("canceled", false)]
|
||||
[InlineData(null, false)]
|
||||
public void Premium_subscription_status_requires_current_access(string? status, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, AccountPlans.IsPremiumSubscriptionStatus(status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Premium_role_receives_higher_cost_capabilities()
|
||||
{
|
||||
|
||||
@@ -228,6 +228,25 @@ public sealed class ApplicationChecklistTests
|
||||
Assert.Equal(custom!.Id, next!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Learning_recommendations_preserve_user_decisions_and_reopen_only_auto_completed_items()
|
||||
{
|
||||
var (db, svc) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedAsync(db, "user-1");
|
||||
|
||||
var first = await svc.SyncLearningRecommendationsAsync("user-1", job.Id, ["Kubernetes", "AWS"], default);
|
||||
var kubernetes = first.Single(item => item.Keyword == "Kubernetes");
|
||||
await svc.UpdateAsync("user-1", job.Id, kubernetes.Id,
|
||||
new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default);
|
||||
|
||||
await svc.SyncLearningRecommendationsAsync("user-1", job.Id, [], default);
|
||||
var reopened = await svc.SyncLearningRecommendationsAsync("user-1", job.Id, ["Kubernetes", "AWS"], default);
|
||||
|
||||
Assert.Equal(ChecklistStatuses.Done, reopened.Single(item => item.Keyword == "Kubernetes").Status);
|
||||
Assert.Equal(ChecklistStatuses.Pending, reopened.Single(item => item.Keyword == "AWS").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interview_prep_is_only_outstanding_at_the_interview_stage()
|
||||
{
|
||||
@@ -252,6 +271,7 @@ public sealed class ApplicationChecklistTests
|
||||
Assert.Null(await svc.GetAsync("user-1", other.Id, default));
|
||||
Assert.Null(await svc.AddAsync("user-1", other.Id, new ChecklistItemInput("Sneak", null, null, null, null), default));
|
||||
Assert.False(await svc.DeleteAsync("user-1", other.Id, 1, default));
|
||||
Assert.Empty(await svc.SyncLearningRecommendationsAsync("user-1", other.Id, ["Kubernetes"], default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class BillingControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Webhook_rejects_an_invalid_Stripe_signature()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Stripe:SecretKey"] = "sk_test_fake",
|
||||
["Stripe:PricePremium"] = "price_fake",
|
||||
["Stripe:WebhookSecret"] = "whsec_fake",
|
||||
["App:PublicBaseUrl"] = "https://example.test",
|
||||
}).Build();
|
||||
|
||||
var userStore = new Mock<IUserStore<ApplicationUser>>();
|
||||
var users = new Mock<UserManager<ApplicationUser>>(
|
||||
userStore.Object, Options.Create(new IdentityOptions()), new PasswordHasher<ApplicationUser>(),
|
||||
Array.Empty<IUserValidator<ApplicationUser>>(), Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
||||
new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), null!, NullLogger<UserManager<ApplicationUser>>.Instance);
|
||||
var roleStore = new Mock<IRoleStore<IdentityRole>>();
|
||||
var roles = new Mock<RoleManager<IdentityRole>>(
|
||||
roleStore.Object, Array.Empty<IRoleValidator<IdentityRole>>(), new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(), NullLogger<RoleManager<IdentityRole>>.Instance);
|
||||
|
||||
var controller = new BillingController(configuration, users.Object, roles.Object, NullLogger<BillingController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
||||
};
|
||||
controller.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{}"));
|
||||
controller.Request.Headers["Stripe-Signature"] = "invalid";
|
||||
|
||||
var result = await controller.Webhook(CancellationToken.None);
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Reflection;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CvExportRetentionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Export_pruning_removes_only_expired_date_directories()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"jobtracker-export-retention-{Guid.NewGuid():N}");
|
||||
var exportsRoot = Path.Combine(root, "CvExports");
|
||||
var old = Path.Combine(exportsRoot, "20260101");
|
||||
var keep = Path.Combine(exportsRoot, "20260731");
|
||||
var unrelated = Path.Combine(exportsRoot, "manual");
|
||||
Directory.CreateDirectory(old);
|
||||
Directory.CreateDirectory(keep);
|
||||
Directory.CreateDirectory(unrelated);
|
||||
|
||||
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 exporter = new PlaywrightCvPdfExporter(new AppPaths(config, environment.Object), NullLogger<PlaywrightCvPdfExporter>.Instance, config);
|
||||
var prune = typeof(PlaywrightCvPdfExporter).GetMethod("PruneExpiredExports", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
|
||||
prune.Invoke(exporter, [new DateOnly(2026, 7, 1)]);
|
||||
|
||||
Assert.False(Directory.Exists(old));
|
||||
Assert.True(Directory.Exists(keep));
|
||||
Assert.True(Directory.Exists(unrelated));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,8 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
var result = await controller.Create(request, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var created = Assert.IsType<CreatedAtActionResult>(result.Result);
|
||||
Assert.IsType<JobApplicationDto>(created.Value);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
Assert.Equal(60000m, saved.SalaryMin);
|
||||
Assert.Equal(70000m, saved.SalaryMax);
|
||||
|
||||
@@ -20,6 +20,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\JobTrackerBackend\JobTrackerBackend.csproj" />
|
||||
<ProjectReference Include="..\JobTrackerApi\JobTrackerApi.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -226,6 +226,74 @@ public sealed class ProfileCvControllerTests
|
||||
Assert.Null(user.ProfileCvText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Background_processing_bypasses_the_http_user_filter_for_the_owned_run()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "# Ada Lovelace\n\n## Skills\nC#" };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.FindByIdAsync(user.Id)).ReturnsAsync(user);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService.Setup(x => x.SummarizeSectionAsync(
|
||||
It.Is<string>(instruction => instruction.StartsWith("Rewrite this CV", StringComparison.Ordinal)),
|
||||
It.IsAny<string>(), 1800, 500))
|
||||
.ReturnsAsync(user.ProfileCvText);
|
||||
aiService.Setup(x => x.SummarizeSectionAsync(
|
||||
It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)),
|
||||
It.IsAny<string>(), 3200, 900))
|
||||
.ReturnsAsync("""
|
||||
{"version":"1","contact":{"fullName":"Ada Lovelace"},"summary":[],"jobs":[],"education":[],"skills":["C#"],"languages":[],"interests":[],"otherSections":[]}
|
||||
""");
|
||||
|
||||
await using var db = CreateDb(userId: null);
|
||||
var paths = CreatePaths();
|
||||
var orphanedPath = Path.Combine(paths.CvArtifactsRoot, "orphaned.pdf");
|
||||
await System.IO.File.WriteAllTextAsync(orphanedPath, "obsolete");
|
||||
var currentPath = Path.Combine(paths.CvArtifactsRoot, "current.pdf");
|
||||
await System.IO.File.WriteAllTextAsync(currentPath, "current");
|
||||
db.CvUploadArtifacts.Add(new CvUploadArtifact
|
||||
{
|
||||
OwnerUserId = user.Id,
|
||||
OriginalFileName = "orphaned.pdf",
|
||||
StoredFileName = "orphaned.pdf",
|
||||
MimeType = "application/pdf",
|
||||
ByteSize = 8,
|
||||
Sha256 = "orphaned",
|
||||
StoragePath = orphanedPath,
|
||||
});
|
||||
var currentArtifact = new CvUploadArtifact
|
||||
{
|
||||
OwnerUserId = user.Id,
|
||||
OriginalFileName = "current.pdf",
|
||||
StoredFileName = "current.pdf",
|
||||
MimeType = "application/pdf",
|
||||
ByteSize = 7,
|
||||
Sha256 = "current",
|
||||
StoragePath = currentPath,
|
||||
};
|
||||
db.CvUploadArtifacts.Add(currentArtifact);
|
||||
var run = new CvExtractionRun
|
||||
{
|
||||
OwnerUserId = user.Id,
|
||||
Trigger = "improve",
|
||||
ParserVersion = "test",
|
||||
NormalizerVersion = "test",
|
||||
LlmPromptVersion = "test",
|
||||
Status = "queued",
|
||||
};
|
||||
db.CvExtractionRuns.Add(run);
|
||||
await db.SaveChangesAsync();
|
||||
user.CurrentCvUploadArtifactId = currentArtifact.Id;
|
||||
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
|
||||
await controller.ProcessQueuedRunAsync(run.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal("pending_review", run.Status);
|
||||
Assert.NotNull(run.CompletedAtUtc);
|
||||
Assert.Equal(currentArtifact.Id, Assert.Single(await db.CvUploadArtifacts.IgnoreQueryFilters().ToListAsync()).Id);
|
||||
Assert.False(System.IO.File.Exists(orphanedPath));
|
||||
Assert.True(System.IO.File.Exists(currentPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_reconstructs_flattened_pdf_cv_before_save()
|
||||
{
|
||||
@@ -1255,7 +1323,7 @@ public sealed class ProfileCvControllerTests
|
||||
};
|
||||
}
|
||||
|
||||
private static JobTrackerContext CreateDb(string userId = "user-1")
|
||||
private static JobTrackerContext CreateDb(string? userId = "user-1")
|
||||
{
|
||||
return TestHostFactory.CreateInMemoryDb(userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Moq;
|
||||
using System.Reflection;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class PublicCvControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Pdf_download_is_rate_limited()
|
||||
{
|
||||
var attribute = typeof(PublicCvController).GetMethod(nameof(PublicCvController.DownloadPdf))!
|
||||
.GetCustomAttribute<EnableRateLimitingAttribute>();
|
||||
|
||||
Assert.Equal("public-pdf", attribute?.PolicyName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pdf_download_uses_the_public_variant_render()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "owner-1", FirstName = "Ada", LastName = "Lovelace" };
|
||||
var variants = new Mock<ICvVariantService>();
|
||||
var pdf = new Mock<ICvPdfExporter>();
|
||||
var render = new ThemedCvRenderResult("modern", "ada-cv.pdf", "<html>CV</html>");
|
||||
variants.Setup(x => x.GetPublicOwnerAsync("public-slug", It.IsAny<CancellationToken>())).ReturnsAsync(user.Id);
|
||||
variants.Setup(x => x.RenderPublicAsync("public-slug", It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((render, user.Id));
|
||||
pdf.Setup(x => x.ExportAsync(It.IsAny<TailoredCvRenderResult>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CvPdfArtifact("ada-cv.pdf", "unused", [1, 2, 3]));
|
||||
|
||||
var controller = new PublicCvController(TestHostFactory.CreateUserManager(user).Object, variants.Object, pdf.Object);
|
||||
|
||||
var result = Assert.IsType<FileContentResult>(await controller.DownloadPdf("public-slug", CancellationToken.None));
|
||||
|
||||
Assert.Equal("application/pdf", result.ContentType);
|
||||
Assert.Equal("ada-cv.pdf", result.FileDownloadName);
|
||||
Assert.Equal([1, 2, 3], result.FileContents);
|
||||
pdf.Verify(x => x.ExportAsync(
|
||||
It.Is<TailoredCvRenderResult>(value => value.TemplateId == "modern" && value.Html == "<html>CV</html>"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pdf_download_does_not_render_an_unknown_or_private_slug()
|
||||
{
|
||||
var variants = new Mock<ICvVariantService>();
|
||||
variants.Setup(x => x.GetPublicOwnerAsync("private-slug", It.IsAny<CancellationToken>())).ReturnsAsync((string?)null);
|
||||
var pdf = new Mock<ICvPdfExporter>();
|
||||
var controller = new PublicCvController(TestHostFactory.CreateUserManager().Object, variants.Object, pdf.Object);
|
||||
|
||||
Assert.IsType<NotFoundResult>(await controller.DownloadPdf("private-slug", CancellationToken.None));
|
||||
variants.Verify(x => x.RenderPublicAsync(It.IsAny<string>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
pdf.VerifyNoOtherCalls();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user