From 5a45abcce71c22879f3c33a6770e6387f12d55b3 Mon Sep 17 00:00:00 2001 From: Pavlo Sushkov Date: Wed, 19 Aug 2026 16:37:41 +0200 Subject: [PATCH] feat: scope runtime workflows by candidate --- docs/phase-2-architecture.md | 13 +++ .../CandidateProfileRepository.java | 12 --- .../service/RuntimeCandidateContext.java | 24 +++++ .../llm/application/JobAnalysisService.java | 11 ++- .../application/ResumeGenerationService.java | 20 +++- ...didateProfileBootstrapIntegrationTest.java | 28 ++++-- .../service/RuntimeCandidateContextTest.java | 44 +++++++++ .../jobpilot/jobs/PostgresPersistenceIT.java | 24 +++-- .../application/JobAnalysisServiceTest.java | 58 +++++++++++ ...stgresDocumentGenerationConcurrencyIT.java | 2 +- .../ResumeGenerationServiceTest.java | 97 ++++++++++++++++++- 11 files changed, 297 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/jobpilot/candidate/service/RuntimeCandidateContext.java create mode 100644 src/test/java/com/jobpilot/candidate/service/RuntimeCandidateContextTest.java diff --git a/docs/phase-2-architecture.md b/docs/phase-2-architecture.md index 7466811..e187272 100644 --- a/docs/phase-2-architecture.md +++ b/docs/phase-2-architecture.md @@ -88,6 +88,19 @@ Startup invokes a small runner which delegates to `CandidateProfileBootstrapServ Older version facts are never updated by the mapper. Only their active marker and update timestamp change when a higher version becomes active. Logs contain only the version and fact counts. +### Runtime candidate compatibility boundary + +Candidate-specific analysis and document generation resolve the configured +`jobpilot.candidate-profile.candidate-key` to its persistent candidate row. Runtime resolution +does not create a candidate and does not fall back to whichever candidate happens to exist. Each +candidate may have its own active profile, so multiple active profiles may coexist in one database +and `profileVersion` is unique only within a candidate. + +Analysis persists the selected `candidate_profile_id`. Document generation uses that exact profile +identity and verifies that its owning candidate matches the configured runtime candidate; +`profileVersion` remains response and audit metadata, not a globally unique identity. HTTP and +Telegram contracts remain unchanged. Authenticated per-request candidate selection is future work. + ## Validation Validation covers required text, bounded string and collection sizes, positive profile versions, reasonable education years, non-negative bounded commercial Java experience, stable-key syntax, typed categories and levels, duplicate stable keys, duplicate active facts, and duplicate normalized technology/keyword values. diff --git a/src/main/java/com/jobpilot/candidate/repository/CandidateProfileRepository.java b/src/main/java/com/jobpilot/candidate/repository/CandidateProfileRepository.java index cc2abca..772df3d 100644 --- a/src/main/java/com/jobpilot/candidate/repository/CandidateProfileRepository.java +++ b/src/main/java/com/jobpilot/candidate/repository/CandidateProfileRepository.java @@ -8,16 +8,4 @@ public interface CandidateProfileRepository extends JpaRepository findByCandidateIdAndActiveTrue(Long candidateId); Optional findByCandidateIdAndProfileVersion(Long candidateId, int profileVersion); - - /** - * Legacy installation-wide lookups. The database permits version reuse and one active profile - * per candidate, so these methods are safe only while their callers operate with one configured - * candidate. {@code JobAnalysisService} and {@code ResumeGenerationService} are migrated to - * candidate-scoped access when candidate context reaches those workflows. - */ - Optional findByActiveTrue(); - - Optional findByProfileVersion(int profileVersion); - - long countByActiveTrue(); } diff --git a/src/main/java/com/jobpilot/candidate/service/RuntimeCandidateContext.java b/src/main/java/com/jobpilot/candidate/service/RuntimeCandidateContext.java new file mode 100644 index 0000000..4a51313 --- /dev/null +++ b/src/main/java/com/jobpilot/candidate/service/RuntimeCandidateContext.java @@ -0,0 +1,24 @@ +package com.jobpilot.candidate.service; + +import com.jobpilot.candidate.config.CandidateProfileProperties; +import com.jobpilot.candidate.repository.CandidateRepository; +import java.util.Optional; +import org.springframework.stereotype.Service; + +/** Resolves the configured compatibility identity for candidate-specific runtime workflows. */ +@Service +public class RuntimeCandidateContext { + private final CandidateProfileProperties properties; + private final CandidateRepository candidates; + + public RuntimeCandidateContext(CandidateProfileProperties properties, + CandidateRepository candidates) { + this.properties = properties; + this.candidates = candidates; + } + + public Optional candidateId() { + return candidates.findByStableKey(properties.candidateKey()) + .map(candidate -> candidate.getId()); + } +} diff --git a/src/main/java/com/jobpilot/llm/application/JobAnalysisService.java b/src/main/java/com/jobpilot/llm/application/JobAnalysisService.java index 8e93a50..74cf998 100644 --- a/src/main/java/com/jobpilot/llm/application/JobAnalysisService.java +++ b/src/main/java/com/jobpilot/llm/application/JobAnalysisService.java @@ -2,6 +2,7 @@ import com.jobpilot.candidate.domain.CandidateProfile; import com.jobpilot.candidate.repository.CandidateProfileRepository; +import com.jobpilot.candidate.service.RuntimeCandidateContext; import com.jobpilot.config.JobPilotProperties; import com.jobpilot.jobs.domain.ExtractedRequirements; import com.jobpilot.jobs.domain.Job; @@ -46,6 +47,7 @@ public class JobAnalysisService { private final JobRepository jobs; private final JobRequirementRepository requirements; private final CandidateProfileRepository profiles; + private final RuntimeCandidateContext candidateContext; private final JobAnalysisRepository analyses; private final JobAnalysisJson analysisJson; private final JobAnalysisPromptBuilder prompts; @@ -61,7 +63,9 @@ public class JobAnalysisService { private final TransactionTemplate transactions; public JobAnalysisService(JobRepository jobs, JobRequirementRepository requirements, - CandidateProfileRepository profiles, JobAnalysisRepository analyses, + CandidateProfileRepository profiles, + RuntimeCandidateContext candidateContext, + JobAnalysisRepository analyses, JobAnalysisJson analysisJson, JobAnalysisPromptBuilder prompts, JobAnalysisCacheKey keys, LlmStructuredResponseValidator validator, @@ -73,6 +77,7 @@ public JobAnalysisService(JobRepository jobs, JobRequirementRepository requireme this.jobs = jobs; this.requirements = requirements; this.profiles = profiles; + this.candidateContext = candidateContext; this.analyses = analyses; this.analysisJson = analysisJson; this.prompts = prompts; @@ -137,7 +142,9 @@ private Preparation prepare(long jobId, boolean candidateSpecific) { CandidateProfile profile = null; CandidateTruthSnapshot truth = null; if (candidateSpecific) { - profile = profiles.findByActiveTrue().orElse(null); + profile = candidateContext.candidateId() + .flatMap(profiles::findByCandidateIdAndActiveTrue) + .orElse(null); if (profile == null) return Preparation.immediate(result( JobAnalysisResultStatus.PROFILE_NOT_FOUND, null, jobId, null, null, null)); diff --git a/src/main/java/com/jobpilot/resume/application/ResumeGenerationService.java b/src/main/java/com/jobpilot/resume/application/ResumeGenerationService.java index 704d45a..70bd779 100644 --- a/src/main/java/com/jobpilot/resume/application/ResumeGenerationService.java +++ b/src/main/java/com/jobpilot/resume/application/ResumeGenerationService.java @@ -10,6 +10,7 @@ import com.jobpilot.candidate.repository.CandidateProjectBulletRepository; import com.jobpilot.candidate.repository.CandidateProjectRepository; import com.jobpilot.candidate.repository.CandidateSkillRepository; +import com.jobpilot.candidate.service.RuntimeCandidateContext; import com.jobpilot.jobs.domain.Job; import com.jobpilot.jobs.repository.JobRepository; import com.jobpilot.llm.application.JobAnalysisService; @@ -68,6 +69,7 @@ public class ResumeGenerationService { private final JobRepository jobs; private final CandidateProfileRepository profiles; + private final RuntimeCandidateContext candidateContext; private final CandidateSkillRepository skills; private final CandidateLanguageRepository languages; private final CandidateProjectRepository projects; @@ -98,6 +100,7 @@ public class ResumeGenerationService { public ResumeGenerationService( JobRepository jobs, CandidateProfileRepository profiles, + RuntimeCandidateContext candidateContext, CandidateSkillRepository skills, CandidateLanguageRepository languages, CandidateProjectRepository projects, CandidateProjectBulletRepository bullets, JobAnalysisRepository analyses, JobAnalysisJson analysisJson, @@ -114,6 +117,7 @@ public ResumeGenerationService( Clock clock, PlatformTransactionManager transactionManager) { this.jobs = jobs; this.profiles = profiles; + this.candidateContext = candidateContext; this.skills = skills; this.languages = languages; this.projects = projects; @@ -384,13 +388,21 @@ public DocumentDownload downloadCoverNote(long id, DocumentFormat format) { private Context context(JobAnalysisResult result) { Job job = jobs.findById(result.jobId()).orElseThrow(); - CandidateProfile profile = profiles.findByActiveTrue().orElse(null); - if (profile == null || result.candidateProfileVersion() == null - || profile.getProfileVersion() != result.candidateProfileVersion()) return null; + Long candidateId = candidateContext.candidateId().orElse(null); + if (candidateId == null) return null; JobAnalysis analysis = analyses.findById(result.analysisId()).orElseThrow(); + CandidateProfile analysisProfile = analysis.getCandidateProfile(); + if (analysisProfile == null) { + throw new IllegalStateException("Candidate-specific analysis has no candidate profile"); + } + CandidateProfile profile = profiles.findById(analysisProfile.getId()).orElseThrow(); if (!analysis.getJob().getId().equals(job.getId()) + || !profile.getCandidate().getId().equals(candidateId) + || !profile.getId().equals(analysisProfile.getId()) || !java.util.Objects.equals(analysis.getCandidateProfileVersion(), - profile.getProfileVersion())) { + profile.getProfileVersion()) + || !java.util.Objects.equals(result.candidateProfileVersion(), + analysis.getCandidateProfileVersion())) { throw new IllegalStateException("Analysis identity is incompatible with document facts"); } return new Context(CandidateDocumentFacts.from(profile), diff --git a/src/test/java/com/jobpilot/candidate/service/CandidateProfileBootstrapIntegrationTest.java b/src/test/java/com/jobpilot/candidate/service/CandidateProfileBootstrapIntegrationTest.java index 578349c..73111a0 100644 --- a/src/test/java/com/jobpilot/candidate/service/CandidateProfileBootstrapIntegrationTest.java +++ b/src/test/java/com/jobpilot/candidate/service/CandidateProfileBootstrapIntegrationTest.java @@ -35,7 +35,7 @@ class CandidateProfileBootstrapIntegrationTest { @Test void validProfileIsBootstrappedAndRepeatedBootstrapIsIdempotent() { - CandidateProfile active = profiles.findByActiveTrue().orElseThrow(); + CandidateProfile active = activeConfiguredProfile(); long before = profiles.count(); CandidateProfileBootstrapResult result = bootstrap.bootstrap(configuredProfile); @@ -43,7 +43,8 @@ void validProfileIsBootstrappedAndRepeatedBootstrapIsIdempotent() { assertThat(result.created()).isFalse(); assertThat(result.profileId()).isEqualTo(active.getId()); assertThat(profiles.count()).isEqualTo(before); - assertThat(profiles.countByActiveTrue()).isOne(); + assertThat(profiles.findByCandidateIdAndActiveTrue(configuredCandidate().getId())) + .contains(active); assertThat(active.getSkills()).hasSize(65); assertThat(active.getLanguages()).hasSize(4); assertThat(active.getProjects()).hasSize(4); @@ -51,8 +52,8 @@ void validProfileIsBootstrappedAndRepeatedBootstrapIsIdempotent() { @Test void bootstrappedProfileIsOwnedByTheConfiguredCandidate() { - CandidateProfile active = profiles.findByActiveTrue().orElseThrow(); - Candidate owner = candidates.findByStableKey(configuredProfile.candidateKey()).orElseThrow(); + CandidateProfile active = activeConfiguredProfile(); + Candidate owner = configuredCandidate(); assertThat(active.getCandidate().getId()).isEqualTo(owner.getId()); assertThat(owner.getStableKey()).isEqualTo("default"); @@ -93,12 +94,13 @@ void candidatesIndependentlyBootstrapTheSameVersionAndRemainActive() { assertThat(profiles.findByCandidateIdAndProfileVersion(other.getId(), 1)) .contains(otherProfile); assertThat(profiles.findByCandidateIdAndActiveTrue(owner.getId())).contains(configured); - assertThat(profiles.countByActiveTrue()).isEqualTo(2); + assertThat(profiles.findAll()).filteredOn(CandidateProfile::isActive).hasSize(2); } @Test void higherVersionCreatesNewActiveVersionAndPreservesPreviousFacts() { - CandidateProfile previous = profiles.findByActiveTrue().orElseThrow(); + Candidate owner = configuredCandidate(); + CandidateProfile previous = activeConfiguredProfile(); String originalName = previous.getFullName(); String originalSourceHash = previous.getSourceHash(); String originalSkill = previous.getSkills().getFirst().getDisplayName(); @@ -108,9 +110,9 @@ void higherVersionCreatesNewActiveVersionAndPreservesPreviousFacts() { assertThat(result.created()).isTrue(); assertThat(profiles.count()).isEqualTo(2); - assertThat(profiles.countByActiveTrue()).isOne(); - CandidateProfile current = profiles.findByActiveTrue().orElseThrow(); - CandidateProfile storedPrevious = profiles.findByProfileVersion(1).orElseThrow(); + CandidateProfile current = profiles.findByCandidateIdAndActiveTrue(owner.getId()).orElseThrow(); + CandidateProfile storedPrevious = profiles.findByCandidateIdAndProfileVersion(owner.getId(), 1) + .orElseThrow(); assertThat(current.getProfileVersion()).isEqualTo(2); assertThat(storedPrevious.isActive()).isFalse(); assertThat(storedPrevious.getFullName()).isEqualTo(originalName); @@ -119,4 +121,12 @@ void higherVersionCreatesNewActiveVersionAndPreservesPreviousFacts() { assertThat(storedPrevious.getProjects().getFirst().getBullets().getFirst().getVerifiedText()) .isEqualTo(originalBullet); } + + private Candidate configuredCandidate() { + return candidates.findByStableKey(configuredProfile.candidateKey()).orElseThrow(); + } + + private CandidateProfile activeConfiguredProfile() { + return profiles.findByCandidateIdAndActiveTrue(configuredCandidate().getId()).orElseThrow(); + } } diff --git a/src/test/java/com/jobpilot/candidate/service/RuntimeCandidateContextTest.java b/src/test/java/com/jobpilot/candidate/service/RuntimeCandidateContextTest.java new file mode 100644 index 0000000..fc31177 --- /dev/null +++ b/src/test/java/com/jobpilot/candidate/service/RuntimeCandidateContextTest.java @@ -0,0 +1,44 @@ +package com.jobpilot.candidate.service; + +import static com.jobpilot.candidate.CandidateProfileTestData.validProfile; +import static com.jobpilot.candidate.CandidateProfileTestData.withCandidateKey; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.jobpilot.candidate.domain.Candidate; +import com.jobpilot.candidate.repository.CandidateRepository; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class RuntimeCandidateContextTest { + private final CandidateRepository candidates = mock(CandidateRepository.class); + + @Test + void resolvesOnlyTheConfiguredStableKeyToPersistentIdentity() { + Candidate candidate = mock(Candidate.class); + RuntimeCandidateContext context = new RuntimeCandidateContext( + withCandidateKey(validProfile(1), "configured-candidate"), candidates); + when(candidates.findByStableKey("configured-candidate")).thenReturn(Optional.of(candidate)); + when(candidate.getId()).thenReturn(42L); + + assertThat(context.candidateId()).contains(42L); + + verify(candidates).findByStableKey("configured-candidate"); + verifyNoMoreInteractions(candidates); + } + + @Test + void missingConfiguredCandidateReturnsAbsentWithoutCreatingOrFallingBack() { + RuntimeCandidateContext context = new RuntimeCandidateContext( + withCandidateKey(validProfile(1), "missing-candidate"), candidates); + when(candidates.findByStableKey("missing-candidate")).thenReturn(Optional.empty()); + + assertThat(context.candidateId()).isEmpty(); + + verify(candidates).findByStableKey("missing-candidate"); + verifyNoMoreInteractions(candidates); + } +} diff --git a/src/test/java/com/jobpilot/jobs/PostgresPersistenceIT.java b/src/test/java/com/jobpilot/jobs/PostgresPersistenceIT.java index 490dbb4..3554210 100644 --- a/src/test/java/com/jobpilot/jobs/PostgresPersistenceIT.java +++ b/src/test/java/com/jobpilot/jobs/PostgresPersistenceIT.java @@ -283,11 +283,12 @@ void cascadesJobDeletionToRequirementsAndScores() { @Test void roundTripsCandidateProfileAndAllVerifiedFactTypes() { - CandidateProfile profile = candidateProfiles.findByActiveTrue().orElseThrow(); + CandidateProfile profile = activeDefaultProfile(); entityManager.flush(); entityManager.clear(); - CandidateProfile reloaded = candidateProfiles.findByProfileVersion(1).orElseThrow(); + CandidateProfile reloaded = candidateProfiles.findByCandidateIdAndProfileVersion( + defaultCandidate().getId(), 1).orElseThrow(); assertThat(reloaded.getFullName()).isEqualTo("Pavlo Sushkov"); assertThat(reloaded.getCommercialJavaExperienceYears()).isEqualByComparingTo(BigDecimal.ZERO); @@ -322,7 +323,7 @@ void differentCandidatesCanReuseAProfileVersionAndEachRemainActive() { .contains(otherProfile); assertThat(candidateProfiles.findByCandidateIdAndActiveTrue(other.getId())) .contains(otherProfile); - assertThat(candidateProfiles.countByActiveTrue()).isEqualTo(2); + assertThat(candidateProfiles.findAll()).filteredOn(CandidateProfile::isActive).hasSize(2); } @Test @@ -380,7 +381,7 @@ void tracksApplicationTransitionsAndImmutableHistoryWithOptimisticVersioning() { void persistsResumeVersionFactReferencesAndCascadesResumeDeletion() { Instant now = Instant.parse("2026-07-19T11:00:00Z"); Job job = jobs.saveAndFlush(job("resume", "https://example.com/jobs/resume", now)); - CandidateProfile profile = candidateProfiles.findByActiveTrue().orElseThrow(); + CandidateProfile profile = activeDefaultProfile(); var skill = candidateSkills.findByCandidateProfileIdOrderByDisplayOrder(profile.getId()).getFirst(); var language = candidateLanguages.findByCandidateProfileIdOrderByDisplayOrder(profile.getId()) .stream().filter(value -> value.isAllowedInCv()).findFirst().orElseThrow(); @@ -421,7 +422,7 @@ void persistsResumeVersionFactReferencesAndCascadesResumeDeletion() { void persistsCoverNoteLinkedToVerifiedProfileAndResume() { Instant now = Instant.parse("2026-07-19T12:00:00Z"); Job job = jobs.saveAndFlush(job("cover", "https://example.com/jobs/cover", now)); - CandidateProfile profile = candidateProfiles.findByActiveTrue().orElseThrow(); + CandidateProfile profile = activeDefaultProfile(); ResumeVersion resume = resumeVersions.saveAndFlush(new ResumeVersion( job, profile, profile.getProfileVersion(), "JAVA DEVELOPER INTERN", "Verified summary", "Preview", "Changes", "Claims", null, null, @@ -470,7 +471,7 @@ void persistsLlmUsageAccountingWithoutRawPayloadsOrSecrets() { void persistsStructuredAnalysisReservationAndDeterministicCacheIdentity() { Instant now = Instant.parse("2026-07-19T13:30:00Z"); Job job = jobs.saveAndFlush(job("analysis", "https://example.com/jobs/analysis", now)); - CandidateProfile profile = candidateProfiles.findByActiveTrue().orElseThrow(); + CandidateProfile profile = activeDefaultProfile(); LlmBudgetReservation reservation = llmBudgetReservations.saveAndFlush( new LlmBudgetReservation("1".repeat(64), job, LlmOperationType.JOB_ANALYSIS, "synthetic-provider", "model-a", java.time.LocalDate.parse("2026-07-19"), @@ -856,7 +857,16 @@ private Job job(String externalId, String url, Instant seenAt) { private CandidateProfile profile(int version, boolean active) { // Owned by the candidate V14 seeds; a profile cannot exist without an owner. - return profile(candidates.findByStableKey("default").orElseThrow(), version, active); + return profile(defaultCandidate(), version, active); + } + + private Candidate defaultCandidate() { + return candidates.findByStableKey("default").orElseThrow(); + } + + private CandidateProfile activeDefaultProfile() { + return candidateProfiles.findByCandidateIdAndActiveTrue(defaultCandidate().getId()) + .orElseThrow(); } private CandidateProfile profile(Candidate candidate, int version, boolean active) { diff --git a/src/test/java/com/jobpilot/llm/application/JobAnalysisServiceTest.java b/src/test/java/com/jobpilot/llm/application/JobAnalysisServiceTest.java index f66c820..ab125a0 100644 --- a/src/test/java/com/jobpilot/llm/application/JobAnalysisServiceTest.java +++ b/src/test/java/com/jobpilot/llm/application/JobAnalysisServiceTest.java @@ -6,10 +6,15 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobpilot.candidate.domain.Candidate; +import com.jobpilot.candidate.domain.CandidateProfile; +import com.jobpilot.candidate.repository.CandidateProfileRepository; +import com.jobpilot.candidate.repository.CandidateRepository; import com.jobpilot.jobs.domain.RawJob; import com.jobpilot.jobs.repository.JobRepository; import com.jobpilot.jobs.repository.JobRequirementRepository; @@ -31,6 +36,7 @@ import com.jobpilot.llm.repository.JobAnalysisRepository; import com.jobpilot.llm.repository.LlmBudgetReservationRepository; import com.jobpilot.llm.repository.LlmUsageEventRepository; +import java.math.BigDecimal; import java.time.Instant; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -67,6 +73,8 @@ class JobAnalysisServiceTest { @Autowired private JobRepository jobs; @Autowired private JobRequirementRepository requirements; @Autowired private JobScoreRepository scores; + @Autowired private CandidateProfileRepository profiles; + @SpyBean private CandidateRepository candidates; @SpyBean private JobAnalysisRepository analyses; @Autowired private LlmBudgetReservationRepository reservations; @Autowired private LlmUsageEventRepository usage; @@ -280,6 +288,41 @@ void changedJobContentCreatesANewCacheEntryAndPaidCall() { assertThat(reservations.count()).isEqualTo(2); } + @Test + void candidateSpecificAnalysisUsesConfiguredCandidateWithAnotherActiveSameVersion() { + Candidate configured = candidates.findByStableKey("default").orElseThrow(); + CandidateProfile configuredProfile = profiles + .findByCandidateIdAndActiveTrue(configured.getId()).orElseThrow(); + Candidate other = candidates.saveAndFlush(new Candidate( + "other-runtime", Instant.parse("2026-07-19T07:00:00Z"))); + CandidateProfile otherProfile = profiles.saveAndFlush(new CandidateProfile( + other, configuredProfile.getProfileVersion(), "Other Candidate", "Elsewhere", + "Other University", "Other Degree", 2024, null, true, false, + BigDecimal.ZERO, "f".repeat(64), Instant.parse("2026-07-19T07:00:00Z"), true)); + long jobId = createJob("candidate-scope", description()).getId(); + when(provider.execute(any())).thenReturn(new LlmResponse(validJson(), 100L, 30L)); + + var result = service.analyze(jobId, true); + + assertThat(result.status()).isEqualTo(JobAnalysisResultStatus.CREATED); + assertThat(otherProfile.getProfileVersion()).isEqualTo(configuredProfile.getProfileVersion()); + assertThat(analyses.findById(result.analysisId()).orElseThrow().getCandidateProfile().getId()) + .isEqualTo(configuredProfile.getId()); + } + + @Test + void nonCandidateSpecificAnalysisDoesNotResolveRuntimeCandidate() { + long jobId = createJob("generic-scope", description()).getId(); + reset(candidates); + when(provider.execute(any())).thenReturn(new LlmResponse(genericValidJson(), 100L, 30L)); + + var result = service.analyze(jobId, false); + + assertThat(result.status()).isEqualTo(JobAnalysisResultStatus.CREATED); + assertThat(analyses.findById(result.analysisId()).orElseThrow().getCandidateProfile()).isNull(); + verifyNoInteractions(candidates); + } + @Test void analysisStorageFailureIsNeverReportedAsProviderSuccess() { long jobId = createJob("store-failure", description()).getId(); @@ -330,4 +373,19 @@ private String validJson() { throw new AssertionError(exception); } } + + private String genericValidJson() { + JobAnalysisData data = new JobAnalysisData("Synthetic Java internship", + List.of("Java", "Spring Boot"), List.of(), + List.of("Build backend services"), null, null, null, + "Bucharest, Romania", null, List.of(), List.of(), + List.of("Work authorization is unknown"), + List.of(new EvidenceReference(EvidenceSource.VACANCY, + "job.description", "Java backend internship")), 80, false); + try { + return mapper.writeValueAsString(data); + } catch (JsonProcessingException exception) { + throw new AssertionError(exception); + } + } } diff --git a/src/test/java/com/jobpilot/resume/application/PostgresDocumentGenerationConcurrencyIT.java b/src/test/java/com/jobpilot/resume/application/PostgresDocumentGenerationConcurrencyIT.java index e147554..d0e1898 100644 --- a/src/test/java/com/jobpilot/resume/application/PostgresDocumentGenerationConcurrencyIT.java +++ b/src/test/java/com/jobpilot/resume/application/PostgresDocumentGenerationConcurrencyIT.java @@ -281,8 +281,8 @@ private PreparedIdentity prepareIdentity(long jobId, GenerateDocumentsCommand co var analysisResult = analysisService.analyze(jobId, true); return new TransactionTemplate(transactionManager).execute(status -> { var job = jobs.findById(jobId).orElseThrow(); - var profile = profiles.findByActiveTrue().orElseThrow(); var analysis = analyses.findById(analysisResult.analysisId()).orElseThrow(); + var profile = profiles.findById(analysis.getCandidateProfile().getId()).orElseThrow(); var candidateFacts = CandidateDocumentFacts.from(profile); var jobFacts = JobDocumentFacts.from(job, analysis.getId(), analysis.getCacheKey(), analysisResult.analysis()); diff --git a/src/test/java/com/jobpilot/resume/application/ResumeGenerationServiceTest.java b/src/test/java/com/jobpilot/resume/application/ResumeGenerationServiceTest.java index 261dd00..b82a85a 100644 --- a/src/test/java/com/jobpilot/resume/application/ResumeGenerationServiceTest.java +++ b/src/test/java/com/jobpilot/resume/application/ResumeGenerationServiceTest.java @@ -4,18 +4,31 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.verifyNoInteractions; import com.jobpilot.applications.domain.ApplicationStatus; import com.jobpilot.applications.domain.ApplicationStatusChangeSource; import com.jobpilot.applications.repository.ApplicationRepository; import com.jobpilot.applications.application.ApplicationTrackerService; +import com.jobpilot.candidate.domain.Candidate; +import com.jobpilot.candidate.domain.CandidateProfile; +import com.jobpilot.candidate.repository.CandidateProfileRepository; +import com.jobpilot.candidate.repository.CandidateRepository; import com.jobpilot.jobs.domain.RawJob; import com.jobpilot.jobs.repository.JobRequirementRepository; import com.jobpilot.jobs.repository.JobRepository; import com.jobpilot.jobs.repository.JobScoreRepository; import com.jobpilot.jobs.service.JobProcessor; import com.jobpilot.llm.api.LlmProvider; +import com.jobpilot.llm.application.JobAnalysisService; +import com.jobpilot.llm.domain.JobAnalysis; +import com.jobpilot.llm.domain.JobAnalysisData; +import com.jobpilot.llm.domain.JobAnalysisJson; +import com.jobpilot.llm.domain.JobAnalysisResult; +import com.jobpilot.llm.domain.JobAnalysisResultStatus; +import com.jobpilot.llm.domain.LlmFailureCategory; +import com.jobpilot.llm.domain.LlmOperationType; import com.jobpilot.llm.repository.JobAnalysisRepository; import com.jobpilot.llm.repository.LlmBudgetReservationRepository; import com.jobpilot.llm.repository.LlmUsageEventRepository; @@ -24,6 +37,7 @@ import com.jobpilot.resume.domain.DocumentFailureCategory; import com.jobpilot.resume.domain.DocumentFormat; import com.jobpilot.resume.domain.DocumentRenderStatus; +import com.jobpilot.resume.domain.ResumeVersion; import com.jobpilot.resume.repository.CoverNoteRepository; import com.jobpilot.resume.repository.ResumeVersionRepository; import com.jobpilot.resume.storage.DocumentKind; @@ -31,9 +45,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.FileTime; +import java.math.BigDecimal; import java.time.Duration; import java.time.Instant; import java.util.Comparator; +import java.util.List; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; @@ -49,6 +65,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.DynamicPropertyRegistry; @@ -90,9 +107,13 @@ static void storage(DynamicPropertyRegistry registry) { @Autowired private JobRepository jobs; @Autowired private JobRequirementRepository requirements; @Autowired private JobScoreRepository scores; + @Autowired private CandidateRepository candidates; + @Autowired private CandidateProfileRepository profiles; @Autowired private ResumeVersionRepository resumes; @Autowired private CoverNoteRepository coverNotes; @Autowired private JobAnalysisRepository analyses; + @Autowired private JobAnalysisJson analysisJson; + @SpyBean private JobAnalysisService analysisService; @Autowired private LlmBudgetReservationRepository reservations; @Autowired private LlmUsageEventRepository usage; @Autowired private ApplicationRepository applications; @@ -104,7 +125,7 @@ static void storage(DynamicPropertyRegistry registry) { @BeforeEach void cleanDatabase() throws Exception { - org.mockito.Mockito.reset(claimObserver); + org.mockito.Mockito.reset(claimObserver, analysisService); resumeRenderer.resetControl(); jdbc.update("delete from application_status_history"); applications.deleteAll(); @@ -209,6 +230,51 @@ void createsTruthfulPrivateArtifactsCachesThemAndRequiresHumanSelection() throws .isEqualTo(ApplicationStatus.SAVED); } + @Test + void generationUsesAnalysisProfileWhenAnotherCandidateHasSameActiveVersion() { + CandidateProfile configured = configuredProfile(); + CandidateProfile other = createOtherProfile( + "other-documents", configured.getProfileVersion()); + long jobId = createDocumentJob("candidate-profile-identity"); + + DocumentGenerationResult result = service.generate(jobId, + new GenerateDocumentsCommand(false, Set.of(DocumentFormat.DOCX), false)); + + assertThat(result.status()).isEqualTo(DocumentGenerationStatus.CREATED); + ResumeVersion resume = resumes.findById(result.resumeVersionId()).orElseThrow(); + assertThat(other.getProfileVersion()).isEqualTo(configured.getProfileVersion()); + assertThat(resume.getCandidateProfile().getId()).isEqualTo(configured.getId()); + assertThat(analyses.findById(resume.getSourceAnalysis().getId()).orElseThrow() + .getCandidateProfile().getId()) + .isEqualTo(configured.getId()); + } + + @Test + void sameProfileVersionFromAnotherCandidateCannotSatisfyDocumentIdentity() { + CandidateProfile configured = configuredProfile(); + CandidateProfile other = createOtherProfile( + "other-analysis", configured.getProfileVersion()); + long jobId = createDocumentJob("candidate-analysis-mismatch"); + var job = jobs.findById(jobId).orElseThrow(); + Instant now = Instant.parse("2026-07-19T10:00:00Z"); + JobAnalysisData data = minimalAnalysis(); + JobAnalysis mismatched = new JobAnalysis(job, other, LlmOperationType.JOB_ANALYSIS, + "disabled", "disabled", "job-analysis-v1", "1".repeat(64), + other.getSourceHash(), "2".repeat(64), now); + mismatched.completeFallback(data, LlmFailureCategory.DISABLED, null, analysisJson, now); + analyses.saveAndFlush(mismatched); + JobAnalysisResult mismatchedResult = new JobAnalysisResult( + JobAnalysisResultStatus.DISABLED, mismatched.getId(), jobId, + other.getProfileVersion(), data, LlmFailureCategory.DISABLED); + doReturn(mismatchedResult).when(analysisService).analyze(jobId, true); + + DocumentGenerationResult result = service.generate(jobId, + new GenerateDocumentsCommand(false, Set.of(DocumentFormat.DOCX), false)); + + assertThat(result.status()).isEqualTo(DocumentGenerationStatus.ANALYSIS_FAILED); + assertThat(resumes.count()).isZero(); + } + /** * The waiter honours an interrupt: it stops well inside the fifteen-second default budget and * leaves the interrupted status set for whoever asked it to stop — a graceful shutdown, @@ -385,6 +451,35 @@ private static Path temporaryStorage() { } } + private long createDocumentJob(String externalId) { + return processor.process(new RawJob("synthetic", externalId, + "https://example.invalid/jobs/" + externalId, "Java Backend Intern", + "Synthetic Company", "Bucharest, Romania", + "Java Spring Boot SQL internship with REST API work and mentorship.", + "INTERN", Instant.parse("2026-07-19T08:00:00Z"), null, + "Synthetic candidate identity fixture")).job().getId(); + } + + private CandidateProfile configuredProfile() { + Candidate configured = candidates.findByStableKey("default").orElseThrow(); + return profiles.findByCandidateIdAndActiveTrue(configured.getId()).orElseThrow(); + } + + private CandidateProfile createOtherProfile(String stableKey, int profileVersion) { + Instant now = Instant.parse("2026-07-19T07:00:00Z"); + Candidate other = candidates.saveAndFlush(new Candidate(stableKey, now)); + return profiles.saveAndFlush(new CandidateProfile(other, profileVersion, + "Other Candidate", "Elsewhere", "Other University", "Other Degree", + 2024, null, true, false, BigDecimal.ZERO, "f".repeat(64), now, true)); + } + + private JobAnalysisData minimalAnalysis() { + return new JobAnalysisData("Synthetic Java internship", List.of("Java"), List.of(), + List.of("Build backend services"), null, null, null, + "Bucharest, Romania", null, List.of(), List.of(), + List.of("Work authorization is unknown"), List.of(), 80, true); + } + private int count(String table, long id) { return jdbc.queryForObject("select count(*) from " + table + " where " + (table.startsWith("resume_version_")