From 910c515c1cc12599df417d68af8afd833e794e05 Mon Sep 17 00:00:00 2001 From: gloox Date: Tue, 24 Mar 2026 22:31:32 -0500 Subject: [PATCH 01/21] created loadtest.js --- .../backend/coapp/_performance/loadtest.js | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/test/java/com/backend/coapp/_performance/loadtest.js 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 0000000..f34a8f1 --- /dev/null +++ b/src/test/java/com/backend/coapp/_performance/loadtest.js @@ -0,0 +1,80 @@ +/* +How to run (you must have k6 installed): + +``` +cd src/test/java/com/backend/coapp/_performance +k6 run loadtest.js +``` + +Note: if your environment is on the free tier of render, +you must make sure the backend is running +and will not have to spin up before running this load test + + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + + +export const options = { + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 20 }, + { duration: '30s', target: 0 }, + ], +}; + +const BASE_URL = 'https://coapp-backend-dev.onrender.com'; +let i = 1; + +export default function () { + + i += 1 + + // Feature 1: log in and get token + const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ + email: 'geetloomba79@gmail.com', + password: '123qwe', + }), { headers: { 'Content-Type': 'application/json' } }); + + // Extract token to be used for the remaining requests + const authToken = loginRes.json('token'); + check(loginRes, { 'logged in': (r) => r.status === 200 }); + const authParams = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + }; + + // Feature 2: GET a list of applications for the user + const getApplicationRes = http.get(`${BASE_URL}/api/application`, authParams); + + check(getApplicationRes, { 'Get Application status 200': (r) => r.status === 200 }); + + // Feature 2: POST a new application + const payload = JSON.stringify( + {"companyId":"69a4ddcab0a73ab3e5bd5a8b","jobTitle":`test${i}`,"numPositions":"1","status":"NOT_APPLIED","applicationDeadline":"2100-03-01","jobDescription":"","sourceLink":""} + ); + const postApplicationRes = http.post(`${BASE_URL}/api/application`, payload, authParams); + const applicationId = postApplicationRes.json().applicationId; + check(postApplicationRes, { 'Post Application status 200': (r) => r.status === 201 }); + + // Feature 2: DELETE application (for test cleanup) + const deleteApplicationRes = http.request('DELETE', `${BASE_URL}/api/application/${applicationId}`, null, authParams); + check(deleteApplicationRes, { 'Delete Application status 200': (r) => r.status === 200 }); + + // Feature 1: GET user information + const res2 = http.get(`${BASE_URL}/api/user/about-me`, authParams); + + check(res2, { 'GET user information status 200': (r) => r.status === 200 }); + + // Feature 1: log out + const res3 = http.get(`${BASE_URL}/api/auth/logout`, authParams); + + check(res3, { 'GET log out status 200': (r) => r.status === 200 }); + + + sleep(6); + + +} From f83dd3b4d062bc98caa0c70992a5984e9cc59e8e Mon Sep 17 00:00:00 2001 From: gloox Date: Tue, 24 Mar 2026 23:41:56 -0500 Subject: [PATCH 02/21] updated to test remaining features --- .../backend/coapp/_performance/loadtest.js | 157 +++++++++++++----- 1 file changed, 113 insertions(+), 44 deletions(-) diff --git a/src/test/java/com/backend/coapp/_performance/loadtest.js b/src/test/java/com/backend/coapp/_performance/loadtest.js index f34a8f1..aa55157 100644 --- a/src/test/java/com/backend/coapp/_performance/loadtest.js +++ b/src/test/java/com/backend/coapp/_performance/loadtest.js @@ -1,20 +1,21 @@ /* -How to run (you must have k6 installed): +Load testing script -``` +Requirements: +- k6 must be installed globally +- a valid backend on the specified url must be running on render (paid tier) +- 69a4ddcab0a73ab3e5bd5a8b is a valid companyId (Test company) + + +How to run: cd src/test/java/com/backend/coapp/_performance k6 run loadtest.js -``` -Note: if your environment is on the free tier of render, -you must make sure the backend is running -and will not have to spin up before running this load test +*/ - */ import http from 'k6/http'; import { check, sleep } from 'k6'; - export const options = { stages: [ { duration: '30s', target: 20 }, @@ -24,21 +25,23 @@ export const options = { }; const BASE_URL = 'https://coapp-backend-dev.onrender.com'; -let i = 1; +const TEST_COMPANY_ID = "69a4ddcab0a73ab3e5bd5a8b" export default function () { - i += 1 + const uniqueId = `v${__VU}i${__ITER}`; + + // Feature 1: Authentication and Profile ------------------------------------------------------------------------------------ - // Feature 1: log in and get token + // Request 1: Login (Write/Session Creation) const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ email: 'geetloomba79@gmail.com', password: '123qwe', }), { headers: { 'Content-Type': 'application/json' } }); - // Extract token to be used for the remaining requests const authToken = loginRes.json('token'); - check(loginRes, { 'logged in': (r) => r.status === 200 }); + check(loginRes, { 'login status 200': (r) => r.status === 200 }); + const authParams = { headers: { 'Content-Type': 'application/json', @@ -46,35 +49,101 @@ export default function () { }, }; - // Feature 2: GET a list of applications for the user - const getApplicationRes = http.get(`${BASE_URL}/api/application`, authParams); - - check(getApplicationRes, { 'Get Application status 200': (r) => r.status === 200 }); - - // Feature 2: POST a new application - const payload = JSON.stringify( - {"companyId":"69a4ddcab0a73ab3e5bd5a8b","jobTitle":`test${i}`,"numPositions":"1","status":"NOT_APPLIED","applicationDeadline":"2100-03-01","jobDescription":"","sourceLink":""} - ); - const postApplicationRes = http.post(`${BASE_URL}/api/application`, payload, authParams); - const applicationId = postApplicationRes.json().applicationId; - check(postApplicationRes, { 'Post Application status 200': (r) => r.status === 201 }); - - // Feature 2: DELETE application (for test cleanup) - const deleteApplicationRes = http.request('DELETE', `${BASE_URL}/api/application/${applicationId}`, null, authParams); - check(deleteApplicationRes, { 'Delete Application status 200': (r) => r.status === 200 }); - - // Feature 1: GET user information - const res2 = http.get(`${BASE_URL}/api/user/about-me`, authParams); - - check(res2, { 'GET user information status 200': (r) => r.status === 200 }); - - // Feature 1: log out - const res3 = http.get(`${BASE_URL}/api/auth/logout`, authParams); - - check(res3, { 'GET log out status 200': (r) => r.status === 200 }); - - - sleep(6); - - + // 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); + const reviewId = postReviewRes.json().reviewId; + check(postReviewRes, { 'post review status 201': (r) => r.status === 201}); + + // Request 3: Delete Review (Cleanup/Write) + if (reviewId) { + const delReviewRes = http.del(`${BASE_URL}/api/companies/${targetCompId}/reviews/${reviewId}`, 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 ------------------------------------------------------------------------------------ + + // 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.del(`${BASE_URL}/api/user/experience/${expId}`, null, authParams); + check(delExpRes, { 'delete experience status 200': (r) => r.status === 200 }); + } + + // Logout and final safety sleep + http.get(`${BASE_URL}/api/auth/logout`, authParams); + + sleep(6); // to maintain the 200 Requests Per Minute target across 20 users } From 6af531d834639abc1098bd306293166242d1fa68 Mon Sep 17 00:00:00 2001 From: gloox Date: Wed, 25 Mar 2026 15:40:40 -0500 Subject: [PATCH 03/21] finished load test --- .../backend/coapp/_performance/loadtest.js | 100 +++++++++++++++--- 1 file changed, 85 insertions(+), 15 deletions(-) diff --git a/src/test/java/com/backend/coapp/_performance/loadtest.js b/src/test/java/com/backend/coapp/_performance/loadtest.js index aa55157..d5faeb1 100644 --- a/src/test/java/com/backend/coapp/_performance/loadtest.js +++ b/src/test/java/com/backend/coapp/_performance/loadtest.js @@ -1,26 +1,97 @@ /* Load testing script +written with the help of gemini flash 3.0 + Requirements: - k6 must be installed globally -- a valid backend on the specified url must be running on render (paid tier) -- 69a4ddcab0a73ab3e5bd5a8b is a valid companyId (Test company) +- 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: cd src/test/java/com/backend/coapp/_performance k6 run loadtest.js +SAMPLE k6 OUTPUT & ANALYSIS (from a successful run): +This output demonstrates the test passing all requirements with 100% success rate. + + /\ Grafana /‾‾/ + /\ / \ |\ __ / / + / \/ \ | |/ / / ‾‾\ + / \ | ( | (‾) | + / __________ \ |_|\_\ \_____/ + + execution: local + script: 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.......: 2702 21.460896/s : Total checks run (~15 checks per iteration x 193 iterations) + checks_succeeded...: 100.00% 2702 out of 2702 : PERFECT: All API assertions passed (status codes 200/201) + checks_failed......: 0.00% 0 out of 2702 : No failures in business logic checks + + ✓ 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=258.67ms min=98.93ms med=140.27ms max=1.82s p(90)=682.56ms p(95)=749.53ms : Response times: Excellent (avg <300ms, p95 <1s) + { expected_response:true }...: avg=258.67ms min=98.93ms med=140.27ms max=1.82s p(90)=682.56ms p(95)=749.53ms + http_req_failed................: 0.00% 0 out of 2895 ← PERFECT: Zero HTTP errors (no 4xx/5xx) + http_reqs......................: 2895 22.993817/s ← ~23 req/s = ~1380 RPM (FAR exceeds 200 RPM requirement) + + EXECUTION + iteration_duration.............: avg=9.89s min=7.96s med=10.1s max=12.48s p(90)=11.05s p(95)=11.52s : Each full iteration ~10s (15 reqs + sleep(6)) + iterations.....................: 193 1.532921/s : Total loops completed across all VUs + vus............................: 1 min=1 max=20 : VUs ramped correctly + vus_max........................: 20 min=20 max=20 : ✓ Met: 20 concurrent users achieved & held for 1m + + NETWORK + data_received..................: 1.1 MB 9.0 kB/s + data_sent......................: 327 kB 2.6 kB/s + +running (2m05.9s), 00/20 VUs, 193 complete and 0 interrupted iterations +default ✓ [======================================] 00/20 VUs 2m0s + +KEY METRICS EXPLAINED & REQUIREMENT PASS: +- CONCURRENT USERS: vus_max=20 → Handled 20 real users (testuser1-20@test.com) simultaneously. +- REQUESTS PER MINUTE: http_reqs=2895 over ~2min = ~1380 RPM (>> 200 RPM required). +- SUCCESS RATE: 100% checks & HTTP → No errors, all features (auth, CRUD, search, AI quota) functional under load. +- LATENCY: avg=259ms, p95=750ms → Responsive (sub-second for 95% of requests). +- STABILITY: Held peak for 1m (steady-state stage), graceful ramp up/down → Realistic traffic simulation. +- CONCLUSION: System PASSES requirements with significant headroom (6x+ RPM capacity demonstrated). + */ + import http from 'k6/http'; import { check, sleep } from 'k6'; export const options = { stages: [ - { duration: '30s', target: 20 }, - { duration: '1m', target: 20 }, - { duration: '30s', target: 0 }, + { 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 ], }; @@ -29,13 +100,15 @@ 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: 'geetloomba79@gmail.com', + email: userEmail, password: '123qwe', }), { headers: { 'Content-Type': 'application/json' } }); @@ -100,14 +173,12 @@ export default function () { "jobTitle": "Software Intern" }); const postReviewRes = http.post(`${BASE_URL}/api/companies/${TEST_COMPANY_ID}/reviews`, reviewPayload, authParams); - const reviewId = postReviewRes.json().reviewId; check(postReviewRes, { 'post review status 201': (r) => r.status === 201}); // Request 3: Delete Review (Cleanup/Write) - if (reviewId) { - const delReviewRes = http.del(`${BASE_URL}/api/companies/${targetCompId}/reviews/${reviewId}`, null, authParams); - check(delReviewRes, { 'delete review status 200': (r) => r.status === 200 }); - } + 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 ------------------------------------------------------------------------------------ @@ -138,12 +209,11 @@ export default function () { // Request 3: Delete Experience (Cleanup/Write) if (expId) { - const delExpRes = http.del(`${BASE_URL}/api/user/experience/${expId}`, null, authParams); + const delExpRes = http.request("DELETE", `${BASE_URL}/api/user/experience/${expId}`, null, authParams); check(delExpRes, { 'delete experience status 200': (r) => r.status === 200 }); } - // Logout and final safety sleep http.get(`${BASE_URL}/api/auth/logout`, authParams); - sleep(6); // to maintain the 200 Requests Per Minute target across 20 users + sleep(6); // so as to not have TOO many requests since this is already way too overkill for the minimum } From 833f5122c37d5374f17f7f098681400965fe0231 Mon Sep 17 00:00:00 2001 From: Ngo Bao Date: Thu, 26 Mar 2026 17:15:35 -0500 Subject: [PATCH 04/21] Detected reach usage limit and throw proper exception --- docs/API-feat6.md | 10 ++++++++ .../genai/GenAIOutOfServiceException.java | 8 +++++++ .../coapp/handler/GlobalExceptionHandler.java | 10 ++++++++ .../model/enumeration/GenAIErrorCode.java | 3 ++- .../service/GenAIResumeAdvisorService.java | 9 ++++--- .../service/genAI/GeminiGenAIService.java | 13 ++++++++-- .../GenAIResumeAdvisorControllerTest.java | 24 +++++++++++++++---- .../service/genAI/GeminiGenAIServiceTest.java | 14 +++++++++++ 8 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/backend/coapp/exception/genai/GenAIOutOfServiceException.java diff --git a/docs/API-feat6.md b/docs/API-feat6.md index 86deba4..25a4fef 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 0000000..e49fd97 --- /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() { + super("Our AI service is currently unavailable. Please try again later."); + } +} diff --git a/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java b/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java index 043d7f7..055eff8 100644 --- a/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java @@ -385,4 +385,14 @@ 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", ex.getMessage())); + } } 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 5e728be..98e01b4 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/GenAIResumeAdvisorService.java b/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java index 2c6ea8d..5672b60 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,7 @@ 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) */ public String getAdvice(String userId, String applicationId, String prompt) throws OverCharacterLimitException, @@ -65,7 +63,8 @@ public String getAdvice(String userId, String applicationId, String prompt) GenAIUsageManagementServiceException, UserNotFoundException, GenAIQuotaExceededException, - ConcurrencyException { + ConcurrencyException, + GenAIOutOfServiceException { 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 203f699..fa25643 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,19 @@ 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.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.stereotype.Service; @Service +@Slf4j @ConditionalOnProperty(name = "gen-ai.provider", havingValue = "gemini") public class GeminiGenAIService implements GenAIService { private final Client geminiClient; @@ -29,11 +32,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"); } @@ -48,6 +52,11 @@ public String generateResponse(String prompt) geminiClient.models.generateContent(this.model, prompt, null); return response.text(); } catch (Exception e) { + log.error("Gemini exception type: {}", e.getClass().getName()); + log.error("Gemini exception message: {}", e.getMessage()); + if (e.getMessage().contains("429")) { + throw new GenAIOutOfServiceException(); + } throw new GenAIServiceException(e.getMessage()); } } diff --git a/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java b/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java index ba143ba..dacbe78 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,24 @@ void resumeAdvisor_whenServiceFails_expect500() throws Exception { .getAdvice(mockUser.getId(), validRequest.getApplicationId(), validRequest.getUserPrompt()); } + @Test + void resumeAdvisor_whenReachLimit_expect503() throws Exception { + when(genAIResumeAdvisorService.getAdvice(anyString(), any(), anyString())) + .thenThrow(new GenAIOutOfServiceException()); + + 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/service/genAI/GeminiGenAIServiceTest.java b/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java index a8fbaa6..7bbde9c 100644 --- a/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java +++ b/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java @@ -5,6 +5,7 @@ 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; @@ -90,4 +91,17 @@ void generateResponse_whenPromptExceedsMaxCharacters_expectOverCharacterLimitExc verifyNoInteractions(models); assertNotNull(ex); } + + @Test + void generateResponse_whenGeminiClientThrows429_expectGenAIServiceException() { + when(models.generateContent(eq(MODEL), eq(VALID_PROMPT), isNull())) + .thenThrow(new RuntimeException("429 Too Many requests")); + + GenAIOutOfServiceException ex = + assertThrows( + GenAIOutOfServiceException.class, + () -> geminiGenAIService.generateResponse(VALID_PROMPT)); + + assertNotNull(ex.getMessage()); + } } From 6fc34fa37893719226ef52b448e643884032a750 Mon Sep 17 00:00:00 2001 From: Ngo Bao Date: Fri, 27 Mar 2026 17:15:19 -0500 Subject: [PATCH 05/21] Added GenAIException to ExceptionHandler --- .../genai/GenAIOutOfServiceException.java | 4 +-- .../coapp/handler/GlobalExceptionHandler.java | 25 +++++++++++++++---- .../service/GenAIResumeAdvisorService.java | 4 ++- .../service/genAI/GeminiGenAIService.java | 4 +-- .../GenAIResumeAdvisorControllerTest.java | 22 +++++++++++++++- 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/backend/coapp/exception/genai/GenAIOutOfServiceException.java b/src/main/java/com/backend/coapp/exception/genai/GenAIOutOfServiceException.java index e49fd97..92507e9 100644 --- a/src/main/java/com/backend/coapp/exception/genai/GenAIOutOfServiceException.java +++ b/src/main/java/com/backend/coapp/exception/genai/GenAIOutOfServiceException.java @@ -2,7 +2,7 @@ /** This exception will be thrown when we reach usage limit. User need to try again later. */ public class GenAIOutOfServiceException extends RuntimeException { - public GenAIOutOfServiceException() { - super("Our AI service is currently unavailable. Please try again later."); + 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 055eff8..83e90ed 100644 --- a/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java @@ -25,7 +25,10 @@ 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 +66,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: Undefined exception: " + ex.getMessage(); log.error(errorMessage); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body( @@ -112,7 +115,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 +336,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( @@ -393,6 +396,18 @@ public ResponseEntity> handleGenAIOutOfServiceException( 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", ex.getMessage())); + .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/service/GenAIResumeAdvisorService.java b/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java index 5672b60..638485d 100644 --- a/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java +++ b/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java @@ -55,6 +55,7 @@ public GenAIResumeAdvisorService( * @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, @@ -64,7 +65,8 @@ public String getAdvice(String userId, String applicationId, String prompt) UserNotFoundException, GenAIQuotaExceededException, ConcurrencyException, - GenAIOutOfServiceException { + 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 fa25643..5f5472d 100644 --- a/src/main/java/com/backend/coapp/service/genAI/GeminiGenAIService.java +++ b/src/main/java/com/backend/coapp/service/genAI/GeminiGenAIService.java @@ -52,10 +52,8 @@ public String generateResponse(String prompt) geminiClient.models.generateContent(this.model, prompt, null); return response.text(); } catch (Exception e) { - log.error("Gemini exception type: {}", e.getClass().getName()); - log.error("Gemini exception message: {}", e.getMessage()); if (e.getMessage().contains("429")) { - throw new GenAIOutOfServiceException(); + throw new GenAIOutOfServiceException(e.getMessage()); } throw new GenAIServiceException(e.getMessage()); } diff --git a/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java b/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java index dacbe78..bf84695 100644 --- a/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java +++ b/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java @@ -254,10 +254,28 @@ 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()); + .thenThrow(new GenAIOutOfServiceException("foo exception")); mockMvc .perform( @@ -317,4 +335,6 @@ void getRemainingQuota_whenServiceFails_expect500() throws Exception { verify(genAIUsageManagementService, times(1)).getNumberOfRequestLeft(mockUser.getId()); } + + } From 714675f6f4c4995824f56c099c71194f3b74cc53 Mon Sep 17 00:00:00 2001 From: Ngo Bao Date: Fri, 27 Mar 2026 17:19:04 -0500 Subject: [PATCH 06/21] Applied format --- .../coapp/handler/GlobalExceptionHandler.java | 26 ++++++++++++------- .../service/GenAIResumeAdvisorService.java | 2 +- .../GenAIResumeAdvisorControllerTest.java | 20 +++++++------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java b/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java index 83e90ed..3711163 100644 --- a/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java @@ -25,10 +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. - * */ + *

Internal exceptions (5** HTTP status) will be logged for debugging. + */ @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { @@ -396,18 +397,25 @@ public ResponseEntity> handleGenAIOutOfServiceException( 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.")); + .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) { + 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.")); + .body( + Map.of( + "error", + SystemErrorCode.INTERNAL_ERROR, + "message", + "GenAI Service failed. Please try again later.")); } - - } diff --git a/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java b/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java index 638485d..dcbd90d 100644 --- a/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java +++ b/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java @@ -66,7 +66,7 @@ public String getAdvice(String userId, String applicationId, String prompt) GenAIQuotaExceededException, ConcurrencyException, GenAIOutOfServiceException, - GenAIServiceException{ + GenAIServiceException { String applicationJobDescription = null; String applicationJobTitle = null; if (prompt.length() > GenAIConstants.MAX_PROMPT_CHARACTERS) { diff --git a/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java b/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java index bf84695..7edbbe2 100644 --- a/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java +++ b/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java @@ -257,19 +257,19 @@ void resumeAdvisor_whenServiceFails_expect500() throws Exception { @Test void resumeAdvisor_whenGenAIServiceFails_expect500() throws Exception { when(genAIResumeAdvisorService.getAdvice(anyString(), any(), anyString())) - .thenThrow(new GenAIServiceException("Internal error")); + .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())); + .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()); + .getAdvice(mockUser.getId(), validRequest.getApplicationId(), validRequest.getUserPrompt()); } @Test @@ -335,6 +335,4 @@ void getRemainingQuota_whenServiceFails_expect500() throws Exception { verify(genAIUsageManagementService, times(1)).getNumberOfRequestLeft(mockUser.getId()); } - - } From 5713dd3816e860e199e9dc70b982e40225945e65 Mon Sep 17 00:00:00 2001 From: gloox Date: Fri, 27 Mar 2026 18:03:31 -0500 Subject: [PATCH 07/21] added gemini comment --- src/test/java/com/backend/coapp/_performance/loadtest.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/test/java/com/backend/coapp/_performance/loadtest.js b/src/test/java/com/backend/coapp/_performance/loadtest.js index d5faeb1..6a05057 100644 --- a/src/test/java/com/backend/coapp/_performance/loadtest.js +++ b/src/test/java/com/backend/coapp/_performance/loadtest.js @@ -192,6 +192,10 @@ export default function () { // 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 }); From 2d0abf60da575aad051ca896ed76fb64194e5730 Mon Sep 17 00:00:00 2001 From: Ngo Bao Date: Sat, 28 Mar 2026 09:23:00 -0500 Subject: [PATCH 08/21] Checked status code instead of checking error message to detect Gemini error --- .../coapp/handler/GlobalExceptionHandler.java | 2 +- .../service/genAI/GeminiGenAIService.java | 9 +++- .../service/genAI/GeminiGenAIServiceTest.java | 54 ++++++++++++++++++- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java b/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java index 3711163..b6dbae6 100644 --- a/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java @@ -67,7 +67,7 @@ public ResponseEntity> handleEmailServiceException(EmailServ @ExceptionHandler(RuntimeException.class) public ResponseEntity> handleRuntimeException(RuntimeException ex) { - String errorMessage = "ERROR: Undefined exception: " + ex.getMessage(); + String errorMessage = "ERROR: Runtime exception: " + ex.getMessage(); log.error(errorMessage); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body( 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 5f5472d..3505681 100644 --- a/src/main/java/com/backend/coapp/service/genAI/GeminiGenAIService.java +++ b/src/main/java/com/backend/coapp/service/genAI/GeminiGenAIService.java @@ -5,11 +5,13 @@ 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 @@ -51,11 +53,14 @@ public String generateResponse(String prompt) GenerateContentResponse response = geminiClient.models.generateContent(this.model, prompt, null); return response.text(); - } catch (Exception e) { - if (e.getMessage().contains("429")) { + } 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/service/genAI/GeminiGenAIServiceTest.java b/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java index 7bbde9c..be10f78 100644 --- a/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java +++ b/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java @@ -11,6 +11,7 @@ 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; @@ -19,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 { @@ -93,9 +95,13 @@ void generateResponse_whenPromptExceedsMaxCharacters_expectOverCharacterLimitExc } @Test - void generateResponse_whenGeminiClientThrows429_expectGenAIServiceException() { + void generateResponse_whenGeminiClientThrows429_expectGenAIOutOfServiceException() { when(models.generateContent(eq(MODEL), eq(VALID_PROMPT), isNull())) - .thenThrow(new RuntimeException("429 Too Many requests")); + .thenThrow( + new ApiException( + HttpStatus.TOO_MANY_REQUESTS.value(), + HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), + "Please try again")); GenAIOutOfServiceException ex = assertThrows( @@ -104,4 +110,48 @@ void generateResponse_whenGeminiClientThrows429_expectGenAIServiceException() { 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()); + } + + @Test + void generateResponse_whenGeminiClientThrows400_expectGenAIServiceException() { + when(models.generateContent(eq(MODEL), eq(VALID_PROMPT), isNull())) + .thenThrow( + new ApiException( + HttpStatus.BAD_REQUEST.value(), + HttpStatus.BAD_REQUEST.getReasonPhrase(), + "Invalid API key")); + + 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()); + } } From 718e462c4270eb02013950ced7fb88d569a739f7 Mon Sep 17 00:00:00 2001 From: Ngo Bao Date: Sat, 28 Mar 2026 09:32:00 -0500 Subject: [PATCH 09/21] Added 1 test case >600 status to enhance codecov --- .../coapp/service/genAI/GeminiGenAIServiceTest.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) 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 be10f78..bc268de 100644 --- a/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java +++ b/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java @@ -127,14 +127,12 @@ void generateResponse_whenGeminiClientThrowsOutOfServiceStatus_expectGenAIOutOfS assertNotNull(ex.getMessage()); } - @Test - void generateResponse_whenGeminiClientThrows400_expectGenAIServiceException() { + @ParameterizedTest + @ValueSource(ints = {400, 600}) + void generateResponse_whenGeminiClientThrowsNonOutOfServiceStatus_expectGenAIServiceException( + int statusCode) { when(models.generateContent(eq(MODEL), eq(VALID_PROMPT), isNull())) - .thenThrow( - new ApiException( - HttpStatus.BAD_REQUEST.value(), - HttpStatus.BAD_REQUEST.getReasonPhrase(), - "Invalid API key")); + .thenThrow(new ApiException(statusCode, "Error", "Something went wrong")); GenAIServiceException ex = assertThrows( From 601d932b1fcd79b5731294bc91714f25191b3543 Mon Sep 17 00:00:00 2001 From: gloox Date: Mon, 30 Mar 2026 19:27:56 -0500 Subject: [PATCH 10/21] fix bug by updating application date --- .../coapp/service/ApplicationService.java | 4 +++ .../coapp/service/ApplicationServiceTest.java | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/main/java/com/backend/coapp/service/ApplicationService.java b/src/main/java/com/backend/coapp/service/ApplicationService.java index a6d7993..eb4093d 100644 --- a/src/main/java/com/backend/coapp/service/ApplicationService.java +++ b/src/main/java/com/backend/coapp/service/ApplicationService.java @@ -199,6 +199,10 @@ public ApplicationResponse updateApplication( newDateApplied = LocalDate.now(); } + if (statusChanged && newStatus == ApplicationStatus.NOT_APPLIED) { + newDateApplied = null; + } + if (newDateApplied != null && !newDateApplied.isBefore(existingApp.getApplicationDeadline()) && !newDateApplied.isEqual(existingApp.getApplicationDeadline())) { diff --git a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java index 33a04d2..d0adfc0 100644 --- a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java +++ b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java @@ -624,6 +624,31 @@ 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_whenCompanyIsChangedToValidCompany_expectSuccess() { CompanyModel secondCompany = new CompanyModel("Amazon", "Seattle", "https://amazon.com"); From 5788ab94e3399a57105d78713c3ec5d6bdce95f6 Mon Sep 17 00:00:00 2001 From: gloox Date: Mon, 30 Mar 2026 19:30:53 -0500 Subject: [PATCH 11/21] spotlessly --- .../coapp/service/ApplicationServiceTest.java | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java index d0adfc0..9069a54 100644 --- a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java +++ b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java @@ -632,19 +632,19 @@ void updateApplication_whenStatusChangesToNotApplied_dateAppliedBecomesNull() { 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()); + 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()); } From e5b98984b11b6a8e4f0df596f79fa5c9b1ef8cd0 Mon Sep 17 00:00:00 2001 From: gloox Date: Mon, 30 Mar 2026 19:47:28 -0500 Subject: [PATCH 12/21] fixed status changed interviewing bug --- .../coapp/service/ApplicationService.java | 6 +++ .../coapp/service/ApplicationServiceTest.java | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/main/java/com/backend/coapp/service/ApplicationService.java b/src/main/java/com/backend/coapp/service/ApplicationService.java index eb4093d..6a1429e 100644 --- a/src/main/java/com/backend/coapp/service/ApplicationService.java +++ b/src/main/java/com/backend/coapp/service/ApplicationService.java @@ -203,6 +203,12 @@ public ApplicationResponse updateApplication( newDateApplied = null; } + if (statusChanged + && (existingApp.getStatus() == ApplicationStatus.INTERVIEWING + || existingApp.getStatus() == ApplicationStatus.INTERVIEW_SCHEDULED)) { + newInterviewDateTime = null; + } + if (newDateApplied != null && !newDateApplied.isBefore(existingApp.getApplicationDeadline()) && !newDateApplied.isEqual(existingApp.getApplicationDeadline())) { diff --git a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java index 9069a54..ddea219 100644 --- a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java +++ b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java @@ -649,6 +649,56 @@ void updateApplication_whenStatusChangesToNotApplied_dateAppliedBecomesNull() { 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.REJECTED, + 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.REJECTED, + existingApp.getApplicationDeadline(), + null, + null, + null, + null, + null, + existingApp.getInterviewDateTime()); + + assertNull(response.getInterviewDateTime()); + } + @Test void updateApplication_whenCompanyIsChangedToValidCompany_expectSuccess() { CompanyModel secondCompany = new CompanyModel("Amazon", "Seattle", "https://amazon.com"); From dc817d8603f023cfd31a340119e9cfd79e822573 Mon Sep 17 00:00:00 2001 From: gloox Date: Mon, 30 Mar 2026 20:03:42 -0500 Subject: [PATCH 13/21] fixed failed tests --- .../java/com/backend/coapp/service/ApplicationService.java | 4 +++- .../InterviewFilterCrossFeatureIntegrationTest.java | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/backend/coapp/service/ApplicationService.java b/src/main/java/com/backend/coapp/service/ApplicationService.java index 6a1429e..06fbe42 100644 --- a/src/main/java/com/backend/coapp/service/ApplicationService.java +++ b/src/main/java/com/backend/coapp/service/ApplicationService.java @@ -205,7 +205,9 @@ public ApplicationResponse updateApplication( if (statusChanged && (existingApp.getStatus() == ApplicationStatus.INTERVIEWING - || existingApp.getStatus() == ApplicationStatus.INTERVIEW_SCHEDULED)) { + || existingApp.getStatus() == ApplicationStatus.INTERVIEW_SCHEDULED) + && newStatus != ApplicationStatus.INTERVIEW_SCHEDULED + && newStatus != ApplicationStatus.INTERVIEWING) { newInterviewDateTime = null; } diff --git a/src/test/java/com/backend/coapp/_integration/InterviewFilterCrossFeatureIntegrationTest.java b/src/test/java/com/backend/coapp/_integration/InterviewFilterCrossFeatureIntegrationTest.java index b7cadec..574a67f 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(); From 9b9cf4a834a76da674ce0c16c36dbd9aa56d9be9 Mon Sep 17 00:00:00 2001 From: gloox Date: Mon, 30 Mar 2026 20:14:09 -0500 Subject: [PATCH 14/21] code coverage --- .../coapp/service/ApplicationServiceTest.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java index ddea219..b5b50e6 100644 --- a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java +++ b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java @@ -699,6 +699,58 @@ void updateApplication_whenStatusChangesFromInterviewScheduled_InterviewDateBeco 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_whenCompanyIsChangedToValidCompany_expectSuccess() { CompanyModel secondCompany = new CompanyModel("Amazon", "Seattle", "https://amazon.com"); From 5132fe865868891f79d563c67c0eb50aa5b60085 Mon Sep 17 00:00:00 2001 From: gloox Date: Tue, 31 Mar 2026 09:49:57 -0500 Subject: [PATCH 15/21] changed logic in accordance with comments from niko and bao --- .../java/com/backend/coapp/service/ApplicationService.java | 3 +-- .../com/backend/coapp/service/ApplicationServiceTest.java | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/backend/coapp/service/ApplicationService.java b/src/main/java/com/backend/coapp/service/ApplicationService.java index 06fbe42..9e8b929 100644 --- a/src/main/java/com/backend/coapp/service/ApplicationService.java +++ b/src/main/java/com/backend/coapp/service/ApplicationService.java @@ -206,8 +206,7 @@ public ApplicationResponse updateApplication( if (statusChanged && (existingApp.getStatus() == ApplicationStatus.INTERVIEWING || existingApp.getStatus() == ApplicationStatus.INTERVIEW_SCHEDULED) - && newStatus != ApplicationStatus.INTERVIEW_SCHEDULED - && newStatus != ApplicationStatus.INTERVIEWING) { + && (newStatus == ApplicationStatus.NOT_APPLIED || newStatus == ApplicationStatus.APPLIED)) { newInterviewDateTime = null; } diff --git a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java index b5b50e6..ebb7ffe 100644 --- a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java +++ b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java @@ -662,7 +662,7 @@ void updateApplication_whenStatusChangesFromInterviewing_InterviewDateBecomesNul existingApp.getId(), testCompany.getId(), "Brand New Title", - ApplicationStatus.REJECTED, + ApplicationStatus.NOT_APPLIED, existingApp.getApplicationDeadline(), null, null, @@ -687,7 +687,7 @@ void updateApplication_whenStatusChangesFromInterviewScheduled_InterviewDateBeco existingApp.getId(), testCompany.getId(), "Brand New Title", - ApplicationStatus.REJECTED, + ApplicationStatus.APPLIED, existingApp.getApplicationDeadline(), null, null, From 319ecdb68295db206d0a1208b3bace5e74257bf1 Mon Sep 17 00:00:00 2001 From: gloox Date: Tue, 31 Mar 2026 14:34:35 -0500 Subject: [PATCH 16/21] fixed mutation testing --- .../backend/coapp/_performance/loadtest.js | 5 +- .../mutation/ApplicationServiceUnitTest.java | 173 +++++++++++++++++- 2 files changed, 174 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/backend/coapp/_performance/loadtest.js b/src/test/java/com/backend/coapp/_performance/loadtest.js index 6a05057..813d3fd 100644 --- a/src/test/java/com/backend/coapp/_performance/loadtest.js +++ b/src/test/java/com/backend/coapp/_performance/loadtest.js @@ -13,8 +13,7 @@ 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: -cd src/test/java/com/backend/coapp/_performance -k6 run loadtest.js +k6 run src/test/java/com/backend/coapp/_performance/loadtest.js SAMPLE k6 OUTPUT & ANALYSIS (from a successful run): This output demonstrates the test passing all requirements with 100% success rate. @@ -219,5 +218,5 @@ export default function () { 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 + // 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/mutation/ApplicationServiceUnitTest.java b/src/test/java/com/backend/coapp/mutation/ApplicationServiceUnitTest.java index 6159b8f..6f130bf 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()); } // ------------------------------------------------------------------------- From 159681aa60a5071cc915b680b8be3020a2307c0f Mon Sep 17 00:00:00 2001 From: gloox Date: Tue, 31 Mar 2026 14:38:57 -0500 Subject: [PATCH 17/21] added nicer load testing comment --- .../backend/coapp/_performance/loadtest.js | 128 ++++++++++++++---- 1 file changed, 101 insertions(+), 27 deletions(-) diff --git a/src/test/java/com/backend/coapp/_performance/loadtest.js b/src/test/java/com/backend/coapp/_performance/loadtest.js index 813d3fd..4c0a44a 100644 --- a/src/test/java/com/backend/coapp/_performance/loadtest.js +++ b/src/test/java/com/backend/coapp/_performance/loadtest.js @@ -1,7 +1,7 @@ /* Load testing script -written with the help of gemini flash 3.0 +Written with the help of Gemini Flash 3.0 Requirements: - k6 must be installed globally @@ -12,30 +12,108 @@ Requirements: 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 -SAMPLE k6 OUTPUT & ANALYSIS (from a successful run): -This output demonstrates the test passing all requirements with 100% success rate. +═══════════════════════════════════════════════════════════════════ +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 /‾‾/ + /\ Grafana /‾‾/ /\ / \ |\ __ / / / \/ \ | |/ / / ‾‾\ / \ | ( | (‾) | / __________ \ |_|\_\ \_____/ + execution: local - script: loadtest.js + 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.......: 2702 21.460896/s : Total checks run (~15 checks per iteration x 193 iterations) - checks_succeeded...: 100.00% 2702 out of 2702 : PERFECT: All API assertions passed (status codes 200/201) - checks_failed......: 0.00% 0 out of 2702 : No failures in business logic checks + 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 @@ -53,35 +131,31 @@ This output demonstrates the test passing all requirements with 100% success rat ✓ delete experience status 200 HTTP - http_req_duration..............: avg=258.67ms min=98.93ms med=140.27ms max=1.82s p(90)=682.56ms p(95)=749.53ms : Response times: Excellent (avg <300ms, p95 <1s) - { expected_response:true }...: avg=258.67ms min=98.93ms med=140.27ms max=1.82s p(90)=682.56ms p(95)=749.53ms - http_req_failed................: 0.00% 0 out of 2895 ← PERFECT: Zero HTTP errors (no 4xx/5xx) - http_reqs......................: 2895 22.993817/s ← ~23 req/s = ~1380 RPM (FAR exceeds 200 RPM requirement) + 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=9.89s min=7.96s med=10.1s max=12.48s p(90)=11.05s p(95)=11.52s : Each full iteration ~10s (15 reqs + sleep(6)) - iterations.....................: 193 1.532921/s : Total loops completed across all VUs - vus............................: 1 min=1 max=20 : VUs ramped correctly - vus_max........................: 20 min=20 max=20 : ✓ Met: 20 concurrent users achieved & held for 1m + 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.1 MB 9.0 kB/s - data_sent......................: 327 kB 2.6 kB/s + data_received..................: 1.3 MB 11 kB/s + data_sent......................: 377 kB 3.1 kB/s + -running (2m05.9s), 00/20 VUs, 193 complete and 0 interrupted iterations -default ✓ [======================================] 00/20 VUs 2m0s -KEY METRICS EXPLAINED & REQUIREMENT PASS: -- CONCURRENT USERS: vus_max=20 → Handled 20 real users (testuser1-20@test.com) simultaneously. -- REQUESTS PER MINUTE: http_reqs=2895 over ~2min = ~1380 RPM (>> 200 RPM required). -- SUCCESS RATE: 100% checks & HTTP → No errors, all features (auth, CRUD, search, AI quota) functional under load. -- LATENCY: avg=259ms, p95=750ms → Responsive (sub-second for 95% of requests). -- STABILITY: Held peak for 1m (steady-state stage), graceful ramp up/down → Realistic traffic simulation. -- CONCLUSION: System PASSES requirements with significant headroom (6x+ RPM capacity demonstrated). + +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'; From 5e3347f2a0abac2759260f66241c2d61b3442bd9 Mon Sep 17 00:00:00 2001 From: gloox Date: Tue, 31 Mar 2026 14:48:29 -0500 Subject: [PATCH 18/21] reduced cognitive complexity --- .../coapp/service/ApplicationService.java | 112 ++++++++++-------- 1 file changed, 64 insertions(+), 48 deletions(-) diff --git a/src/main/java/com/backend/coapp/service/ApplicationService.java b/src/main/java/com/backend/coapp/service/ApplicationService.java index 9e8b929..2ef623d 100644 --- a/src/main/java/com/backend/coapp/service/ApplicationService.java +++ b/src/main/java/com/backend/coapp/service/ApplicationService.java @@ -165,54 +165,81 @@ 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(); - } + // Process logic for status transitions and date constraints + validateAndSyncStatusDates(existingApp, newStatus, newDateApplied, newInterviewDateTime); - if (statusChanged && newStatus == ApplicationStatus.NOT_APPLIED) { - newDateApplied = null; - } + existingApp.setCompanyId(newCompanyId); + existingApp.setJobTitle(newJobTitle); + existingApp.setStatus(newStatus); + existingApp.setApplicationDeadline(newApplicationDeadline); + existingApp.setJobDescription(newJobDescription); + existingApp.setNumPositions(newNumPositions); + existingApp.setSourceLink(newSourceLink); + existingApp.setNotes(newNotes); - if (statusChanged - && (existingApp.getStatus() == ApplicationStatus.INTERVIEWING - || existingApp.getStatus() == ApplicationStatus.INTERVIEW_SCHEDULED) - && (newStatus == ApplicationStatus.NOT_APPLIED || newStatus == ApplicationStatus.APPLIED)) { - newInterviewDateTime = null; + 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 - && !newDateApplied.isBefore(existingApp.getApplicationDeadline()) - && !newDateApplied.isEqual(existingApp.getApplicationDeadline())) { + && existingApp.getApplicationDeadline() != null + && newDateApplied.isAfter(existingApp.getApplicationDeadline())) { throw new InvalidRequestException( "The applied date must be before the application deadline." + existingApp.getApplicationDeadline() @@ -220,19 +247,8 @@ public ApplicationResponse updateApplication( + newDateApplied); } - existingApp.setCompanyId(newCompanyId); - existingApp.setJobTitle(newJobTitle); - existingApp.setStatus(newStatus); - existingApp.setApplicationDeadline(newApplicationDeadline); - 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); } /** From c302ed99475c58765a31f46d57bd02a7a32d8645 Mon Sep 17 00:00:00 2001 From: gloox Date: Tue, 31 Mar 2026 15:07:27 -0500 Subject: [PATCH 19/21] partial coverage fix --- .../coapp/service/ApplicationService.java | 17 +++++++------ .../coapp/service/ApplicationServiceTest.java | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/backend/coapp/service/ApplicationService.java b/src/main/java/com/backend/coapp/service/ApplicationService.java index 2ef623d..c36cdb6 100644 --- a/src/main/java/com/backend/coapp/service/ApplicationService.java +++ b/src/main/java/com/backend/coapp/service/ApplicationService.java @@ -237,14 +237,15 @@ private void validateAndSyncStatusDates( } } - if (newDateApplied != null - && existingApp.getApplicationDeadline() != null - && newDateApplied.isAfter(existingApp.getApplicationDeadline())) { - throw new InvalidRequestException( - "The applied date must be before the application deadline." - + existingApp.getApplicationDeadline() - + " " - + newDateApplied); + 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); diff --git a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java index ebb7ffe..1cfd47f 100644 --- a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java +++ b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java @@ -751,6 +751,31 @@ void updateApplication_whenStatusChangesFromInterviewScheduled_InterviewDateBeco 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"); From 229e766ea9cc3993e2307422a059b9d92ee379fb Mon Sep 17 00:00:00 2001 From: gloox Date: Tue, 31 Mar 2026 15:07:48 -0500 Subject: [PATCH 20/21] spotlessly --- .../coapp/service/ApplicationServiceTest.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java index 1cfd47f..30e94e6 100644 --- a/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java +++ b/src/test/java/com/backend/coapp/service/ApplicationServiceTest.java @@ -756,20 +756,20 @@ 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()); + 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()); From 731cbad8dfb0657af22e4bcfce3ecc71c26be0c6 Mon Sep 17 00:00:00 2001 From: Ngo Bao Date: Tue, 31 Mar 2026 16:14:40 -0500 Subject: [PATCH 21/21] Updated README.md and CONTRIBUTING.md with steps to run tests --- .github/CONTRIBUTING.md | 23 ++++++++++++++++++++++- README.md | 8 ++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7e5e7ef..a37baa3 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 090c565..52e2db0 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 + ```