diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7e5e7ef1..a37baa3c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -121,4 +121,25 @@ After log in successfully, you can find `JWT` in cookie: Postman will also cache your cookie here: \ ![tokenExample.png](docs/img/tokenExample.png) -If you need to clean cookie and login against, please select `Cookies` and remove `Authorization` cookie \ No newline at end of file +If you need to clean cookie and login against, please select `Cookies` and remove `Authorization` cookie + +### Testing + +#### Unit tests + +To run unit tests, you can either run through IntelliJ or through terminal using this command: +```shell +./gradlew test +``` +> \[!IMPORTANT\] +> Since we are using testing container, make sure to have `Docker` running in the background before triggering the unit tests + + +#### Mutation tests +To run mutation tests, you can use the following command: +```shell +./gradlew pitest +``` + +#### Load tests +To run the load test, please follow the instructions shown in [loadtest.js](src/test/java/com/backend/coapp/_performance/loadtest.js). \ No newline at end of file diff --git a/README.md b/README.md index 090c5652..52e2db0b 100644 --- a/README.md +++ b/README.md @@ -34,5 +34,9 @@ docker image build -t coapp-backend . 2. Run docker image ```bash -docker run -d -p 8080:8080 coapp-backend -``` +docker run -d \ + -p 8080:8080 \ + -v $(pwd)/local.properties:/app/local.properties \ + -e SPRING_CONFIG_ADDITIONAL_LOCATION=file:/app/local.properties \ + coapp-backend + ``` diff --git a/docs/API-feat6.md b/docs/API-feat6.md index 86deba49..25a4fef5 100644 --- a/docs/API-feat6.md +++ b/docs/API-feat6.md @@ -53,6 +53,16 @@ Response body: } ``` +**Response 503 SERVICE UNAVAILABLE** + +Response body: +```json +{ + "error": "SERVICE_UNAVAILABLE", + "message": "Our AI service is currently unavailable. Please try again later." +} +``` + **Path:** `api/resume-ai-advisor/remaining-quota` **Method:** `GET` diff --git a/src/main/java/com/backend/coapp/exception/genai/GenAIOutOfServiceException.java b/src/main/java/com/backend/coapp/exception/genai/GenAIOutOfServiceException.java new file mode 100644 index 00000000..92507e93 --- /dev/null +++ b/src/main/java/com/backend/coapp/exception/genai/GenAIOutOfServiceException.java @@ -0,0 +1,8 @@ +package com.backend.coapp.exception.genai; + +/** This exception will be thrown when we reach usage limit. User need to try again later. */ +public class GenAIOutOfServiceException extends RuntimeException { + public GenAIOutOfServiceException(String message) { + super("Our AI service failure. " + message); + } +} diff --git a/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java b/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java index 043d7f7a..b6dbae6e 100644 --- a/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java @@ -25,7 +25,11 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import tools.jackson.databind.exc.InvalidFormatException; -/** Exception handler for controller. */ +/** + * Exception handler for controller. + * + *

