diff --git a/src/main/java/com/jobtracker/controller/GoogleDriveController.java b/src/main/java/com/jobtracker/controller/GoogleDriveController.java index 9550e77..92e051b 100644 --- a/src/main/java/com/jobtracker/controller/GoogleDriveController.java +++ b/src/main/java/com/jobtracker/controller/GoogleDriveController.java @@ -112,8 +112,8 @@ public ResponseEntity copyBaseResume( @Operation(summary = "Detect placeholders in a configured base resume") @PreAuthorize("hasRole('BETA')") @PostMapping("/resume-placeholders") - public ResponseEntity detectResumePlaceholders( - @Valid @RequestBody ResumePlaceholderRequest request) { + public ResponseEntity detectResumePlaceholders( + @Valid @RequestBody ResumePlaceholderDetectionRequest request) { return ResponseEntity.ok(resumeGenerationService.detectPlaceholders(request)); } @@ -124,6 +124,6 @@ public ResponseEntity generateResume( @PathVariable UUID applicationId, @Valid @RequestBody ResumePlaceholderRequest request) { return ResponseEntity.status(HttpStatus.CREATED) - .body(resumeGenerationService.generateResume(applicationId, request)); + .body(resumeGenerationService.generateTemplateResume(applicationId, request)); } } diff --git a/src/main/java/com/jobtracker/dto/gdrive/ResumePlaceholderDetectionRequest.java b/src/main/java/com/jobtracker/dto/gdrive/ResumePlaceholderDetectionRequest.java new file mode 100644 index 0000000..96995cb --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/ResumePlaceholderDetectionRequest.java @@ -0,0 +1,13 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +@Schema(description = "Request for resume placeholder detection") +public record ResumePlaceholderDetectionRequest( + @Schema(description = "Configured base resume identifier") + @NotNull(message = "baseResumeId is required") + UUID baseResumeId +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/ResumePlaceholderDetectionResponse.java b/src/main/java/com/jobtracker/dto/gdrive/ResumePlaceholderDetectionResponse.java new file mode 100644 index 0000000..fb2f528 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/ResumePlaceholderDetectionResponse.java @@ -0,0 +1,12 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; +import java.util.UUID; + +@Schema(description = "Resume template placeholder detection result") +public record ResumePlaceholderDetectionResponse( + UUID baseResumeId, + List placeholders +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/ResumePlaceholderResponse.java b/src/main/java/com/jobtracker/dto/gdrive/ResumePlaceholderResponse.java index 49b19e9..b504cc6 100644 --- a/src/main/java/com/jobtracker/dto/gdrive/ResumePlaceholderResponse.java +++ b/src/main/java/com/jobtracker/dto/gdrive/ResumePlaceholderResponse.java @@ -7,20 +7,15 @@ import java.util.Map; import java.util.UUID; -@Schema(description = "Resume placeholder detection or generation result") +@Schema(description = "Generated resume from template placeholders") public record ResumePlaceholderResponse( UUID applicationId, UUID baseResumeId, - List placeholders, - Map values, String copiedFileId, - String copiedFileName, - String documentWebViewLink, String pdfFileId, - String pdfFileName, - String pdfWebViewLink, - String vacancyFolderId, - String vacancyFolderName, - String vacancyFolderWebViewLink, + String documentUrl, + String pdfUrl, + Map values, + List placeholders, LocalDateTime generatedAt ) {} diff --git a/src/main/java/com/jobtracker/service/ResumeGenerationService.java b/src/main/java/com/jobtracker/service/ResumeGenerationService.java index 14e4b5b..07bc777 100644 --- a/src/main/java/com/jobtracker/service/ResumeGenerationService.java +++ b/src/main/java/com/jobtracker/service/ResumeGenerationService.java @@ -1,6 +1,8 @@ package com.jobtracker.service; import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.dto.gdrive.ResumePlaceholderDetectionRequest; +import com.jobtracker.dto.gdrive.ResumePlaceholderDetectionResponse; import com.jobtracker.dto.gdrive.ResumePlaceholderRequest; import com.jobtracker.dto.gdrive.ResumePlaceholderResponse; import com.jobtracker.entity.GoogleDriveBaseResume; @@ -51,32 +53,20 @@ public ResumeGenerationService(GoogleDriveApiClient googleDriveApiClient, } @Transactional - public ResumePlaceholderResponse detectPlaceholders(ResumePlaceholderRequest request) { + public ResumePlaceholderDetectionResponse detectPlaceholders(ResumePlaceholderDetectionRequest request) { UUID userId = securityUtils.getCurrentUserId(); GoogleDriveConnection connection = getConnectionWithFreshAccessToken(); GoogleDriveBaseResume baseResume = getBaseResume(request.baseResumeId(), userId); String documentText = googleDriveApiClient.readGoogleDocText(connection.getAccessToken(), baseResume.getGoogleFileId()); - return new ResumePlaceholderResponse( - null, + return new ResumePlaceholderDetectionResponse( baseResume.getId(), - detectPlaceholders(documentText), - Map.of(), - null, - null, - null, - null, - null, - null, - null, - null, - null, - null + detectPlaceholders(documentText) ); } @Transactional - public ResumePlaceholderResponse generateResume(UUID applicationId, ResumePlaceholderRequest request) { + public ResumePlaceholderResponse generateTemplateResume(UUID applicationId, ResumePlaceholderRequest request) { UUID userId = securityUtils.getCurrentUserId(); GoogleDriveConnection connection = getConnectionWithFreshAccessToken(); JobApplication application = applicationRepository.findByIdAndUserId(applicationId, userId) @@ -131,17 +121,12 @@ public ResumePlaceholderResponse generateResume(UUID applicationId, ResumePlaceh return new ResumePlaceholderResponse( application.getId(), baseResume.getId(), - remainingPlaceholders, - values, copiedFile.id(), - copiedFile.name(), - copiedDocumentUrl, pdfFile.id(), - pdfFile.name(), + copiedDocumentUrl, resolveDocumentLink(pdfFile), - vacancyFolder.id(), - vacancyFolder.name(), - resolveFolderLink(vacancyFolder.id(), vacancyFolder.webViewLink()), + values, + remainingPlaceholders, generatedAt ); } @@ -260,9 +245,4 @@ private String resolveDocumentLink(GoogleDriveApiClient.DriveFileMetadata file) : "https://docs.google.com/document/d/" + file.id() + "/edit"; } - private String resolveFolderLink(String folderId, String webViewLink) { - return StringUtils.hasText(webViewLink) - ? webViewLink - : "https://drive.google.com/drive/folders/" + folderId; - } } diff --git a/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java index 78fef1e..ccec425 100644 --- a/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java +++ b/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java @@ -249,12 +249,16 @@ public void replaceGoogleDocPlaceholders(String accessToken, String documentId, } List requests = values.entrySet().stream() + .filter(entry -> entry.getKey() != null && !entry.getKey().isBlank()) .map(entry -> new Request().setReplaceAllText(new ReplaceAllTextRequest() .setContainsText(new SubstringMatchCriteria() - .setText("{{" + entry.getKey() + "}}") + .setText(toPlaceholderToken(entry.getKey())) .setMatchCase(true)) .setReplaceText(entry.getValue() == null ? "" : entry.getValue()))) .toList(); + if (requests.isEmpty()) { + return; + } executeDocsOp(accessToken, "replace placeholders", docs -> { docs.documents().batchUpdate(documentId, new BatchUpdateDocumentRequest().setRequests(requests)).execute(); @@ -355,4 +359,8 @@ private DriveFileMetadata toDriveFileMetadata(File file) { file.getMimeType(), file.getWebViewLink()); } + + private String toPlaceholderToken(String key) { + return "{{" + key.trim() + "}}"; + } } diff --git a/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java b/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java index 0f04d94..9ba59b5 100644 --- a/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java +++ b/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java @@ -73,6 +73,7 @@ class GoogleDriveControllerIT extends AbstractIntegrationTest { @BeforeEach void setUp() throws Exception { + googleDriveApiClient.reset(); googleDriveOAuthStateRepository.deleteAll(); googleDriveBaseResumeRepository.deleteAll(); googleDriveConnectionRepository.deleteAll(); @@ -331,6 +332,116 @@ void copyResume_shouldReturn400WhenNoRootFolderConfigured() throws Exception { .andExpect(status().isBadRequest()); } + @Test + void detectResumePlaceholders_shouldReturnTemplatePlaceholders() throws Exception { + GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnection()); + GoogleDriveBaseResume resume = buildBaseResume(connection); + googleDriveBaseResumeRepository.save(resume); + + mockMvc.perform(post("/api/v1/google-drive/resume-placeholders") + .header("Authorization", "Bearer " + betaAccessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"baseResumeId\":\"" + resume.getId() + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.baseResumeId").value(resume.getId().toString())) + .andExpect(jsonPath("$.applicationId").doesNotExist()) + .andExpect(jsonPath("$.placeholders[0]").value("SUMMARY")) + .andExpect(jsonPath("$.placeholders[1]").value("SKILLS")); + } + + @Test + void generateResume_shouldReplaceTemplatePlaceholdersAndReturnGeneratedResume() throws Exception { + GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnectionWithRootFolder()); + GoogleDriveBaseResume resume = buildBaseResume(connection); + googleDriveBaseResumeRepository.save(resume); + + JobApplication application = new JobApplication(); + application.setUser(userRepository.findByEmail("driveuser@example.com").orElseThrow()); + application.setVacancyName("Backend Engineer"); + application.setOrganization("Acme"); + application.setApplicationDate(LocalDate.now()); + application = applicationRepository.save(application); + + googleDriveApiClient.fileMetadataById.put("root-folder-id", + new GoogleDriveApiClient.DriveFileMetadata( + "root-folder-id", + "Job Tracker Root", + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/root-folder-id" + )); + + mockMvc.perform(post("/api/v1/google-drive/applications/" + application.getId() + "/generated-resumes") + .header("Authorization", "Bearer " + betaAccessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "baseResumeId": "%s", + "values": { + "SUMMARY": "Senior Java Engineer", + "SKILLS": "Spring Boot, PostgreSQL" + } + } + """.formatted(resume.getId()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.applicationId").value(application.getId().toString())) + .andExpect(jsonPath("$.baseResumeId").value(resume.getId().toString())) + .andExpect(jsonPath("$.values.SUMMARY").value("Senior Java Engineer")) + .andExpect(jsonPath("$.values.SKILLS").value("Spring Boot, PostgreSQL")) + .andExpect(jsonPath("$.placeholders").isEmpty()) + .andExpect(jsonPath("$.copiedFileId").value("copied-file")) + .andExpect(jsonPath("$.pdfFileId").value("pdf-file")) + .andExpect(jsonPath("$.documentUrl").value("https://docs.google.com/document/d/copied-file/edit")) + .andExpect(jsonPath("$.pdfUrl").value("https://docs.google.com/document/d/pdf-file/edit")) + .andExpect(jsonPath("$.generatedAt").isNotEmpty()); + + JobApplication savedApplication = applicationRepository.findById(application.getId()).orElseThrow(); + assertThat(savedApplication.getDriveResumeFileId()).isEqualTo("copied-file"); + assertThat(savedApplication.getDriveResumeDocumentUrl()).contains("copied-file"); + } + + @Test + void generateResume_shouldKeepUnresolvedPlaceholdersInResponse() throws Exception { + GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnectionWithRootFolder()); + GoogleDriveBaseResume resume = buildBaseResume(connection); + googleDriveBaseResumeRepository.save(resume); + googleDriveApiClient.setDocumentText("resume-file-id", "{{SUMMARY}}\n{{SKILLS}}\n{{UNKNOWN}}"); + + JobApplication application = new JobApplication(); + application.setUser(userRepository.findByEmail("driveuser@example.com").orElseThrow()); + application.setVacancyName("Backend Engineer"); + application.setOrganization("Acme"); + application.setApplicationDate(LocalDate.now()); + application = applicationRepository.save(application); + + googleDriveApiClient.fileMetadataById.put("root-folder-id", + new GoogleDriveApiClient.DriveFileMetadata( + "root-folder-id", + "Job Tracker Root", + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/root-folder-id" + )); + + mockMvc.perform(post("/api/v1/google-drive/applications/" + application.getId() + "/generated-resumes") + .header("Authorization", "Bearer " + betaAccessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "baseResumeId": "%s", + "values": { + "SUMMARY": "Senior Java Engineer", + "SKILLS": "Spring Boot, PostgreSQL" + } + } + """.formatted(resume.getId()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.values.SUMMARY").value("Senior Java Engineer")) + .andExpect(jsonPath("$.values.SKILLS").value("Spring Boot, PostgreSQL")) + .andExpect(jsonPath("$.placeholders[0]").value("UNKNOWN")); + + assertThat(googleDriveApiClient.readGoogleDocText(connection.getAccessToken(), "copied-file")) + .contains("{{UNKNOWN}}"); + } + private GoogleDriveConnection buildConnectionWithRootFolder() { GoogleDriveConnection connection = buildConnection(); connection.setRootFolderId("root-folder-id"); @@ -371,6 +482,8 @@ FakeGoogleDriveApiClient googleDriveApiClient() { } static class FakeGoogleDriveApiClient implements GoogleDriveApiClient { + private static final String DEFAULT_TEMPLATE_TEXT = "{{SUMMARY}}\n{{SKILLS}}"; + private OAuthTokens tokens = new OAuthTokens( "drive-access", "drive-refresh", @@ -380,6 +493,18 @@ static class FakeGoogleDriveApiClient implements GoogleDriveApiClient { private GoogleDriveAccountProfile accountProfile = new GoogleDriveAccountProfile("perm-123", "connected@example.com", "Drive User"); private final Map fileMetadataById = new HashMap<>(); + private final Map documentTextById = new HashMap<>(); + + void reset() { + fileMetadataById.clear(); + documentTextById.clear(); + documentTextById.put("resume-file-id", DEFAULT_TEMPLATE_TEXT); + documentTextById.put("copied-file", DEFAULT_TEMPLATE_TEXT); + } + + void setDocumentText(String documentId, String text) { + documentTextById.put(documentId, text); + } @Override public String buildAuthorizationUrl(String state) { @@ -418,16 +543,33 @@ public DriveFileMetadata createFolder(String accessToken, String parentFolderId, @Override public DriveFileMetadata copyGoogleDoc(String accessToken, String sourceFileId, String targetFolderId, String newName) { + String sourceText = documentTextById.getOrDefault(sourceFileId, DEFAULT_TEMPLATE_TEXT); + documentTextById.put("copied-file", sourceText); return new DriveFileMetadata("copied-file", newName, GOOGLE_DOC_MIME_TYPE, null); } @Override public String readGoogleDocText(String accessToken, String documentId) { - return "{{SUMMARY}}\n{{SKILLS}}"; + return documentTextById.getOrDefault(documentId, DEFAULT_TEMPLATE_TEXT); } @Override public void replaceGoogleDocPlaceholders(String accessToken, String documentId, Map values) { + if (values == null || values.isEmpty()) { + return; + } + String currentText = documentTextById.getOrDefault(documentId, DEFAULT_TEMPLATE_TEXT); + String updatedText = currentText; + for (Map.Entry entry : values.entrySet()) { + String replacement = entry.getValue() == null ? "" : entry.getValue(); + String token = entry.getKey(); + if (token == null || token.isBlank()) { + continue; + } + String placeholderToken = "{{" + token.trim() + "}}"; + updatedText = updatedText.replace(placeholderToken, replacement); + } + documentTextById.put(documentId, updatedText); } @Override diff --git a/src/test/java/com/jobtracker/unit/ResumeGenerationTemplateFlowTest.java b/src/test/java/com/jobtracker/unit/ResumeGenerationTemplateFlowTest.java new file mode 100644 index 0000000..d0f144c --- /dev/null +++ b/src/test/java/com/jobtracker/unit/ResumeGenerationTemplateFlowTest.java @@ -0,0 +1,142 @@ +package com.jobtracker.unit; + +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.dto.gdrive.ResumePlaceholderRequest; +import com.jobtracker.entity.GoogleDriveBaseResume; +import com.jobtracker.entity.GoogleDriveConnection; +import com.jobtracker.entity.JobApplication; +import com.jobtracker.entity.User; +import com.jobtracker.repository.ApplicationRepository; +import com.jobtracker.repository.GoogleDriveBaseResumeRepository; +import com.jobtracker.repository.GoogleDriveConnectionRepository; +import com.jobtracker.service.GoogleDriveApiClient; +import com.jobtracker.service.ResumeGenerationService; +import com.jobtracker.util.SecurityUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ResumeGenerationTemplateFlowTest { + + private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID BASE_RESUME_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID APPLICATION_ID = UUID.fromString("00000000-0000-0000-0000-000000000003"); + + @Mock private GoogleDriveApiClient googleDriveApiClient; + @Mock private GoogleDriveConnectionRepository connectionRepository; + @Mock private GoogleDriveBaseResumeRepository baseResumeRepository; + @Mock private ApplicationRepository applicationRepository; + @Mock private SecurityUtils securityUtils; + + @Test + void generateResume_shouldReplaceStrictPlaceholdersFromProvidedValues() { + ResumeGenerationService service = new ResumeGenerationService( + googleDriveApiClient, + new GoogleDriveProperties("client", "secret", "cb", "frontend", "auth", "token"), + connectionRepository, + baseResumeRepository, + applicationRepository, + securityUtils + ); + + User user = new User(); + user.setId(USER_ID); + + GoogleDriveConnection connection = new GoogleDriveConnection(); + connection.setUser(user); + connection.setAccessToken("access-token"); + connection.setRefreshToken("refresh-token"); + connection.setAccessTokenExpiresAt(LocalDateTime.now().plusHours(1)); + connection.setRootFolderId("root-folder-id"); + + GoogleDriveBaseResume baseResume = new GoogleDriveBaseResume(); + baseResume.setId(BASE_RESUME_ID); + baseResume.setConnection(connection); + baseResume.setGoogleFileId("base-doc-id"); + baseResume.setDocumentName("Base Resume"); + + JobApplication application = new JobApplication(); + application.setId(APPLICATION_ID); + application.setUser(user); + application.setVacancyName("Backend Engineer"); + application.setOrganization("Acme"); + application.setApplicationDate(LocalDate.now()); + application.setDriveVacancyFolderId("vacancy-folder-id"); + + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(connection)); + when(applicationRepository.findByIdAndUserId(APPLICATION_ID, USER_ID)).thenReturn(Optional.of(application)); + when(baseResumeRepository.findByIdAndConnectionUserId(BASE_RESUME_ID, USER_ID)).thenReturn(Optional.of(baseResume)); + when(googleDriveApiClient.getFileMetadata("access-token", "root-folder-id")) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "root-folder-id", + "Root", + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/root-folder-id" + )); + when(googleDriveApiClient.getFileMetadata("access-token", "vacancy-folder-id")) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "vacancy-folder-id", + "Vacancy", + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/vacancy-folder-id" + )); + when(googleDriveApiClient.copyGoogleDoc(eq("access-token"), eq("base-doc-id"), eq("vacancy-folder-id"), any())) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "copied-doc-id", + "Copied Resume", + GoogleDriveApiClient.GOOGLE_DOC_MIME_TYPE, + "https://docs.google.com/document/d/copied-doc-id/edit" + )); + AtomicReference mockDocumentContent = new AtomicReference<>("{{SUMMARY}}\n{{SKILLS}}\n{{UNMAPPED}}"); + when(googleDriveApiClient.readGoogleDocText("access-token", "copied-doc-id")) + .thenAnswer(invocation -> mockDocumentContent.get()); + doAnswer(invocation -> { + Map replacements = invocation.getArgument(2); + String updated = mockDocumentContent.get(); + for (Map.Entry entry : replacements.entrySet()) { + updated = updated.replace("{{" + entry.getKey().trim() + "}}", entry.getValue()); + } + mockDocumentContent.set(updated); + return null; + }).when(googleDriveApiClient).replaceGoogleDocPlaceholders(eq("access-token"), eq("copied-doc-id"), any()); + when(googleDriveApiClient.exportGoogleDocAsPdf(eq("access-token"), eq("copied-doc-id"), eq("vacancy-folder-id"), any())) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "pdf-id", + "Resume.pdf", + "application/pdf", + "https://drive.google.com/file/d/pdf-id/view" + )); + + service.generateTemplateResume(APPLICATION_ID, new ResumePlaceholderRequest( + BASE_RESUME_ID, + Map.of( + "SUMMARY", "Senior Java Engineer", + "SKILLS", "Spring Boot, PostgreSQL" + ) + )); + + ArgumentCaptor> valuesCaptor = ArgumentCaptor.forClass(Map.class); + verify(googleDriveApiClient).replaceGoogleDocPlaceholders(eq("access-token"), eq("copied-doc-id"), valuesCaptor.capture()); + assertThat(valuesCaptor.getValue()).containsEntry("SUMMARY", "Senior Java Engineer"); + assertThat(valuesCaptor.getValue()).containsEntry("SKILLS", "Spring Boot, PostgreSQL"); + assertThat(mockDocumentContent.get()).contains("{{UNMAPPED}}"); + } +}