diff --git a/.env.example b/.env.example index 3278439..46734b1 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,13 @@ MAIL_USERNAME=no-reply@example.dev MAIL_SMTP_AUTH=true MAIL_SMTP_SSL=true +# Google Drive OAuth +# Redirect URI must exactly match the OAuth client config in Google Cloud Console. +GOOGLE_DRIVE_CLIENT_ID= +GOOGLE_DRIVE_CLIENT_SECRET= +GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8080/api/v1/google-drive/oauth/callback +GOOGLE_DRIVE_OAUTH_COMPLETE_URL=http://localhost:5173/settings/google-drive/callback + # ============================================ # Synkra AIOX Environment Configuration # ============================================ diff --git a/README.md b/README.md index 0cb00cf..10ebf80 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,19 @@ Flyway seeds the roles catalog (`USER`, `BETA`, `ADMIN`) and then assigns `ROLE_ | GET | `/api/v1/gamification/achievements` | List achievement catalog with unlocked state | | POST | `/api/v1/gamification/events` | Apply a tracked XP event and return updated profile | +### Google Drive + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/google-drive/oauth/start` | Generate the Google OAuth authorization URL for the authenticated user | +| GET | `/api/v1/google-drive/oauth/callback` | Google OAuth callback endpoint used by Google Cloud | +| GET | `/api/v1/google-drive/status` | Return current Google Drive connection status, configured root folder, and base resumes | +| DELETE | `/api/v1/google-drive/connection` | Disconnect the current user's Google account and remove stored Drive preferences | +| PUT | `/api/v1/google-drive/root-folder` | Validate and save the user's base Drive folder | +| POST | `/api/v1/google-drive/base-resumes` | Register a Google Docs base resume by Google Docs URL or file ID | +| DELETE | `/api/v1/google-drive/base-resumes/{baseResumeId}` | Remove a configured base resume | +| POST | `/api/v1/google-drive/applications/{applicationId}/resume-copies` | Copy a configured base resume into the application's Drive subfolder | + ## Application Status Values - `RH` @@ -210,9 +223,156 @@ export DB_URL=jdbc:mariadb://localhost:3306/jobtracker?createDatabaseIfNotExist= export DB_USERNAME=jobtracker export DB_PASSWORD=jobtracker export JWT_SECRET=your-secret-key-at-least-256-bits-long +export GOOGLE_DRIVE_CLIENT_ID=your-google-client-id +export GOOGLE_DRIVE_CLIENT_SECRET=your-google-client-secret +export GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8080/api/v1/google-drive/oauth/callback +export GOOGLE_DRIVE_OAUTH_COMPLETE_URL=http://localhost:5173/settings/google-drive/callback mvn spring-boot:run ``` +## Google Drive integration + +This backend supports per-user Google Drive OAuth2 for resume-copy automation. It does **not** replace the app's JWT auth flow; users stay authenticated with the existing bearer token, and Google is connected separately with `POST /api/v1/google-drive/oauth/start`. + +### Required Google Cloud setup + +1. Create a Google Cloud OAuth client for a web application. +2. Enable the **Google Drive API**. +3. Add the backend callback URL as an authorized redirect URI. Example local value: + - `http://localhost:8080/api/v1/google-drive/oauth/callback` +4. Configure these environment variables: + +| Variable | Required | Description | +|----------|----------|-------------| +| `GOOGLE_DRIVE_CLIENT_ID` | Yes | Google OAuth client ID | +| `GOOGLE_DRIVE_CLIENT_SECRET` | Yes | Google OAuth client secret | +| `GOOGLE_DRIVE_REDIRECT_URI` | Yes | Backend callback URL registered in Google Cloud | +| `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` | Yes | Frontend page that receives the final `status` and `message` query params after OAuth finishes | +| `GOOGLE_DRIVE_AUTHORIZATION_URI` | No | Override Google authorization endpoint | +| `GOOGLE_DRIVE_TOKEN_URI` | No | Override Google token endpoint | + +### OAuth flow expectations + +1. Frontend calls `POST /api/v1/google-drive/oauth/start` with the user's JWT bearer token. +2. Backend creates a short-lived OAuth state tied to that authenticated user and returns: + - `authorizationUrl` + - `state` + - `redirectUri` + - `scopes` +3. Frontend opens `authorizationUrl` in a new tab or popup. +4. Google redirects back to `GET /api/v1/google-drive/oauth/callback`. +5. Backend exchanges the authorization code for user-scoped Drive credentials, stores them, and redirects the browser to `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` with: + - `status=success|error` + - `message=` + +### Scope used + +- `https://www.googleapis.com/auth/drive` + +This scope is used so the backend can validate user-selected Drive folders, read chosen Google Docs metadata, create vacancy subfolders, and copy Google Docs files on behalf of the authenticated user. + +### Supported files + +- Base resumes must be **Google Docs** (`application/vnd.google-apps.document`). +- The root folder must be a **Google Drive folder**. +- The frontend Gemini button that opens `https://gemini.google.com/gem/f8ed7c14b062` is frontend-only and does not require a backend endpoint. + +### Resume copy behavior + +When the frontend later calls `POST /api/v1/google-drive/applications/{applicationId}/resume-copies`: + +1. Backend verifies the current user owns the application. +2. Backend refreshes the user's Google access token if needed. +3. Backend verifies the configured root folder still exists and is a folder. +4. Backend finds or creates a vacancy subfolder under that root folder using the application identity. +5. Backend copies the selected base Google Doc into that subfolder. +6. Backend renames the copy with an `APP-` prefix plus vacancy context. +7. Backend returns a Google Docs web URL for the copied file. + +### Google Drive request/response shapes + +`POST /api/v1/google-drive/oauth/start` + +```json +{} +``` + +Response: + +```json +{ + "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth?...", + "state": "generated-state", + "redirectUri": "http://localhost:8080/api/v1/google-drive/oauth/callback", + "scopes": [ + "https://www.googleapis.com/auth/drive" + ] +} +``` + +`GET /api/v1/google-drive/status` + +```json +{ + "configured": true, + "connected": true, + "googleEmail": "user@gmail.com", + "googleDisplayName": "User Name", + "googleAccountId": "permission-id", + "rootFolderId": "drive-folder-id", + "rootFolderName": "Job Tracker Root", + "connectedAt": "2026-05-05T12:00:00", + "baseResumes": [ + { + "id": "uuid", + "googleFileId": "google-doc-id", + "documentName": "Resume Base", + "webViewLink": "https://docs.google.com/document/d/google-doc-id/edit", + "createdAt": "2026-05-05T12:05:00" + } + ] +} +``` + +`PUT /api/v1/google-drive/root-folder` + +```json +{ + "folderIdOrUrl": "https://drive.google.com/drive/folders/drive-folder-id" +} +``` + +`POST /api/v1/google-drive/base-resumes` + +```json +{ + "documentIdOrUrl": "https://docs.google.com/document/d/google-doc-id/edit" +} +``` + +`POST /api/v1/google-drive/applications/{applicationId}/resume-copies` + +```json +{ + "baseResumeId": "base-resume-uuid" +} +``` + +Response: + +```json +{ + "applicationId": "application-uuid", + "baseResumeId": "base-resume-uuid", + "copiedFileId": "copied-google-doc-id", + "copiedFileName": "APP-application-uuid - Backend Engineer - Resume Base", + "documentWebViewLink": "https://docs.google.com/document/d/copied-google-doc-id/edit", + "vacancyFolderId": "vacancy-folder-id", + "vacancyFolderName": "Backend Engineer - APP-application-uuid", + "vacancyFolderWebViewLink": "https://drive.google.com/drive/folders/vacancy-folder-id" +} +``` + ## Running Tests ```bash @@ -274,6 +434,10 @@ If `APP_SEED_ENABLED=true` and `APP_SEED_USER_EMAIL` is not provided (or the use | `JWT_ACCESS_TOKEN_EXPIRATION_MS` | `900000` | Access token TTL (15 min) | | `JWT_REFRESH_TOKEN_EXPIRATION_MS` | `604800000` | Refresh token TTL (7 days) | | `CORS_ALLOWED_ORIGINS` | `http://localhost:3000,http://localhost:5173` | Allowed CORS origins | +| `GOOGLE_DRIVE_CLIENT_ID` | *(empty)* | Google OAuth client ID for Drive integration | +| `GOOGLE_DRIVE_CLIENT_SECRET` | *(empty)* | Google OAuth client secret for Drive integration | +| `GOOGLE_DRIVE_REDIRECT_URI` | `http://localhost:8080/api/v1/google-drive/oauth/callback` | OAuth callback URL registered in Google Cloud | +| `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` | *(empty)* | Frontend URL that receives OAuth completion redirects | | `RATE_LIMIT_AUTH_LOGIN_LIMIT_FOR_PERIOD` | `10` | Max login requests allowed per refresh period | | `RATE_LIMIT_AUTH_LOGIN_REFRESH_PERIOD` | `1m` | Window used by the login rate limiter | | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` | OTLP gRPC endpoint (Jaeger/OpenTelemetry collector) | diff --git a/pom.xml b/pom.xml index 4c98fc4..57b8b71 100644 --- a/pom.xml +++ b/pom.xml @@ -148,6 +148,30 @@ jsoup 1.17.2 + + commons-codec + commons-codec + + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + + + com.google.apis + google-api-services-drive + v3-rev20240521-2.0.0 + + + + + com.google.auth + google-auth-library-oauth2-http + 1.25.0 + diff --git a/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java b/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java new file mode 100644 index 0000000..3296758 --- /dev/null +++ b/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java @@ -0,0 +1,119 @@ +package com.jobtracker.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; +import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; +import java.util.List; + +/** + * Configures the Spring OAuth2 client infrastructure used by the Google Drive integration. + *

+ * The {@link ClientRegistration} bean captures all Google OAuth2 parameters in one place so that + * Spring's standard {@link DefaultAuthorizationCodeTokenResponseClient} and + * {@link DefaultRefreshTokenTokenResponseClient} can handle token exchange and refresh without any + * manual HTTP calls. + *