Internal exceptions (5** HTTP status) will be logged for debugging. + */ @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { @@ -63,7 +67,7 @@ public ResponseEntity> handleEmailServiceException(EmailServ @ExceptionHandler(RuntimeException.class) public ResponseEntity> handleRuntimeException(RuntimeException ex) { - String errorMessage = "ERROR: Reset verification code service failed: " + ex.getMessage(); + String errorMessage = "ERROR: Runtime exception: " + ex.getMessage(); log.error(errorMessage); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body( @@ -112,7 +116,7 @@ public ResponseEntity> handleAuthAccountAlreadyVerifyExcepti @ExceptionHandler(AuthenticationException.class) public ResponseEntity> handleAuthenticationException( AuthenticationException ex) { - String errorMessage = "ERROR: JWT Service failed: " + ex.getMessage(); + String errorMessage = "ERROR: Authentication Service failed: " + ex.getMessage(); log.error(errorMessage); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body( @@ -333,7 +337,7 @@ public ResponseEntity> handleConcurrencyException(Concurrenc @ExceptionHandler(GenAIUsageManagementServiceException.class) public ResponseEntity> handleGenAIUsageManagementServiceException( GenAIUsageManagementServiceException ex) { - String errorMessage = "ERROR: Application Service failed: " + ex.getMessage(); + String errorMessage = "ERROR: GenAI Service failed: " + ex.getMessage(); log.error(errorMessage); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body( @@ -385,4 +389,33 @@ public ResponseEntity> handleHttpMessageNotReadable( return ResponseEntity.badRequest() .body(Map.of("error", RequestErrorCode.INVALID_FORMAT_FIELD.name(), "message", message)); } + + @ExceptionHandler(GenAIOutOfServiceException.class) + public ResponseEntity> handleGenAIOutOfServiceException( + GenAIOutOfServiceException ex) { + + String errorMessage = "ERROR: Resume workshop Service failed: " + ex.getMessage(); + log.error(errorMessage); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body( + Map.of( + "error", + GenAIErrorCode.SERVICE_UNAVAILABLE, + "message", + "Our AI service is currently unavailable. Please try again later.")); + } + + @ExceptionHandler(GenAIServiceException.class) + public ResponseEntity> handleGenAIServiceException(GenAIServiceException ex) { + + String errorMessage = "ERROR: GenAI Service failed: " + ex.getMessage(); + log.error(errorMessage); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + Map.of( + "error", + SystemErrorCode.INTERNAL_ERROR, + "message", + "GenAI Service failed. Please try again later.")); + } } diff --git a/src/main/java/com/backend/coapp/model/enumeration/GenAIErrorCode.java b/src/main/java/com/backend/coapp/model/enumeration/GenAIErrorCode.java index 5e728be6..98e01b45 100644 --- a/src/main/java/com/backend/coapp/model/enumeration/GenAIErrorCode.java +++ b/src/main/java/com/backend/coapp/model/enumeration/GenAIErrorCode.java @@ -3,5 +3,6 @@ public enum GenAIErrorCode { OVER_LIMIT_CHARACTER, OVER_LIMIT_CHATBOT_REQUEST, - OTHER_REQUEST_IN_PROGRESS + OTHER_REQUEST_IN_PROGRESS, + SERVICE_UNAVAILABLE } diff --git a/src/main/java/com/backend/coapp/service/ApplicationService.java b/src/main/java/com/backend/coapp/service/ApplicationService.java index a6d79936..c36cdb69 100644 --- a/src/main/java/com/backend/coapp/service/ApplicationService.java +++ b/src/main/java/com/backend/coapp/service/ApplicationService.java @@ -165,49 +165,29 @@ public ApplicationResponse updateApplication( "You do not have permission to edit this application."); } - boolean companyChanged = !Objects.equals(existingApp.getCompanyId(), newCompanyId); - boolean titleChanged = !Objects.equals(existingApp.getJobTitle(), newJobTitle); - boolean statusChanged = !Objects.equals(existingApp.getStatus(), newStatus); - boolean deadlineChanged = - !Objects.equals(existingApp.getApplicationDeadline(), newApplicationDeadline); - boolean descChanged = !Objects.equals(existingApp.getJobDescription(), newJobDescription); - boolean positionsChanged = !Objects.equals(existingApp.getNumPositions(), newNumPositions); - boolean linkChanged = !Objects.equals(existingApp.getSourceLink(), newSourceLink); - boolean dateAppliedChanged = !Objects.equals(existingApp.getDateApplied(), newDateApplied); - boolean notesChanged = !Objects.equals(existingApp.getNotes(), newNotes); - boolean interviewDateChanged = - !Objects.equals(existingApp.getInterviewDateTime(), newInterviewDateTime); - - if (!companyChanged - && !titleChanged - && !descChanged - && !linkChanged - && !dateAppliedChanged - && !notesChanged - && !statusChanged - && !deadlineChanged - && !positionsChanged - && !interviewDateChanged) { + boolean hasChanges = + !Objects.equals(existingApp.getCompanyId(), newCompanyId) + || !Objects.equals(existingApp.getJobTitle(), newJobTitle) + || !Objects.equals(existingApp.getStatus(), newStatus) + || !Objects.equals(existingApp.getApplicationDeadline(), newApplicationDeadline) + || !Objects.equals(existingApp.getJobDescription(), newJobDescription) + || !Objects.equals(existingApp.getNumPositions(), newNumPositions) + || !Objects.equals(existingApp.getSourceLink(), newSourceLink) + || !Objects.equals(existingApp.getDateApplied(), newDateApplied) + || !Objects.equals(existingApp.getNotes(), newNotes) + || !Objects.equals(existingApp.getInterviewDateTime(), newInterviewDateTime); + + if (!hasChanges) { throw new NoChangesDetectedException("No fields were changed."); } - if (companyChanged && this.companyRepository.findById(newCompanyId).isEmpty()) { + if (!Objects.equals(existingApp.getCompanyId(), newCompanyId) + && this.companyRepository.findById(newCompanyId).isEmpty()) { throw new CompanyNotFoundException(); } - if (statusChanged && newStatus == ApplicationStatus.APPLIED) { - newDateApplied = LocalDate.now(); - } - - if (newDateApplied != null - && !newDateApplied.isBefore(existingApp.getApplicationDeadline()) - && !newDateApplied.isEqual(existingApp.getApplicationDeadline())) { - throw new InvalidRequestException( - "The applied date must be before the application deadline." - + existingApp.getApplicationDeadline() - + " " - + newDateApplied); - } + // Process logic for status transitions and date constraints + validateAndSyncStatusDates(existingApp, newStatus, newDateApplied, newInterviewDateTime); existingApp.setCompanyId(newCompanyId); existingApp.setJobTitle(newJobTitle); @@ -216,14 +196,62 @@ public ApplicationResponse updateApplication( existingApp.setJobDescription(newJobDescription); existingApp.setNumPositions(newNumPositions); existingApp.setSourceLink(newSourceLink); - existingApp.setDateApplied(newDateApplied); existingApp.setNotes(newNotes); - existingApp.setInterviewDateTime(newInterviewDateTime); ApplicationModel updatedApp = this.applicationRepository.save(existingApp); return ApplicationResponse.fromModel(updatedApp); } + /** + * Validates and synchronizes application dates based on status changes + * + * @param existingApp The existing application model + * @param newStatus The proposed new status + * @param newDateApplied The proposed applied date + * @param newInterviewDateTime The proposed interview date + * @throws InvalidRequestException If dates violate business logic + */ + private void validateAndSyncStatusDates( + ApplicationModel existingApp, + ApplicationStatus newStatus, + LocalDate newDateApplied, + LocalDateTime newInterviewDateTime) { + + boolean statusChanged = !Objects.equals(existingApp.getStatus(), newStatus); + + if (statusChanged) { + if (newStatus == ApplicationStatus.APPLIED) { + newDateApplied = LocalDate.now(); + } else if (newStatus == ApplicationStatus.NOT_APPLIED) { + newDateApplied = null; + } + + boolean wasInterviewing = + existingApp.getStatus() == ApplicationStatus.INTERVIEWING + || existingApp.getStatus() == ApplicationStatus.INTERVIEW_SCHEDULED; + boolean isReverting = + newStatus == ApplicationStatus.NOT_APPLIED || newStatus == ApplicationStatus.APPLIED; + + if (wasInterviewing && isReverting) { + newInterviewDateTime = null; + } + } + + if (newDateApplied != null) { + LocalDate deadline = existingApp.getApplicationDeadline(); + if (deadline != null && newDateApplied.isAfter(deadline)) { + throw new InvalidRequestException( + "The applied date must be before the application deadline." + + deadline + + " " + + newDateApplied); + } + } + + existingApp.setDateApplied(newDateApplied); + existingApp.setInterviewDateTime(newInterviewDateTime); + } + /** * Delete a job application * diff --git a/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java b/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java index 2c6ea8d4..dcbd90d8 100644 --- a/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java +++ b/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java @@ -2,10 +2,7 @@ import com.backend.coapp.exception.application.ApplicationNotFoundException; import com.backend.coapp.exception.application.ApplicationNotOwnedException; -import com.backend.coapp.exception.genai.ConcurrencyException; -import com.backend.coapp.exception.genai.GenAIQuotaExceededException; -import com.backend.coapp.exception.genai.GenAIUsageManagementServiceException; -import com.backend.coapp.exception.genai.OverCharacterLimitException; +import com.backend.coapp.exception.genai.*; import com.backend.coapp.exception.global.UserNotFoundException; import com.backend.coapp.model.document.ApplicationModel; import com.backend.coapp.model.document.UserExperienceModel; @@ -57,6 +54,8 @@ public GenAIResumeAdvisorService( * @throws UserNotFoundException when user doesn't exist * @throws GenAIQuotaExceededException when user exceed GenAI usage limit * @throws ConcurrencyException when the same user make a request twice + * @throws GenAIOutOfServiceException when we reach usage limit (internally) + * @throws GenAIServiceException when there is something wrong for GenAI service (internally) */ public String getAdvice(String userId, String applicationId, String prompt) throws OverCharacterLimitException, @@ -65,7 +64,9 @@ public String getAdvice(String userId, String applicationId, String prompt) GenAIUsageManagementServiceException, UserNotFoundException, GenAIQuotaExceededException, - ConcurrencyException { + ConcurrencyException, + GenAIOutOfServiceException, + GenAIServiceException { String applicationJobDescription = null; String applicationJobTitle = null; if (prompt.length() > GenAIConstants.MAX_PROMPT_CHARACTERS) { diff --git a/src/main/java/com/backend/coapp/service/genAI/GeminiGenAIService.java b/src/main/java/com/backend/coapp/service/genAI/GeminiGenAIService.java index 203f6999..35056811 100644 --- a/src/main/java/com/backend/coapp/service/genAI/GeminiGenAIService.java +++ b/src/main/java/com/backend/coapp/service/genAI/GeminiGenAIService.java @@ -1,16 +1,21 @@ package com.backend.coapp.service.genAI; +import com.backend.coapp.exception.genai.GenAIOutOfServiceException; import com.backend.coapp.exception.genai.GenAIServiceException; import com.backend.coapp.exception.genai.OverCharacterLimitException; import com.backend.coapp.util.GenAIConstants; import com.google.genai.Client; +import com.google.genai.errors.ApiException; import com.google.genai.types.GenerateContentResponse; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @Service +@Slf4j @ConditionalOnProperty(name = "gen-ai.provider", havingValue = "gemini") public class GeminiGenAIService implements GenAIService { private final Client geminiClient; @@ -29,11 +34,12 @@ public GeminiGenAIService(Client geminiClient) { * @param prompt client's prompt * @return GenAI response * @throws IllegalArgumentException when invalid input - * @throws GenAIServiceException when there is something wrong with GenAI provider. + * @throws GenAIServiceException when there is something wrong with GenAI provider + * @throws GenAIOutOfServiceException when we reach usage limit (internally) */ @Override public String generateResponse(String prompt) - throws IllegalArgumentException, GenAIServiceException { + throws IllegalArgumentException, GenAIServiceException, GenAIOutOfServiceException { if (prompt == null || prompt.isBlank()) { throw new IllegalArgumentException("Prompt can't be null or blank"); } @@ -47,6 +53,12 @@ public String generateResponse(String prompt) GenerateContentResponse response = geminiClient.models.generateContent(this.model, prompt, null); return response.text(); + } catch (ApiException e) { + if (e.code() == HttpStatus.TOO_MANY_REQUESTS.value() || (e.code() >= 500 && e.code() < 600)) { + // This includes 503 - SERVICE UNAVAILABLE and 500 - INTERNAL ERROR + throw new GenAIOutOfServiceException(e.getMessage()); + } + throw new GenAIServiceException(e.getMessage()); } catch (Exception e) { throw new GenAIServiceException(e.getMessage()); } diff --git a/src/test/java/com/backend/coapp/_integration/InterviewFilterCrossFeatureIntegrationTest.java b/src/test/java/com/backend/coapp/_integration/InterviewFilterCrossFeatureIntegrationTest.java index b7cadece..574a67f6 100644 --- a/src/test/java/com/backend/coapp/_integration/InterviewFilterCrossFeatureIntegrationTest.java +++ b/src/test/java/com/backend/coapp/_integration/InterviewFilterCrossFeatureIntegrationTest.java @@ -109,6 +109,7 @@ void interviewFilterFlow_whenUserSchedulesAndFiltersByDate_expectCorrectApplicat .status(ApplicationStatus.INTERVIEWING) .applicationDeadline(LocalDate.now().plusDays(10)) .interviewDateTime(interviewDateA) + .status(ApplicationStatus.INTERVIEWING) .build(); mockMvc .perform( @@ -174,6 +175,7 @@ void interviewFilterFlow_whenUserSchedulesAndFiltersByDate_expectCorrectApplicat UpdateApplicationRequest.builder() .companyId(testCompanyId) .jobTitle("Job B") + .status(ApplicationStatus.INTERVIEWING) .interviewDateTime(newDateB) .build(); diff --git a/src/test/java/com/backend/coapp/_performance/loadtest.js b/src/test/java/com/backend/coapp/_performance/loadtest.js new file mode 100644 index 00000000..4c0a44a0 --- /dev/null +++ b/src/test/java/com/backend/coapp/_performance/loadtest.js @@ -0,0 +1,296 @@ +/* +Load testing script + +Written with the help of Gemini Flash 3.0 + +Requirements: +- k6 must be installed globally +- BASE_URL is a valid backend on the specified url must be running on render (paid tier) +- TEST_COMPANY_ID is a valid companyId (Test company) +- 20 accounts in the database with testuser1@test.com up to testuser20@test.com with passwords 123qwe + +Performance target: +The system must be able to concurrently handle at least 20 users generating a total of 200 requests per minute. + + +How to run: +k6 run src/test/java/com/backend/coapp/_performance/loadtest.js + +═══════════════════════════════════════════════════════════════════ +PERFORMANCE ANALYSIS - TARGET MET AND EXCEEDED +═══════════════════════════════════════════════════════════════════ + +CONCURRENT USER TARGET: 20 users +──────────────────────────────── +✓ MET: The test ran with exactly 20 virtual users (VUs) concurrently + at peak load, simulating 20 independent authenticated sessions. + All 20 VUs completed their iterations without interruption + (227 complete, 0 interrupted). + +REQUEST RATE TARGET: 200 requests per minute +───────────────────────────────────────────── +✓ EXCEEDED: The system handled 28.23 requests/second, which equals + approximately 1,694 requests per minute — over 8x the required target. + + Target : 200 req/min ( 3.33 req/s ) + Achieved: 1,694 req/min ( 28.23 req/s ) + Excess : +1,494 req/min ( +747% above target ) + + 3,405 total HTTP requests were completed successfully across the + 2-minute test window, with 0 failures recorded. + +RELIABILITY +─────────── +✓ PERFECT: 100% of all checks passed (3,178 out of 3,178). + Every endpoint returned the expected HTTP status code on every + single request across all 14 tested operations: + + - Authentication : login (200) + - User profile : get profile (200) + - Applications : post (201), delete (200), search (200), filter (200) + - Companies : get (200) + - Reviews : post (201), delete (200) + - Interviews : get (200), get filtered (200) + - AI quota : get (200) + - Experience : post (200), delete (200) + + http_req_failed: 0.00% — zero failed HTTP requests out of 3,405. + This confirms the backend is both highly available and correct + under concurrent load. + +RESPONSE TIME +───────────── +✓ ACCEPTABLE: Average response time was 546ms, with a median of 594ms. + Given that the backend is hosted on Render (a cloud provider with + cold-start and network latency characteristics), and that each + request involves authenticated REST calls over the internet, + these figures are reasonable and consistent. + + avg : 546ms + med : 594ms + p(90): 1.0s — 90% of requests completed within 1 second + p(95): 1.03s — 95% of requests completed within 1.03 seconds + max : 1.72s — worst-case response, still within acceptable bounds + + The tight gap between p(90) and p(95) (only 30ms) indicates + highly consistent and predictable performance under load, with + no significant outliers or tail latency spikes. + +CONCLUSION +────────── +The system comfortably meets and substantially exceeds the stated +performance requirements. Under a realistic multi-user workload +covering 14 distinct API operations — including authenticated CRUD +actions across applications, reviews, interviews, and experience — +the backend sustained over 1,694 requests per minute with 20 +concurrent users, a 0% failure rate, and sub-second response times +at the 90th percentile. No bottlenecks, timeouts, or degradation +were observed during the test window. + +═══════════════════════════════════════════════════════════════════ +SAMPLE k6 OUTPUT (from a successful run): +═══════════════════════════════════════════════════════════════════ + +k6 run src/test/java/com/backend/coapp/_performance/loadtest.js + + /\ Grafana /‾‾/ + /\ / \ |\ __ / / + / \/ \ | |/ / / ‾‾\ + / \ | ( | (‾) | + / __________ \ |_|\_\ \_____/ + + + execution: local + script: src/test/java/com/backend/coapp/_performance/loadtest.js + output: - + + scenarios: (100.00%) 1 scenario, 20 max VUs, 2m30s max duration (incl. graceful stop): + * default: Up to 20 looping VUs for 2m0s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s) + + + + █ TOTAL RESULTS + + checks_total.......: 3178 26.351949/s + checks_succeeded...: 100.00% 3178 out of 3178 + checks_failed......: 0.00% 0 out of 3178 + + ✓ login status 200 + ✓ get profile status 200 + ✓ post application status 201 + ✓ delete application status 200 + ✓ search applications status 200 + ✓ filter applications status 200 + ✓ get companies status 200 + ✓ post review status 201 + ✓ delete review status 200 + ✓ get interviews status 200 + ✓ get filtered interviews status 200 + ✓ get ai quota status 200 + ✓ post experience status 200 + ✓ delete experience status 200 + + HTTP + http_req_duration..............: avg=546.26ms min=96.75ms med=594.21ms max=1.72s p(90)=1s p(95)=1.03s + { expected_response:true }...: avg=546.26ms min=96.75ms med=594.21ms max=1.72s p(90)=1s p(95)=1.03s + http_req_failed................: 0.00% 0 out of 3405 + http_reqs......................: 3405 28.234231/s + + EXECUTION + iteration_duration.............: avg=8.2s min=2s med=9.12s max=13.01s p(90)=10.93s p(95)=11.02s + iterations.....................: 227 1.882282/s + vus............................: 2 min=1 max=20 + vus_max........................: 20 min=20 max=20 + + NETWORK + data_received..................: 1.3 MB 11 kB/s + data_sent......................: 377 kB 3.1 kB/s + + + + +running (2m00.6s), 00/20 VUs, 227 complete and 0 interrupted iterations +default ✓ [======================================] 00/20 VUs 2m0s + +*/ + + + +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, // Gradually increase from 0 to 20 userss over 30 seconds + { duration: '1m', target: 20 }, // Stay at 20 concurrent users for 1 minute + // 200 RPM target with sleep(6) + { duration: '30s', target: 0 }, // go back down to 0 users + ], +}; + +const BASE_URL = 'https://coapp-backend-dev.onrender.com'; +const TEST_COMPANY_ID = "69a4ddcab0a73ab3e5bd5a8b" + +export default function () { + + // VU ID (1 to 20) + const userEmail = `testuser${__VU}@test.com`; + const uniqueId = `v${__VU}i${__ITER}`; + + // Feature 1: Authentication and Profile ------------------------------------------------------------------------------------ + + // Request 1: Login (Write/Session Creation) + const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ + email: userEmail, + password: '123qwe', + }), { headers: { 'Content-Type': 'application/json' } }); + + const authToken = loginRes.json('token'); + check(loginRes, { 'login status 200': (r) => r.status === 200 }); + + const authParams = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + }; + + // Request 2: Get About Me (Read) + const aboutMeRes = http.get(`${BASE_URL}/api/user/about-me`, authParams); + check(aboutMeRes, { 'get profile status 200': (r) => r.status === 200 }); + + // Feature 2: Application Management ------------------------------------------------------------------------------------ + + // Request 1: Create Application (Write) + const appPayload = JSON.stringify({ + "companyId": TEST_COMPANY_ID, + "jobTitle": `Engineer_${uniqueId}`, + "numPositions": "1", + "status": "NOT_APPLIED", + "applicationDeadline": "2100-01-01", + "jobDescription": "Load testing", + "sourceLink": "https://test.com" + }); + const postAppRes = http.post(`${BASE_URL}/api/application`, appPayload, authParams); + const applicationId = postAppRes.json().applicationId; + check(postAppRes, { 'post application status 201': (r) => r.status === 201 }); + + // Request 2: Delete Application (Cleanup/Write) + if (applicationId) { + const delAppRes = http.del(`${BASE_URL}/api/application/${applicationId}`, null, authParams); + check(delAppRes, { 'delete application status 200': (r) => r.status === 200 }); + } + + // Feature 3: Application Filtering/Search ------------------------------------------------------------------------------------ + + // Request 1: Search by Text (Read) + const searchRes = http.get(`${BASE_URL}/api/application?search=Niche`, authParams); + check(searchRes, { 'search applications status 200': (r) => r.status === 200 }); + + // Request 2: Filter by Multiple Statuses (Read) + const filterRes = http.get(`${BASE_URL}/api/application?status=APPLIED,INTERVIEWING`, authParams); + check(filterRes, { 'filter applications status 200': (r) => r.status === 200 }); + + // Feature 4: Company Wiki and Reviews ------------------------------------------------------------------------------------ + + // Request 1: Get All Companies (Read) + const getCompaniesRes = http.get(`${BASE_URL}/api/companies?usePagination=true&size=10`, authParams); + check(getCompaniesRes, { 'get companies status 200': (r) => r.status === 200 }); + + // Request 2: Create Review for Company (Write) + const reviewPayload = JSON.stringify({ + "rating": 5, + "comment": `Great mentorship`, + "workTermSeason": "Summer", + "workTermYear": 2025, + "jobTitle": "Software Intern" + }); + const postReviewRes = http.post(`${BASE_URL}/api/companies/${TEST_COMPANY_ID}/reviews`, reviewPayload, authParams); + check(postReviewRes, { 'post review status 201': (r) => r.status === 201}); + + // Request 3: Delete Review (Cleanup/Write) + const delReviewRes = http.del(`${BASE_URL}/api/companies/${TEST_COMPANY_ID}/reviews`, null, authParams); + check(delReviewRes, { 'delete review status 200': (r) => r.status === 200 }); + + + // Feature 5: Interview Applications ------------------------------------------------------------------------------------ + + // Request 1: Get All Interviews (Read) + const getInterviewsRes = http.get(`${BASE_URL}/api/application/interviews`, authParams); + check(getInterviewsRes, { 'get interviews status 200': (r) => r.status === 200 }); + + // Request 2: Get Interviews with Date Filter (Read/Logic Test) + const dateFilteredRes = http.get(`${BASE_URL}/api/application/interviews?startDate=2024-01-01&endDate=2025-12-31`, authParams); + check(dateFilteredRes, { 'get filtered interviews status 200': (r) => r.status === 200 }); + + // Feature 6: AI Resume Builder and Profile Experience ------------------------------------------------------------------------------------ + + // NOTE: For feature 6, we don’t perform load tests on the API that evolve Gemini API call since there is a + // Gemini usage limit on the free tier version. Instead, we perform load tests on getting AI quota + // and creating/deleting experiences. We have confirmed this with the instructor. + + // Request 1: Get Remaining Quota (Read) + const quotaRes = http.get(`${BASE_URL}/api/resume-ai-advisor/remaining-quota`, authParams); + check(quotaRes, { 'get ai quota status 200': (r) => r.status === 200 }); + + // Request 2: Create Experience Entry (Write) + const expPayload = JSON.stringify({ + "companyId": TEST_COMPANY_ID, + "roleTitle": "Software Developer", + "roleDescription": `Handled microservices ${uniqueId}`, + "startDate": "2023-01-01" + }); + const postExpRes = http.post(`${BASE_URL}/api/user/experience`, expPayload, authParams); + const expId = postExpRes.json('experienceId'); + check(postExpRes, { 'post experience status 200': (r) => r.status === 200 }); + + // Request 3: Delete Experience (Cleanup/Write) + if (expId) { + const delExpRes = http.request("DELETE", `${BASE_URL}/api/user/experience/${expId}`, null, authParams); + check(delExpRes, { 'delete experience status 200': (r) => r.status === 200 }); + } + + http.get(`${BASE_URL}/api/auth/logout`, authParams); + + // sleep(6); // so as to not have TOO many requests since this is already way too overkill for the minimum +} diff --git a/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java b/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java index ba143ba1..7edbbe20 100644 --- a/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java +++ b/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java @@ -11,12 +11,10 @@ import com.backend.coapp.dto.request.GenAIResumeAdvisorRequest; import com.backend.coapp.exception.application.ApplicationNotFoundException; import com.backend.coapp.exception.application.ApplicationNotOwnedException; -import com.backend.coapp.exception.genai.ConcurrencyException; -import com.backend.coapp.exception.genai.GenAIQuotaExceededException; -import com.backend.coapp.exception.genai.GenAIUsageManagementServiceException; -import com.backend.coapp.exception.genai.OverCharacterLimitException; +import com.backend.coapp.exception.genai.*; import com.backend.coapp.exception.global.UserNotFoundException; import com.backend.coapp.model.document.UserModel; +import com.backend.coapp.model.enumeration.GenAIErrorCode; import com.backend.coapp.model.enumeration.SystemErrorCode; import com.backend.coapp.model.enumeration.UserErrorCode; import com.backend.coapp.service.GenAIResumeAdvisorService; @@ -256,6 +254,42 @@ void resumeAdvisor_whenServiceFails_expect500() throws Exception { .getAdvice(mockUser.getId(), validRequest.getApplicationId(), validRequest.getUserPrompt()); } + @Test + void resumeAdvisor_whenGenAIServiceFails_expect500() throws Exception { + when(genAIResumeAdvisorService.getAdvice(anyString(), any(), anyString())) + .thenThrow(new GenAIServiceException("Internal error")); + + mockMvc + .perform( + post("/api/resume-ai-advisor") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest)) + .principal(this.authentication)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.error").value(SystemErrorCode.INTERNAL_ERROR.name())); + + verify(genAIResumeAdvisorService, times(1)) + .getAdvice(mockUser.getId(), validRequest.getApplicationId(), validRequest.getUserPrompt()); + } + + @Test + void resumeAdvisor_whenReachLimit_expect503() throws Exception { + when(genAIResumeAdvisorService.getAdvice(anyString(), any(), anyString())) + .thenThrow(new GenAIOutOfServiceException("foo exception")); + + mockMvc + .perform( + post("/api/resume-ai-advisor") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest)) + .principal(this.authentication)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.error").value(GenAIErrorCode.SERVICE_UNAVAILABLE.name())); + + verify(genAIResumeAdvisorService, times(1)) + .getAdvice(mockUser.getId(), validRequest.getApplicationId(), validRequest.getUserPrompt()); + } + @Test void getRemainingQuota_whenSuccess_expectOkAndRemainingQuota() throws Exception { when(genAIUsageManagementService.getNumberOfRequestLeft(anyString())).thenReturn(7); diff --git a/src/test/java/com/backend/coapp/mutation/ApplicationServiceUnitTest.java b/src/test/java/com/backend/coapp/mutation/ApplicationServiceUnitTest.java index 6159b8f1..6f130bfc 100644 --- a/src/test/java/com/backend/coapp/mutation/ApplicationServiceUnitTest.java +++ b/src/test/java/com/backend/coapp/mutation/ApplicationServiceUnitTest.java @@ -477,6 +477,176 @@ void updateApplication_whenStatusChangedToApplied_expectDateAppliedSetToToday() assertEquals(LocalDate.now(), response.getDateApplied()); } + @Test + void updateApplication_whenStatusChangesToNotApplied_dateAppliedBecomesNull() { + ApplicationModel appliedApp = + ApplicationModel.builder() + .userId("user_001") + .companyId("company_001") + .jobTitle("Software Engineer") + .status(ApplicationStatus.APPLIED) + .applicationDeadline(LocalDate.now().plusDays(5)) + .dateApplied(LocalDate.now()) // Has a date + .build(); + ReflectionTestUtils.setField(appliedApp, "id", "app_001"); + + when(mockAppRepo.findById("app_001")).thenReturn(Optional.of(appliedApp)); + when(mockCompRepo.findById("company_001")).thenReturn(Optional.of(testCompany)); + when(mockAppRepo.save(any())).thenAnswer(i -> i.getArgument(0)); + + ApplicationResponse response = + executeUpdate( + "app_001", + getValidUpdateBuilder() + .status(ApplicationStatus.NOT_APPLIED) + .jobTitle("Brand New Title") + .dateApplied(LocalDate.now())); + + assertNull(response.getDateApplied()); + assertEquals(ApplicationStatus.NOT_APPLIED, response.getStatus()); + + ArgumentCaptor appCaptor = ArgumentCaptor.forClass(ApplicationModel.class); + verify(mockAppRepo).save(appCaptor.capture()); + assertNull(appCaptor.getValue().getDateApplied()); + } + + @Test + void updateApplication_whenStatusFromInterviewingToNotApplied_interviewDateTimeBecomesNull() { + LocalDateTime datetime = LocalDateTime.now(); + ApplicationModel interviewingApp = + ApplicationModel.builder() + .userId("user_001") + .companyId("company_001") + .jobTitle("Software Engineer") + .status(ApplicationStatus.INTERVIEWING) + .applicationDeadline(LocalDate.now().plusDays(5)) + .interviewDateTime(datetime) + .build(); + ReflectionTestUtils.setField(interviewingApp, "id", "app_001"); + + when(mockAppRepo.findById("app_001")).thenReturn(Optional.of(interviewingApp)); + when(mockCompRepo.findById("company_001")).thenReturn(Optional.of(testCompany)); + when(mockAppRepo.save(any())).thenAnswer(i -> i.getArgument(0)); + + ApplicationResponse response = + executeUpdate( + "app_001", + getValidUpdateBuilder() + .status(ApplicationStatus.NOT_APPLIED) + .jobTitle("Brand New Title") + .interviewDateTime(datetime)); + + assertNull(response.getInterviewDateTime()); + assertEquals(ApplicationStatus.NOT_APPLIED, response.getStatus()); + + ArgumentCaptor appCaptor = ArgumentCaptor.forClass(ApplicationModel.class); + verify(mockAppRepo).save(appCaptor.capture()); + assertNull(appCaptor.getValue().getInterviewDateTime()); + } + + @Test + void updateApplication_whenStatusFromInterviewingToApplied_interviewDateTimeBecomesNull() { + LocalDateTime datetime = LocalDateTime.now(); + ApplicationModel interviewingApp = + ApplicationModel.builder() + .userId("user_001") + .companyId("company_001") + .jobTitle("Software Engineer") + .status(ApplicationStatus.INTERVIEWING) + .applicationDeadline(LocalDate.now().plusDays(5)) + .interviewDateTime(datetime) + .build(); + ReflectionTestUtils.setField(interviewingApp, "id", "app_001"); + + when(mockAppRepo.findById("app_001")).thenReturn(Optional.of(interviewingApp)); + when(mockCompRepo.findById("company_001")).thenReturn(Optional.of(testCompany)); + when(mockAppRepo.save(any())).thenAnswer(i -> i.getArgument(0)); + + ApplicationResponse response = + executeUpdate( + "app_001", + getValidUpdateBuilder() + .status(ApplicationStatus.APPLIED) + .jobTitle("Brand New Title") + .interviewDateTime(datetime)); + + assertNull(response.getInterviewDateTime()); + assertEquals(ApplicationStatus.APPLIED, response.getStatus()); + + ArgumentCaptor appCaptor = ArgumentCaptor.forClass(ApplicationModel.class); + verify(mockAppRepo).save(appCaptor.capture()); + assertNull(appCaptor.getValue().getInterviewDateTime()); + } + + @Test + void + updateApplication_whenStatusFromInterviewScheduledToNotApplied_interviewDateTimeBecomesNull() { + LocalDateTime datetime = LocalDateTime.now(); + ApplicationModel scheduledApp = + ApplicationModel.builder() + .userId("user_001") + .companyId("company_001") + .jobTitle("Software Engineer") + .status(ApplicationStatus.INTERVIEW_SCHEDULED) + .applicationDeadline(LocalDate.now().plusDays(5)) + .interviewDateTime(datetime) + .build(); + ReflectionTestUtils.setField(scheduledApp, "id", "app_001"); + + when(mockAppRepo.findById("app_001")).thenReturn(Optional.of(scheduledApp)); + when(mockCompRepo.findById("company_001")).thenReturn(Optional.of(testCompany)); + when(mockAppRepo.save(any())).thenAnswer(i -> i.getArgument(0)); + + ApplicationResponse response = + executeUpdate( + "app_001", + getValidUpdateBuilder() + .status(ApplicationStatus.NOT_APPLIED) + .jobTitle("Brand New Title") + .interviewDateTime(datetime)); + + assertNull(response.getInterviewDateTime()); + assertEquals(ApplicationStatus.NOT_APPLIED, response.getStatus()); + + ArgumentCaptor appCaptor = ArgumentCaptor.forClass(ApplicationModel.class); + verify(mockAppRepo).save(appCaptor.capture()); + assertNull(appCaptor.getValue().getInterviewDateTime()); + } + + @Test + void updateApplication_whenStatusFromInterviewScheduledToApplied_interviewDateTimeBecomesNull() { + LocalDateTime datetime = LocalDateTime.now(); + ApplicationModel scheduledApp = + ApplicationModel.builder() + .userId("user_001") + .companyId("company_001") + .jobTitle("Software Engineer") + .status(ApplicationStatus.INTERVIEW_SCHEDULED) + .applicationDeadline(LocalDate.now().plusDays(5)) + .interviewDateTime(datetime) + .build(); + ReflectionTestUtils.setField(scheduledApp, "id", "app_001"); + + when(mockAppRepo.findById("app_001")).thenReturn(Optional.of(scheduledApp)); + when(mockCompRepo.findById("company_001")).thenReturn(Optional.of(testCompany)); + when(mockAppRepo.save(any())).thenAnswer(i -> i.getArgument(0)); + + ApplicationResponse response = + executeUpdate( + "app_001", + getValidUpdateBuilder() + .status(ApplicationStatus.APPLIED) + .jobTitle("Brand New Title") + .interviewDateTime(datetime)); + + assertNull(response.getInterviewDateTime()); + assertEquals(ApplicationStatus.APPLIED, response.getStatus()); + + ArgumentCaptor appCaptor = ArgumentCaptor.forClass(ApplicationModel.class); + verify(mockAppRepo).save(appCaptor.capture()); + assertNull(appCaptor.getValue().getInterviewDateTime()); + } + @Test void updateApplication_whenAppliedDateAfterDeadline_expectInvalidRequest() { when(mockAppRepo.findById("app_001")).thenReturn(Optional.of(existingApp)); @@ -727,7 +897,7 @@ void getFilteredApplications_whenSortDesc_expectDescDirectionInQuery() { } @Test - void getFilteredApplications_whenPage2Size3_expectSkip6() { + void getFilteredApplications_whenPage2Size3_expectSkip6Limit3() { mockFilteredQuery(List.of(existingApp, existingApp), 10L); applicationService.getFilteredApplications("user_001", null, null, "dateApplied", "desc", 2, 3); @@ -735,6 +905,7 @@ void getFilteredApplications_whenPage2Size3_expectSkip6() { ArgumentCaptor captor = ArgumentCaptor.forClass(Query.class); verify(mockMongoTemplate).find(captor.capture(), eq(ApplicationModel.class)); assertEquals(6L, captor.getValue().getSkip()); + assertEquals(3L, captor.getValue().getLimit()); } // ------------------------------------------------------------------------- diff --git a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java index 33a04d26..30e94e6f 100644 --- a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java +++ b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java @@ -624,6 +624,158 @@ void updateApplication_whenStatusChangesToDateApplied_dateAppliedChanges() { assertNotNull(response.getDateApplied()); } + @Test + void updateApplication_whenStatusChangesToNotApplied_dateAppliedBecomesNull() { + this.existingApp.setStatus(ApplicationStatus.APPLIED); + this.existingApp.setDateApplied(date); + + this.applicationRepository.save(existingApp); + + ApplicationResponse response = + this.applicationService.updateApplication( + "user_001", + existingApp.getId(), + testCompany.getId(), + "Brand New Title", + ApplicationStatus.NOT_APPLIED, + existingApp.getApplicationDeadline(), + null, + null, + null, + null, + null, + existingApp.getInterviewDateTime()); + + assertNull(response.getDateApplied()); + } + + @Test + void updateApplication_whenStatusChangesFromInterviewing_InterviewDateBecomesNull() { + this.existingApp.setStatus(ApplicationStatus.INTERVIEWING); + this.existingApp.setInterviewDateTime(datetime); + + this.applicationRepository.save(existingApp); + + ApplicationResponse response = + this.applicationService.updateApplication( + "user_001", + existingApp.getId(), + testCompany.getId(), + "Brand New Title", + ApplicationStatus.NOT_APPLIED, + existingApp.getApplicationDeadline(), + null, + null, + null, + null, + null, + existingApp.getInterviewDateTime()); + + assertNull(response.getInterviewDateTime()); + } + + @Test + void updateApplication_whenStatusChangesFromInterviewScheduled_InterviewDateBecomesNull() { + this.existingApp.setStatus(ApplicationStatus.INTERVIEW_SCHEDULED); + this.existingApp.setInterviewDateTime(datetime); + + this.applicationRepository.save(existingApp); + + ApplicationResponse response = + this.applicationService.updateApplication( + "user_001", + existingApp.getId(), + testCompany.getId(), + "Brand New Title", + ApplicationStatus.APPLIED, + existingApp.getApplicationDeadline(), + null, + null, + null, + null, + null, + existingApp.getInterviewDateTime()); + + assertNull(response.getInterviewDateTime()); + } + + @Test + void + updateApplication_whenStatusChangesFromInterviewToInterviewScheduld_InterviewDateHasNoChange() { + this.existingApp.setStatus(ApplicationStatus.INTERVIEWING); + this.existingApp.setInterviewDateTime(datetime); + + this.applicationRepository.save(existingApp); + + ApplicationResponse response = + this.applicationService.updateApplication( + "user_001", + existingApp.getId(), + testCompany.getId(), + "Brand New Title", + ApplicationStatus.INTERVIEW_SCHEDULED, + existingApp.getApplicationDeadline(), + null, + null, + null, + null, + null, + existingApp.getInterviewDateTime()); + + assertEquals(datetime, response.getInterviewDateTime()); + } + + @Test + void + updateApplication_whenStatusChangesFromInterviewScheduledToInterviewing_InterviewDateHasNoChange() { + this.existingApp.setStatus(ApplicationStatus.INTERVIEW_SCHEDULED); + this.existingApp.setInterviewDateTime(datetime); + + this.applicationRepository.save(existingApp); + + ApplicationResponse response = + this.applicationService.updateApplication( + "user_001", + existingApp.getId(), + testCompany.getId(), + "Brand New Title", + ApplicationStatus.INTERVIEWING, + existingApp.getApplicationDeadline(), + null, + null, + null, + null, + null, + existingApp.getInterviewDateTime()); + + assertEquals(datetime, response.getInterviewDateTime()); + } + + @Test + void updateApplication_whenDeadlineIsNullAndDateAppliedIsSet_expectSuccess() { + this.existingApp.setApplicationDeadline(null); + this.applicationRepository.save(existingApp); + + ApplicationResponse response = + this.applicationService.updateApplication( + "user_001", + existingApp.getId(), + testCompany.getId(), + existingApp.getJobTitle(), + existingApp.getStatus(), + null, // new deadline is null + existingApp.getJobDescription(), + existingApp.getNumPositions(), + existingApp.getSourceLink(), + LocalDate.now(), // new date applied is NOT null + existingApp.getNotes(), + existingApp.getInterviewDateTime()); + + assertNotNull(response); + assertNull(response.getApplicationDeadline()); + assertNotNull(response.getDateApplied()); + } + @Test void updateApplication_whenCompanyIsChangedToValidCompany_expectSuccess() { CompanyModel secondCompany = new CompanyModel("Amazon", "Seattle", "https://amazon.com"); diff --git a/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java b/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java index a8fbaa67..bc268de1 100644 --- a/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java +++ b/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java @@ -5,11 +5,13 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; +import com.backend.coapp.exception.genai.GenAIOutOfServiceException; import com.backend.coapp.exception.genai.GenAIServiceException; import com.backend.coapp.exception.genai.OverCharacterLimitException; import com.backend.coapp.util.GenAIConstants; import com.google.genai.Client; import com.google.genai.Models; +import com.google.genai.errors.ApiException; import com.google.genai.types.GenerateContentResponse; import java.lang.reflect.Field; import org.junit.jupiter.api.BeforeEach; @@ -18,6 +20,7 @@ import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mockito; +import org.springframework.http.HttpStatus; class GeminiGenAIServiceTest { @@ -90,4 +93,63 @@ void generateResponse_whenPromptExceedsMaxCharacters_expectOverCharacterLimitExc verifyNoInteractions(models); assertNotNull(ex); } + + @Test + void generateResponse_whenGeminiClientThrows429_expectGenAIOutOfServiceException() { + when(models.generateContent(eq(MODEL), eq(VALID_PROMPT), isNull())) + .thenThrow( + new ApiException( + HttpStatus.TOO_MANY_REQUESTS.value(), + HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), + "Please try again")); + + GenAIOutOfServiceException ex = + assertThrows( + GenAIOutOfServiceException.class, + () -> geminiGenAIService.generateResponse(VALID_PROMPT)); + + assertNotNull(ex.getMessage()); + } + + @ParameterizedTest + @ValueSource(ints = {429, 503, 500}) + void generateResponse_whenGeminiClientThrowsOutOfServiceStatus_expectGenAIOutOfServiceException( + int statusCode) { + HttpStatus httpStatus = HttpStatus.valueOf(statusCode); + when(models.generateContent(eq(MODEL), eq(VALID_PROMPT), isNull())) + .thenThrow(new ApiException(statusCode, httpStatus.getReasonPhrase(), "Please try again")); + + GenAIOutOfServiceException ex = + assertThrows( + GenAIOutOfServiceException.class, + () -> geminiGenAIService.generateResponse(VALID_PROMPT)); + + assertNotNull(ex.getMessage()); + } + + @ParameterizedTest + @ValueSource(ints = {400, 600}) + void generateResponse_whenGeminiClientThrowsNonOutOfServiceStatus_expectGenAIServiceException( + int statusCode) { + when(models.generateContent(eq(MODEL), eq(VALID_PROMPT), isNull())) + .thenThrow(new ApiException(statusCode, "Error", "Something went wrong")); + + GenAIServiceException ex = + assertThrows( + GenAIServiceException.class, () -> geminiGenAIService.generateResponse(VALID_PROMPT)); + + assertNotNull(ex.getMessage()); + } + + @Test + void generateResponse_whenGeminiClientThrowsRuntimeException_expectGenAIServiceException() { + when(models.generateContent(eq(MODEL), eq(VALID_PROMPT), isNull())) + .thenThrow(new RuntimeException()); + + GenAIServiceException ex = + assertThrows( + GenAIServiceException.class, () -> geminiGenAIService.generateResponse(VALID_PROMPT)); + + assertNotNull(ex.getMessage()); + } }