feat(workspace): Application Workspace foundation (Phase 5 milestone 1)
Every JobApplication gets a dedicated workspace at /applications/{id} — a
surface, not a new data store. It owns no data and duplicates none: CV comes
from the Phase 4 CvVariant lens, analysis/match/interview from the existing
AiWorkspacePanel, documents from Attachments, communication from
Correspondence, activity from JobEvent, stage semantics from JobPipeline. No
career data is copied and nothing here writes.
- GET /api/jobapplications/{id}/workspace: one aggregate read (role, company,
stage, dates, attached CV variant, cover letter, documents, AI history,
recent activity) replacing the page fanning out across endpoints
- Next recommended action: ordered rules answering "what do I do next?", the
core product principle for this phase
- ApplicationWorkspacePage: left nav + linkable ?section=, reusing the existing
component for each domain; later-milestone sections say so rather than faking
- Entry point from the job dialog via an optional onOpenWorkspace callback —
the dialog must not depend on router context (it is mounted without a
<Router> in several suites), so the caller owns navigation
- 8 backend tests (aggregate, CV variant surfacing, counts, activity ordering,
next-step rules, tenant scoping)
Local: 314 backend, 88/88 frontend (31 suites), tsc clean, production build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class ApplicationWorkspaceTests
|
||||
{
|
||||
private static (JobTrackerContext db, ApplicationWorkspaceService svc) New(string userId)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(s => s.UserId).Returns(userId);
|
||||
var db = new JobTrackerContext(options, currentUser.Object);
|
||||
return (db, new ApplicationWorkspaceService(db));
|
||||
}
|
||||
|
||||
private static async Task<JobApplication> SeedAsync(JobTrackerContext db, string owner, Action<JobApplication>? tweak = null)
|
||||
{
|
||||
var company = new Company { OwnerUserId = owner, Name = "Acme" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = owner,
|
||||
CompanyId = company.Id,
|
||||
JobTitle = "Backend Developer",
|
||||
Status = "Applied",
|
||||
Location = "Oslo",
|
||||
Description = "Needs .NET and SQL.",
|
||||
DateApplied = DateTime.UtcNow.AddDays(-3),
|
||||
};
|
||||
tweak?.Invoke(job);
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Overview_aggregates_the_application_without_copying_career_data()
|
||||
{
|
||||
var (db, svc) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedAsync(db, "user-1");
|
||||
|
||||
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.NotNull(o);
|
||||
Assert.Equal("Backend Developer", o!.JobTitle);
|
||||
Assert.Equal("Acme", o.Company);
|
||||
Assert.Equal("Oslo", o.Location);
|
||||
Assert.True(o.HasJobDescription);
|
||||
Assert.Null(o.Cv.VariantId); // no variant attached yet
|
||||
Assert.False(o.HasCoverLetter);
|
||||
Assert.Equal(0, o.DocumentCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Overview_surfaces_the_attached_cv_variant()
|
||||
{
|
||||
var (db, svc) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedAsync(db, "user-1");
|
||||
db.CvVariants.Add(new CvVariant
|
||||
{
|
||||
OwnerUserId = "user-1",
|
||||
JobApplicationId = job.Id,
|
||||
PublicSlug = Guid.NewGuid().ToString("N"),
|
||||
Name = "Backend CV",
|
||||
SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings { ThemeId = "nordic" }),
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.Equal("Backend CV", o!.Cv.VariantName);
|
||||
Assert.Equal("nordic", o.Cv.ThemeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Overview_counts_documents_and_ai_history()
|
||||
{
|
||||
var (db, svc) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedAsync(db, "user-1");
|
||||
db.Attachments.Add(new Attachment { JobApplicationId = job.Id, FileName = "cert.pdf", FilePath = "/tmp/a.pdf", FileType = "PDF" });
|
||||
db.AiInteractions.Add(new AiInteraction
|
||||
{
|
||||
OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "job-analysis",
|
||||
Title = "Job analysis", Provider = "p", ResultJson = "{}", CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.Equal(1, o!.DocumentCount);
|
||||
Assert.Equal(1, o.AiInteractionCount);
|
||||
Assert.NotNull(o.LastAiAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Overview_returns_recent_activity_newest_first()
|
||||
{
|
||||
var (db, svc) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedAsync(db, "user-1");
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-2) });
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Applied", At = DateTime.Now });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.Equal(new[] { "StatusChanged", "Created" }, o!.RecentActivity.Select(a => a.Type));
|
||||
}
|
||||
|
||||
// --- next recommended action: "the user should never ask what to do next" ---
|
||||
|
||||
[Fact]
|
||||
public async Task Next_step_asks_for_the_advert_when_there_is_no_description()
|
||||
{
|
||||
var (db, svc) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedAsync(db, "user-1", j => j.Description = null);
|
||||
|
||||
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.Equal("add-job-details", o!.NextStep!.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Next_step_walks_cv_then_cover_letter_then_documents()
|
||||
{
|
||||
var (db, svc) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedAsync(db, "user-1");
|
||||
|
||||
Assert.Equal("prepare-cv", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key);
|
||||
|
||||
job.TailoredCvText = "tailored";
|
||||
await db.SaveChangesAsync();
|
||||
Assert.Equal("write-cover-letter", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key);
|
||||
|
||||
job.CoverLetterText = "Dear team";
|
||||
await db.SaveChangesAsync();
|
||||
Assert.Equal("attach-documents", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Next_step_prioritises_interview_prep_at_the_interview_stage()
|
||||
{
|
||||
var (db, svc) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedAsync(db, "user-1", j =>
|
||||
{
|
||||
j.Status = "Interview";
|
||||
j.TailoredCvText = "tailored";
|
||||
j.CoverLetterText = "letter";
|
||||
});
|
||||
db.Attachments.Add(new Attachment { JobApplicationId = job.Id, FileName = "c.pdf", FilePath = "/tmp/c.pdf", FileType = "PDF" });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.Equal("prepare-interview", o!.NextStep!.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Another_users_application_is_not_visible()
|
||||
{
|
||||
var (db, svc) = New("user-1");
|
||||
await using var _ = db;
|
||||
var other = await SeedAsync(db, "user-2");
|
||||
|
||||
Assert.Null(await svc.GetOverviewAsync("user-1", other.Id, default));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user