+ * How the Spring Security context provides the authorized client:
+ * When a user completes the OAuth2 consent flow, {@code GoogleDriveOAuthService.handleCallback()} + * calls {@code authorizationCodeTokenResponseClient.getTokenResponse(grantRequest)}. The resulting + * access and refresh tokens are stored in our {@code google_drive_connections} table via + * {@code GoogleDriveConnectionRepository}. For every subsequent Drive API call, + * {@code GoogleDriveService} loads the connection, refreshes the token if needed using + * {@code refreshTokenResponseClient}, and passes the fresh access token to {@link + * com.jobtracker.service.DriveClientFactory#create} – which wraps it in a Google + * {@link com.google.auth.oauth2.OAuth2Credentials} so the SDK can authenticate automatically. + */ +@Configuration +public class GoogleDriveClientConfig { + + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 30_000; + + /** + * The {@link ClientRegistration} for Google Drive. Built programmatically from + * {@link GoogleDriveProperties} so we have a single source of truth for OAuth2 parameters. + * Note: a {@code ClientRegistrationRepository} bean is intentionally not created; + * that would trigger Spring Security's OAuth2 login auto-configuration which is incompatible + * with this application's stateless JWT architecture. + */ + @Bean + public ClientRegistration googleDriveClientRegistration(GoogleDriveProperties properties) { + return ClientRegistration.withRegistrationId("google-drive") + .clientId(properties.getClientId()) + .clientSecret(properties.getClientSecret()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(properties.getRedirectUri()) + .scope(properties.getScopes().toArray(new String[0])) + .authorizationUri(properties.getAuthorizationUri()) + .tokenUri(properties.getTokenUri()) + .build(); + } + + /** + * Token response client for the Authorization Code grant. Replaces manual RestClient HTTP + * calls with Spring Security's type-safe implementation. Configured with the same timeouts + * used elsewhere in the application. + */ + @Bean + public OAuth2AccessTokenResponseClient authorizationCodeTokenResponseClient() { + DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient(); + client.setRestOperations(buildRestTemplate()); + return client; + } + + /** + * Token response client for the Refresh Token grant. Used by {@code GoogleDriveService} to + * silently renew expired access tokens before each Drive API call. + */ + @Bean + public OAuth2AccessTokenResponseClient refreshTokenResponseClient() { + DefaultRefreshTokenTokenResponseClient client = new DefaultRefreshTokenTokenResponseClient(); + client.setRestOperations(buildRestTemplate()); + return client; + } + + /** + * Builds a {@link RestTemplate} configured with the message converters and error handler + * required by Spring Security's OAuth2 token endpoint clients. + *

+ * {@link FormHttpMessageConverter} encodes the token-exchange form body; without it the + * request body is empty and Google returns {@code invalid_grant}. + * {@link OAuth2AccessTokenResponseHttpMessageConverter} parses Google's JSON token response; + * without it the response body cannot be deserialized and the exchange fails. + * {@link OAuth2ErrorResponseErrorHandler} translates Google error responses into typed + * {@link org.springframework.security.oauth2.core.OAuth2AuthorizationException}s rather than + * raw {@link org.springframework.web.client.HttpClientErrorException}s. + *

+ * This {@link RestTemplate} is only used for OAuth2 token-endpoint exchanges. + * It intentionally registers only the two converters required for that operation, which is + * why the default message converters ({@code StringHttpMessageConverter}, etc.) are not + * present. + */ + private RestTemplate buildRestTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout((int) Duration.ofMillis(CONNECT_TIMEOUT_MS).toMillis()); + factory.setReadTimeout((int) Duration.ofMillis(READ_TIMEOUT_MS).toMillis()); + RestTemplate restTemplate = new RestTemplate(factory); + restTemplate.setMessageConverters(List.of( + new FormHttpMessageConverter(), + new OAuth2AccessTokenResponseHttpMessageConverter() + )); + restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); + return restTemplate; + } +} diff --git a/src/main/java/com/jobtracker/config/GoogleDriveProperties.java b/src/main/java/com/jobtracker/config/GoogleDriveProperties.java new file mode 100644 index 0000000..7c1ef58 --- /dev/null +++ b/src/main/java/com/jobtracker/config/GoogleDriveProperties.java @@ -0,0 +1,83 @@ +package com.jobtracker.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class GoogleDriveProperties { + + private static final List DEFAULT_SCOPES = List.of("https://www.googleapis.com/auth/drive"); + + private final String clientId; + private final String clientSecret; + private final String redirectUri; + private final String oauthCompleteUrl; + private final String authorizationUri; + private final String tokenUri; + private final List scopes; + + public GoogleDriveProperties( + @Value("${app.google-drive.client-id:}") String clientId, + @Value("${app.google-drive.client-secret:}") String clientSecret, + @Value("${app.google-drive.redirect-uri:}") String redirectUri, + @Value("${app.google-drive.oauth-complete-url:}") String oauthCompleteUrl, + @Value("${app.google-drive.authorization-uri:https://accounts.google.com/o/oauth2/v2/auth}") String authorizationUri, + @Value("${app.google-drive.token-uri:https://oauth2.googleapis.com/token}") String tokenUri + ) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + this.oauthCompleteUrl = oauthCompleteUrl; + this.authorizationUri = authorizationUri; + this.tokenUri = tokenUri; + this.scopes = DEFAULT_SCOPES; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getRedirectUri() { + return redirectUri; + } + + public String getOauthCompleteUrl() { + return oauthCompleteUrl; + } + + public String getAuthorizationUri() { + return authorizationUri; + } + + public String getTokenUri() { + return tokenUri; + } + + public List getScopes() { + return scopes; + } + + public String getScopeValue() { + return String.join(" ", scopes); + } + + public boolean isConfigured() { + return hasText(clientId) && hasText(clientSecret) && hasText(redirectUri) && hasText(oauthCompleteUrl); + } + + public void validateConfigured() { + if (!isConfigured()) { + throw new IllegalStateException("Google Drive integration is not configured on the server"); + } + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/jobtracker/config/SecurityConfig.java b/src/main/java/com/jobtracker/config/SecurityConfig.java index e9ef786..b201027 100644 --- a/src/main/java/com/jobtracker/config/SecurityConfig.java +++ b/src/main/java/com/jobtracker/config/SecurityConfig.java @@ -38,13 +38,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers(HttpMethod.POST, - "/api/v1/auth/register", - "/api/v1/auth/login", - "/api/v1/auth/refresh", - "/api/v1/auth/forgot-password", - "/api/v1/auth/reset-password", - "/api/v1/auth/logout").permitAll() + .requestMatchers("/api/v1/auth/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/google-drive/oauth/callback").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() // Actuator is served on a dedicated management port (8081) that is never // exposed to the host; security is enforced via Docker network isolation. diff --git a/src/main/java/com/jobtracker/controller/GoogleDriveController.java b/src/main/java/com/jobtracker/controller/GoogleDriveController.java new file mode 100644 index 0000000..fc4df99 --- /dev/null +++ b/src/main/java/com/jobtracker/controller/GoogleDriveController.java @@ -0,0 +1,98 @@ +package com.jobtracker.controller; + +import com.jobtracker.dto.auth.MessageResponse; +import com.jobtracker.dto.gdrive.*; +import com.jobtracker.service.GoogleDriveOAuthService; +import com.jobtracker.service.GoogleDriveService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.util.UUID; + +@Tag(name = "Google Drive", description = "Google Drive OAuth and resume copy endpoints") +@RestController +@RequestMapping("/api/v1/google-drive") +public class GoogleDriveController { + + private final GoogleDriveOAuthService googleDriveOAuthService; + private final GoogleDriveService googleDriveService; + + public GoogleDriveController(GoogleDriveOAuthService googleDriveOAuthService, GoogleDriveService googleDriveService) { + this.googleDriveOAuthService = googleDriveOAuthService; + this.googleDriveService = googleDriveService; + } + + @Operation( + summary = "Start Google Drive OAuth flow", + responses = @ApiResponse(responseCode = "200", description = "Authorization URL generated", + content = @Content(schema = @Schema(implementation = GoogleDriveOAuthStartResponse.class))) + ) + @PostMapping("/oauth/start") + public ResponseEntity startOauth() { + return ResponseEntity.ok(googleDriveOAuthService.startAuthorization()); + } + + @Operation(summary = "Google Drive OAuth callback") + @GetMapping("/oauth/callback") + public void oauthCallback(@RequestParam(required = false) String state, + @RequestParam(required = false) String code, + @RequestParam(required = false) String error, + HttpServletResponse response) throws IOException { + response.sendRedirect(googleDriveOAuthService.handleCallback(state, code, error)); + } + + @Operation( + summary = "Get Google Drive status", + responses = @ApiResponse(responseCode = "200", description = "Current Google Drive integration status", + content = @Content(schema = @Schema(implementation = GoogleDriveStatusResponse.class))) + ) + @GetMapping("/status") + public ResponseEntity getStatus() { + return ResponseEntity.ok(googleDriveService.getStatus()); + } + + @Operation(summary = "Disconnect Google Drive") + @DeleteMapping("/connection") + public ResponseEntity disconnect() { + return ResponseEntity.ok(googleDriveOAuthService.disconnect()); + } + + @Operation(summary = "Update Google Drive root folder") + @PutMapping("/root-folder") + public ResponseEntity updateRootFolder( + @Valid @RequestBody GoogleDriveRootFolderRequest request) { + return ResponseEntity.ok(googleDriveService.updateRootFolder(request)); + } + + @Operation(summary = "Register a Google Docs base resume") + @PostMapping("/base-resumes") + public ResponseEntity addBaseResume( + @Valid @RequestBody GoogleDriveBaseResumeRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(googleDriveService.addBaseResume(request)); + } + + @Operation(summary = "Delete a configured base resume") + @DeleteMapping("/base-resumes/{baseResumeId}") + public ResponseEntity deleteBaseResume(@PathVariable UUID baseResumeId) { + googleDriveService.deleteBaseResume(baseResumeId); + return ResponseEntity.ok(new MessageResponse("Base resume deleted successfully")); + } + + @Operation(summary = "Copy a base resume into an application folder") + @PostMapping("/applications/{applicationId}/resume-copies") + public ResponseEntity copyBaseResume( + @PathVariable UUID applicationId, + @Valid @RequestBody GoogleDriveResumeCopyRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(googleDriveService.copyBaseResumeToApplication(applicationId, request)); + } +} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeRequest.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeRequest.java new file mode 100644 index 0000000..65dc2c2 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeRequest.java @@ -0,0 +1,11 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "Request to register a Google Docs base resume") +public record GoogleDriveBaseResumeRequest( + @Schema(description = "Google Docs document ID or URL", example = "https://docs.google.com/document/d/1234567890abcdef/edit") + @NotBlank(message = "documentIdOrUrl is required") + String documentIdOrUrl +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeResponse.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeResponse.java new file mode 100644 index 0000000..792bfc8 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeResponse.java @@ -0,0 +1,15 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Schema(description = "Configured Google Docs base resume") +public record GoogleDriveBaseResumeResponse( + UUID id, + String googleFileId, + String documentName, + String webViewLink, + LocalDateTime createdAt +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveOAuthStartResponse.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveOAuthStartResponse.java new file mode 100644 index 0000000..1d38649 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveOAuthStartResponse.java @@ -0,0 +1,17 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "OAuth start response for Google Drive connection") +public record GoogleDriveOAuthStartResponse( + @Schema(description = "Google OAuth authorization URL") + String authorizationUrl, + @Schema(description = "Opaque state generated by the backend") + String state, + @Schema(description = "Configured backend redirect URI") + String redirectUri, + @Schema(description = "OAuth scopes requested by the backend") + List scopes +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyRequest.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyRequest.java new file mode 100644 index 0000000..e81f89e --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyRequest.java @@ -0,0 +1,13 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +@Schema(description = "Request to copy a configured base resume into an application folder") +public record GoogleDriveResumeCopyRequest( + @Schema(description = "Configured base resume identifier") + @NotNull(message = "baseResumeId is required") + UUID baseResumeId +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyResponse.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyResponse.java new file mode 100644 index 0000000..74034d8 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyResponse.java @@ -0,0 +1,17 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.UUID; + +@Schema(description = "Result of copying a Google Docs resume into Drive") +public record GoogleDriveResumeCopyResponse( + UUID applicationId, + UUID baseResumeId, + String copiedFileId, + String copiedFileName, + String documentWebViewLink, + String vacancyFolderId, + String vacancyFolderName, + String vacancyFolderWebViewLink +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveRootFolderRequest.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveRootFolderRequest.java new file mode 100644 index 0000000..3880d20 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveRootFolderRequest.java @@ -0,0 +1,11 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "Request to configure the user's Google Drive root folder") +public record GoogleDriveRootFolderRequest( + @Schema(description = "Google Drive folder ID or URL", example = "https://drive.google.com/drive/folders/1234567890abcdef") + @NotBlank(message = "folderIdOrUrl is required") + String folderIdOrUrl +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveStatusResponse.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveStatusResponse.java new file mode 100644 index 0000000..6335021 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveStatusResponse.java @@ -0,0 +1,19 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "Current Google Drive integration status") +public record GoogleDriveStatusResponse( + boolean configured, + boolean connected, + String googleEmail, + String googleDisplayName, + String googleAccountId, + String rootFolderId, + String rootFolderName, + LocalDateTime connectedAt, + List baseResumes +) {} diff --git a/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java b/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java new file mode 100644 index 0000000..ca78d03 --- /dev/null +++ b/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java @@ -0,0 +1,107 @@ +package com.jobtracker.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.UuidGenerator; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "google_drive_base_resumes", indexes = { + @Index(name = "idx_gdrive_resume_connection", columnList = "connection_id"), + @Index(name = "idx_gdrive_resume_file", columnList = "google_file_id"), + @Index(name = "uk_gdrive_resume_connection_file", columnList = "connection_id,google_file_id", unique = true) +}) +public class GoogleDriveBaseResume { + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "connection_id", nullable = false) + private GoogleDriveConnection connection; + + @Column(name = "google_file_id", nullable = false, length = 255) + private String googleFileId; + + @Column(name = "document_name", nullable = false, length = 255) + private String documentName; + + @Column(name = "web_view_link", length = 2048) + private String webViewLink; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public GoogleDriveConnection getConnection() { + return connection; + } + + public void setConnection(GoogleDriveConnection connection) { + this.connection = connection; + } + + public String getGoogleFileId() { + return googleFileId; + } + + public void setGoogleFileId(String googleFileId) { + this.googleFileId = googleFileId; + } + + public String getDocumentName() { + return documentName; + } + + public void setDocumentName(String documentName) { + this.documentName = documentName; + } + + public String getWebViewLink() { + return webViewLink; + } + + public void setWebViewLink(String webViewLink) { + this.webViewLink = webViewLink; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/jobtracker/entity/GoogleDriveConnection.java b/src/main/java/com/jobtracker/entity/GoogleDriveConnection.java new file mode 100644 index 0000000..ee34ae0 --- /dev/null +++ b/src/main/java/com/jobtracker/entity/GoogleDriveConnection.java @@ -0,0 +1,199 @@ +package com.jobtracker.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.UuidGenerator; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "google_drive_connections", indexes = { + @Index(name = "idx_gdrive_connection_user", columnList = "user_id", unique = true), + @Index(name = "idx_gdrive_connection_google_account", columnList = "google_account_id") +}) +public class GoogleDriveConnection { + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Column(name = "google_account_id", nullable = false, length = 255) + private String googleAccountId; + + @Column(name = "google_email", nullable = false, length = 255) + private String googleEmail; + + @Column(name = "google_display_name", length = 255) + private String googleDisplayName; + + @Column(name = "access_token", nullable = false, length = 4096) + private String accessToken; + + @Column(name = "refresh_token", nullable = false, length = 4096) + private String refreshToken; + + @Column(name = "access_token_expires_at", nullable = false) + private LocalDateTime accessTokenExpiresAt; + + @Column(name = "granted_scopes", nullable = false, length = 2048) + private String grantedScopes; + + @Column(name = "root_folder_id", length = 255) + private String rootFolderId; + + @Column(name = "root_folder_name", length = 255) + private String rootFolderName; + + @Column(name = "connected_at", nullable = false) + private LocalDateTime connectedAt; + + @OneToMany(mappedBy = "connection", cascade = CascadeType.ALL, orphanRemoval = true) + private List baseResumes = new ArrayList<>(); + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + if (connectedAt == null) { + connectedAt = createdAt; + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getGoogleAccountId() { + return googleAccountId; + } + + public void setGoogleAccountId(String googleAccountId) { + this.googleAccountId = googleAccountId; + } + + public String getGoogleEmail() { + return googleEmail; + } + + public void setGoogleEmail(String googleEmail) { + this.googleEmail = googleEmail; + } + + public String getGoogleDisplayName() { + return googleDisplayName; + } + + public void setGoogleDisplayName(String googleDisplayName) { + this.googleDisplayName = googleDisplayName; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public LocalDateTime getAccessTokenExpiresAt() { + return accessTokenExpiresAt; + } + + public void setAccessTokenExpiresAt(LocalDateTime accessTokenExpiresAt) { + this.accessTokenExpiresAt = accessTokenExpiresAt; + } + + public String getGrantedScopes() { + return grantedScopes; + } + + public void setGrantedScopes(String grantedScopes) { + this.grantedScopes = grantedScopes; + } + + public String getRootFolderId() { + return rootFolderId; + } + + public void setRootFolderId(String rootFolderId) { + this.rootFolderId = rootFolderId; + } + + public String getRootFolderName() { + return rootFolderName; + } + + public void setRootFolderName(String rootFolderName) { + this.rootFolderName = rootFolderName; + } + + public LocalDateTime getConnectedAt() { + return connectedAt; + } + + public void setConnectedAt(LocalDateTime connectedAt) { + this.connectedAt = connectedAt; + } + + public List getBaseResumes() { + return baseResumes; + } + + public void setBaseResumes(List baseResumes) { + this.baseResumes = baseResumes; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/jobtracker/entity/GoogleDriveOAuthState.java b/src/main/java/com/jobtracker/entity/GoogleDriveOAuthState.java new file mode 100644 index 0000000..0013852 --- /dev/null +++ b/src/main/java/com/jobtracker/entity/GoogleDriveOAuthState.java @@ -0,0 +1,79 @@ +package com.jobtracker.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.UuidGenerator; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "google_drive_oauth_states", indexes = { + @Index(name = "uk_gdrive_oauth_state", columnList = "state_token", unique = true), + @Index(name = "idx_gdrive_oauth_user", columnList = "user_id"), + @Index(name = "idx_gdrive_oauth_expires", columnList = "expires_at") +}) +public class GoogleDriveOAuthState { + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "state_token", nullable = false, length = 255) + private String stateToken; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getStateToken() { + return stateToken; + } + + public void setStateToken(String stateToken) { + this.stateToken = stateToken; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/jobtracker/entity/JobApplication.java b/src/main/java/com/jobtracker/entity/JobApplication.java index 14bcb64..0f43616 100644 --- a/src/main/java/com/jobtracker/entity/JobApplication.java +++ b/src/main/java/com/jobtracker/entity/JobApplication.java @@ -84,6 +84,9 @@ public class JobApplication { @Column(name = "archived_at") private LocalDateTime archivedAt; + @Column(name = "drive_vacancy_folder_id", length = 255) + private String driveVacancyFolderId; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @@ -168,6 +171,9 @@ protected void onUpdate() { public LocalDateTime getArchivedAt() { return archivedAt; } public void setArchivedAt(LocalDateTime archivedAt) { this.archivedAt = archivedAt; } + public String getDriveVacancyFolderId() { return driveVacancyFolderId; } + public void setDriveVacancyFolderId(String driveVacancyFolderId) { this.driveVacancyFolderId = driveVacancyFolderId; } + public User getUser() { return user; } public void setUser(User user) { this.user = user; } diff --git a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java index c6b03a4..9352265 100644 --- a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java @@ -45,6 +45,12 @@ public ResponseEntity> handleConflict(ConflictException ex) return buildResponse(HttpStatus.CONFLICT, ex.getMessage()); } + @ExceptionHandler(ServiceUnavailableException.class) + public ResponseEntity> handleServiceUnavailable(ServiceUnavailableException ex) { + log.warn("event=SERVICE_UNAVAILABLE message={}", ex.getMessage()); + return buildResponse(HttpStatus.SERVICE_UNAVAILABLE, ex.getMessage()); + } + @ExceptionHandler({BadCredentialsException.class, AuthenticationException.class}) public ResponseEntity> handleBadCredentials(RuntimeException ex) { return buildResponse(HttpStatus.UNAUTHORIZED, "Invalid credentials"); diff --git a/src/main/java/com/jobtracker/exception/ServiceUnavailableException.java b/src/main/java/com/jobtracker/exception/ServiceUnavailableException.java new file mode 100644 index 0000000..a6070ae --- /dev/null +++ b/src/main/java/com/jobtracker/exception/ServiceUnavailableException.java @@ -0,0 +1,8 @@ +package com.jobtracker.exception; + +public class ServiceUnavailableException extends RuntimeException { + + public ServiceUnavailableException(String message) { + super(message); + } +} diff --git a/src/main/java/com/jobtracker/repository/ApplicationRepository.java b/src/main/java/com/jobtracker/repository/ApplicationRepository.java index 9bf1e2f..25319bd 100644 --- a/src/main/java/com/jobtracker/repository/ApplicationRepository.java +++ b/src/main/java/com/jobtracker/repository/ApplicationRepository.java @@ -6,8 +6,10 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; @@ -52,4 +54,15 @@ public interface ApplicationRepository extends JpaRepository findByStatusIsNullAndUpdatedAtBefore(LocalDateTime updatedAt); List findByStatusIsNotNullAndStatusNotAndUpdatedAtBefore(ApplicationStatus status, LocalDateTime updatedAt); + + /** + * Atomically sets {@code driveVacancyFolderId} on an application only when the column is + * currently {@code NULL}. Returns the number of rows updated (1 on success, 0 if another + * concurrent request already stored a folder ID). Callers should re-read the entity to + * obtain the winning folder ID when this method returns 0. + */ + @Modifying + @Transactional + @Query("UPDATE JobApplication a SET a.driveVacancyFolderId = :folderId WHERE a.id = :id AND a.driveVacancyFolderId IS NULL") + int setDriveVacancyFolderIdIfAbsent(@Param("id") UUID id, @Param("folderId") String folderId); } diff --git a/src/main/java/com/jobtracker/repository/GoogleDriveBaseResumeRepository.java b/src/main/java/com/jobtracker/repository/GoogleDriveBaseResumeRepository.java new file mode 100644 index 0000000..eaf5cd8 --- /dev/null +++ b/src/main/java/com/jobtracker/repository/GoogleDriveBaseResumeRepository.java @@ -0,0 +1,17 @@ +package com.jobtracker.repository; + +import com.jobtracker.entity.GoogleDriveBaseResume; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface GoogleDriveBaseResumeRepository extends JpaRepository { + + List findAllByConnectionIdOrderByCreatedAtAsc(UUID connectionId); + + Optional findByIdAndConnectionUserId(UUID id, UUID userId); + + Optional findByConnectionIdAndGoogleFileId(UUID connectionId, String googleFileId); +} diff --git a/src/main/java/com/jobtracker/repository/GoogleDriveConnectionRepository.java b/src/main/java/com/jobtracker/repository/GoogleDriveConnectionRepository.java new file mode 100644 index 0000000..25457a5 --- /dev/null +++ b/src/main/java/com/jobtracker/repository/GoogleDriveConnectionRepository.java @@ -0,0 +1,14 @@ +package com.jobtracker.repository; + +import com.jobtracker.entity.GoogleDriveConnection; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface GoogleDriveConnectionRepository extends JpaRepository { + + @EntityGraph(attributePaths = "baseResumes") + Optional findByUserId(UUID userId); +} diff --git a/src/main/java/com/jobtracker/repository/GoogleDriveOAuthStateRepository.java b/src/main/java/com/jobtracker/repository/GoogleDriveOAuthStateRepository.java new file mode 100644 index 0000000..da5cb2f --- /dev/null +++ b/src/main/java/com/jobtracker/repository/GoogleDriveOAuthStateRepository.java @@ -0,0 +1,17 @@ +package com.jobtracker.repository; + +import com.jobtracker.entity.GoogleDriveOAuthState; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +public interface GoogleDriveOAuthStateRepository extends JpaRepository { + + Optional findByStateToken(String stateToken); + + void deleteByExpiresAtBefore(LocalDateTime expiresAt); + + void deleteByUserId(UUID userId); +} diff --git a/src/main/java/com/jobtracker/service/DriveClientFactory.java b/src/main/java/com/jobtracker/service/DriveClientFactory.java new file mode 100644 index 0000000..2901edc --- /dev/null +++ b/src/main/java/com/jobtracker/service/DriveClientFactory.java @@ -0,0 +1,63 @@ +package com.jobtracker.service; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.drive.Drive; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.OAuth2Credentials; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Date; + +/** + * Factory that creates ready-to-use {@link Drive} SDK client instances from a stored access token. + * The {@link HttpTransport} and {@link JsonFactory} are shared singletons (thread-safe) to avoid + * the overhead of re-creating TLS contexts on every API call. + */ +@Component +public class DriveClientFactory { + + private static final String APPLICATION_NAME = "JobApplyTracker"; + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 30_000; + + private final HttpTransport httpTransport; + private final JsonFactory jsonFactory = GsonFactory.getDefaultInstance(); + + public DriveClientFactory() throws GeneralSecurityException, IOException { + this.httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + } + + /** + * Creates a {@link Drive} client authenticated with the given access token. + * + * @param accessToken the current OAuth2 access token + * @param accessTokenExpiresAt expiry hint passed to the Google credentials (may be {@code null}) + * @return a configured {@link Drive} instance + */ + public Drive create(String accessToken, LocalDateTime accessTokenExpiresAt) { + Date expiryDate = accessTokenExpiresAt != null + ? Date.from(accessTokenExpiresAt.toInstant(ZoneOffset.UTC)) + : null; + AccessToken token = new AccessToken(accessToken, expiryDate); + OAuth2Credentials credentials = OAuth2Credentials.create(token); + + HttpRequestInitializer requestInitializer = request -> { + new HttpCredentialsAdapter(credentials).initialize(request); + request.setConnectTimeout(CONNECT_TIMEOUT_MS); + request.setReadTimeout(READ_TIMEOUT_MS); + }; + + return new Drive.Builder(httpTransport, jsonFactory, requestInitializer) + .setApplicationName(APPLICATION_NAME) + .build(); + } +} diff --git a/src/main/java/com/jobtracker/service/GoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/GoogleDriveApiClient.java new file mode 100644 index 0000000..163b0cd --- /dev/null +++ b/src/main/java/com/jobtracker/service/GoogleDriveApiClient.java @@ -0,0 +1,32 @@ +package com.jobtracker.service; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface GoogleDriveApiClient { + + String GOOGLE_DOC_MIME_TYPE = "application/vnd.google-apps.document"; + String GOOGLE_FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; + + String buildAuthorizationUrl(String state); + + OAuthTokens exchangeAuthorizationCode(String code); + + OAuthTokens refreshAccessToken(String refreshToken); + + GoogleDriveAccountProfile getCurrentAccount(String accessToken); + + DriveFileMetadata getFileMetadata(String accessToken, String fileId); + + Optional findFolderByName(String accessToken, String parentFolderId, String folderName); + + DriveFileMetadata createFolder(String accessToken, String parentFolderId, String folderName); + + DriveFileMetadata copyGoogleDoc(String accessToken, String sourceFileId, String targetFolderId, String newName); + + record OAuthTokens(String accessToken, String refreshToken, LocalDateTime accessTokenExpiresAt, String scope) {} + + record GoogleDriveAccountProfile(String accountId, String emailAddress, String displayName) {} + + record DriveFileMetadata(String id, String name, String mimeType, String webViewLink) {} +} diff --git a/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java b/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java new file mode 100644 index 0000000..6d3d34d --- /dev/null +++ b/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java @@ -0,0 +1,171 @@ +package com.jobtracker.service; + +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.dto.auth.MessageResponse; +import com.jobtracker.dto.gdrive.GoogleDriveOAuthStartResponse; +import com.jobtracker.entity.GoogleDriveConnection; +import com.jobtracker.entity.GoogleDriveOAuthState; +import com.jobtracker.entity.User; +import com.jobtracker.exception.BadRequestException; +import com.jobtracker.repository.GoogleDriveConnectionRepository; +import com.jobtracker.repository.GoogleDriveOAuthStateRepository; +import com.jobtracker.util.SecurityUtils; +import org.apache.commons.codec.digest.DigestUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.UUID; + +@Service +public class GoogleDriveOAuthService { + + private static final Logger log = LoggerFactory.getLogger(GoogleDriveOAuthService.class); + private static final int OAUTH_STATE_TTL_MINUTES = 10; + + private final GoogleDriveApiClient googleDriveApiClient; + private final GoogleDriveProperties googleDriveProperties; + private final GoogleDriveConnectionRepository connectionRepository; + private final GoogleDriveOAuthStateRepository oauthStateRepository; + private final SecurityUtils securityUtils; + + public GoogleDriveOAuthService(GoogleDriveApiClient googleDriveApiClient, + GoogleDriveProperties googleDriveProperties, + GoogleDriveConnectionRepository connectionRepository, + GoogleDriveOAuthStateRepository oauthStateRepository, + SecurityUtils securityUtils) { + this.googleDriveApiClient = googleDriveApiClient; + this.googleDriveProperties = googleDriveProperties; + this.connectionRepository = connectionRepository; + this.oauthStateRepository = oauthStateRepository; + this.securityUtils = securityUtils; + } + + @Transactional + public GoogleDriveOAuthStartResponse startAuthorization() { + validateServerConfigured(); + oauthStateRepository.deleteByExpiresAtBefore(LocalDateTime.now()); + + User currentUser = securityUtils.getCurrentUser(); + String state = generateState(currentUser.getId()); + + GoogleDriveOAuthState oauthState = new GoogleDriveOAuthState(); + oauthState.setUser(currentUser); + oauthState.setStateToken(state); + oauthState.setExpiresAt(LocalDateTime.now().plusMinutes(OAUTH_STATE_TTL_MINUTES)); + oauthStateRepository.save(oauthState); + + return new GoogleDriveOAuthStartResponse( + googleDriveApiClient.buildAuthorizationUrl(state), + state, + googleDriveProperties.getRedirectUri(), + googleDriveProperties.getScopes() + ); + } + + @Transactional + public String handleCallback(String state, String code, String error) { + validateServerConfigured(); + oauthStateRepository.deleteByExpiresAtBefore(LocalDateTime.now()); + + GoogleDriveOAuthState oauthState = null; + try { + if (state != null && !state.isBlank()) { + oauthState = oauthStateRepository.findByStateToken(state).orElse(null); + } + if (error != null && !error.isBlank()) { + return buildFrontendRedirect("error", "Google returned an OAuth error: " + error); + } + if (state == null || state.isBlank()) { + return buildFrontendRedirect("error", "Missing OAuth state"); + } + if (oauthState == null) { + throw new BadRequestException("Invalid or expired Google OAuth state"); + } + if (oauthState.getExpiresAt().isBefore(LocalDateTime.now())) { + throw new BadRequestException("Google OAuth state expired"); + } + if (code == null || code.isBlank()) { + return buildFrontendRedirect("error", "Missing authorization code"); + } + + GoogleDriveApiClient.OAuthTokens tokens = googleDriveApiClient.exchangeAuthorizationCode(code); + GoogleDriveApiClient.GoogleDriveAccountProfile accountProfile = + googleDriveApiClient.getCurrentAccount(tokens.accessToken()); + + GoogleDriveConnection connection = connectionRepository.findByUserId(oauthState.getUser().getId()) + .orElseGet(GoogleDriveConnection::new); + boolean accountChanged = connection.getGoogleAccountId() != null + && !connection.getGoogleAccountId().equals(accountProfile.accountId()); + + connection.setUser(oauthState.getUser()); + connection.setGoogleAccountId(accountProfile.accountId()); + connection.setGoogleEmail(accountProfile.emailAddress()); + connection.setGoogleDisplayName(accountProfile.displayName()); + connection.setAccessToken(tokens.accessToken()); + connection.setRefreshToken(tokens.refreshToken()); + connection.setAccessTokenExpiresAt(tokens.accessTokenExpiresAt()); + connection.setGrantedScopes(tokens.scope() == null || tokens.scope().isBlank() + ? googleDriveProperties.getScopeValue() : tokens.scope()); + connection.setConnectedAt(LocalDateTime.now()); + if (accountChanged) { + connection.setRootFolderId(null); + connection.setRootFolderName(null); + connection.getBaseResumes().clear(); + } + connectionRepository.save(connection); + + return buildFrontendRedirect("success", "Google Drive connected successfully"); + } catch (BadRequestException ex) { + return buildFrontendRedirect("error", ex.getMessage()); + } catch (Exception ex) { + log.error("event=GOOGLE_OAUTH_CALLBACK_ERROR state={}", state, ex); + return buildFrontendRedirect("error", "An unexpected error occurred. Please try again."); + } finally { + if (oauthState != null) { + oauthStateRepository.delete(oauthState); + } + } + } + + /** + * Disconnects Google Drive and invalidates any pending OAuth authorization states for the user. + * Revoking pending states prevents a stale consent tab from re-connecting Drive after + * intentional disconnection. + */ + @Transactional + public MessageResponse disconnect() { + UUID userId = securityUtils.getCurrentUserId(); + oauthStateRepository.deleteByUserId(userId); + connectionRepository.findByUserId(userId) + .ifPresent(connectionRepository::delete); + return new MessageResponse("Google Drive connection removed"); + } + + private void validateServerConfigured() { + try { + googleDriveProperties.validateConfigured(); + } catch (IllegalStateException ex) { + throw new BadRequestException(ex.getMessage()); + } + } + + private String generateState(UUID userId) { + String raw = userId + ":" + UUID.randomUUID() + ":" + System.nanoTime(); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(DigestUtils.sha256(raw)); + } + + private String buildFrontendRedirect(String status, String message) { + return UriComponentsBuilder.fromUriString(googleDriveProperties.getOauthCompleteUrl()) + .queryParam("status", status) + .queryParam("message", message) + .build() + .encode() + .toUriString(); + } +} diff --git a/src/main/java/com/jobtracker/service/GoogleDriveService.java b/src/main/java/com/jobtracker/service/GoogleDriveService.java new file mode 100644 index 0000000..192a84f --- /dev/null +++ b/src/main/java/com/jobtracker/service/GoogleDriveService.java @@ -0,0 +1,331 @@ +package com.jobtracker.service; + +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.dto.gdrive.*; +import com.jobtracker.entity.GoogleDriveBaseResume; +import com.jobtracker.entity.GoogleDriveConnection; +import com.jobtracker.entity.JobApplication; +import com.jobtracker.exception.BadRequestException; +import com.jobtracker.exception.ResourceNotFoundException; +import com.jobtracker.repository.ApplicationRepository; +import com.jobtracker.repository.GoogleDriveBaseResumeRepository; +import com.jobtracker.repository.GoogleDriveConnectionRepository; +import com.jobtracker.util.SecurityUtils; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class GoogleDriveService { + + private static final Pattern GOOGLE_DRIVE_PATH_ID_PATTERN = Pattern.compile("/(?:d|folders)/([a-zA-Z0-9_-]{10,})"); + private static final Pattern GOOGLE_DRIVE_QUERY_ID_PATTERN = Pattern.compile("[?&]id=([a-zA-Z0-9_-]{10,})"); + + private final GoogleDriveApiClient googleDriveApiClient; + private final GoogleDriveProperties googleDriveProperties; + private final GoogleDriveConnectionRepository connectionRepository; + private final GoogleDriveBaseResumeRepository baseResumeRepository; + private final ApplicationRepository applicationRepository; + private final SecurityUtils securityUtils; + + public GoogleDriveService(GoogleDriveApiClient googleDriveApiClient, + GoogleDriveProperties googleDriveProperties, + GoogleDriveConnectionRepository connectionRepository, + GoogleDriveBaseResumeRepository baseResumeRepository, + ApplicationRepository applicationRepository, + SecurityUtils securityUtils) { + this.googleDriveApiClient = googleDriveApiClient; + this.googleDriveProperties = googleDriveProperties; + this.connectionRepository = connectionRepository; + this.baseResumeRepository = baseResumeRepository; + this.applicationRepository = applicationRepository; + this.securityUtils = securityUtils; + } + + @Transactional(readOnly = true) + public GoogleDriveStatusResponse getStatus() { + if (!googleDriveProperties.isConfigured()) { + return new GoogleDriveStatusResponse(false, false, null, null, null, null, null, null, List.of()); + } + + Optional connectionOptional = connectionRepository.findByUserId(securityUtils.getCurrentUserId()); + if (connectionOptional.isEmpty()) { + return new GoogleDriveStatusResponse(true, false, null, null, null, null, null, null, List.of()); + } + + return toStatusResponse(connectionOptional.get()); + } + + @Transactional + public GoogleDriveStatusResponse updateRootFolder(GoogleDriveRootFolderRequest request) { + GoogleDriveConnection connection = getConnectionWithFreshAccessToken(); + String folderId = extractGoogleFileId(request.folderIdOrUrl()); + + GoogleDriveApiClient.DriveFileMetadata folder = googleDriveApiClient.getFileMetadata(connection.getAccessToken(), folderId); + if (!GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE.equals(folder.mimeType())) { + throw new BadRequestException("Configured root folder must be a Google Drive folder"); + } + + connection.setRootFolderId(folder.id()); + connection.setRootFolderName(folder.name()); + connectionRepository.save(connection); + return toStatusResponse(connection); + } + + @Transactional + public GoogleDriveBaseResumeResponse addBaseResume(GoogleDriveBaseResumeRequest request) { + GoogleDriveConnection connection = getConnectionWithFreshAccessToken(); + String documentId = extractGoogleFileId(request.documentIdOrUrl()); + + GoogleDriveApiClient.DriveFileMetadata file = googleDriveApiClient.getFileMetadata(connection.getAccessToken(), documentId); + if (!GoogleDriveApiClient.GOOGLE_DOC_MIME_TYPE.equals(file.mimeType())) { + throw new BadRequestException("Only Google Docs base resumes are supported"); + } + + GoogleDriveBaseResume resume = baseResumeRepository + .findByConnectionIdAndGoogleFileId(connection.getId(), file.id()) + .orElseGet(GoogleDriveBaseResume::new); + + resume.setConnection(connection); + resume.setGoogleFileId(file.id()); + resume.setDocumentName(file.name()); + resume.setWebViewLink(resolveDocumentLink(file)); + try { + GoogleDriveBaseResume saved = baseResumeRepository.save(resume); + return toBaseResumeResponse(saved); + } catch (DataIntegrityViolationException ex) { + // Concurrent insert on the same (connection_id, google_file_id) – return existing + return baseResumeRepository.findByConnectionIdAndGoogleFileId(connection.getId(), file.id()) + .map(this::toBaseResumeResponse) + .orElseThrow(() -> new BadRequestException("Failed to save base resume")); + } + } + + @Transactional + public void deleteBaseResume(UUID baseResumeId) { + UUID userId = securityUtils.getCurrentUserId(); + GoogleDriveBaseResume resume = baseResumeRepository.findByIdAndConnectionUserId(baseResumeId, userId) + .orElseThrow(() -> new ResourceNotFoundException("Base resume not found with id: " + baseResumeId)); + baseResumeRepository.delete(resume); + } + + @Transactional + public GoogleDriveResumeCopyResponse copyBaseResumeToApplication(UUID applicationId, GoogleDriveResumeCopyRequest request) { + UUID userId = securityUtils.getCurrentUserId(); + GoogleDriveConnection connection = getConnectionWithFreshAccessToken(); + JobApplication application = applicationRepository.findByIdAndUserId(applicationId, userId) + .orElseThrow(() -> new ResourceNotFoundException("Application not found with id: " + applicationId)); + + if (!StringUtils.hasText(connection.getRootFolderId())) { + throw new BadRequestException("Configure a Google Drive root folder before copying resumes"); + } + + GoogleDriveBaseResume baseResume = baseResumeRepository.findByIdAndConnectionUserId(request.baseResumeId(), userId) + .orElseThrow(() -> new ResourceNotFoundException("Base resume not found with id: " + request.baseResumeId())); + + GoogleDriveApiClient.DriveFileMetadata rootFolder = + googleDriveApiClient.getFileMetadata(connection.getAccessToken(), connection.getRootFolderId()); + if (!GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE.equals(rootFolder.mimeType())) { + throw new BadRequestException("Configured root folder is no longer a valid Google Drive folder"); + } + connection.setRootFolderName(rootFolder.name()); + + // Use the stored Drive folder ID for this application when available, so that renaming + // the vacancy/organization after the first copy still resolves to the correct folder. + GoogleDriveApiClient.DriveFileMetadata vacancyFolder = + resolveOrCreateVacancyFolder(connection, application, rootFolder.id(), userId); + + String copiedFileName = buildCopiedDocumentName(application, baseResume.getDocumentName()); + GoogleDriveApiClient.DriveFileMetadata copiedFile = googleDriveApiClient.copyGoogleDoc( + connection.getAccessToken(), + baseResume.getGoogleFileId(), + vacancyFolder.id(), + copiedFileName + ); + + connectionRepository.save(connection); + return new GoogleDriveResumeCopyResponse( + application.getId(), + baseResume.getId(), + copiedFile.id(), + copiedFile.name(), + resolveDocumentLink(copiedFile), + vacancyFolder.id(), + vacancyFolder.name(), + resolveFolderLink(vacancyFolder.id(), vacancyFolder.webViewLink()) + ); + } + + /** + * Resolves the Drive vacancy folder for an application, creating it if needed. + * + *

The folder ID is stored on the {@link JobApplication} row after the first copy so that + * subsequent copies continue to use the same folder even if the vacancy name or organisation + * is later edited. The ID is stored with a conditional UPDATE ({@code WHERE + * drive_vacancy_folder_id IS NULL}) so that concurrent copy requests for the same application + * race to store their folder ID; the loser discards the orphan folder it created and uses the + * winner's ID instead. + */ + private GoogleDriveApiClient.DriveFileMetadata resolveOrCreateVacancyFolder( + GoogleDriveConnection connection, + JobApplication application, + String rootFolderId, + UUID userId) { + + // Fast path: use the stored folder ID (handles renames and removes the need for a name search) + if (StringUtils.hasText(application.getDriveVacancyFolderId())) { + return googleDriveApiClient.getFileMetadata(connection.getAccessToken(), application.getDriveVacancyFolderId()); + } + + // Slow path: search by name then create if not found + String vacancyFolderName = buildVacancyFolderName(application); + GoogleDriveApiClient.DriveFileMetadata folder = googleDriveApiClient + .findFolderByName(connection.getAccessToken(), rootFolderId, vacancyFolderName) + .orElseGet(() -> googleDriveApiClient.createFolder(connection.getAccessToken(), rootFolderId, vacancyFolderName)); + + // Attempt to atomically record the resolved folder ID; if a concurrent request beat us, + // fetch the winning ID from the DB and use that folder instead. + int updated = applicationRepository.setDriveVacancyFolderIdIfAbsent(application.getId(), folder.id()); + if (updated == 0) { + String winningFolderId = applicationRepository.findByIdAndUserId(application.getId(), userId) + .map(JobApplication::getDriveVacancyFolderId) + .filter(StringUtils::hasText) + .orElse(folder.id()); // concurrent tx hasn't committed yet; use our own folder this time + if (!winningFolderId.equals(folder.id())) { + folder = googleDriveApiClient.getFileMetadata(connection.getAccessToken(), winningFolderId); + } + } + + return folder; + } + + private GoogleDriveConnection getConnectionWithFreshAccessToken() { + requireServerConfigured(); + GoogleDriveConnection connection = connectionRepository.findByUserId(securityUtils.getCurrentUserId()) + .orElseThrow(() -> new BadRequestException("Google Drive is not connected for the current user")); + return refreshAccessTokenIfNeeded(connection); + } + + private GoogleDriveConnection refreshAccessTokenIfNeeded(GoogleDriveConnection connection) { + if (connection.getAccessTokenExpiresAt() != null + && connection.getAccessTokenExpiresAt().isAfter(LocalDateTime.now().plusMinutes(1))) { + return connection; + } + + GoogleDriveApiClient.OAuthTokens refreshed = googleDriveApiClient.refreshAccessToken(connection.getRefreshToken()); + connection.setAccessToken(refreshed.accessToken()); + connection.setAccessTokenExpiresAt(refreshed.accessTokenExpiresAt()); + if (StringUtils.hasText(refreshed.scope())) { + connection.setGrantedScopes(refreshed.scope()); + } + return connectionRepository.save(connection); + } + + private GoogleDriveStatusResponse toStatusResponse(GoogleDriveConnection connection) { + List resumes = baseResumeRepository.findAllByConnectionIdOrderByCreatedAtAsc(connection.getId()) + .stream() + .sorted(Comparator.comparing(GoogleDriveBaseResume::getCreatedAt)) + .map(this::toBaseResumeResponse) + .toList(); + return new GoogleDriveStatusResponse( + true, + true, + connection.getGoogleEmail(), + connection.getGoogleDisplayName(), + connection.getGoogleAccountId(), + connection.getRootFolderId(), + connection.getRootFolderName(), + connection.getConnectedAt(), + resumes + ); + } + + private GoogleDriveBaseResumeResponse toBaseResumeResponse(GoogleDriveBaseResume resume) { + return new GoogleDriveBaseResumeResponse( + resume.getId(), + resume.getGoogleFileId(), + resume.getDocumentName(), + resume.getWebViewLink(), + resume.getCreatedAt() + ); + } + + private void requireServerConfigured() { + if (!googleDriveProperties.isConfigured()) { + throw new BadRequestException("Google Drive integration is not configured on the server"); + } + } + + private String extractGoogleFileId(String rawValue) { + if (!StringUtils.hasText(rawValue)) { + throw new BadRequestException("Google file or folder ID is required"); + } + + String trimmed = rawValue.trim(); + if (!trimmed.contains("/")) { + return trimmed; + } + + Matcher pathMatcher = GOOGLE_DRIVE_PATH_ID_PATTERN.matcher(trimmed); + if (pathMatcher.find()) { + return pathMatcher.group(1); + } + + Matcher queryMatcher = GOOGLE_DRIVE_QUERY_ID_PATTERN.matcher(trimmed); + if (queryMatcher.find()) { + return queryMatcher.group(1); + } + throw new BadRequestException("Could not extract a Google file or folder ID from the provided value"); + } + + private String buildVacancyFolderName(JobApplication application) { + String suffix = " - APP-" + application.getId().toString(); + String rawBase = firstNonBlank(application.getVacancyName(), application.getOrganization(), "Application"); + String truncatedBase = truncateFileName(sanitizeFileName(rawBase), 180 - suffix.length()); + return truncatedBase + suffix; + } + + private String buildCopiedDocumentName(JobApplication application, String baseResumeName) { + String vacancyName = firstNonBlank(application.getVacancyName(), application.getOrganization(), "Application"); + String prefix = "APP-" + application.getId() + " - " + vacancyName; + return truncateFileName(sanitizeFileName(prefix + " - " + baseResumeName), 220); + } + + private String sanitizeFileName(String value) { + return value.replaceAll("[\\\\/:*?\"<>|]+", "-").replaceAll("\\s+", " ").trim(); + } + + private String truncateFileName(String value, int maxLength) { + return value.length() <= maxLength ? value : value.substring(0, maxLength).trim(); + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (StringUtils.hasText(value)) { + return value.trim(); + } + } + return null; + } + + private String resolveDocumentLink(GoogleDriveApiClient.DriveFileMetadata file) { + return StringUtils.hasText(file.webViewLink()) + ? file.webViewLink() + : "https://docs.google.com/document/d/" + file.id() + "/edit"; + } + + private String resolveFolderLink(String folderId, String webViewLink) { + return StringUtils.hasText(webViewLink) + ? webViewLink + : "https://drive.google.com/drive/folders/" + folderId; + } +} diff --git a/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java new file mode 100644 index 0000000..13b1615 --- /dev/null +++ b/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java @@ -0,0 +1,275 @@ +package com.jobtracker.service; + +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.About; +import com.google.api.services.drive.model.File; +import com.google.api.services.drive.model.FileList; +import com.google.api.services.drive.model.User; +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.exception.BadRequestException; +import com.jobtracker.exception.ServiceUnavailableException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Google Drive API client backed by the official Google Drive Java SDK and Spring Security's + * OAuth2 token exchange infrastructure. + * + *

    + *
  • All Drive file operations (getFileMetadata, findFolderByName, createFolder, copyGoogleDoc, + * getCurrentAccount) use the {@code google-api-services-drive} SDK via {@link DriveClientFactory}. + *
  • OAuth2 token exchange and refresh use Spring Security's + * {@link org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient} + * and {@link org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient}, + * eliminating all manual HTTP token requests. + *
  • Google API errors are classified: 4xx responses become {@link BadRequestException} (HTTP + * 400); 5xx and rate-limit (429) responses become {@link ServiceUnavailableException} (HTTP + * 503) so that callers can distinguish retryable failures from invalid requests. + *
