From 2817afa77ce171cc838be7f2e959c1cb11a99881 Mon Sep 17 00:00:00 2001 From: vitorhugo-java Date: Mon, 8 Jun 2026 20:02:28 -0300 Subject: [PATCH 1/4] feat(legal): add privacy_policy_accepted field to User and enforce on registration Adds two columns to the users table (Flyway V28): privacy_policy_accepted and privacy_policy_accepted_at. RegisterRequest now validates that the client sends acceptedPrivacyPolicy=true (400 otherwise). AuthService sets the flag and timestamp at registration time. UserResponse exposes the field. Co-Authored-By: Claude Sonnet 4.6 --- .../com/jobtracker/dto/auth/RegisterRequest.java | 7 ++++++- .../java/com/jobtracker/dto/auth/UserResponse.java | 4 +++- src/main/java/com/jobtracker/entity/User.java | 12 ++++++++++++ src/main/java/com/jobtracker/mapper/AuthMapper.java | 3 ++- .../java/com/jobtracker/service/AuthService.java | 2 ++ .../V28__add_privacy_policy_accepted_to_users.sql | 3 +++ 6 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/migration/V28__add_privacy_policy_accepted_to_users.sql diff --git a/src/main/java/com/jobtracker/dto/auth/RegisterRequest.java b/src/main/java/com/jobtracker/dto/auth/RegisterRequest.java index 19d3ee90..df9f9e58 100644 --- a/src/main/java/com/jobtracker/dto/auth/RegisterRequest.java +++ b/src/main/java/com/jobtracker/dto/auth/RegisterRequest.java @@ -1,6 +1,7 @@ package com.jobtracker.dto.auth; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -25,5 +26,9 @@ public record RegisterRequest( @Schema(description = "Confirm password (must match password)", example = "secureP@ss1") @NotBlank(message = "Confirm password is required") - String confirmPassword + String confirmPassword, + + @Schema(description = "User must accept the Privacy Policy to register", example = "true") + @AssertTrue(message = "You must accept the Privacy Policy to create an account") + boolean acceptedPrivacyPolicy ) {} diff --git a/src/main/java/com/jobtracker/dto/auth/UserResponse.java b/src/main/java/com/jobtracker/dto/auth/UserResponse.java index 937b02bc..959719c4 100644 --- a/src/main/java/com/jobtracker/dto/auth/UserResponse.java +++ b/src/main/java/com/jobtracker/dto/auth/UserResponse.java @@ -19,5 +19,7 @@ public record UserResponse( @Schema(description = "Granted application roles", example = "[\"USER\", \"BETA\"]") Set roles, @Schema(description = "Whether the user can access Google integration features", example = "true") - boolean canUseGoogleIntegration + boolean canUseGoogleIntegration, + @Schema(description = "Whether the user has accepted the Privacy Policy", example = "true") + boolean privacyPolicyAccepted ) {} diff --git a/src/main/java/com/jobtracker/entity/User.java b/src/main/java/com/jobtracker/entity/User.java index 94f59570..768d1c34 100644 --- a/src/main/java/com/jobtracker/entity/User.java +++ b/src/main/java/com/jobtracker/entity/User.java @@ -32,6 +32,12 @@ public class User { @Column(name = "reminder_time", nullable = false) private LocalTime reminderTime; + @Column(name = "privacy_policy_accepted", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") + private boolean privacyPolicyAccepted; + + @Column(name = "privacy_policy_accepted_at") + private LocalDateTime privacyPolicyAcceptedAt; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -79,4 +85,10 @@ protected void onUpdate() { public Set getRoles() { return roles; } public void setRoles(Set roles) { this.roles = roles; } + + public boolean isPrivacyPolicyAccepted() { return privacyPolicyAccepted; } + public void setPrivacyPolicyAccepted(boolean privacyPolicyAccepted) { this.privacyPolicyAccepted = privacyPolicyAccepted; } + + public LocalDateTime getPrivacyPolicyAcceptedAt() { return privacyPolicyAcceptedAt; } + public void setPrivacyPolicyAcceptedAt(LocalDateTime privacyPolicyAcceptedAt) { this.privacyPolicyAcceptedAt = privacyPolicyAcceptedAt; } } diff --git a/src/main/java/com/jobtracker/mapper/AuthMapper.java b/src/main/java/com/jobtracker/mapper/AuthMapper.java index 72874153..dde07338 100644 --- a/src/main/java/com/jobtracker/mapper/AuthMapper.java +++ b/src/main/java/com/jobtracker/mapper/AuthMapper.java @@ -22,6 +22,7 @@ public UserResponse toUserResponse(User user) { user.getEmail(), user.getReminderTime(), roles, - roles.contains(RoleName.BETA.name())); + roles.contains(RoleName.BETA.name()), + user.isPrivacyPolicyAccepted()); } } diff --git a/src/main/java/com/jobtracker/service/AuthService.java b/src/main/java/com/jobtracker/service/AuthService.java index 231c4e51..448ce417 100644 --- a/src/main/java/com/jobtracker/service/AuthService.java +++ b/src/main/java/com/jobtracker/service/AuthService.java @@ -92,6 +92,8 @@ public AuthResponse register(RegisterRequest request) { user.setEmail(request.email()); user.setPasswordHash(passwordEncoder.encode(request.password())); user.setRoles(Set.of(resolveDefaultUserRole())); + user.setPrivacyPolicyAccepted(true); + user.setPrivacyPolicyAcceptedAt(java.time.LocalDateTime.now()); user = userRepository.save(user); log.info("event=REGISTRATION_SUCCESS email={} userId={}", user.getEmail(), user.getId()); return buildAuthResponse(user); diff --git a/src/main/resources/db/migration/V28__add_privacy_policy_accepted_to_users.sql b/src/main/resources/db/migration/V28__add_privacy_policy_accepted_to_users.sql new file mode 100644 index 00000000..7e2b739b --- /dev/null +++ b/src/main/resources/db/migration/V28__add_privacy_policy_accepted_to_users.sql @@ -0,0 +1,3 @@ +ALTER TABLE users + ADD COLUMN privacy_policy_accepted TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN privacy_policy_accepted_at DATETIME NULL; From 37d80d66ccc8c2f641da4fa41a98563501c422a2 Mon Sep 17 00:00:00 2001 From: vitorhugo-java Date: Mon, 8 Jun 2026 20:35:44 -0300 Subject: [PATCH 2/4] fix(tests): add acceptedPrivacyPolicy and privacyPolicyAccepted to all test constructors --- .../integration/ApplicationControllerIT.java | 2 +- .../integration/AuthControllerIT.java | 28 +++++++++---------- .../integration/AuthRateLimiterIT.java | 2 +- .../integration/GamificationControllerIT.java | 2 +- .../integration/GoogleDriveControllerIT.java | 2 +- .../integration/GptOAuthFlowIT.java | 2 +- .../jobtracker/integration/mcp/McpAuthIT.java | 2 +- .../integration/mcp/McpToolsIT.java | 2 +- .../com/jobtracker/unit/AuthServiceTest.java | 10 +++---- .../unit/mcp/McpProfileToolsTest.java | 2 +- 10 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/test/java/com/jobtracker/integration/ApplicationControllerIT.java b/src/test/java/com/jobtracker/integration/ApplicationControllerIT.java index c1703b5d..d24a787c 100644 --- a/src/test/java/com/jobtracker/integration/ApplicationControllerIT.java +++ b/src/test/java/com/jobtracker/integration/ApplicationControllerIT.java @@ -51,7 +51,7 @@ void setUp() throws Exception { refreshTokenRepository.deleteAll(); userRepository.deleteAll(); - RegisterRequest reg = new RegisterRequest("App User", "appuser@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("App User", "appuser@example.com", "pass1234", "pass1234", true); MvcResult result = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) diff --git a/src/test/java/com/jobtracker/integration/AuthControllerIT.java b/src/test/java/com/jobtracker/integration/AuthControllerIT.java index 8a66f4b8..8c1130f3 100644 --- a/src/test/java/com/jobtracker/integration/AuthControllerIT.java +++ b/src/test/java/com/jobtracker/integration/AuthControllerIT.java @@ -68,7 +68,7 @@ void cleanDb() { @Test void register_shouldReturn201_setRefreshTokenCookie_andReturnAccessToken() throws Exception { - RegisterRequest request = new RegisterRequest("Test User", "register@example.com", "pass1234", "pass1234"); + RegisterRequest request = new RegisterRequest("Test User", "register@example.com", "pass1234", "pass1234", true); MvcResult result = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) @@ -99,7 +99,7 @@ void register_shouldReturn201_setRefreshTokenCookie_andReturnAccessToken() throw @Test void register_shouldReturn409_whenEmailAlreadyExists() throws Exception { - RegisterRequest request = new RegisterRequest("Test User", "duplicate@example.com", "pass1234", "pass1234"); + RegisterRequest request = new RegisterRequest("Test User", "duplicate@example.com", "pass1234", "pass1234", true); mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) @@ -114,7 +114,7 @@ void register_shouldReturn409_whenEmailAlreadyExists() throws Exception { @Test void register_shouldReturn400_whenPasswordsDoNotMatch() throws Exception { - RegisterRequest request = new RegisterRequest("Test User", "mismatch@example.com", "pass1234", "different"); + RegisterRequest request = new RegisterRequest("Test User", "mismatch@example.com", "pass1234", "different", true); mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) @@ -125,7 +125,7 @@ void register_shouldReturn400_whenPasswordsDoNotMatch() throws Exception { @Test void login_shouldReturn200_setRefreshTokenCookie_andReturnAccessToken() throws Exception { // First register - RegisterRequest reg = new RegisterRequest("Login User", "login@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("Login User", "login@example.com", "pass1234", "pass1234", true); mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))); @@ -164,7 +164,7 @@ void login_shouldReturn401_whenBadCredentials() throws Exception { @Test void refresh_shouldReadFromCookie_returnNewAccessToken_andRotateRefreshTokenCookie() throws Exception { // Register to get initial tokens - RegisterRequest reg = new RegisterRequest("Refresh User", "refresh@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("Refresh User", "refresh@example.com", "pass1234", "pass1234", true); MvcResult regResult = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) @@ -213,7 +213,7 @@ void refresh_shouldReturn401_whenRefreshTokenIsInvalid() throws Exception { @Test void logout_shouldClearRefreshTokenCookie() throws Exception { // Register - RegisterRequest reg = new RegisterRequest("Logout User", "logout@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("Logout User", "logout@example.com", "pass1234", "pass1234", true); MvcResult regResult = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) @@ -247,7 +247,7 @@ void logout_shouldClearRefreshTokenCookie() throws Exception { @Test void me_shouldReturn200_whenAuthenticated() throws Exception { - RegisterRequest reg = new RegisterRequest("Me User", "me@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("Me User", "me@example.com", "pass1234", "pass1234", true); MvcResult regResult = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) @@ -271,7 +271,7 @@ void me_shouldReturn403_whenNotAuthenticated() throws Exception { @Test void me_shouldReturn403_whenTokenDoesNotContainRoleUser() throws Exception { - RegisterRequest reg = new RegisterRequest("Admin Token User", "admin-token@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("Admin Token User", "admin-token@example.com", "pass1234", "pass1234", true); mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) @@ -290,7 +290,7 @@ void me_shouldReturn403_whenTokenDoesNotContainRoleUser() throws Exception { @Test void sendTestEmail_shouldReturn200_whenAuthenticated() throws Exception { - RegisterRequest reg = new RegisterRequest("Mail User", "mail-user@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("Mail User", "mail-user@example.com", "pass1234", "pass1234", true); MvcResult regResult = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) @@ -321,7 +321,7 @@ void forgotPassword_shouldReturn200_regardlessOfEmailExistence() throws Exceptio @Test void updateProfile_shouldReturn200_whenAuthenticated() throws Exception { - RegisterRequest reg = new RegisterRequest("Profile User", "profile@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("Profile User", "profile@example.com", "pass1234", "pass1234", true); MvcResult regResult = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) @@ -340,7 +340,7 @@ void updateProfile_shouldReturn200_whenAuthenticated() throws Exception { @Test void changePassword_shouldReturn200_andAllowLoginWithNewPassword() throws Exception { - RegisterRequest reg = new RegisterRequest("Password User", "password-change@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("Password User", "password-change@example.com", "pass1234", "pass1234", true); MvcResult regResult = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) @@ -365,7 +365,7 @@ void changePassword_shouldReturn200_andAllowLoginWithNewPassword() throws Except @Test void changePassword_shouldReturn400_whenCurrentPasswordIsInvalid() throws Exception { - RegisterRequest reg = new RegisterRequest("Password User", "password-invalid@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("Password User", "password-invalid@example.com", "pass1234", "pass1234", true); MvcResult regResult = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) @@ -383,7 +383,7 @@ void changePassword_shouldReturn400_whenCurrentPasswordIsInvalid() throws Except @Test void passkeyLoginOptions_shouldReturnFallbackWhenUserHasNoPasskeys() throws Exception { - RegisterRequest reg = new RegisterRequest("No Passkey User", "no-passkey@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("No Passkey User", "no-passkey@example.com", "pass1234", "pass1234", true); mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) @@ -407,7 +407,7 @@ void passkeyRegisterOptions_shouldReturn403WhenNotAuthenticated() throws Excepti @Test void passkeyMe_shouldReturnHasPasskeysFalseByDefault() throws Exception { - RegisterRequest reg = new RegisterRequest("Passkey Status User", "passkey-status@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("Passkey Status User", "passkey-status@example.com", "pass1234", "pass1234", true); MvcResult regResult = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) diff --git a/src/test/java/com/jobtracker/integration/AuthRateLimiterIT.java b/src/test/java/com/jobtracker/integration/AuthRateLimiterIT.java index 1214195a..bfdbe3c0 100644 --- a/src/test/java/com/jobtracker/integration/AuthRateLimiterIT.java +++ b/src/test/java/com/jobtracker/integration/AuthRateLimiterIT.java @@ -25,7 +25,7 @@ class AuthRateLimiterIT extends AbstractIntegrationTest { @Test void login_shouldReturn429_whenRateLimitIsExceeded() throws Exception { - RegisterRequest registerRequest = new RegisterRequest("Rate Limit User", "ratelimit@example.com", "pass1234", "pass1234"); + RegisterRequest registerRequest = new RegisterRequest("Rate Limit User", "ratelimit@example.com", "pass1234", "pass1234", true); LoginRequest loginRequest = new LoginRequest("ratelimit@example.com", "pass1234"); mockMvc.perform(post("/api/v1/auth/register") diff --git a/src/test/java/com/jobtracker/integration/GamificationControllerIT.java b/src/test/java/com/jobtracker/integration/GamificationControllerIT.java index d283115e..a8fafe8d 100644 --- a/src/test/java/com/jobtracker/integration/GamificationControllerIT.java +++ b/src/test/java/com/jobtracker/integration/GamificationControllerIT.java @@ -121,7 +121,7 @@ void getAchievements_shouldReturnCatalog() throws Exception { } private String registerAndGetAccessToken(String email) throws Exception { - RegisterRequest request = new RegisterRequest("Gamification User", email, "pass1234", "pass1234"); + RegisterRequest request = new RegisterRequest("Gamification User", email, "pass1234", "pass1234", true); MvcResult result = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) diff --git a/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java b/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java index 518c62e2..7a602abd 100644 --- a/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java +++ b/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java @@ -87,7 +87,7 @@ void setUp() throws Exception { userInterviewMetricsRepository.deleteAll(); userRepository.deleteAll(); - RegisterRequest reg = new RegisterRequest("Drive User", "driveuser@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("Drive User", "driveuser@example.com", "pass1234", "pass1234", true); MvcResult result = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) diff --git a/src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java b/src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java index 23edf7d0..caa69161 100644 --- a/src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java +++ b/src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java @@ -223,7 +223,7 @@ void googleDriveCallback_shouldRemainPublic() throws Exception { } private AuthResponse registerUser(String email, String password) throws Exception { - RegisterRequest request = new RegisterRequest("GPT User", email, password, password); + RegisterRequest request = new RegisterRequest("GPT User", email, password, password, true); MvcResult result = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) diff --git a/src/test/java/com/jobtracker/integration/mcp/McpAuthIT.java b/src/test/java/com/jobtracker/integration/mcp/McpAuthIT.java index f091b9ee..6904cf7e 100644 --- a/src/test/java/com/jobtracker/integration/mcp/McpAuthIT.java +++ b/src/test/java/com/jobtracker/integration/mcp/McpAuthIT.java @@ -59,7 +59,7 @@ void setUp() throws Exception { refreshTokenRepository.deleteAll(); userRepository.deleteAll(); - RegisterRequest reg = new RegisterRequest("MCP Test User", "mcp-test@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("MCP Test User", "mcp-test@example.com", "pass1234", "pass1234", true); MvcResult result = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) diff --git a/src/test/java/com/jobtracker/integration/mcp/McpToolsIT.java b/src/test/java/com/jobtracker/integration/mcp/McpToolsIT.java index 3c4402f9..af08f7e4 100644 --- a/src/test/java/com/jobtracker/integration/mcp/McpToolsIT.java +++ b/src/test/java/com/jobtracker/integration/mcp/McpToolsIT.java @@ -101,7 +101,7 @@ void setUp() throws Exception { refreshTokenRepository.deleteAll(); userRepository.deleteAll(); - RegisterRequest reg = new RegisterRequest("Tools Test User", "tools-test@example.com", "pass1234", "pass1234"); + RegisterRequest reg = new RegisterRequest("Tools Test User", "tools-test@example.com", "pass1234", "pass1234", true); MvcResult result = mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reg))) diff --git a/src/test/java/com/jobtracker/unit/AuthServiceTest.java b/src/test/java/com/jobtracker/unit/AuthServiceTest.java index 508d8088..e4da1839 100644 --- a/src/test/java/com/jobtracker/unit/AuthServiceTest.java +++ b/src/test/java/com/jobtracker/unit/AuthServiceTest.java @@ -56,7 +56,7 @@ class AuthServiceTest { @Test void register_shouldReturnAuthResponse_whenValidRequest() { - RegisterRequest request = new RegisterRequest("John", "john@example.com", "pass1234", "pass1234"); + RegisterRequest request = new RegisterRequest("John", "john@example.com", "pass1234", "pass1234", true); User savedUser = buildUser(USER_UUID, "john@example.com"); when(userRepository.existsByEmail(request.email())).thenReturn(false); @@ -71,7 +71,7 @@ void register_shouldReturnAuthResponse_whenValidRequest() { "john@example.com", LocalTime.of(9, 0), Set.of("USER"), - false)); + false, true)); AuthResponse result = authService.register(request); @@ -85,7 +85,7 @@ void register_shouldReturnAuthResponse_whenValidRequest() { @Test void register_shouldThrow_whenPasswordsDoNotMatch() { - RegisterRequest request = new RegisterRequest("John", "john@example.com", "pass1234", "different"); + RegisterRequest request = new RegisterRequest("John", "john@example.com", "pass1234", "different", true); assertThatThrownBy(() -> authService.register(request)) .isInstanceOf(BadRequestException.class) .hasMessageContaining("Passwords do not match"); @@ -93,7 +93,7 @@ void register_shouldThrow_whenPasswordsDoNotMatch() { @Test void register_shouldThrow_whenEmailAlreadyExists() { - RegisterRequest request = new RegisterRequest("John", "john@example.com", "pass1234", "pass1234"); + RegisterRequest request = new RegisterRequest("John", "john@example.com", "pass1234", "pass1234", true); when(userRepository.existsByEmail(request.email())).thenReturn(true); assertThatThrownBy(() -> authService.register(request)) .isInstanceOf(ConflictException.class) @@ -115,7 +115,7 @@ void login_shouldReturnAuthResponse_whenValidCredentials() { "john@example.com", LocalTime.of(9, 0), Set.of("USER"), - false)); + false, true)); AuthResponse result = authService.login(request); diff --git a/src/test/java/com/jobtracker/unit/mcp/McpProfileToolsTest.java b/src/test/java/com/jobtracker/unit/mcp/McpProfileToolsTest.java index 2e9fb7d6..2440cfab 100644 --- a/src/test/java/com/jobtracker/unit/mcp/McpProfileToolsTest.java +++ b/src/test/java/com/jobtracker/unit/mcp/McpProfileToolsTest.java @@ -62,7 +62,7 @@ void currentUser_serializesAuthenticatedUser() throws Exception { "john@example.com", java.time.LocalTime.of(19, 0), java.util.Set.of("USER", "BETA"), - true); + true, true); when(securityUtils.getCurrentUser()).thenReturn(user); when(authMapper.toUserResponse(user)).thenReturn(expected); From e3216d2ac8f5cdddfce9ce7ff7b41334ae64d23a Mon Sep 17 00:00:00 2001 From: vitorhugo-java Date: Mon, 8 Jun 2026 21:12:58 -0300 Subject: [PATCH 3/4] fix: replace Lombok on ToolExecutionMetric and add missing acceptedPrivacyPolicy in E2E tests ToolExecutionMetric was using Lombok annotations (@Getter, @Setter, @Builder) which fail annotation processing under GraalVM/Java 25. Replaced with manual getters, setters, and a static inner Builder following the existing codebase style. E2E register payloads were missing the acceptedPrivacyPolicy field introduced in a recent migration, causing all registration calls to return 400. Added acceptedPrivacyPolicy: true to all affected test payloads in ApplicationE2ETest and AuthE2ETest. Co-Authored-By: Claude Sonnet 4.6 --- .../entity/ToolExecutionMetric.java | 74 +++++++++++++++++-- .../jobtracker/e2e/ApplicationE2ETest.java | 6 +- .../java/com/jobtracker/e2e/AuthE2ETest.java | 18 +++-- 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/jobtracker/entity/ToolExecutionMetric.java b/src/main/java/com/jobtracker/entity/ToolExecutionMetric.java index eb5c1ded..c6115ef5 100644 --- a/src/main/java/com/jobtracker/entity/ToolExecutionMetric.java +++ b/src/main/java/com/jobtracker/entity/ToolExecutionMetric.java @@ -1,7 +1,6 @@ package com.jobtracker.entity; import jakarta.persistence.*; -import lombok.*; import org.hibernate.annotations.UuidGenerator; import java.time.LocalDateTime; @@ -13,11 +12,6 @@ @Index(name = "idx_tool_metrics_created_at", columnList = "created_at"), @Index(name = "idx_tool_metrics_expensive", columnList = "expensive") }) -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor public class ToolExecutionMetric { @Id @@ -58,4 +52,72 @@ protected void onCreate() { createdAt = LocalDateTime.now(); } } + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + + public String getToolName() { return toolName; } + public void setToolName(String toolName) { this.toolName = toolName; } + + public long getExecutionTimeMs() { return executionTimeMs; } + public void setExecutionTimeMs(long executionTimeMs) { this.executionTimeMs = executionTimeMs; } + + public int getRequestBytes() { return requestBytes; } + public void setRequestBytes(int requestBytes) { this.requestBytes = requestBytes; } + + public int getResponseBytes() { return responseBytes; } + public void setResponseBytes(int responseBytes) { this.responseBytes = responseBytes; } + + public int getRequestTokens() { return requestTokens; } + public void setRequestTokens(int requestTokens) { this.requestTokens = requestTokens; } + + public int getResponseTokens() { return responseTokens; } + public void setResponseTokens(int responseTokens) { this.responseTokens = responseTokens; } + + public int getTotalTokens() { return totalTokens; } + public void setTotalTokens(int totalTokens) { this.totalTokens = totalTokens; } + + public boolean isExpensive() { return expensive; } + public void setExpensive(boolean expensive) { this.expensive = expensive; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public static Builder builder() { return new Builder(); } + + public static class Builder { + private String toolName; + private long executionTimeMs; + private int requestBytes; + private int responseBytes; + private int requestTokens; + private int responseTokens; + private int totalTokens; + private boolean expensive; + private LocalDateTime createdAt; + + public Builder toolName(String toolName) { this.toolName = toolName; return this; } + public Builder executionTimeMs(long executionTimeMs) { this.executionTimeMs = executionTimeMs; return this; } + public Builder requestBytes(int requestBytes) { this.requestBytes = requestBytes; return this; } + public Builder responseBytes(int responseBytes) { this.responseBytes = responseBytes; return this; } + public Builder requestTokens(int requestTokens) { this.requestTokens = requestTokens; return this; } + public Builder responseTokens(int responseTokens) { this.responseTokens = responseTokens; return this; } + public Builder totalTokens(int totalTokens) { this.totalTokens = totalTokens; return this; } + public Builder expensive(boolean expensive) { this.expensive = expensive; return this; } + public Builder createdAt(LocalDateTime createdAt) { this.createdAt = createdAt; return this; } + + public ToolExecutionMetric build() { + ToolExecutionMetric m = new ToolExecutionMetric(); + m.toolName = toolName; + m.executionTimeMs = executionTimeMs; + m.requestBytes = requestBytes; + m.responseBytes = responseBytes; + m.requestTokens = requestTokens; + m.responseTokens = responseTokens; + m.totalTokens = totalTokens; + m.expensive = expensive; + m.createdAt = createdAt; + return m; + } + } } diff --git a/src/test/java/com/jobtracker/e2e/ApplicationE2ETest.java b/src/test/java/com/jobtracker/e2e/ApplicationE2ETest.java index 81c7a17e..81fa3b62 100644 --- a/src/test/java/com/jobtracker/e2e/ApplicationE2ETest.java +++ b/src/test/java/com/jobtracker/e2e/ApplicationE2ETest.java @@ -47,7 +47,8 @@ void setUp() { "name": "App E2E User", "email": "appe2e@example.com", "password": "pass1234", - "confirmPassword": "pass1234" + "confirmPassword": "pass1234", + "acceptedPrivacyPolicy": true } """) .post("/api/v1/auth/register") @@ -282,7 +283,8 @@ void getById_shouldReturn404_whenBelongsToAnotherUser() { .contentType("application/json") .body(""" {"name": "Other User", "email": "other@example.com", - "password": "pass1234", "confirmPassword": "pass1234"} + "password": "pass1234", "confirmPassword": "pass1234", + "acceptedPrivacyPolicy": true} """) .post("/api/v1/auth/register"); diff --git a/src/test/java/com/jobtracker/e2e/AuthE2ETest.java b/src/test/java/com/jobtracker/e2e/AuthE2ETest.java index 96efad5f..1a5fba7f 100644 --- a/src/test/java/com/jobtracker/e2e/AuthE2ETest.java +++ b/src/test/java/com/jobtracker/e2e/AuthE2ETest.java @@ -57,7 +57,8 @@ void fullAuthFlow_register_login_refresh_logout() { "name": "E2E User", "email": "e2e@example.com", "password": "pass1234", - "confirmPassword": "pass1234" + "confirmPassword": "pass1234", + "acceptedPrivacyPolicy": true } """) .when() @@ -168,7 +169,8 @@ void register_shouldSetHttpOnlySecureSameSiteCookie() { "name": "Cookie User", "email": "cookies@example.com", "password": "pass1234", - "confirmPassword": "pass1234" + "confirmPassword": "pass1234", + "acceptedPrivacyPolicy": true } """) .when() @@ -192,7 +194,8 @@ void register_shouldReturn409_whenEmailDuplicated() { "name": "Dup User", "email": "dup@example.com", "password": "pass1234", - "confirmPassword": "pass1234" + "confirmPassword": "pass1234", + "acceptedPrivacyPolicy": true } """; @@ -212,7 +215,8 @@ void register_shouldReturn400_whenPasswordsMismatch() { "name": "Mismatch User", "email": "mismatch@example.com", "password": "pass1234", - "confirmPassword": "different" + "confirmPassword": "different", + "acceptedPrivacyPolicy": true } """) .when() @@ -231,7 +235,8 @@ void login_shouldReturn401_whenWrongPassword() { "name": "Wrong Pass", "email": "wrongpass@example.com", "password": "pass1234", - "confirmPassword": "pass1234" + "confirmPassword": "pass1234", + "acceptedPrivacyPolicy": true } """) .post("/api/v1/auth/register") @@ -265,7 +270,8 @@ void logout_shouldReturnSuccess_andClearCookie() { "name": "Logout User", "email": "logout@example.com", "password": "pass1234", - "confirmPassword": "pass1234" + "confirmPassword": "pass1234", + "acceptedPrivacyPolicy": true } """) .when() From 353292591628b7bb92ae4350b0b6a1e85877b348 Mon Sep 17 00:00:00 2001 From: vitorhugo-java Date: Tue, 9 Jun 2026 08:31:46 -0300 Subject: [PATCH 4/4] feat(status): migrate application status from enum to DB-backed table Replaces the hardcoded ApplicationStatus Java enum with a database-backed `application_statuses` table, enabling dynamic status management without code deployments. Key changes: - Flyway V29: creates application_statuses table seeded with 9 English values (RH, Pending HR Response, Pending Hiring Manager Response, Technical Test, Pending Technical Test Response, Offer Negotiation, Ghosting, Rejected, Approved) and adds `to_send_later` boolean column - ApplicationStatusEntity + ApplicationStatusRepository for JPA access - JobApplication/InterviewEvent: status fields changed from enum to String - ApplicationResponse: new `toSendLater` field - ApplicationService: added listStatuses(), validateStatus() with 400 on unknown values; buildRequest() enforces applicationDate required when status is provided - GET /api/v1/applications/statuses endpoint returns ordered status list - MCP: new List-Statuses tool; updated Create-Application and Update-Application-Status descriptions to always call it first - GamificationService, InterviewMetricsService, DashboardService, StaleApplicationsScheduler: migrated from enum comparisons to String checks, retaining backward-compat with old PT-BR constant names - Tests: updated all integration/e2e/unit tests to use new English status values; added data.sql seed + defer-datasource-initialization for H2 All 212 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- .../com/jobtracker/config/FakeDataSeeder.java | 12 ++-- .../controller/ApplicationController.java | 13 ++++ .../dto/application/ApplicationResponse.java | 2 + .../entity/ApplicationStatusEntity.java | 22 +++++++ .../com/jobtracker/entity/InterviewEvent.java | 17 ++--- .../com/jobtracker/entity/JobApplication.java | 21 +++--- .../jobtracker/mapper/ApplicationMapper.java | 5 +- .../com/jobtracker/mcp/McpPromptsConfig.java | 1 + .../McpApplicationStatusesResource.java | 26 ++++---- .../mcp/tools/McpAnalyticsTools.java | 11 ++-- .../mcp/tools/McpApplicationTools.java | 22 ++++++- .../repository/ApplicationRepository.java | 7 +- .../ApplicationStatusRepository.java | 11 ++++ .../service/ApplicationService.java | 64 ++++++++++++------- .../jobtracker/service/DashboardService.java | 20 ++++-- .../service/GamificationService.java | 42 ++++++------ .../service/InterviewMetricsService.java | 39 +++++------ .../tasks/StaleApplicationsScheduler.java | 12 ++-- .../V29__add_application_status_table.sql | 23 +++++++ .../jobtracker/e2e/ApplicationE2ETest.java | 6 +- .../integration/ApplicationControllerIT.java | 24 +++---- .../integration/ApplicationRepositoryIT.java | 3 +- .../unit/ApplicationServiceTest.java | 23 ++++--- .../unit/GamificationServiceTest.java | 47 +++++++------- .../unit/InterviewMetricsServiceTest.java | 37 +++++++---- .../unit/mcp/McpAnalyticsToolsTest.java | 35 +++++----- .../unit/mcp/McpApplicationToolsTest.java | 2 +- src/test/resources/application-test.yml | 1 + src/test/resources/data.sql | 10 +++ 29 files changed, 346 insertions(+), 212 deletions(-) create mode 100644 src/main/java/com/jobtracker/entity/ApplicationStatusEntity.java create mode 100644 src/main/java/com/jobtracker/repository/ApplicationStatusRepository.java create mode 100644 src/main/resources/db/migration/V29__add_application_status_table.sql create mode 100644 src/test/resources/data.sql diff --git a/src/main/java/com/jobtracker/config/FakeDataSeeder.java b/src/main/java/com/jobtracker/config/FakeDataSeeder.java index 8df449ce..6765c21e 100644 --- a/src/main/java/com/jobtracker/config/FakeDataSeeder.java +++ b/src/main/java/com/jobtracker/config/FakeDataSeeder.java @@ -2,7 +2,6 @@ import com.jobtracker.entity.JobApplication; import com.jobtracker.entity.User; -import com.jobtracker.entity.enums.ApplicationStatus; import com.jobtracker.repository.ApplicationRepository; import com.jobtracker.repository.UserRepository; import net.datafaker.Faker; @@ -95,8 +94,13 @@ private JobApplication buildFakeApplication(User user) { return application; } - private ApplicationStatus randomStatus() { - ApplicationStatus[] statuses = ApplicationStatus.values(); - return statuses[ThreadLocalRandom.current().nextInt(statuses.length)]; + private static final String[] STATUSES = { + "RH", "Pending HR Response", "Pending Hiring Manager Response", + "Technical Test", "Pending Technical Test Response", + "Offer Negotiation", "Ghosting", "Rejected", "Approved" + }; + + private String randomStatus() { + return STATUSES[ThreadLocalRandom.current().nextInt(STATUSES.length)]; } } diff --git a/src/main/java/com/jobtracker/controller/ApplicationController.java b/src/main/java/com/jobtracker/controller/ApplicationController.java index 04a8dd81..09249e7d 100644 --- a/src/main/java/com/jobtracker/controller/ApplicationController.java +++ b/src/main/java/com/jobtracker/controller/ApplicationController.java @@ -208,6 +208,19 @@ public ResponseEntity> getOverdue() { return ResponseEntity.ok(applicationService.getOverdue()); } + @Operation( + summary = "List valid application statuses", + description = "Returns all valid status values ordered by display order", + responses = { + @ApiResponse(responseCode = "200", description = "List of valid status values") + } + ) + @PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_read:applications')") + @GetMapping("/statuses") + public ResponseEntity> getStatuses() { + return ResponseEntity.ok(applicationService.listStatuses()); + } + @Operation( summary = "Extract link metadata", description = "Extracts rich preview metadata (title, description, image, domain) from a URL", diff --git a/src/main/java/com/jobtracker/dto/application/ApplicationResponse.java b/src/main/java/com/jobtracker/dto/application/ApplicationResponse.java index d70b340f..08ec82fe 100644 --- a/src/main/java/com/jobtracker/dto/application/ApplicationResponse.java +++ b/src/main/java/com/jobtracker/dto/application/ApplicationResponse.java @@ -60,6 +60,8 @@ public record ApplicationResponse( @Schema(description = "Timestamp when the latest Google Docs resume was generated", example = "2024-06-11T14:45:00") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime driveResumeGeneratedAt, + @Schema(description = "Whether this application is queued to be sent later (draft mode)", example = "false") + boolean toSendLater, @Schema(description = "Number of interviews held for this application", example = "2") int interviewCount, @Schema(description = "Record creation timestamp", example = "2024-06-01T10:00:00") diff --git a/src/main/java/com/jobtracker/entity/ApplicationStatusEntity.java b/src/main/java/com/jobtracker/entity/ApplicationStatusEntity.java new file mode 100644 index 00000000..e65593cf --- /dev/null +++ b/src/main/java/com/jobtracker/entity/ApplicationStatusEntity.java @@ -0,0 +1,22 @@ +package com.jobtracker.entity; + +import jakarta.persistence.*; + +@Entity +@Table(name = "application_statuses") +public class ApplicationStatusEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false, length = 100) + private String name; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + public Long getId() { return id; } + public String getName() { return name; } + public int getDisplayOrder() { return displayOrder; } +} diff --git a/src/main/java/com/jobtracker/entity/InterviewEvent.java b/src/main/java/com/jobtracker/entity/InterviewEvent.java index 6c57ccf0..5e6008f3 100644 --- a/src/main/java/com/jobtracker/entity/InterviewEvent.java +++ b/src/main/java/com/jobtracker/entity/InterviewEvent.java @@ -1,10 +1,7 @@ package com.jobtracker.entity; -import com.jobtracker.entity.enums.ApplicationStatus; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.Index; @@ -37,13 +34,11 @@ public class InterviewEvent { @JoinColumn(name = "application_id", nullable = false) private JobApplication application; - @Enumerated(EnumType.STRING) @Column(name = "old_status", length = 100, columnDefinition = "varchar(100)") - private ApplicationStatus oldStatus; + private String oldStatus; - @Enumerated(EnumType.STRING) @Column(name = "new_status", nullable = false, length = 100, columnDefinition = "varchar(100)") - private ApplicationStatus newStatus; + private String newStatus; @Column(name = "occurred_at", nullable = false) private LocalDateTime occurredAt; @@ -79,19 +74,19 @@ public void setApplication(JobApplication application) { this.application = application; } - public ApplicationStatus getOldStatus() { + public String getOldStatus() { return oldStatus; } - public void setOldStatus(ApplicationStatus oldStatus) { + public void setOldStatus(String oldStatus) { this.oldStatus = oldStatus; } - public ApplicationStatus getNewStatus() { + public String getNewStatus() { return newStatus; } - public void setNewStatus(ApplicationStatus newStatus) { + public void setNewStatus(String newStatus) { this.newStatus = newStatus; } diff --git a/src/main/java/com/jobtracker/entity/JobApplication.java b/src/main/java/com/jobtracker/entity/JobApplication.java index 698fd905..6e13a851 100644 --- a/src/main/java/com/jobtracker/entity/JobApplication.java +++ b/src/main/java/com/jobtracker/entity/JobApplication.java @@ -1,6 +1,5 @@ package com.jobtracker.entity; -import com.jobtracker.entity.enums.ApplicationStatus; import jakarta.persistence.*; import java.time.LocalDate; import java.time.LocalDateTime; @@ -49,13 +48,14 @@ public class JobApplication { @Column(name = "next_step_date_time") private LocalDateTime nextStepDateTime; - @Enumerated(EnumType.STRING) @Column(nullable = true, length = 100, columnDefinition = "varchar(100)") - private ApplicationStatus status; + private String status; - @Enumerated(EnumType.STRING) @Column(name = "previous_status", nullable = true, length = 100, columnDefinition = "varchar(100)") - private ApplicationStatus previousStatus; + private String previousStatus; + + @Column(name = "to_send_later", nullable = false) + private boolean toSendLater; @Column(name = "recruiter_dm_reminder_enabled", nullable = false) private boolean recruiterDmReminderEnabled; @@ -156,11 +156,14 @@ protected void onUpdate() { public LocalDateTime getNextStepDateTime() { return nextStepDateTime; } public void setNextStepDateTime(LocalDateTime nextStepDateTime) { this.nextStepDateTime = nextStepDateTime; } - public ApplicationStatus getStatus() { return status; } - public void setStatus(ApplicationStatus status) { this.status = status; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public String getPreviousStatus() { return previousStatus; } + public void setPreviousStatus(String previousStatus) { this.previousStatus = previousStatus; } - public ApplicationStatus getPreviousStatus() { return previousStatus; } - public void setPreviousStatus(ApplicationStatus previousStatus) { this.previousStatus = previousStatus; } + public boolean isToSendLater() { return toSendLater; } + public void setToSendLater(boolean toSendLater) { this.toSendLater = toSendLater; } public boolean isRecruiterDmReminderEnabled() { return recruiterDmReminderEnabled; } public void setRecruiterDmReminderEnabled(boolean recruiterDmReminderEnabled) { this.recruiterDmReminderEnabled = recruiterDmReminderEnabled; } diff --git a/src/main/java/com/jobtracker/mapper/ApplicationMapper.java b/src/main/java/com/jobtracker/mapper/ApplicationMapper.java index 48fedfb4..10b97b65 100644 --- a/src/main/java/com/jobtracker/mapper/ApplicationMapper.java +++ b/src/main/java/com/jobtracker/mapper/ApplicationMapper.java @@ -18,8 +18,8 @@ public ApplicationResponse toResponse(JobApplication app) { app.isRhAcceptedConnection(), app.isInterviewScheduled(), app.getNextStepDateTime(), - app.getStatus() != null ? app.getStatus().getDisplayName() : null, - app.getPreviousStatus() != null ? app.getPreviousStatus().getDisplayName() : null, + app.getStatus(), + app.getPreviousStatus(), app.isRecruiterDmReminderEnabled(), app.getRecruiterDmSentAt(), app.getNote(), @@ -31,6 +31,7 @@ public ApplicationResponse toResponse(JobApplication app) { app.getDriveResumeFileName(), app.getDriveResumeDocumentUrl(), app.getDriveResumeGeneratedAt(), + app.isToSendLater(), app.getInterviewCount(), app.getCreatedAt(), app.getUpdatedAt() diff --git a/src/main/java/com/jobtracker/mcp/McpPromptsConfig.java b/src/main/java/com/jobtracker/mcp/McpPromptsConfig.java index a1e37c2f..f045613a 100644 --- a/src/main/java/com/jobtracker/mcp/McpPromptsConfig.java +++ b/src/main/java/com/jobtracker/mcp/McpPromptsConfig.java @@ -16,6 +16,7 @@ * Registers MCP prompt templates. */ @Service +@SuppressWarnings("unused") public class McpPromptsConfig { /** diff --git a/src/main/java/com/jobtracker/mcp/resources/McpApplicationStatusesResource.java b/src/main/java/com/jobtracker/mcp/resources/McpApplicationStatusesResource.java index 13afa7ee..75d33860 100644 --- a/src/main/java/com/jobtracker/mcp/resources/McpApplicationStatusesResource.java +++ b/src/main/java/com/jobtracker/mcp/resources/McpApplicationStatusesResource.java @@ -1,7 +1,8 @@ package com.jobtracker.mcp.resources; -import com.jobtracker.entity.enums.ApplicationStatus; +import com.jobtracker.entity.ApplicationStatusEntity; import com.jobtracker.mcp.McpResourcesConfig; +import com.jobtracker.repository.ApplicationStatusRepository; import io.modelcontextprotocol.spec.McpSchema.Role; import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.annotation.McpResource.McpAnnotations; @@ -10,13 +11,19 @@ @Service public class McpApplicationStatusesResource { - private static final String LAST_MODIFIED = "2026-06-04"; + private static final String LAST_MODIFIED = "2026-06-08"; + + private final ApplicationStatusRepository applicationStatusRepository; + + public McpApplicationStatusesResource(ApplicationStatusRepository applicationStatusRepository) { + this.applicationStatusRepository = applicationStatusRepository; + } @McpResource( uri = McpResourcesConfig.URI_APPLICATION_STATUSES, name = "Application Statuses", title = "Application Statuses", - description = "Markdown catalog of valid application statuses and their meanings.", + description = "Markdown catalog of valid application statuses.", mimeType = "text/markdown", annotations = @McpAnnotations( audience = {Role.ASSISTANT}, @@ -26,19 +33,16 @@ public String applicationStatuses() { StringBuilder text = new StringBuilder(""" # Valid Application Status Values - Use the exact display names below (case-sensitive): + Use the exact names below (case-sensitive). Call List-Statuses or \ + GET /api/v1/applications/statuses to retrieve them at runtime. """); - for (ApplicationStatus status : ApplicationStatus.values()) { - text.append("- ") - .append(status.getDisplayName()) - .append(" — ") - .append(status.getDescription()) - .append('\n'); + for (ApplicationStatusEntity status : applicationStatusRepository.findAllByOrderByDisplayOrderAsc()) { + text.append("- ").append(status.getName()).append('\n'); } - text.append("\nOmit status (pass null) when logging a fresh cold outreach application.\n"); + text.append("\nOmit status (pass null) when logging a draft/to-send-later application.\n"); return text.toString(); } } diff --git a/src/main/java/com/jobtracker/mcp/tools/McpAnalyticsTools.java b/src/main/java/com/jobtracker/mcp/tools/McpAnalyticsTools.java index 60311c6d..dc662d64 100644 --- a/src/main/java/com/jobtracker/mcp/tools/McpAnalyticsTools.java +++ b/src/main/java/com/jobtracker/mcp/tools/McpAnalyticsTools.java @@ -5,7 +5,6 @@ import com.jobtracker.dto.analytics.WeeklySummaryResponse; import com.jobtracker.dto.application.ApplicationResponse; import com.jobtracker.entity.JobApplication; -import com.jobtracker.entity.enums.ApplicationStatus; import com.jobtracker.mapper.ApplicationMapper; import com.jobtracker.repository.ApplicationRepository; import com.jobtracker.service.ApplicationService; @@ -78,8 +77,10 @@ public AnalyticsResponse getAnalytics( int total = apps.size(); int interviewCount = (int) apps.stream().filter(JobApplication::isInterviewScheduled).count(); - int rejectionCount = (int) apps.stream().filter(a -> ApplicationStatus.REJEITADO == a.getStatus()).count(); - int ghostingCount = (int) apps.stream().filter(a -> ApplicationStatus.GHOSTING == a.getStatus()).count(); + int rejectionCount = (int) apps.stream().filter(a -> + "REJEITADO".equals(a.getStatus()) || "Rejected".equals(a.getStatus())).count(); + int ghostingCount = (int) apps.stream().filter(a -> + "GHOSTING".equals(a.getStatus()) || "Ghosting".equals(a.getStatus())).count(); double interviewRate = total > 0 ? Math.round(interviewCount * 1000.0 / total) / 10.0 : 0.0; double rejectionRate = total > 0 ? Math.round(rejectionCount * 1000.0 / total) / 10.0 : 0.0; @@ -96,7 +97,7 @@ public AnalyticsResponse getAnalytics( Map statusBreakdown = new LinkedHashMap<>(); for (JobApplication app : apps) { - String key = app.getStatus() != null ? app.getStatus().getDisplayName() : "To Send Later"; + String key = app.getStatus() != null ? app.getStatus() : "To Send Later"; statusBreakdown.merge(key, 1, Integer::sum); } @@ -136,7 +137,7 @@ public List getApplicationsByOrganization() { .map(entry -> { List orgApps = entry.getValue(); List statuses = orgApps.stream() - .map(a -> a.getStatus() != null ? a.getStatus().getDisplayName() : null) + .map(JobApplication::getStatus) .filter(Objects::nonNull) .distinct() .toList(); diff --git a/src/main/java/com/jobtracker/mcp/tools/McpApplicationTools.java b/src/main/java/com/jobtracker/mcp/tools/McpApplicationTools.java index 88655907..cddc99fc 100644 --- a/src/main/java/com/jobtracker/mcp/tools/McpApplicationTools.java +++ b/src/main/java/com/jobtracker/mcp/tools/McpApplicationTools.java @@ -8,6 +8,7 @@ import com.jobtracker.dto.application.UpdateStatusRequest; import com.jobtracker.service.ApplicationService; import com.jobtracker.service.ToolMetricsCollector; +import java.util.List; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool.McpAnnotations; import org.springaicommunity.mcp.annotation.McpToolParam; @@ -16,7 +17,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import java.util.UUID; @@ -32,6 +32,22 @@ public McpApplicationTools(ApplicationService applicationService, this.metricsCollector = metricsCollector; } + // --- Status tools --- + + @McpTool( + name = "List-Statuses", + title = "List Statuses", + description = "Returns all valid application status values. ALWAYS call this before setting any status. Never hardcode or assume status values.", + annotations = @McpAnnotations( + title = "List Statuses", + readOnlyHint = true, + destructiveHint = false, + idempotentHint = true, + openWorldHint = false)) + public List listStatuses() { + return metricsCollector.measure("List-Statuses", null, applicationService::listStatuses); + } + // --- Read tools --- @McpTool( @@ -125,7 +141,7 @@ public List getOverdueApplications() { @McpTool( name = "Create-Application", title = "Create Application", - description = "Create a new job application record.", + description = "Create a new job application record. IMPORTANT: Call List-Statuses first to get valid status values. Never use a status value from memory.", annotations = @McpAnnotations( title = "Create Application", readOnlyHint = false, @@ -199,7 +215,7 @@ public ApplicationResponse updateApplication( @McpTool( name = "Update-Application-Status", title = "Update Application Status", - description = "Update only the status of an existing job application.", + description = "Update only the status of an existing job application. IMPORTANT: Call List-Statuses first to get valid status values. Never use a status value from memory.", annotations = @McpAnnotations( title = "Update Application Status", readOnlyHint = false, diff --git a/src/main/java/com/jobtracker/repository/ApplicationRepository.java b/src/main/java/com/jobtracker/repository/ApplicationRepository.java index f2450d88..2c65f1bb 100644 --- a/src/main/java/com/jobtracker/repository/ApplicationRepository.java +++ b/src/main/java/com/jobtracker/repository/ApplicationRepository.java @@ -1,7 +1,6 @@ package com.jobtracker.repository; import com.jobtracker.entity.JobApplication; -import com.jobtracker.entity.enums.ApplicationStatus; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -29,7 +28,7 @@ public interface ApplicationRepository extends JpaRepository statuses); + long countByUserIdAndStatusInAndArchivedFalse(@Param("userId") UUID userId, @Param("statuses") List statuses); List findAllByUser_IdAndArchivedFalse(UUID userId); @@ -55,7 +54,7 @@ public interface ApplicationRepository extends JpaRepository findByStatusIsNullAndUpdatedAtBefore(LocalDateTime updatedAt); - List findByStatusIsNotNullAndStatusNotAndUpdatedAtBefore(ApplicationStatus status, LocalDateTime updatedAt); + List findByStatusIsNotNullAndStatusNotAndUpdatedAtBefore(String status, LocalDateTime updatedAt); /** * Atomically sets {@code driveVacancyFolderId} on an application only when the column is diff --git a/src/main/java/com/jobtracker/repository/ApplicationStatusRepository.java b/src/main/java/com/jobtracker/repository/ApplicationStatusRepository.java new file mode 100644 index 00000000..bb83ee0d --- /dev/null +++ b/src/main/java/com/jobtracker/repository/ApplicationStatusRepository.java @@ -0,0 +1,11 @@ +package com.jobtracker.repository; + +import com.jobtracker.entity.ApplicationStatusEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ApplicationStatusRepository extends JpaRepository { + List findAllByOrderByDisplayOrderAsc(); + boolean existsByName(String name); +} diff --git a/src/main/java/com/jobtracker/service/ApplicationService.java b/src/main/java/com/jobtracker/service/ApplicationService.java index c57916d6..1777d4d3 100644 --- a/src/main/java/com/jobtracker/service/ApplicationService.java +++ b/src/main/java/com/jobtracker/service/ApplicationService.java @@ -1,12 +1,13 @@ package com.jobtracker.service; import com.jobtracker.dto.application.*; +import com.jobtracker.entity.ApplicationStatusEntity; import com.jobtracker.entity.JobApplication; -import com.jobtracker.entity.enums.ApplicationStatus; import com.jobtracker.exception.BadRequestException; import com.jobtracker.exception.ResourceNotFoundException; import com.jobtracker.mapper.ApplicationMapper; import com.jobtracker.repository.ApplicationRepository; +import com.jobtracker.repository.ApplicationStatusRepository; import com.jobtracker.repository.InterviewEventRepository; import com.jobtracker.util.SecurityUtils; import io.micrometer.tracing.Span; @@ -39,6 +40,7 @@ public class ApplicationService { ); private final ApplicationRepository applicationRepository; + private final ApplicationStatusRepository applicationStatusRepository; private final InterviewEventRepository interviewEventRepository; private final ApplicationMapper applicationMapper; private final GamificationService gamificationService; @@ -47,6 +49,7 @@ public class ApplicationService { private final Tracer tracer; public ApplicationService(ApplicationRepository applicationRepository, + ApplicationStatusRepository applicationStatusRepository, InterviewEventRepository interviewEventRepository, ApplicationMapper applicationMapper, GamificationService gamificationService, @@ -54,6 +57,7 @@ public ApplicationService(ApplicationRepository applicationRepository, SecurityUtils securityUtils, Tracer tracer) { this.applicationRepository = applicationRepository; + this.applicationStatusRepository = applicationStatusRepository; this.interviewEventRepository = interviewEventRepository; this.applicationMapper = applicationMapper; this.gamificationService = gamificationService; @@ -62,6 +66,14 @@ public ApplicationService(ApplicationRepository applicationRepository, this.tracer = tracer; } + @Transactional(readOnly = true) + public List listStatuses() { + return applicationStatusRepository.findAllByOrderByDisplayOrderAsc() + .stream() + .map(ApplicationStatusEntity::getName) + .toList(); + } + @Transactional public ApplicationResponse create(ApplicationRequest request) { Span span = tracer.nextSpan().name("create-application").start(); @@ -94,7 +106,7 @@ public ApplicationResponse update(UUID id, ApplicationRequest request) { UUID userId = securityUtils.getCurrentUserId(); JobApplication app = applicationRepository.findByIdAndUserId(id, userId) .orElseThrow(() -> new ResourceNotFoundException("Application not found with id: " + id)); - ApplicationStatus previousStatus = app.getStatus(); + String previousStatus = app.getStatus(); boolean previousInterviewScheduled = app.isInterviewScheduled(); String previousNote = app.getNote(); mapRequestToEntity(request, app); @@ -109,8 +121,8 @@ public ApplicationResponse updateStatus(UUID id, UpdateStatusRequest request) { UUID userId = securityUtils.getCurrentUserId(); JobApplication app = applicationRepository.findByIdAndUserId(id, userId) .orElseThrow(() -> new ResourceNotFoundException("Application not found with id: " + id)); - ApplicationStatus previousStatus = app.getStatus(); - applyStatusChange(app, resolveStatus(request.status())); + String previousStatus = app.getStatus(); + applyStatusChange(app, validateStatus(request.status())); JobApplication saved = applicationRepository.save(app); interviewMetricsService.recordStatusTransition(saved, previousStatus, saved.getStatus()); gamificationService.onApplicationStatusUpdated(saved, previousStatus); @@ -205,20 +217,23 @@ public List getOverdue() { } private void mapRequestToEntity(ApplicationRequest request, JobApplication app) { - boolean isSendLater = request.status() == null || request.status().isBlank(); + boolean isSendLater = request.status() == null || request.status().isBlank() + || TO_SEND_LATER_STATUS.equalsIgnoreCase(request.status()); if (!isSendLater && request.applicationDate() == null) { - throw new BadRequestException("Application date is required when 'Send Later' is not marked"); + throw new BadRequestException( + "applicationDate is required when status is provided. Set status to null for 'To Send Later'."); } app.setVacancyName(normalizeOptionalText(request.vacancyName())); app.setRecruiterName(request.recruiterName()); app.setOrganization(request.organization()); app.setVacancyLink(request.vacancyLink()); + app.setToSendLater(isSendLater); app.setApplicationDate(isSendLater ? null : request.applicationDate()); app.setRhAcceptedConnection(Boolean.TRUE.equals(request.rhAcceptedConnection())); app.setInterviewScheduled(Boolean.TRUE.equals(request.interviewScheduled())); app.setNextStepDateTime(request.nextStepDateTime()); - applyStatusChange(app, resolveStatus(request.status())); + applyStatusChange(app, isSendLater ? null : validateStatus(request.status())); app.setRecruiterDmReminderEnabled(Boolean.TRUE.equals(request.recruiterDmReminderEnabled())); app.setNote(normalizeOptionalText(request.note())); app.setPlatform(request.platform()); @@ -227,31 +242,37 @@ private void mapRequestToEntity(ApplicationRequest request, JobApplication app) } } - private void applyStatusChange(JobApplication app, ApplicationStatus newStatus) { - ApplicationStatus currentStatus = app.getStatus(); - if ((newStatus == ApplicationStatus.REJEITADO || newStatus == ApplicationStatus.GHOSTING) - && currentStatus != newStatus) { + private void applyStatusChange(JobApplication app, String newStatus) { + String currentStatus = app.getStatus(); + if (isRejectedOrGhosting(newStatus) && !newStatus.equals(currentStatus)) { app.setPreviousStatus(currentStatus); } - if (newStatus != ApplicationStatus.REJEITADO && newStatus != ApplicationStatus.GHOSTING) { + if (!isRejectedOrGhosting(newStatus)) { app.setPreviousStatus(null); } app.setStatus(newStatus); if (newStatus == null) { app.setApplicationDate(null); + app.setToSendLater(true); } } - private ApplicationStatus resolveStatus(String statusName) { + private static boolean isRejectedOrGhosting(String status) { + if (status == null) return false; + return "REJEITADO".equals(status) || "Rejected".equals(status) + || "GHOSTING".equals(status) || "Ghosting".equals(status); + } + + private String validateStatus(String statusName) { if (statusName == null || statusName.isBlank()) { return null; } - - try { - return ApplicationStatus.fromDisplayName(statusName); - } catch (IllegalArgumentException e) { - throw new BadRequestException("Invalid status value: " + statusName); + if (!applicationStatusRepository.existsByName(statusName)) { + throw new BadRequestException( + "Invalid status value: '" + statusName + + "'. Call GET /api/v1/applications/statuses for valid options."); } + return statusName; } private Sort buildSort(String sort) { @@ -289,12 +310,7 @@ private Specification buildSpecification(UUID userId, String sta if (TO_SEND_LATER_STATUS.equalsIgnoreCase(status)) { predicates.add(cb.isNull(root.get("status"))); } else { - try { - ApplicationStatus appStatus = ApplicationStatus.fromDisplayName(status); - predicates.add(cb.equal(root.get("status"), appStatus)); - } catch (IllegalArgumentException e) { - throw new BadRequestException("Invalid status filter: " + status); - } + predicates.add(cb.equal(root.get("status"), status)); } } diff --git a/src/main/java/com/jobtracker/service/DashboardService.java b/src/main/java/com/jobtracker/service/DashboardService.java index d6245b22..13b13cfe 100644 --- a/src/main/java/com/jobtracker/service/DashboardService.java +++ b/src/main/java/com/jobtracker/service/DashboardService.java @@ -1,7 +1,6 @@ package com.jobtracker.service; import com.jobtracker.dto.dashboard.DashboardSummaryResponse; -import com.jobtracker.entity.enums.ApplicationStatus; import com.jobtracker.repository.ApplicationRepository; import com.jobtracker.util.SecurityUtils; import org.springframework.stereotype.Service; @@ -33,10 +32,14 @@ public DashboardSummaryResponse getSummary() { long totalApplications = applicationRepository.countByUserIdAndArchivedFalse(userId); - List waitingStatuses = List.of( - ApplicationStatus.FIZ_A_RH_AGUARDANDO_ATUALIZACAO, - ApplicationStatus.FIZ_A_HIRING_MANAGER_AGUARDANDO_ATUALIZACAO, - ApplicationStatus.FIZ_TESTE_TECNICO_AGUARDANDO_ATUALIZACAO + // Include both legacy enum constant names (old records) and new English values (new records). + List waitingStatuses = List.of( + "FIZ_A_RH_AGUARDANDO_ATUALIZACAO", + "FIZ_A_HIRING_MANAGER_AGUARDANDO_ATUALIZACAO", + "FIZ_TESTE_TECNICO_AGUARDANDO_ATUALIZACAO", + "Pending HR Response", + "Pending Hiring Manager Response", + "Pending Technical Test Response" ); long waitingResponses = applicationRepository.countByUserIdAndStatusInAndArchivedFalse(userId, waitingStatuses); @@ -49,9 +52,12 @@ public DashboardSummaryResponse getSummary() { long toSendLater = applicationRepository.countByUserIdAndStatusIsNullAndArchivedFalse(userId); - long rejectedCount = applicationRepository.countByUserIdAndStatusAndArchivedFalse(userId, ApplicationStatus.REJEITADO); + // countByUserIdAndStatusInAndArchivedFalse handles both old and new value names. + long rejectedCount = applicationRepository.countByUserIdAndStatusInAndArchivedFalse( + userId, List.of("REJEITADO", "Rejected")); - long ghostingCount = applicationRepository.countByUserIdAndStatusAndArchivedFalse(userId, ApplicationStatus.GHOSTING); + long ghostingCount = applicationRepository.countByUserIdAndStatusInAndArchivedFalse( + userId, List.of("GHOSTING", "Ghosting")); double averageDailyApplications = roundToTwoDecimals( applicationRepository.countByUserIdAndApplicationDateSince(userId, LocalDate.now().minusDays(29)) / 30.0 diff --git a/src/main/java/com/jobtracker/service/GamificationService.java b/src/main/java/com/jobtracker/service/GamificationService.java index 5c7bfb0b..cdba5df4 100644 --- a/src/main/java/com/jobtracker/service/GamificationService.java +++ b/src/main/java/com/jobtracker/service/GamificationService.java @@ -11,7 +11,6 @@ import com.jobtracker.entity.User; import com.jobtracker.entity.UserAchievement; import com.jobtracker.entity.UserGamification; -import com.jobtracker.entity.enums.ApplicationStatus; import com.jobtracker.entity.enums.GamificationEventType; import com.jobtracker.exception.ResourceNotFoundException; import com.jobtracker.repository.AchievementRepository; @@ -28,7 +27,6 @@ import java.time.LocalTime; import java.time.temporal.ChronoUnit; import java.util.Comparator; -import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -52,13 +50,19 @@ public class GamificationService { * - OFFER_WON maps to RH_NEGOCIACAO, which is the closest current backend status * to an offer/closing stage until dedicated OFFER/HIRED statuses exist. */ - private static final Set INTERVIEW_PROGRESS_STATUSES = EnumSet.of( - ApplicationStatus.ENTREVISTA_MARCADA, - ApplicationStatus.FIZ_A_RH_AGUARDANDO_ATUALIZACAO, - ApplicationStatus.FIZ_A_HIRING_MANAGER_AGUARDANDO_ATUALIZACAO, - ApplicationStatus.TESTE_TECNICO, - ApplicationStatus.FIZ_TESTE_TECNICO_AGUARDANDO_ATUALIZACAO, - ApplicationStatus.RH_NEGOCIACAO + // Includes both legacy enum constant names (stored in DB for old records) and new English values. + private static final Set INTERVIEW_PROGRESS_STATUSES = Set.of( + "ENTREVISTA_MARCADA", + "FIZ_A_RH_AGUARDANDO_ATUALIZACAO", + "FIZ_A_HIRING_MANAGER_AGUARDANDO_ATUALIZACAO", + "TESTE_TECNICO", + "FIZ_TESTE_TECNICO_AGUARDANDO_ATUALIZACAO", + "RH_NEGOCIACAO", + "Pending HR Response", + "Pending Hiring Manager Response", + "Technical Test", + "Pending Technical Test Response", + "Offer Negotiation" ); private static final String EARLY_BIRD = "EARLY_BIRD"; @@ -210,7 +214,7 @@ public void onApplicationCreated(JobApplication application) { @Transactional public void onApplicationUpdated(JobApplication application, - ApplicationStatus previousStatus, + String previousStatus, boolean previousInterviewScheduled, String previousNote) { if (!StringUtils.hasText(previousNote) && hasNote(application)) { @@ -222,17 +226,17 @@ public void onApplicationUpdated(JobApplication application, if (statusEntered(previousStatus, application.getStatus(), INTERVIEW_PROGRESS_STATUSES)) { awardInterviewProgress(application, LocalDateTime.now()); } - if (application.getStatus() != previousStatus && qualifiesForOfferWon(application)) { + if (!java.util.Objects.equals(application.getStatus(), previousStatus) && qualifiesForOfferWon(application)) { awardOfferWon(application, LocalDateTime.now()); } } @Transactional - public void onApplicationStatusUpdated(JobApplication application, ApplicationStatus previousStatus) { + public void onApplicationStatusUpdated(JobApplication application, String previousStatus) { if (statusEntered(previousStatus, application.getStatus(), INTERVIEW_PROGRESS_STATUSES)) { awardInterviewProgress(application, LocalDateTime.now()); } - if (application.getStatus() != previousStatus && qualifiesForOfferWon(application)) { + if (!java.util.Objects.equals(application.getStatus(), previousStatus) && qualifiesForOfferWon(application)) { awardOfferWon(application, LocalDateTime.now()); } } @@ -449,7 +453,8 @@ private boolean hasPersistentSignal(List applications) { } private boolean hasGhostbusterSignal(List applications) { - return applications.stream().anyMatch(application -> application.getStatus() == ApplicationStatus.GHOSTING); + return applications.stream().anyMatch(application -> + "GHOSTING".equals(application.getStatus()) || "Ghosting".equals(application.getStatus())); } private int calculateCurrentStreak(List applications) { @@ -510,15 +515,14 @@ private boolean qualifiesForInterviewProgress(JobApplication application) { } private boolean qualifiesForOfferWon(JobApplication application) { - return application.getStatus() == ApplicationStatus.RH_NEGOCIACAO; + String s = application.getStatus(); + return "RH_NEGOCIACAO".equals(s) || "Offer Negotiation".equals(s); } - private boolean statusEntered(ApplicationStatus previousStatus, - ApplicationStatus currentStatus, - Set qualifyingStatuses) { + private boolean statusEntered(String previousStatus, String currentStatus, Set qualifyingStatuses) { return currentStatus != null && qualifyingStatuses.contains(currentStatus) - && currentStatus != previousStatus; + && !currentStatus.equals(previousStatus); } private void updateLastActivity(UserGamification state, LocalDateTime occurredAt) { diff --git a/src/main/java/com/jobtracker/service/InterviewMetricsService.java b/src/main/java/com/jobtracker/service/InterviewMetricsService.java index 15dfe907..ccfbb3d3 100644 --- a/src/main/java/com/jobtracker/service/InterviewMetricsService.java +++ b/src/main/java/com/jobtracker/service/InterviewMetricsService.java @@ -2,27 +2,31 @@ import com.jobtracker.entity.InterviewEvent; import com.jobtracker.entity.JobApplication; -import com.jobtracker.entity.enums.ApplicationStatus; import com.jobtracker.repository.ApplicationRepository; import com.jobtracker.repository.InterviewEventRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.EnumSet; import java.util.Set; import java.util.UUID; @Service public class InterviewMetricsService { - private static final Set INTERVIEW_STATUSES = EnumSet.of( - ApplicationStatus.ENTREVISTA_MARCADA, - ApplicationStatus.FIZ_A_RH_AGUARDANDO_ATUALIZACAO, - ApplicationStatus.FIZ_A_HIRING_MANAGER_AGUARDANDO_ATUALIZACAO, - ApplicationStatus.TESTE_TECNICO, - ApplicationStatus.FIZ_TESTE_TECNICO_AGUARDANDO_ATUALIZACAO, - ApplicationStatus.RH_NEGOCIACAO + // Includes both legacy enum constant names (stored in DB for old records) and new English values. + private static final Set INTERVIEW_STATUSES = Set.of( + "ENTREVISTA_MARCADA", + "FIZ_A_RH_AGUARDANDO_ATUALIZACAO", + "FIZ_A_HIRING_MANAGER_AGUARDANDO_ATUALIZACAO", + "TESTE_TECNICO", + "FIZ_TESTE_TECNICO_AGUARDANDO_ATUALIZACAO", + "RH_NEGOCIACAO", + "Pending HR Response", + "Pending Hiring Manager Response", + "Technical Test", + "Pending Technical Test Response", + "Offer Negotiation" ); private final ApplicationRepository applicationRepository; @@ -35,28 +39,15 @@ public InterviewMetricsService(ApplicationRepository applicationRepository, } public boolean isInterviewStatus(String status) { - if (status == null || status.isBlank()) { - return false; - } - try { - return isInterviewStatus(ApplicationStatus.fromDisplayName(status)); - } catch (IllegalArgumentException ignored) { - return false; - } - } - - public boolean isInterviewStatus(ApplicationStatus status) { return status != null && INTERVIEW_STATUSES.contains(status); } - public boolean wasInterviewTriggered(ApplicationStatus oldStatus, ApplicationStatus newStatus) { + public boolean wasInterviewTriggered(String oldStatus, String newStatus) { return isInterviewStatus(newStatus) && !isInterviewStatus(oldStatus); } @Transactional - public void recordStatusTransition(JobApplication application, - ApplicationStatus oldStatus, - ApplicationStatus newStatus) { + public void recordStatusTransition(JobApplication application, String oldStatus, String newStatus) { if (!wasInterviewTriggered(oldStatus, newStatus)) { return; } diff --git a/src/main/java/com/jobtracker/tasks/StaleApplicationsScheduler.java b/src/main/java/com/jobtracker/tasks/StaleApplicationsScheduler.java index 53696c23..4bfe771f 100644 --- a/src/main/java/com/jobtracker/tasks/StaleApplicationsScheduler.java +++ b/src/main/java/com/jobtracker/tasks/StaleApplicationsScheduler.java @@ -1,7 +1,6 @@ package com.jobtracker.tasks; import com.jobtracker.entity.JobApplication; -import com.jobtracker.entity.enums.ApplicationStatus; import com.jobtracker.repository.ApplicationRepository; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -29,19 +28,20 @@ public void updateStaleApplications() { List toSendLaterExpired = applicationRepository.findByStatusIsNullAndUpdatedAtBefore(oneWeekAgo); for (JobApplication application : toSendLaterExpired) { application.setPreviousStatus(null); - application.setStatus(ApplicationStatus.TARDE_DEMAIS); + application.setStatus("Rejected"); + application.setToSendLater(false); } if (!toSendLaterExpired.isEmpty()) { applicationRepository.saveAll(toSendLaterExpired); } List staleApplications = applicationRepository - .findByStatusIsNotNullAndStatusNotAndUpdatedAtBefore(ApplicationStatus.GHOSTING, oneMonthAgo); + .findByStatusIsNotNullAndStatusNotAndUpdatedAtBefore("Ghosting", oneMonthAgo); for (JobApplication application : staleApplications) { - ApplicationStatus currentStatus = application.getStatus(); - if (currentStatus != ApplicationStatus.GHOSTING) { + String currentStatus = application.getStatus(); + if (!"Ghosting".equals(currentStatus)) { application.setPreviousStatus(currentStatus); - application.setStatus(ApplicationStatus.GHOSTING); + application.setStatus("Ghosting"); } } if (!staleApplications.isEmpty()) { diff --git a/src/main/resources/db/migration/V29__add_application_status_table.sql b/src/main/resources/db/migration/V29__add_application_status_table.sql new file mode 100644 index 00000000..dd8e12df --- /dev/null +++ b/src/main/resources/db/migration/V29__add_application_status_table.sql @@ -0,0 +1,23 @@ +CREATE TABLE application_statuses ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + display_order INT NOT NULL, + CONSTRAINT uq_application_statuses_name UNIQUE (name) +); + +INSERT INTO application_statuses (name, display_order) VALUES +('RH', 1), +('Pending HR Response', 2), +('Pending Hiring Manager Response', 3), +('Technical Test', 4), +('Pending Technical Test Response', 5), +('Offer Negotiation', 6), +('Ghosting', 7), +('Rejected', 8), +('Approved', 9); + +ALTER TABLE job_applications + ADD COLUMN to_send_later BOOLEAN NOT NULL DEFAULT FALSE; + +-- Migrate existing "to send later" records: null status means draft/to-send-later +UPDATE job_applications SET to_send_later = TRUE WHERE status IS NULL; diff --git a/src/test/java/com/jobtracker/e2e/ApplicationE2ETest.java b/src/test/java/com/jobtracker/e2e/ApplicationE2ETest.java index 81fa3b62..aae71aa0 100644 --- a/src/test/java/com/jobtracker/e2e/ApplicationE2ETest.java +++ b/src/test/java/com/jobtracker/e2e/ApplicationE2ETest.java @@ -113,7 +113,7 @@ void fullApplicationCrud_createFetchUpdateArchiveAndHardDelete() { "applicationDate": "%s", "rhAcceptedConnection": true, "interviewScheduled": false, - "status": "Fiz a RH - Aguardando Atualização", + "status": "Pending HR Response", "recruiterDmReminderEnabled": false, "note": "Updated note" } @@ -122,7 +122,7 @@ void fullApplicationCrud_createFetchUpdateArchiveAndHardDelete() { .then() .statusCode(200) .body("vacancyName", equalTo("Senior Software Engineer")) - .body("status", equalTo("Fiz a RH - Aguardando Atualização")) + .body("status", equalTo("Pending HR Response")) .body("rhAcceptedConnection", equalTo(true)) .body("note", equalTo("Updated note")); @@ -219,7 +219,7 @@ void getAll_withFilter_shouldReturnFilteredResults() { {"vacancyName": "App Tecnico", "recruiterName": "Recruiter B", "organization": "Tech", "vacancyLink": "https://example.com/b", "applicationDate": "%s", "rhAcceptedConnection": false, "interviewScheduled": true, - "status": "Teste Técnico", "recruiterDmReminderEnabled": false} + "status": "Technical Test", "recruiterDmReminderEnabled": false} """.formatted(applicationDate)) .post("/api/v1/applications").then().statusCode(201); diff --git a/src/test/java/com/jobtracker/integration/ApplicationControllerIT.java b/src/test/java/com/jobtracker/integration/ApplicationControllerIT.java index d24a787c..e2cd2185 100644 --- a/src/test/java/com/jobtracker/integration/ApplicationControllerIT.java +++ b/src/test/java/com/jobtracker/integration/ApplicationControllerIT.java @@ -157,9 +157,9 @@ void updateStatus_shouldReturn200() throws Exception { mockMvc.perform(patch("/api/v1/applications/{id}/status", id) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"Teste Técnico\"}")) + .content("{\"status\": \"Technical Test\"}")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("Teste Técnico")); + .andExpect(jsonPath("$.status").value("Technical Test")); } @Test @@ -181,7 +181,7 @@ void statusUpdates_shouldExposeCumulativeInterviewCountWithoutRepeatedSaveDouble mockMvc.perform(patch("/api/v1/applications/{id}/status", id) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"Teste Técnico\"}")) + .content("{\"status\": \"Technical Test\"}")) .andExpect(status().isOk()); mockMvc.perform(get("/api/v1/dashboard/summary") @@ -192,13 +192,13 @@ void statusUpdates_shouldExposeCumulativeInterviewCountWithoutRepeatedSaveDouble mockMvc.perform(patch("/api/v1/applications/{id}/status", id) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"Teste Técnico\"}")) + .content("{\"status\": \"Technical Test\"}")) .andExpect(status().isOk()); mockMvc.perform(patch("/api/v1/applications/{id}/status", id) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"RH (Negociação)\"}")) + .content("{\"status\": \"Offer Negotiation\"}")) .andExpect(status().isOk()); mockMvc.perform(get("/api/v1/dashboard/summary") @@ -209,13 +209,13 @@ void statusUpdates_shouldExposeCumulativeInterviewCountWithoutRepeatedSaveDouble mockMvc.perform(patch("/api/v1/applications/{id}/status", id) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"Rejeitado\"}")) + .content("{\"status\": \"Rejected\"}")) .andExpect(status().isOk()); mockMvc.perform(patch("/api/v1/applications/{id}/status", id) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"Entrevista marcada\"}")) + .content("{\"status\": \"Pending HR Response\"}")) .andExpect(status().isOk()); mockMvc.perform(get("/api/v1/dashboard/summary") @@ -325,21 +325,21 @@ void applicationLifecycle_shouldAutoAwardXpAndAvoidDuplicates() throws Exception mockMvc.perform(patch("/api/v1/applications/{id}/status", id) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"Teste Técnico\"}")) + .content("{\"status\": \"Technical Test\"}")) .andExpect(status().isOk()); assertProfileXp(75, 1); mockMvc.perform(patch("/api/v1/applications/{id}/status", id) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"Teste Técnico\"}")) + .content("{\"status\": \"Technical Test\"}")) .andExpect(status().isOk()); assertProfileXp(75, 1); ApplicationRequest addNoteRequest = new ApplicationRequest( "Gamified App", "Recruiter", "Org", "https://example.com/job", LocalDate.now(), - false, false, null, "Teste Técnico", false, "First note", null, null + false, false, null, "Technical Test", false, "First note", null, null ); mockMvc.perform(put("/api/v1/applications/{id}", id) @@ -352,7 +352,7 @@ void applicationLifecycle_shouldAutoAwardXpAndAvoidDuplicates() throws Exception ApplicationRequest updateNoteRequest = new ApplicationRequest( "Gamified App", "Recruiter", "Org", "https://example.com/job", LocalDate.now(), - false, false, null, "Teste Técnico", false, "Edited note", null, null + false, false, null, "Technical Test", false, "Edited note", null, null ); mockMvc.perform(put("/api/v1/applications/{id}", id) @@ -365,7 +365,7 @@ void applicationLifecycle_shouldAutoAwardXpAndAvoidDuplicates() throws Exception mockMvc.perform(patch("/api/v1/applications/{id}/status", id) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"RH (Negociação)\"}")) + .content("{\"status\": \"Offer Negotiation\"}")) .andExpect(status().isOk()); assertProfileXp(580, 3); } diff --git a/src/test/java/com/jobtracker/integration/ApplicationRepositoryIT.java b/src/test/java/com/jobtracker/integration/ApplicationRepositoryIT.java index d11e9d2e..a1d0394e 100644 --- a/src/test/java/com/jobtracker/integration/ApplicationRepositoryIT.java +++ b/src/test/java/com/jobtracker/integration/ApplicationRepositoryIT.java @@ -2,7 +2,6 @@ import com.jobtracker.entity.JobApplication; import com.jobtracker.entity.User; -import com.jobtracker.entity.enums.ApplicationStatus; import com.jobtracker.repository.ApplicationRepository; import com.jobtracker.repository.UserRepository; import org.junit.jupiter.api.BeforeEach; @@ -122,7 +121,7 @@ private JobApplication buildApp(String vacancyName, User user) { JobApplication app = new JobApplication(); app.setVacancyName(vacancyName); app.setOrganization("HR"); - app.setStatus(ApplicationStatus.RH); + app.setStatus("RH"); app.setApplicationDate(LocalDate.now().minusDays(1)); app.setUser(user); return app; diff --git a/src/test/java/com/jobtracker/unit/ApplicationServiceTest.java b/src/test/java/com/jobtracker/unit/ApplicationServiceTest.java index 2bae2923..1da59323 100644 --- a/src/test/java/com/jobtracker/unit/ApplicationServiceTest.java +++ b/src/test/java/com/jobtracker/unit/ApplicationServiceTest.java @@ -3,11 +3,11 @@ import com.jobtracker.dto.application.*; import com.jobtracker.entity.JobApplication; import com.jobtracker.entity.User; -import com.jobtracker.entity.enums.ApplicationStatus; import com.jobtracker.exception.BadRequestException; import com.jobtracker.exception.ResourceNotFoundException; import com.jobtracker.mapper.ApplicationMapper; import com.jobtracker.repository.ApplicationRepository; +import com.jobtracker.repository.ApplicationStatusRepository; import com.jobtracker.repository.InterviewEventRepository; import com.jobtracker.service.ApplicationService; import com.jobtracker.service.GamificationService; @@ -46,6 +46,7 @@ class ApplicationServiceTest { private static final UUID OTHER_UUID = UUID.fromString("00000000-0000-0000-0000-000000000099"); @Mock private ApplicationRepository applicationRepository; + @Mock private ApplicationStatusRepository applicationStatusRepository; @Mock private InterviewEventRepository interviewEventRepository; @Mock private ApplicationMapper applicationMapper; @Mock private GamificationService gamificationService; @@ -71,13 +72,14 @@ void setUp() { void create_shouldSaveAndReturnResponse() { ApplicationRequest request = buildRequest(); when(securityUtils.getCurrentUser()).thenReturn(user); + when(applicationStatusRepository.existsByName("RH")).thenReturn(true); when(applicationRepository.save(any(JobApplication.class))).thenReturn(app); when(applicationMapper.toResponse(app)).thenReturn(response); ApplicationResponse result = applicationService.create(request); assertThat(result.id()).isEqualTo(APP_UUID); verify(applicationRepository).save(any(JobApplication.class)); - verify(interviewMetricsService).recordStatusTransition(app, null, ApplicationStatus.RH); + verify(interviewMetricsService).recordStatusTransition(app, null, "RH"); verify(gamificationService).onApplicationCreated(app); } @@ -104,13 +106,14 @@ void update_shouldUpdateAndReturnResponse() { ApplicationRequest request = buildRequest(); when(securityUtils.getCurrentUserId()).thenReturn(USER_UUID); when(applicationRepository.findByIdAndUserId(APP_UUID, USER_UUID)).thenReturn(Optional.of(app)); + when(applicationStatusRepository.existsByName("RH")).thenReturn(true); when(applicationRepository.save(app)).thenReturn(app); when(applicationMapper.toResponse(app)).thenReturn(response); ApplicationResponse result = applicationService.update(APP_UUID, request); assertThat(result).isNotNull(); - verify(gamificationService).onApplicationUpdated(eq(app), eq(ApplicationStatus.RH), eq(false), eq("Follow up this week")); - verify(interviewMetricsService).recordStatusTransition(app, ApplicationStatus.RH, ApplicationStatus.RH); + verify(gamificationService).onApplicationUpdated(eq(app), eq("RH"), eq(false), eq("Follow up this week")); + verify(interviewMetricsService).recordStatusTransition(app, "RH", "RH"); } @Test @@ -118,14 +121,15 @@ void updateStatus_shouldUpdateStatus() { UpdateStatusRequest statusRequest = new UpdateStatusRequest("RH"); when(securityUtils.getCurrentUserId()).thenReturn(USER_UUID); when(applicationRepository.findByIdAndUserId(APP_UUID, USER_UUID)).thenReturn(Optional.of(app)); + when(applicationStatusRepository.existsByName("RH")).thenReturn(true); when(applicationRepository.save(app)).thenReturn(app); when(applicationMapper.toResponse(app)).thenReturn(response); ApplicationResponse result = applicationService.updateStatus(APP_UUID, statusRequest); assertThat(result).isNotNull(); - assertThat(app.getStatus()).isEqualTo(ApplicationStatus.RH); - verify(interviewMetricsService).recordStatusTransition(app, ApplicationStatus.RH, ApplicationStatus.RH); - verify(gamificationService).onApplicationStatusUpdated(app, ApplicationStatus.RH); + assertThat(app.getStatus()).isEqualTo("RH"); + verify(interviewMetricsService).recordStatusTransition(app, "RH", "RH"); + verify(gamificationService).onApplicationStatusUpdated(app, "RH"); } @Test @@ -147,6 +151,7 @@ void updateStatus_shouldThrow_whenInvalidStatus() { UpdateStatusRequest statusRequest = new UpdateStatusRequest("INVALID_STATUS"); when(securityUtils.getCurrentUserId()).thenReturn(USER_UUID); when(applicationRepository.findByIdAndUserId(APP_UUID, USER_UUID)).thenReturn(Optional.of(app)); + when(applicationStatusRepository.existsByName("INVALID_STATUS")).thenReturn(false); assertThatThrownBy(() -> applicationService.updateStatus(APP_UUID, statusRequest)) .isInstanceOf(BadRequestException.class) @@ -341,7 +346,7 @@ private JobApplication buildApplication(UUID id, User user) { a.setId(id); a.setVacancyName("Software Engineer"); a.setOrganization("HR"); - a.setStatus(ApplicationStatus.RH); + a.setStatus("RH"); a.setApplicationDate(LocalDate.now()); a.setNote("Follow up this week"); a.setUser(user); @@ -360,6 +365,6 @@ private ApplicationResponse buildApplicationResponse(UUID id) { return new ApplicationResponse(id, "Software Engineer", "Recruiter", "HR", "https://example.com/job", LocalDate.now(), false, false, null, "RH", null, false, LocalDateTime.now(), "Follow up this week", null, false, null, null, null, null, null, null, - 0, LocalDateTime.now(), LocalDateTime.now()); + false, 0, LocalDateTime.now(), LocalDateTime.now()); } } diff --git a/src/test/java/com/jobtracker/unit/GamificationServiceTest.java b/src/test/java/com/jobtracker/unit/GamificationServiceTest.java index 36ff1334..5f01dc7f 100644 --- a/src/test/java/com/jobtracker/unit/GamificationServiceTest.java +++ b/src/test/java/com/jobtracker/unit/GamificationServiceTest.java @@ -8,7 +8,6 @@ import com.jobtracker.entity.User; import com.jobtracker.entity.UserAchievement; import com.jobtracker.entity.UserGamification; -import com.jobtracker.entity.enums.ApplicationStatus; import com.jobtracker.entity.enums.GamificationEventType; import com.jobtracker.repository.AchievementRepository; import com.jobtracker.repository.ApplicationRepository; @@ -116,11 +115,11 @@ void applyEvent_shouldAddXpAndDetectLevelUp() { @Test void onApplicationUpdated_shouldAwardInterviewOnlyOnce() { - JobApplication application = application(ApplicationStatus.TESTE_TECNICO, null); + JobApplication application = application("Technical Test", null); when(applicationRepository.save(any(JobApplication.class))).thenAnswer(invocation -> invocation.getArgument(0)); - gamificationService.onApplicationStatusUpdated(application, ApplicationStatus.RH); - gamificationService.onApplicationStatusUpdated(application, ApplicationStatus.TESTE_TECNICO); + gamificationService.onApplicationStatusUpdated(application, "RH"); + gamificationService.onApplicationStatusUpdated(application, "Technical Test"); assertThat(state.getCurrentXp()).isEqualTo(140); assertThat(application.isInterviewProgressXpAwarded()).isTrue(); @@ -129,11 +128,11 @@ void onApplicationUpdated_shouldAwardInterviewOnlyOnce() { @Test void onApplicationUpdated_shouldAwardNoteOnlyWhenTransitioningFromBlank() { - JobApplication application = application(ApplicationStatus.RH, "Fresh note"); + JobApplication application = application("RH", "Fresh note"); when(applicationRepository.save(any(JobApplication.class))).thenAnswer(invocation -> invocation.getArgument(0)); - gamificationService.onApplicationUpdated(application, ApplicationStatus.RH, false, null); - gamificationService.onApplicationUpdated(application, ApplicationStatus.RH, false, "Fresh note"); + gamificationService.onApplicationUpdated(application, "RH", false, null); + gamificationService.onApplicationUpdated(application, "RH", false, "Fresh note"); assertThat(state.getCurrentXp()).isEqualTo(95); assertThat(application.isNoteAddedXpAwarded()).isTrue(); @@ -144,21 +143,21 @@ void getAchievements_shouldUnlockInitialFeasibleAchievements() { List unlocked = new ArrayList<>(); when(securityUtils.getCurrentUser()).thenReturn(user); when(applicationRepository.findAllByUser_IdAndArchivedFalse(user.getId())).thenReturn(List.of( - applicationAt(1, LocalDate.now().minusDays(4), LocalDateTime.now().withHour(8), ApplicationStatus.RH, null), - applicationAt(2, LocalDate.now().minusDays(3), LocalDateTime.now().withHour(8), ApplicationStatus.RH, null), - applicationAt(3, LocalDate.now().minusDays(2), LocalDateTime.now().withHour(8), ApplicationStatus.RH, null), - applicationAt(4, LocalDate.now().minusDays(1), LocalDateTime.now().withHour(8), ApplicationStatus.RH, null), - applicationAt(5, LocalDate.now(), LocalDateTime.now().withHour(8), ApplicationStatus.GHOSTING, LocalDateTime.now().minusDays(1)), - applicationAt(6, LocalDate.now(), LocalDateTime.now().withHour(10), ApplicationStatus.RH, LocalDateTime.now().minusDays(6)), - applicationAt(7, LocalDate.now(), LocalDateTime.now().withHour(10), ApplicationStatus.RH, LocalDateTime.now().minusDays(6).plusHours(1)), - applicationAt(8, LocalDate.now(), LocalDateTime.now().withHour(10), ApplicationStatus.RH, LocalDateTime.now().minusDays(5)), - applicationAt(9, LocalDate.now(), LocalDateTime.now().withHour(10), ApplicationStatus.RH, LocalDateTime.now().minusDays(5).plusHours(1)), - applicationAt(10, LocalDate.now(), LocalDateTime.now().withHour(10), ApplicationStatus.RH, LocalDateTime.now().minusDays(4)), - applicationAt(11, LocalDate.now(), LocalDateTime.now().withHour(10), ApplicationStatus.RH, LocalDateTime.now().minusDays(4).plusHours(1)), - applicationAt(12, LocalDate.now(), LocalDateTime.now().withHour(10), ApplicationStatus.RH, LocalDateTime.now().minusDays(3)), - applicationAt(13, LocalDate.now(), LocalDateTime.now().withHour(10), ApplicationStatus.RH, LocalDateTime.now().minusDays(3).plusHours(1)), - applicationAt(14, LocalDate.now(), LocalDateTime.now().withHour(10), ApplicationStatus.RH, LocalDateTime.now().minusDays(2)), - applicationAt(15, LocalDate.now(), LocalDateTime.now().withHour(10), ApplicationStatus.RH, LocalDateTime.now().minusDays(2).plusHours(1)) + applicationAt(1, LocalDate.now().minusDays(4), LocalDateTime.now().withHour(8), "RH", null), + applicationAt(2, LocalDate.now().minusDays(3), LocalDateTime.now().withHour(8), "RH", null), + applicationAt(3, LocalDate.now().minusDays(2), LocalDateTime.now().withHour(8), "RH", null), + applicationAt(4, LocalDate.now().minusDays(1), LocalDateTime.now().withHour(8), "RH", null), + applicationAt(5, LocalDate.now(), LocalDateTime.now().withHour(8), "Ghosting", LocalDateTime.now().minusDays(1)), + applicationAt(6, LocalDate.now(), LocalDateTime.now().withHour(10), "RH", LocalDateTime.now().minusDays(6)), + applicationAt(7, LocalDate.now(), LocalDateTime.now().withHour(10), "RH", LocalDateTime.now().minusDays(6).plusHours(1)), + applicationAt(8, LocalDate.now(), LocalDateTime.now().withHour(10), "RH", LocalDateTime.now().minusDays(5)), + applicationAt(9, LocalDate.now(), LocalDateTime.now().withHour(10), "RH", LocalDateTime.now().minusDays(5).plusHours(1)), + applicationAt(10, LocalDate.now(), LocalDateTime.now().withHour(10), "RH", LocalDateTime.now().minusDays(4)), + applicationAt(11, LocalDate.now(), LocalDateTime.now().withHour(10), "RH", LocalDateTime.now().minusDays(4).plusHours(1)), + applicationAt(12, LocalDate.now(), LocalDateTime.now().withHour(10), "RH", LocalDateTime.now().minusDays(3)), + applicationAt(13, LocalDate.now(), LocalDateTime.now().withHour(10), "RH", LocalDateTime.now().minusDays(3).plusHours(1)), + applicationAt(14, LocalDate.now(), LocalDateTime.now().withHour(10), "RH", LocalDateTime.now().minusDays(2)), + applicationAt(15, LocalDate.now(), LocalDateTime.now().withHour(10), "RH", LocalDateTime.now().minusDays(2).plusHours(1)) )); when(userAchievementRepository.findAllByUser_IdOrderByAchievedAtDesc(user.getId())).thenAnswer(invocation -> unlocked); when(userAchievementRepository.save(any(UserAchievement.class))).thenAnswer(invocation -> { @@ -172,7 +171,7 @@ void getAchievements_shouldUnlockInitialFeasibleAchievements() { .allMatch(achievement -> achievement.unlocked()); } - private JobApplication application(ApplicationStatus status, String note) { + private JobApplication application(String status, String note) { JobApplication application = new JobApplication(); application.setId(UUID.randomUUID()); application.setUser(user); @@ -185,7 +184,7 @@ private JobApplication application(ApplicationStatus status, String note) { private JobApplication applicationAt(int suffix, LocalDate applicationDate, LocalDateTime createdAt, - ApplicationStatus status, + String status, LocalDateTime dmSentAt) { JobApplication application = new JobApplication(); application.setId(UUID.fromString("00000000-0000-0000-0000-0000000000" + String.format("%02d", suffix))); diff --git a/src/test/java/com/jobtracker/unit/InterviewMetricsServiceTest.java b/src/test/java/com/jobtracker/unit/InterviewMetricsServiceTest.java index 41e4f26d..41b83a4e 100644 --- a/src/test/java/com/jobtracker/unit/InterviewMetricsServiceTest.java +++ b/src/test/java/com/jobtracker/unit/InterviewMetricsServiceTest.java @@ -2,7 +2,6 @@ import com.jobtracker.entity.JobApplication; import com.jobtracker.entity.User; -import com.jobtracker.entity.enums.ApplicationStatus; import com.jobtracker.repository.ApplicationRepository; import com.jobtracker.repository.InterviewEventRepository; import com.jobtracker.service.InterviewMetricsService; @@ -45,26 +44,36 @@ void setUp() { } @Test - void isInterviewStatus_shouldDetectDisplayNames() { - assertThat(service.isInterviewStatus("Entrevista marcada")).isTrue(); - assertThat(service.isInterviewStatus("Fiz a RH - Aguardando Atualização")).isTrue(); - assertThat(service.isInterviewStatus("Teste Técnico")).isTrue(); + void isInterviewStatus_shouldDetectNewEnglishValues() { + assertThat(service.isInterviewStatus("Pending HR Response")).isTrue(); + assertThat(service.isInterviewStatus("Pending Hiring Manager Response")).isTrue(); + assertThat(service.isInterviewStatus("Technical Test")).isTrue(); + assertThat(service.isInterviewStatus("Pending Technical Test Response")).isTrue(); + assertThat(service.isInterviewStatus("Offer Negotiation")).isTrue(); assertThat(service.isInterviewStatus("RH")).isFalse(); + assertThat(service.isInterviewStatus("Rejected")).isFalse(); assertThat(service.isInterviewStatus("Unknown")).isFalse(); } + @Test + void isInterviewStatus_shouldDetectLegacyConstantNames() { + assertThat(service.isInterviewStatus("ENTREVISTA_MARCADA")).isTrue(); + assertThat(service.isInterviewStatus("FIZ_A_RH_AGUARDANDO_ATUALIZACAO")).isTrue(); + assertThat(service.isInterviewStatus("TESTE_TECNICO")).isTrue(); + } + @Test void wasInterviewTriggered_shouldOnlyDetectEntryIntoInterviewStatus() { - assertThat(service.wasInterviewTriggered(ApplicationStatus.RH, ApplicationStatus.TESTE_TECNICO)).isTrue(); - assertThat(service.wasInterviewTriggered(ApplicationStatus.TESTE_TECNICO, ApplicationStatus.TESTE_TECNICO)).isFalse(); - assertThat(service.wasInterviewTriggered(ApplicationStatus.TESTE_TECNICO, ApplicationStatus.RH_NEGOCIACAO)).isFalse(); - assertThat(service.wasInterviewTriggered(ApplicationStatus.TESTE_TECNICO, ApplicationStatus.REJEITADO)).isFalse(); - assertThat(service.wasInterviewTriggered(ApplicationStatus.REJEITADO, ApplicationStatus.RH_NEGOCIACAO)).isTrue(); + assertThat(service.wasInterviewTriggered("RH", "Technical Test")).isTrue(); + assertThat(service.wasInterviewTriggered("Technical Test", "Technical Test")).isFalse(); + assertThat(service.wasInterviewTriggered("Technical Test", "Offer Negotiation")).isFalse(); + assertThat(service.wasInterviewTriggered("Technical Test", "Rejected")).isFalse(); + assertThat(service.wasInterviewTriggered("Rejected", "Offer Negotiation")).isTrue(); } @Test void recordStatusTransition_shouldIncrementCountAndLogEventWhenEnteringInterviewStatus() { - service.recordStatusTransition(application, ApplicationStatus.RH, ApplicationStatus.TESTE_TECNICO); + service.recordStatusTransition(application, "RH", "Technical Test"); assertThat(application.getInterviewCount()).isEqualTo(1); verify(eventRepository).save(any()); @@ -72,7 +81,7 @@ void recordStatusTransition_shouldIncrementCountAndLogEventWhenEnteringInterview @Test void recordStatusTransition_shouldNotIncrementWhenStayingWithinInterviewStatuses() { - service.recordStatusTransition(application, ApplicationStatus.TESTE_TECNICO, ApplicationStatus.RH_NEGOCIACAO); + service.recordStatusTransition(application, "Technical Test", "Offer Negotiation"); assertThat(application.getInterviewCount()).isEqualTo(0); verify(eventRepository, never()).save(any()); @@ -80,8 +89,8 @@ void recordStatusTransition_shouldNotIncrementWhenStayingWithinInterviewStatuses @Test void recordStatusTransition_shouldNotIncrementForNonInterviewTransitions() { - service.recordStatusTransition(application, ApplicationStatus.TESTE_TECNICO, ApplicationStatus.TESTE_TECNICO); - service.recordStatusTransition(application, ApplicationStatus.RH, ApplicationStatus.REJEITADO); + service.recordStatusTransition(application, "Technical Test", "Technical Test"); + service.recordStatusTransition(application, "RH", "Rejected"); assertThat(application.getInterviewCount()).isEqualTo(0); verify(eventRepository, never()).save(any()); diff --git a/src/test/java/com/jobtracker/unit/mcp/McpAnalyticsToolsTest.java b/src/test/java/com/jobtracker/unit/mcp/McpAnalyticsToolsTest.java index 72d3cd90..5c01f3ac 100644 --- a/src/test/java/com/jobtracker/unit/mcp/McpAnalyticsToolsTest.java +++ b/src/test/java/com/jobtracker/unit/mcp/McpAnalyticsToolsTest.java @@ -5,7 +5,6 @@ import com.jobtracker.dto.analytics.WeeklySummaryResponse; import com.jobtracker.dto.application.ApplicationResponse; import com.jobtracker.entity.JobApplication; -import com.jobtracker.entity.enums.ApplicationStatus; import com.jobtracker.mapper.ApplicationMapper; import com.jobtracker.mcp.tools.McpAnalyticsTools; import com.jobtracker.repository.ApplicationRepository; @@ -68,10 +67,10 @@ void getAnalytics_emptyList_returnsZeros() { @Test void getAnalytics_countsCorrectly() { - JobApplication rh = app(ApplicationStatus.RH, false, LocalDate.now().minusDays(5), null); - JobApplication interview = app(ApplicationStatus.ENTREVISTA_MARCADA, true, LocalDate.now().minusDays(3), "LinkedIn"); - JobApplication rejected = app(ApplicationStatus.REJEITADO, false, LocalDate.now().minusDays(10), "Gupy"); - JobApplication ghost = app(ApplicationStatus.GHOSTING, false, LocalDate.now().minusDays(20), null); + JobApplication rh = app("RH", false, LocalDate.now().minusDays(5), null); + JobApplication interview = app("Pending HR Response", true, LocalDate.now().minusDays(3), "LinkedIn"); + JobApplication rejected = app("Rejected", false, LocalDate.now().minusDays(10), "Gupy"); + JobApplication ghost = app("Ghosting", false, LocalDate.now().minusDays(20), null); JobApplication noStatus = app(null, false, null, null); when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); @@ -95,8 +94,8 @@ void getAnalytics_countsCorrectly() { @Test void getAnalytics_filtersDateRange() { LocalDate today = LocalDate.now(); - JobApplication inRange = app(ApplicationStatus.RH, false, today.minusDays(3), null); - JobApplication outOfRange = app(ApplicationStatus.RH, false, today.minusDays(30), null); + JobApplication inRange = app("RH", false, today.minusDays(3), null); + JobApplication outOfRange = app("RH", false, today.minusDays(30), null); when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); when(applicationRepository.findAllByUser_IdAndArchivedFalse(USER_ID)) @@ -112,7 +111,7 @@ void getAnalytics_filtersDateRange() { @Test void getAnalytics_averageDaysToResponse_onlyStatusNotNull() { LocalDate appDate = LocalDate.now().minusDays(10); - JobApplication withStatus = app(ApplicationStatus.RH, false, appDate, null); + JobApplication withStatus = app("RH", false, appDate, null); withStatus.setUpdatedAt(appDate.plusDays(5).atStartOfDay()); JobApplication nullStatus = app(null, false, appDate, null); @@ -129,11 +128,11 @@ void getAnalytics_averageDaysToResponse_onlyStatusNotNull() { @Test void getApplicationsByOrganization_groupsAndSorts() { - JobApplication a1 = app(ApplicationStatus.RH, false, LocalDate.now(), null); + JobApplication a1 = app("RH", false, LocalDate.now(), null); a1.setOrganization("Acme"); - JobApplication a2 = app(ApplicationStatus.ENTREVISTA_MARCADA, true, LocalDate.now(), null); + JobApplication a2 = app("Pending HR Response", true, LocalDate.now(), null); a2.setOrganization("Acme"); - JobApplication a3 = app(ApplicationStatus.RH, false, LocalDate.now(), null); + JobApplication a3 = app("RH", false, LocalDate.now(), null); a3.setOrganization("Beta"); when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); @@ -152,7 +151,7 @@ void getApplicationsByOrganization_groupsAndSorts() { @Test void getApplicationsByOrganization_ignoresNullOrganization() { - JobApplication noOrg = app(ApplicationStatus.RH, false, LocalDate.now(), null); + JobApplication noOrg = app("RH", false, LocalDate.now(), null); when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); when(applicationRepository.findAllByUser_IdAndArchivedFalse(USER_ID)) @@ -166,7 +165,7 @@ void getApplicationsByOrganization_ignoresNullOrganization() { @Test void searchApplications_delegatesToRepositoryAndMaps() { UUID userId = USER_ID; - JobApplication entity = app(ApplicationStatus.RH, false, LocalDate.now(), null); + JobApplication entity = app("RH", false, LocalDate.now(), null); ApplicationResponse response = applicationResponseWithId(UUID.randomUUID()); when(securityUtils.getCurrentUserId()).thenReturn(userId); @@ -183,11 +182,11 @@ void searchApplications_delegatesToRepositoryAndMaps() { @Test void getWeeklySummary_countsThisWeekAndLastWeek() { LocalDate today = LocalDate.now(); - JobApplication thisWeek1 = app(ApplicationStatus.RH, false, today.minusDays(2), null); - JobApplication thisWeek2 = app(ApplicationStatus.RH, true, today.minusDays(1), null); + JobApplication thisWeek1 = app("RH", false, today.minusDays(2), null); + JobApplication thisWeek2 = app("RH", true, today.minusDays(1), null); thisWeek1.setOrganization("Acme"); thisWeek2.setOrganization("Acme"); - JobApplication lastWeekApp = app(ApplicationStatus.RH, false, today.minusDays(10), null); + JobApplication lastWeekApp = app("RH", false, today.minusDays(10), null); when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); when(applicationRepository.findAllByUser_IdAndArchivedFalse(USER_ID)) @@ -217,7 +216,7 @@ void getWeeklySummary_overdueCountDelegatesToService() { assertThat(result.overdueCount()).isEqualTo(2); } - private static JobApplication app(ApplicationStatus status, boolean interviewScheduled, + private static JobApplication app(String status, boolean interviewScheduled, LocalDate applicationDate, String platform) { JobApplication a = new JobApplication(); a.setStatus(status); @@ -238,6 +237,6 @@ private static ApplicationResponse applicationResponseWithId(UUID id) { null, false, null, null, null, null, null, null, - 0, null, null); + false, 0, null, null); } } diff --git a/src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java b/src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java index 7c2871a1..d9531859 100644 --- a/src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java +++ b/src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java @@ -233,6 +233,6 @@ private static ApplicationResponse applicationResponseWithId(UUID id) { null, false, null, null, null, null, null, null, - 0, null, null); + false, 0, null, null); } } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 768006b9..044035c5 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -10,6 +10,7 @@ spring: jpa: hibernate: ddl-auto: create-drop + defer-datasource-initialization: true show-sql: false properties: hibernate: diff --git a/src/test/resources/data.sql b/src/test/resources/data.sql new file mode 100644 index 00000000..07e94e54 --- /dev/null +++ b/src/test/resources/data.sql @@ -0,0 +1,10 @@ +INSERT INTO application_statuses (name, display_order) VALUES +('RH', 1), +('Pending HR Response', 2), +('Pending Hiring Manager Response', 3), +('Technical Test', 4), +('Pending Technical Test Response', 5), +('Offer Negotiation', 6), +('Ghosting', 7), +('Rejected', 8), +('Approved', 9);