feat(workspace): Application Workspace foundation (Phase 5 milestone 1)
CI and Deploy / test (push) Failing after 1m13s
CI and Deploy / deploy (push) Has been skipped

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:
cesnimda
2026-07-18 23:57:24 +02:00
parent 3b59152782
commit e55a6e86b7
10 changed files with 797 additions and 2 deletions
@@ -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));
}
}