+ */ +@Component +public class SdkGoogleDriveApiClient implements GoogleDriveApiClient { + + private static final Logger log = LoggerFactory.getLogger(SdkGoogleDriveApiClient.class); + + private final GoogleDriveProperties properties; + private final ClientRegistration clientRegistration; + private final DriveClientFactory driveClientFactory; + private final OAuth2AccessTokenResponseClient authorizationCodeClient; + private final OAuth2AccessTokenResponseClient refreshTokenClient; + + public SdkGoogleDriveApiClient( + GoogleDriveProperties properties, + ClientRegistration clientRegistration, + DriveClientFactory driveClientFactory, + OAuth2AccessTokenResponseClient authorizationCodeClient, + OAuth2AccessTokenResponseClient refreshTokenClient) { + this.properties = properties; + this.clientRegistration = clientRegistration; + this.driveClientFactory = driveClientFactory; + this.authorizationCodeClient = authorizationCodeClient; + this.refreshTokenClient = refreshTokenClient; + } + + // ── OAuth2 operations ──────────────────────────────────────────────────── + + @Override + public String buildAuthorizationUrl(String state) { + properties.validateConfigured(); + return UriComponentsBuilder + .fromUriString(clientRegistration.getProviderDetails().getAuthorizationUri()) + .queryParam("client_id", clientRegistration.getClientId()) + .queryParam("redirect_uri", clientRegistration.getRedirectUri()) + .queryParam("response_type", "code") + .queryParam("scope", String.join(" ", clientRegistration.getScopes())) + .queryParam("access_type", "offline") + .queryParam("include_granted_scopes", "true") + .queryParam("prompt", "consent") + .queryParam("state", state) + .build() + .encode() + .toUriString(); + } + + /** + * Exchanges an authorization code for tokens using Spring's + * {@link org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient}. + * State validation is performed upstream by {@code GoogleDriveOAuthService} before calling + * this method, so a placeholder value is used here to satisfy the exchange API contract. + */ + @Override + public OAuthTokens exchangeAuthorizationCode(String code) { + OAuth2AuthorizationRequest authRequest = OAuth2AuthorizationRequest.authorizationCode() + .clientId(clientRegistration.getClientId()) + .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri()) + .redirectUri(clientRegistration.getRedirectUri()) + .scopes(clientRegistration.getScopes()) + .state("_") + .build(); + OAuth2AuthorizationResponse authResponse = OAuth2AuthorizationResponse.success(code) + .redirectUri(clientRegistration.getRedirectUri()) + .state("_") + .build(); + try { + OAuth2AccessTokenResponse response = authorizationCodeClient.getTokenResponse( + new OAuth2AuthorizationCodeGrantRequest(clientRegistration, + new OAuth2AuthorizationExchange(authRequest, authResponse))); + if (response.getRefreshToken() == null) { + throw new BadRequestException( + "Google OAuth did not return a refresh token. Reconnect and grant consent again."); + } + return toOAuthTokens(response, response.getRefreshToken().getTokenValue()); + } catch (OAuth2AuthorizationException ex) { + throw translateOAuth2Exception(ex, "exchange authorization code"); + } + } + + @Override + public OAuthTokens refreshAccessToken(String refreshToken) { + // A placeholder access token is required by the grant-request API; Google ignores it. + OAuth2AccessToken placeholder = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, "placeholder", null, null); + try { + OAuth2AccessTokenResponse response = refreshTokenClient.getTokenResponse( + new OAuth2RefreshTokenGrantRequest(clientRegistration, placeholder, + new OAuth2RefreshToken(refreshToken, null))); + return toOAuthTokens(response, refreshToken); + } catch (OAuth2AuthorizationException ex) { + throw translateOAuth2Exception(ex, "refresh access token"); + } + } + + // ── Drive file operations (SDK) ────────────────────────────────────────── + + @Override + public GoogleDriveAccountProfile getCurrentAccount(String accessToken) { + return executeDriveOp(accessToken, "read account", drive -> { + About about = drive.about().get() + .setFields("user(emailAddress,displayName,permissionId)") + .execute(); + User user = about.getUser(); + return new GoogleDriveAccountProfile( + user.getPermissionId(), + user.getEmailAddress(), + user.getDisplayName()); + }); + } + + @Override + public DriveFileMetadata getFileMetadata(String accessToken, String fileId) { + return executeDriveOp(accessToken, "get file metadata", drive -> + toDriveFileMetadata(drive.files().get(fileId) + .setSupportsAllDrives(true) + .setFields("id,name,mimeType,webViewLink") + .execute())); + } + + @Override + public Optional findFolderByName(String accessToken, String parentFolderId, String folderName) { + return executeDriveOp(accessToken, "find folder", drive -> { + String escaped = folderName.replace("\\", "\\\\").replace("'", "\\'"); + String q = "mimeType='" + GOOGLE_FOLDER_MIME_TYPE + "' and trashed=false and '" + + parentFolderId + "' in parents and name='" + escaped + "'"; + FileList result = drive.files().list() + .setQ(q) + .setPageSize(1) + .setFields("files(id,name,mimeType,webViewLink)") + .setSupportsAllDrives(true) + .setIncludeItemsFromAllDrives(true) + .setCorpora("allDrives") + .execute(); + List files = result.getFiles(); + if (files == null || files.isEmpty()) { + return Optional.empty(); + } + return Optional.of(toDriveFileMetadata(files.get(0))); + }); + } + + @Override + public DriveFileMetadata createFolder(String accessToken, String parentFolderId, String folderName) { + return executeDriveOp(accessToken, "create folder", drive -> { + File metadata = new File() + .setName(folderName) + .setMimeType(GOOGLE_FOLDER_MIME_TYPE) + .setParents(List.of(parentFolderId)); + return toDriveFileMetadata(drive.files().create(metadata) + .setSupportsAllDrives(true) + .setFields("id,name,mimeType,webViewLink") + .execute()); + }); + } + + @Override + public DriveFileMetadata copyGoogleDoc(String accessToken, String sourceFileId, String targetFolderId, String newName) { + return executeDriveOp(accessToken, "copy document", drive -> { + File metadata = new File() + .setName(newName) + .setParents(List.of(targetFolderId)); + return toDriveFileMetadata(drive.files().copy(sourceFileId, metadata) + .setSupportsAllDrives(true) + .setFields("id,name,mimeType,webViewLink") + .execute()); + }); + } + + // ── internal helpers ───────────────────────────────────────────────────── + + @FunctionalInterface + private interface DriveOperation { + T execute(Drive drive) throws IOException; + } + + private T executeDriveOp(String accessToken, String action, DriveOperation op) { + Drive drive = driveClientFactory.create(accessToken, null); + try { + return op.execute(drive); + } catch (GoogleJsonResponseException ex) { + throw translateDriveException(action, ex); + } catch (IOException ex) { + log.warn("event=GOOGLE_DRIVE_IO_ERROR action={} message={}", action, ex.getMessage()); + throw new ServiceUnavailableException("Google Drive is temporarily unavailable (" + action + ")"); + } + } + + private RuntimeException translateDriveException(String action, GoogleJsonResponseException ex) { + int status = ex.getStatusCode(); + String message = "Failed to " + action + " (status " + status + ")"; + if (status == 429 || status >= 500) { + log.warn("event=GOOGLE_DRIVE_UPSTREAM_ERROR action={} status={}", action, status); + return new ServiceUnavailableException(message); + } + log.warn("event=GOOGLE_DRIVE_CLIENT_ERROR action={} status={}", action, status); + return new BadRequestException(message); + } + + private RuntimeException translateOAuth2Exception(OAuth2AuthorizationException ex, String action) { + String code = ex.getError().getErrorCode(); + log.warn("event=GOOGLE_OAUTH_ERROR action={} errorCode={}", action, code); + if ("invalid_grant".equals(code) || "invalid_client".equals(code)) { + return new BadRequestException("Google OAuth error during " + action + ": " + code); + } + return new ServiceUnavailableException("Google OAuth service unavailable during " + action); + } + + private OAuthTokens toOAuthTokens(OAuth2AccessTokenResponse response, String refreshTokenValue) { + OAuth2AccessToken accessToken = response.getAccessToken(); + Instant expiresAt = accessToken.getExpiresAt(); + LocalDateTime expiresAtLdt = expiresAt != null + ? LocalDateTime.ofInstant(expiresAt, ZoneOffset.UTC) + : LocalDateTime.now(ZoneOffset.UTC).plusHours(1); + Set scopes = accessToken.getScopes(); + String scopeStr = (scopes == null || scopes.isEmpty()) ? null : String.join(" ", scopes); + return new OAuthTokens(accessToken.getTokenValue(), refreshTokenValue, expiresAtLdt, scopeStr); + } + + private DriveFileMetadata toDriveFileMetadata(File file) { + return new DriveFileMetadata( + file.getId(), + file.getName(), + file.getMimeType(), + file.getWebViewLink()); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fe24882..9b96c61 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,6 +69,13 @@ app: mail: enabled: ${APP_MAIL_ENABLED:true} from: ${APP_MAIL_FROM:no-reply@jobtracker.com} + google-drive: + client-id: ${GOOGLE_DRIVE_CLIENT_ID:} + client-secret: ${GOOGLE_DRIVE_CLIENT_SECRET:} + redirect-uri: ${GOOGLE_DRIVE_REDIRECT_URI:http://localhost:8080/api/v1/google-drive/oauth/callback} + oauth-complete-url: ${GOOGLE_DRIVE_OAUTH_COMPLETE_URL:} + authorization-uri: ${GOOGLE_DRIVE_AUTHORIZATION_URI:https://accounts.google.com/o/oauth2/v2/auth} + token-uri: ${GOOGLE_DRIVE_TOKEN_URI:https://oauth2.googleapis.com/token} management: server: @@ -142,3 +149,6 @@ springdoc: - group: gamification display-name: Gamification API paths-to-match: /api/v1/gamification/** + - group: google-drive + display-name: Google Drive API + paths-to-match: /api/v1/google-drive/** diff --git a/src/main/resources/db/migration/V11__add_google_drive_integration.sql b/src/main/resources/db/migration/V11__add_google_drive_integration.sql new file mode 100644 index 0000000..7d34577 --- /dev/null +++ b/src/main/resources/db/migration/V11__add_google_drive_integration.sql @@ -0,0 +1,50 @@ +CREATE TABLE google_drive_connections ( + id BINARY(16) NOT NULL, + user_id BINARY(16) NOT NULL, + google_account_id VARCHAR(255) NOT NULL, + google_email VARCHAR(255) NOT NULL, + google_display_name VARCHAR(255) NULL, + access_token VARCHAR(4096) NOT NULL, + refresh_token VARCHAR(4096) NOT NULL, + access_token_expires_at DATETIME NOT NULL, + granted_scopes VARCHAR(2048) NOT NULL, + root_folder_id VARCHAR(255) NULL, + root_folder_name VARCHAR(255) NULL, + connected_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_google_drive_connections PRIMARY KEY (id), + CONSTRAINT uk_google_drive_connections_user UNIQUE (user_id), + CONSTRAINT fk_google_drive_connections_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_gdrive_connection_google_account ON google_drive_connections (google_account_id); + +CREATE TABLE google_drive_base_resumes ( + id BINARY(16) NOT NULL, + connection_id BINARY(16) NOT NULL, + google_file_id VARCHAR(255) NOT NULL, + document_name VARCHAR(255) NOT NULL, + web_view_link VARCHAR(2048) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_google_drive_base_resumes PRIMARY KEY (id), + CONSTRAINT uk_google_drive_base_resumes_connection_file UNIQUE (connection_id, google_file_id), + CONSTRAINT fk_google_drive_base_resumes_connection FOREIGN KEY (connection_id) REFERENCES google_drive_connections (id) ON DELETE CASCADE +); + +CREATE INDEX idx_gdrive_base_resume_connection ON google_drive_base_resumes (connection_id); + +CREATE TABLE google_drive_oauth_states ( + id BINARY(16) NOT NULL, + user_id BINARY(16) NOT NULL, + state_token VARCHAR(255) NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_google_drive_oauth_states PRIMARY KEY (id), + CONSTRAINT uk_google_drive_oauth_states_state UNIQUE (state_token), + CONSTRAINT fk_google_drive_oauth_states_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_gdrive_oauth_states_user ON google_drive_oauth_states (user_id); +CREATE INDEX idx_gdrive_oauth_states_expires ON google_drive_oauth_states (expires_at); diff --git a/src/main/resources/db/migration/V12__add_drive_vacancy_folder_id_to_applications.sql b/src/main/resources/db/migration/V12__add_drive_vacancy_folder_id_to_applications.sql new file mode 100644 index 0000000..d2f5d48 --- /dev/null +++ b/src/main/resources/db/migration/V12__add_drive_vacancy_folder_id_to_applications.sql @@ -0,0 +1,2 @@ +ALTER TABLE job_applications + ADD COLUMN drive_vacancy_folder_id VARCHAR(255) NULL; diff --git a/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java b/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java new file mode 100644 index 0000000..095bcab --- /dev/null +++ b/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java @@ -0,0 +1,372 @@ +package com.jobtracker.integration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobtracker.dto.auth.AuthResponse; +import com.jobtracker.dto.auth.RegisterRequest; +import com.jobtracker.entity.GoogleDriveBaseResume; +import com.jobtracker.entity.GoogleDriveConnection; +import com.jobtracker.entity.JobApplication; +import com.jobtracker.repository.ApplicationRepository; +import com.jobtracker.repository.GoogleDriveBaseResumeRepository; +import com.jobtracker.repository.GoogleDriveConnectionRepository; +import com.jobtracker.repository.GoogleDriveOAuthStateRepository; +import com.jobtracker.repository.PasswordResetTokenRepository; +import com.jobtracker.repository.RefreshTokenRepository; +import com.jobtracker.repository.UserAchievementRepository; +import com.jobtracker.repository.UserGamificationRepository; +import com.jobtracker.repository.UserRepository; +import com.jobtracker.service.GoogleDriveApiClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class GoogleDriveControllerIT extends AbstractIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private UserRepository userRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; + @Autowired private PasswordResetTokenRepository passwordResetTokenRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private UserGamificationRepository userGamificationRepository; + @Autowired private UserAchievementRepository userAchievementRepository; + @Autowired private GoogleDriveConnectionRepository googleDriveConnectionRepository; + @Autowired private GoogleDriveBaseResumeRepository googleDriveBaseResumeRepository; + @Autowired private GoogleDriveOAuthStateRepository googleDriveOAuthStateRepository; + @Autowired private FakeGoogleDriveApiClient googleDriveApiClient; + + private String accessToken; + + @BeforeEach + void setUp() throws Exception { + googleDriveOAuthStateRepository.deleteAll(); + googleDriveBaseResumeRepository.deleteAll(); + googleDriveConnectionRepository.deleteAll(); + userAchievementRepository.deleteAll(); + userGamificationRepository.deleteAll(); + applicationRepository.deleteAll(); + passwordResetTokenRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + userRepository.deleteAll(); + + RegisterRequest reg = new RegisterRequest("Drive User", "driveuser@example.com", "pass1234", "pass1234"); + MvcResult result = mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(reg))) + .andReturn(); + + AuthResponse auth = objectMapper.readValue(result.getResponse().getContentAsString(), AuthResponse.class); + accessToken = auth.accessToken(); + } + + @Test + void startOauth_shouldReturnAuthorizationUrl() throws Exception { + mockMvc.perform(post("/api/v1/google-drive/oauth/start") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authorizationUrl").value(org.hamcrest.Matchers.containsString("https://accounts.google.com/o/oauth2/v2/auth"))) + .andExpect(jsonPath("$.state").isNotEmpty()) + .andExpect(jsonPath("$.redirectUri").value("http://localhost:8080/api/v1/google-drive/oauth/callback")) + .andExpect(jsonPath("$.scopes[0]").value("https://www.googleapis.com/auth/drive")); + } + + @Test + void oauthCallback_shouldPersistConnectionAndRedirectToFrontend() throws Exception { + googleDriveApiClient.tokens = new GoogleDriveApiClient.OAuthTokens( + "drive-access", + "drive-refresh", + LocalDateTime.now().plusHours(1), + "https://www.googleapis.com/auth/drive" + ); + googleDriveApiClient.accountProfile = + new GoogleDriveApiClient.GoogleDriveAccountProfile("perm-123", "connected@example.com", "Drive User"); + + MvcResult startResult = mockMvc.perform(post("/api/v1/google-drive/oauth/start") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andReturn(); + + JsonNode startJson = objectMapper.readTree(startResult.getResponse().getContentAsString()); + String state = startJson.get("state").asText(); + + mockMvc.perform(get("/api/v1/google-drive/oauth/callback") + .param("state", state) + .param("code", "auth-code")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", org.hamcrest.Matchers.containsString("status=success"))); + + GoogleDriveConnection connection = googleDriveConnectionRepository.findAll().getFirst(); + assertThat(connection.getGoogleEmail()).isEqualTo("connected@example.com"); + assertThat(connection.getRefreshToken()).isEqualTo("drive-refresh"); + } + + @Test + void updateRootFolder_shouldReturnUpdatedStatus() throws Exception { + googleDriveConnectionRepository.save(buildConnection()); + googleDriveApiClient.fileMetadataById.put("folder-123", + new GoogleDriveApiClient.DriveFileMetadata( + "folder-123", + "Root Folder", + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/folder-123" + )); + + mockMvc.perform(put("/api/v1/google-drive/root-folder") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"folderIdOrUrl\":\"folder-123\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.connected").value(true)) + .andExpect(jsonPath("$.rootFolderId").value("folder-123")) + .andExpect(jsonPath("$.rootFolderName").value("Root Folder")); + } + + @Test + void disconnect_shouldRemoveConnectionAndReturnMessage() throws Exception { + googleDriveConnectionRepository.save(buildConnection()); + assertThat(googleDriveConnectionRepository.findAll()).hasSize(1); + + mockMvc.perform(delete("/api/v1/google-drive/connection") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Google Drive connection removed")); + + assertThat(googleDriveConnectionRepository.findAll()).isEmpty(); + } + + @Test + void disconnect_shouldSucceedEvenWithNoExistingConnection() throws Exception { + mockMvc.perform(delete("/api/v1/google-drive/connection") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Google Drive connection removed")); + } + + @Test + void addBaseResume_shouldPersistResumeAndReturn201() throws Exception { + GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnection()); + googleDriveApiClient.fileMetadataById.put("doc-abc", + new GoogleDriveApiClient.DriveFileMetadata( + "doc-abc", + "My Resume", + GoogleDriveApiClient.GOOGLE_DOC_MIME_TYPE, + "https://docs.google.com/document/d/doc-abc/edit" + )); + + mockMvc.perform(post("/api/v1/google-drive/base-resumes") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"documentIdOrUrl\":\"doc-abc\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.googleFileId").value("doc-abc")) + .andExpect(jsonPath("$.documentName").value("My Resume")); + + assertThat(googleDriveBaseResumeRepository.findAllByConnectionIdOrderByCreatedAtAsc(connection.getId())) + .hasSize(1); + } + + @Test + void addBaseResume_shouldRejectNonGoogleDocsFile() throws Exception { + googleDriveConnectionRepository.save(buildConnection()); + googleDriveApiClient.fileMetadataById.put("pdf-file", + new GoogleDriveApiClient.DriveFileMetadata( + "pdf-file", + "Resume.pdf", + "application/pdf", + null + )); + + mockMvc.perform(post("/api/v1/google-drive/base-resumes") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"documentIdOrUrl\":\"pdf-file\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void deleteBaseResume_shouldRemoveResumeAndReturn200() throws Exception { + GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnection()); + GoogleDriveBaseResume resume = buildBaseResume(connection); + googleDriveBaseResumeRepository.save(resume); + + mockMvc.perform(delete("/api/v1/google-drive/base-resumes/" + resume.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Base resume deleted successfully")); + + assertThat(googleDriveBaseResumeRepository.findAll()).isEmpty(); + } + + @Test + void deleteBaseResume_shouldReturn404ForUnknownId() throws Exception { + mockMvc.perform(delete("/api/v1/google-drive/base-resumes/" + UUID.randomUUID()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNotFound()); + } + + @Test + void copyResume_shouldCreateFolderAndCopyDocument() throws Exception { + GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnectionWithRootFolder()); + GoogleDriveBaseResume resume = buildBaseResume(connection); + googleDriveBaseResumeRepository.save(resume); + + JobApplication application = new JobApplication(); + application.setUser(userRepository.findByEmail("driveuser@example.com").orElseThrow()); + application.setVacancyName("Backend Engineer"); + application.setOrganization("Acme"); + application.setApplicationDate(LocalDate.now()); + application = applicationRepository.save(application); + + googleDriveApiClient.fileMetadataById.put("root-folder-id", + new GoogleDriveApiClient.DriveFileMetadata( + "root-folder-id", + "Job Tracker Root", + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/root-folder-id" + )); + + mockMvc.perform(post("/api/v1/google-drive/applications/" + application.getId() + "/resume-copies") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"baseResumeId\":\"" + resume.getId() + "\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.applicationId").value(application.getId().toString())) + .andExpect(jsonPath("$.baseResumeId").value(resume.getId().toString())) + .andExpect(jsonPath("$.copiedFileId").value("copied-file")) + .andExpect(jsonPath("$.vacancyFolderId").value("created-folder")); + } + + @Test + void copyResume_shouldReturn400WhenNoRootFolderConfigured() throws Exception { + GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnection()); + GoogleDriveBaseResume resume = buildBaseResume(connection); + googleDriveBaseResumeRepository.save(resume); + + JobApplication application = new JobApplication(); + application.setUser(userRepository.findByEmail("driveuser@example.com").orElseThrow()); + application.setVacancyName("SWE"); + application.setOrganization("Corp"); + application.setApplicationDate(LocalDate.now()); + application = applicationRepository.save(application); + + mockMvc.perform(post("/api/v1/google-drive/applications/" + application.getId() + "/resume-copies") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"baseResumeId\":\"" + resume.getId() + "\"}")) + .andExpect(status().isBadRequest()); + } + + private GoogleDriveConnection buildConnectionWithRootFolder() { + GoogleDriveConnection connection = buildConnection(); + connection.setRootFolderId("root-folder-id"); + connection.setRootFolderName("Job Tracker Root"); + return connection; + } + + private GoogleDriveBaseResume buildBaseResume(GoogleDriveConnection connection) { + GoogleDriveBaseResume resume = new GoogleDriveBaseResume(); + resume.setConnection(connection); + resume.setGoogleFileId("resume-file-id"); + resume.setDocumentName("Base Resume"); + resume.setWebViewLink("https://docs.google.com/document/d/resume-file-id/edit"); + return resume; + } + + private GoogleDriveConnection buildConnection() { + GoogleDriveConnection connection = new GoogleDriveConnection(); + connection.setUser(userRepository.findByEmail("driveuser@example.com").orElseThrow()); + connection.setGoogleAccountId("perm-123"); + connection.setGoogleEmail("connected@example.com"); + connection.setGoogleDisplayName("Drive User"); + connection.setAccessToken("drive-access"); + connection.setRefreshToken("drive-refresh"); + connection.setAccessTokenExpiresAt(LocalDateTime.now().plusHours(1)); + connection.setGrantedScopes("https://www.googleapis.com/auth/drive"); + connection.setConnectedAt(LocalDateTime.now()); + return connection; + } + + @TestConfiguration + static class GoogleDriveTestConfig { + @Bean + @Primary + FakeGoogleDriveApiClient googleDriveApiClient() { + return new FakeGoogleDriveApiClient(); + } + } + + static class FakeGoogleDriveApiClient implements GoogleDriveApiClient { + private OAuthTokens tokens = new OAuthTokens( + "drive-access", + "drive-refresh", + LocalDateTime.now().plusHours(1), + "https://www.googleapis.com/auth/drive" + ); + private GoogleDriveAccountProfile accountProfile = + new GoogleDriveAccountProfile("perm-123", "connected@example.com", "Drive User"); + private final Map fileMetadataById = new HashMap<>(); + + @Override + public String buildAuthorizationUrl(String state) { + return "https://accounts.google.com/o/oauth2/v2/auth?state=" + state; + } + + @Override + public OAuthTokens exchangeAuthorizationCode(String code) { + return tokens; + } + + @Override + public OAuthTokens refreshAccessToken(String refreshToken) { + return tokens; + } + + @Override + public GoogleDriveAccountProfile getCurrentAccount(String accessToken) { + return accountProfile; + } + + @Override + public DriveFileMetadata getFileMetadata(String accessToken, String fileId) { + return fileMetadataById.get(fileId); + } + + @Override + public Optional findFolderByName(String accessToken, String parentFolderId, String folderName) { + return Optional.empty(); + } + + @Override + public DriveFileMetadata createFolder(String accessToken, String parentFolderId, String folderName) { + return new DriveFileMetadata("created-folder", folderName, GOOGLE_FOLDER_MIME_TYPE, null); + } + + @Override + public DriveFileMetadata copyGoogleDoc(String accessToken, String sourceFileId, String targetFolderId, String newName) { + return new DriveFileMetadata("copied-file", newName, GOOGLE_DOC_MIME_TYPE, null); + } + } +} diff --git a/src/test/java/com/jobtracker/unit/GoogleDriveOAuthServiceTest.java b/src/test/java/com/jobtracker/unit/GoogleDriveOAuthServiceTest.java new file mode 100644 index 0000000..2c62d9c --- /dev/null +++ b/src/test/java/com/jobtracker/unit/GoogleDriveOAuthServiceTest.java @@ -0,0 +1,248 @@ +package com.jobtracker.unit; + +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.entity.GoogleDriveBaseResume; +import com.jobtracker.entity.GoogleDriveConnection; +import com.jobtracker.entity.GoogleDriveOAuthState; +import com.jobtracker.entity.User; +import com.jobtracker.repository.GoogleDriveConnectionRepository; +import com.jobtracker.repository.GoogleDriveOAuthStateRepository; +import com.jobtracker.service.GoogleDriveApiClient; +import com.jobtracker.service.GoogleDriveOAuthService; +import com.jobtracker.util.SecurityUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GoogleDriveOAuthServiceTest { + + private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final String VALID_STATE = "valid-state-token"; + private static final String AUTH_CODE = "auth-code-123"; + + @Mock private GoogleDriveApiClient googleDriveApiClient; + @Mock private GoogleDriveConnectionRepository connectionRepository; + @Mock private GoogleDriveOAuthStateRepository oauthStateRepository; + @Mock private SecurityUtils securityUtils; + + private GoogleDriveProperties googleDriveProperties; + private GoogleDriveOAuthService oauthService; + + private User user; + private GoogleDriveOAuthState validOAuthState; + + @BeforeEach + void setUp() { + googleDriveProperties = new GoogleDriveProperties( + "client-id", + "client-secret", + "http://localhost:8080/api/v1/google-drive/oauth/callback", + "http://localhost:5173/settings/google-drive/callback", + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token" + ); + oauthService = new GoogleDriveOAuthService( + googleDriveApiClient, + googleDriveProperties, + connectionRepository, + oauthStateRepository, + securityUtils + ); + + user = new User(); + user.setId(USER_ID); + user.setEmail("user@example.com"); + + validOAuthState = new GoogleDriveOAuthState(); + validOAuthState.setUser(user); + validOAuthState.setStateToken(VALID_STATE); + validOAuthState.setExpiresAt(LocalDateTime.now().plusMinutes(5)); + } + + @Test + void handleCallback_shouldRedirectWithError_whenGoogleReturnsError() { + String result = oauthService.handleCallback(VALID_STATE, null, "access_denied"); + + assertThat(result).contains("status=error"); + assertThat(result).contains("Google"); + assertThat(result).contains("OAuth"); + verifyNoInteractions(googleDriveApiClient); + verifyNoInteractions(connectionRepository); + } + + @Test + void handleCallback_shouldRedirectWithError_whenStateIsExpired() { + validOAuthState.setExpiresAt(LocalDateTime.now().minusMinutes(1)); + when(oauthStateRepository.findByStateToken(VALID_STATE)).thenReturn(Optional.of(validOAuthState)); + + String result = oauthService.handleCallback(VALID_STATE, AUTH_CODE, null); + + assertThat(result).contains("status=error"); + assertThat(result).containsIgnoringCase("expired"); + verifyNoInteractions(googleDriveApiClient); + verifyNoInteractions(connectionRepository); + } + + @Test + void handleCallback_shouldRedirectWithError_whenStateIsInvalid() { + when(oauthStateRepository.findByStateToken("unknown-state")).thenReturn(Optional.empty()); + + String result = oauthService.handleCallback("unknown-state", AUTH_CODE, null); + + assertThat(result).contains("status=error"); + assertThat(result).contains("Invalid"); + verifyNoInteractions(googleDriveApiClient); + verifyNoInteractions(connectionRepository); + } + + @Test + void handleCallback_shouldRedirectWithError_whenMissingState() { + String result = oauthService.handleCallback(null, AUTH_CODE, null); + + assertThat(result).contains("status=error"); + assertThat(result).contains("Missing"); + verifyNoInteractions(googleDriveApiClient); + verifyNoInteractions(connectionRepository); + } + + @Test + void handleCallback_shouldRedirectWithError_whenMissingCode() { + when(oauthStateRepository.findByStateToken(VALID_STATE)).thenReturn(Optional.of(validOAuthState)); + + String result = oauthService.handleCallback(VALID_STATE, null, null); + + assertThat(result).contains("status=error"); + assertThat(result).contains("Missing"); + verifyNoInteractions(googleDriveApiClient); + verifyNoInteractions(connectionRepository); + verify(oauthStateRepository).delete(validOAuthState); + } + + @Test + void handleCallback_shouldCreateNewConnection_onFirstConnect() { + when(oauthStateRepository.findByStateToken(VALID_STATE)).thenReturn(Optional.of(validOAuthState)); + when(googleDriveApiClient.exchangeAuthorizationCode(AUTH_CODE)).thenReturn( + new GoogleDriveApiClient.OAuthTokens("access-token", "refresh-token", + LocalDateTime.now().plusHours(1), "https://www.googleapis.com/auth/drive") + ); + when(googleDriveApiClient.getCurrentAccount("access-token")).thenReturn( + new GoogleDriveApiClient.GoogleDriveAccountProfile("perm-1", "user@gmail.com", "User") + ); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + when(connectionRepository.save(any(GoogleDriveConnection.class))).thenAnswer(inv -> inv.getArgument(0)); + + String result = oauthService.handleCallback(VALID_STATE, AUTH_CODE, null); + + assertThat(result).contains("status=success"); + ArgumentCaptor captor = ArgumentCaptor.forClass(GoogleDriveConnection.class); + verify(connectionRepository).save(captor.capture()); + GoogleDriveConnection saved = captor.getValue(); + assertThat(saved.getGoogleEmail()).isEqualTo("user@gmail.com"); + assertThat(saved.getRefreshToken()).isEqualTo("refresh-token"); + verify(oauthStateRepository).delete(validOAuthState); + } + + @Test + void handleCallback_shouldClearRootFolderAndBaseResumes_whenDifferentAccountConnects() { + GoogleDriveConnection existingConnection = new GoogleDriveConnection(); + existingConnection.setGoogleAccountId("old-account-id"); + existingConnection.setRootFolderId("old-root-folder"); + existingConnection.setRootFolderName("Old Root"); + existingConnection.setBaseResumes(new ArrayList<>(List.of(new GoogleDriveBaseResume()))); + + when(oauthStateRepository.findByStateToken(VALID_STATE)).thenReturn(Optional.of(validOAuthState)); + when(googleDriveApiClient.exchangeAuthorizationCode(AUTH_CODE)).thenReturn( + new GoogleDriveApiClient.OAuthTokens("new-access", "new-refresh", + LocalDateTime.now().plusHours(1), "https://www.googleapis.com/auth/drive") + ); + when(googleDriveApiClient.getCurrentAccount("new-access")).thenReturn( + new GoogleDriveApiClient.GoogleDriveAccountProfile("new-account-id", "new@gmail.com", "New User") + ); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(existingConnection)); + when(connectionRepository.save(any(GoogleDriveConnection.class))).thenAnswer(inv -> inv.getArgument(0)); + + String result = oauthService.handleCallback(VALID_STATE, AUTH_CODE, null); + + assertThat(result).contains("status=success"); + ArgumentCaptor captor = ArgumentCaptor.forClass(GoogleDriveConnection.class); + verify(connectionRepository).save(captor.capture()); + GoogleDriveConnection saved = captor.getValue(); + assertThat(saved.getRootFolderId()).isNull(); + assertThat(saved.getRootFolderName()).isNull(); + assertThat(saved.getBaseResumes()).isEmpty(); + assertThat(saved.getGoogleEmail()).isEqualTo("new@gmail.com"); + } + + @Test + void handleCallback_shouldPreserveRootFolder_whenSameAccountReconnects() { + GoogleDriveConnection existingConnection = new GoogleDriveConnection(); + existingConnection.setGoogleAccountId("same-account-id"); + existingConnection.setRootFolderId("existing-root-folder"); + existingConnection.setRootFolderName("Job Tracker Root"); + existingConnection.setBaseResumes(new ArrayList<>()); + + when(oauthStateRepository.findByStateToken(VALID_STATE)).thenReturn(Optional.of(validOAuthState)); + when(googleDriveApiClient.exchangeAuthorizationCode(AUTH_CODE)).thenReturn( + new GoogleDriveApiClient.OAuthTokens("new-access", "new-refresh", + LocalDateTime.now().plusHours(1), "https://www.googleapis.com/auth/drive") + ); + when(googleDriveApiClient.getCurrentAccount("new-access")).thenReturn( + new GoogleDriveApiClient.GoogleDriveAccountProfile("same-account-id", "user@gmail.com", "User") + ); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(existingConnection)); + when(connectionRepository.save(any(GoogleDriveConnection.class))).thenAnswer(inv -> inv.getArgument(0)); + + String result = oauthService.handleCallback(VALID_STATE, AUTH_CODE, null); + + assertThat(result).contains("status=success"); + ArgumentCaptor captor = ArgumentCaptor.forClass(GoogleDriveConnection.class); + verify(connectionRepository).save(captor.capture()); + GoogleDriveConnection saved = captor.getValue(); + assertThat(saved.getRootFolderId()).isEqualTo("existing-root-folder"); + } + + @Test + void handleCallback_shouldDeleteStateEvenOnError() { + validOAuthState.setExpiresAt(LocalDateTime.now().minusMinutes(1)); + when(oauthStateRepository.findByStateToken(VALID_STATE)).thenReturn(Optional.of(validOAuthState)); + + oauthService.handleCallback(VALID_STATE, AUTH_CODE, null); + + verify(oauthStateRepository).delete(validOAuthState); + } + + @Test + void disconnect_shouldDeleteExistingConnection() { + GoogleDriveConnection connection = new GoogleDriveConnection(); + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(connection)); + + oauthService.disconnect(); + + verify(connectionRepository).delete(connection); + } + + @Test + void disconnect_shouldSucceed_whenNoConnectionExists() { + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + oauthService.disconnect(); + + verify(connectionRepository, never()).delete(any()); + } +} diff --git a/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java b/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java new file mode 100644 index 0000000..f155670 --- /dev/null +++ b/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java @@ -0,0 +1,202 @@ +package com.jobtracker.unit; + +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.dto.gdrive.GoogleDriveBaseResumeRequest; +import com.jobtracker.dto.gdrive.GoogleDriveResumeCopyRequest; +import com.jobtracker.dto.gdrive.GoogleDriveRootFolderRequest; +import com.jobtracker.dto.gdrive.GoogleDriveStatusResponse; +import com.jobtracker.entity.GoogleDriveBaseResume; +import com.jobtracker.entity.GoogleDriveConnection; +import com.jobtracker.entity.JobApplication; +import com.jobtracker.entity.User; +import com.jobtracker.exception.BadRequestException; +import com.jobtracker.repository.ApplicationRepository; +import com.jobtracker.repository.GoogleDriveBaseResumeRepository; +import com.jobtracker.repository.GoogleDriveConnectionRepository; +import com.jobtracker.service.GoogleDriveApiClient; +import com.jobtracker.service.GoogleDriveService; +import com.jobtracker.util.SecurityUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GoogleDriveServiceTest { + + private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID CONNECTION_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID RESUME_ID = UUID.fromString("00000000-0000-0000-0000-000000000003"); + private static final UUID APPLICATION_ID = UUID.fromString("00000000-0000-0000-0000-000000000004"); + + @Mock private GoogleDriveApiClient googleDriveApiClient; + @Mock private GoogleDriveConnectionRepository connectionRepository; + @Mock private GoogleDriveBaseResumeRepository baseResumeRepository; + @Mock private ApplicationRepository applicationRepository; + @Mock private SecurityUtils securityUtils; + + private GoogleDriveProperties googleDriveProperties; + + @InjectMocks + private GoogleDriveService googleDriveService; + + private GoogleDriveConnection connection; + private GoogleDriveBaseResume baseResume; + private JobApplication application; + + @BeforeEach + void setUp() { + googleDriveProperties = new GoogleDriveProperties( + "client-id", + "client-secret", + "http://localhost:8080/api/v1/google-drive/oauth/callback", + "http://localhost:5173/settings/google-drive/callback", + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token" + ); + googleDriveService = new GoogleDriveService( + googleDriveApiClient, + googleDriveProperties, + connectionRepository, + baseResumeRepository, + applicationRepository, + securityUtils + ); + + User user = new User(); + user.setId(USER_ID); + user.setEmail("user@example.com"); + + connection = new GoogleDriveConnection(); + connection.setId(CONNECTION_ID); + connection.setUser(user); + connection.setGoogleAccountId("perm-1"); + connection.setGoogleEmail("drive@example.com"); + connection.setAccessToken("access-token"); + connection.setRefreshToken("refresh-token"); + connection.setAccessTokenExpiresAt(LocalDateTime.now().plusHours(1)); + connection.setGrantedScopes("https://www.googleapis.com/auth/drive"); + + baseResume = new GoogleDriveBaseResume(); + baseResume.setId(RESUME_ID); + baseResume.setConnection(connection); + baseResume.setGoogleFileId("resume-file-id"); + baseResume.setDocumentName("Base Resume"); + baseResume.setWebViewLink("https://docs.google.com/document/d/resume-file-id/edit"); + baseResume.setCreatedAt(LocalDateTime.now()); + + application = new JobApplication(); + application.setId(APPLICATION_ID); + application.setVacancyName("Backend Engineer"); + application.setOrganization("Acme"); + application.setApplicationDate(LocalDate.now()); + application.setUser(user); + } + + @Test + void getStatus_shouldReturnDisconnected_whenNoConnectionExists() { + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + GoogleDriveStatusResponse response = googleDriveService.getStatus(); + + assertThat(response.configured()).isTrue(); + assertThat(response.connected()).isFalse(); + assertThat(response.baseResumes()).isEmpty(); + } + + @Test + void updateRootFolder_shouldPersistFolderMetadata() { + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(connection)); + when(googleDriveApiClient.getFileMetadata("access-token", "root-folder-id")) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "root-folder-id", + "Job Tracker Root", + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/root-folder-id" + )); + when(baseResumeRepository.findAllByConnectionIdOrderByCreatedAtAsc(CONNECTION_ID)).thenReturn(List.of()); + + GoogleDriveStatusResponse response = googleDriveService.updateRootFolder(new GoogleDriveRootFolderRequest("root-folder-id")); + + assertThat(response.rootFolderId()).isEqualTo("root-folder-id"); + assertThat(response.rootFolderName()).isEqualTo("Job Tracker Root"); + verify(connectionRepository).save(connection); + } + + @Test + void addBaseResume_shouldRejectNonGoogleDocsFiles() { + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(connection)); + when(googleDriveApiClient.getFileMetadata("access-token", "not-a-doc")) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "not-a-doc", + "Resume.pdf", + "application/pdf", + null + )); + + assertThatThrownBy(() -> googleDriveService.addBaseResume(new GoogleDriveBaseResumeRequest("not-a-doc"))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Only Google Docs base resumes are supported"); + } + + @Test + void copyBaseResumeToApplication_shouldCreateFolderAndCopyDocument() { + connection.setRootFolderId("root-folder-id"); + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(connection)); + when(applicationRepository.findByIdAndUserId(APPLICATION_ID, USER_ID)).thenReturn(Optional.of(application)); + when(baseResumeRepository.findByIdAndConnectionUserId(RESUME_ID, USER_ID)).thenReturn(Optional.of(baseResume)); + when(googleDriveApiClient.getFileMetadata("access-token", "root-folder-id")) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "root-folder-id", + "Job Tracker Root", + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/root-folder-id" + )); + when(googleDriveApiClient.findFolderByName(eq("access-token"), eq("root-folder-id"), contains("APP-"))) + .thenReturn(Optional.empty()); + when(googleDriveApiClient.createFolder(eq("access-token"), eq("root-folder-id"), contains("APP-"))) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "vacancy-folder-id", + "Backend Engineer - APP-" + APPLICATION_ID, + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/vacancy-folder-id" + )); + when(googleDriveApiClient.copyGoogleDoc(eq("access-token"), eq("resume-file-id"), eq("vacancy-folder-id"), contains("APP-"))) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "copied-doc-id", + "APP-" + APPLICATION_ID + " - Backend Engineer - Base Resume", + GoogleDriveApiClient.GOOGLE_DOC_MIME_TYPE, + "https://docs.google.com/document/d/copied-doc-id/edit" + )); + + var response = googleDriveService.copyBaseResumeToApplication(APPLICATION_ID, new GoogleDriveResumeCopyRequest(RESUME_ID)); + + assertThat(response.applicationId()).isEqualTo(APPLICATION_ID); + assertThat(response.baseResumeId()).isEqualTo(RESUME_ID); + assertThat(response.copiedFileId()).isEqualTo("copied-doc-id"); + assertThat(response.documentWebViewLink()).contains("copied-doc-id"); + + ArgumentCaptor folderNameCaptor = ArgumentCaptor.forClass(String.class); + verify(googleDriveApiClient).createFolder(eq("access-token"), eq("root-folder-id"), folderNameCaptor.capture()); + assertThat(folderNameCaptor.getValue()).contains("APP-" + APPLICATION_ID); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index d396fd1..2bc7795 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -31,6 +31,11 @@ app: password-reset-token-expiration-hours: 24 mail: enabled: false + google-drive: + client-id: test-google-client-id + client-secret: test-google-client-secret + redirect-uri: http://localhost:8080/api/v1/google-drive/oauth/callback + oauth-complete-url: http://localhost:5173/settings/google-drive/callback cors: allowed-origins: http://localhost:3000