Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
910c515
created loadtest.js
gloox Mar 25, 2026
f83dd3b
updated to test remaining features
gloox Mar 25, 2026
6af531d
finished load test
gloox Mar 25, 2026
833f512
Detected reach usage limit and throw proper exception
ntgbaoo Mar 26, 2026
6fc34fa
Added GenAIException to ExceptionHandler
ntgbaoo Mar 27, 2026
714675f
Applied format
ntgbaoo Mar 27, 2026
5713dd3
added gemini comment
gloox Mar 27, 2026
2d0abf6
Checked status code instead of checking error message to detect Gemin…
ntgbaoo Mar 28, 2026
718e462
Added 1 test case >600 status to enhance codecov
ntgbaoo Mar 28, 2026
e9d29f7
Merge pull request #195 from Co-App-Team/task/194-GeminiException
ntgbaoo Mar 28, 2026
9b3fd4e
Merge pull request #188 from Co-App-Team/task/159-load-testing
gloox Mar 30, 2026
601d932
fix bug by updating application date
gloox Mar 31, 2026
5788ab9
spotlessly
gloox Mar 31, 2026
e5b9898
fixed status changed interviewing bug
gloox Mar 31, 2026
dc817d8
fixed failed tests
gloox Mar 31, 2026
9b9cf4a
code coverage
gloox Mar 31, 2026
5132fe8
changed logic in accordance with comments from niko and bao
gloox Mar 31, 2026
875281c
Merge branch 'dev' into task/bug-fix-date-applied-nulled
gloox Mar 31, 2026
319ecdb
fixed mutation testing
gloox Mar 31, 2026
159681a
added nicer load testing comment
gloox Mar 31, 2026
5e3347f
reduced cognitive complexity
gloox Mar 31, 2026
c302ed9
partial coverage fix
gloox Mar 31, 2026
229e766
spotlessly
gloox Mar 31, 2026
731cbad
Updated README.md and CONTRIBUTING.md with steps to run tests
ntgbaoo Mar 31, 2026
2ee457a
Merge pull request #196 from Co-App-Team/task/bug-fix-date-applied-nu…
yomi-adt Apr 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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).
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
10 changes: 10 additions & 0 deletions docs/API-feat6.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.backend.coapp.exception.genai;

/** This exception will be thrown when we reach usage limit. User need to try again later. */
public class GenAIOutOfServiceException extends RuntimeException {
public GenAIOutOfServiceException(String message) {
super("Our AI service failure. " + message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@
import org.springframework.web.bind.annotation.RestControllerAdvice;
import tools.jackson.databind.exc.InvalidFormatException;

/** Exception handler for controller. */
/**
* Exception handler for controller.
*
* <p>Internal exceptions (5** HTTP status) will be logged for debugging.
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
Expand Down Expand Up @@ -63,7 +67,7 @@ public ResponseEntity<Map<String, Object>> handleEmailServiceException(EmailServ

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Map<String, Object>> handleRuntimeException(RuntimeException ex) {
String errorMessage = "ERROR: Reset verification code service failed: " + ex.getMessage();
String errorMessage = "ERROR: Runtime exception: " + ex.getMessage();
log.error(errorMessage);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
Expand Down Expand Up @@ -112,7 +116,7 @@ public ResponseEntity<Map<String, Object>> handleAuthAccountAlreadyVerifyExcepti
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<Map<String, Object>> 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(
Expand Down Expand Up @@ -333,7 +337,7 @@ public ResponseEntity<Map<String, Object>> handleConcurrencyException(Concurrenc
@ExceptionHandler(GenAIUsageManagementServiceException.class)
public ResponseEntity<Map<String, Object>> 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(
Expand Down Expand Up @@ -385,4 +389,33 @@ public ResponseEntity<Map<String, Object>> handleHttpMessageNotReadable(
return ResponseEntity.badRequest()
.body(Map.of("error", RequestErrorCode.INVALID_FORMAT_FIELD.name(), "message", message));
}

@ExceptionHandler(GenAIOutOfServiceException.class)
public ResponseEntity<Map<String, Object>> handleGenAIOutOfServiceException(
GenAIOutOfServiceException ex) {

String errorMessage = "ERROR: Resume workshop Service failed: " + ex.getMessage();
log.error(errorMessage);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(
Map.of(
"error",
GenAIErrorCode.SERVICE_UNAVAILABLE,
"message",
"Our AI service is currently unavailable. Please try again later."));
}

@ExceptionHandler(GenAIServiceException.class)
public ResponseEntity<Map<String, Object>> 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."));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
public enum GenAIErrorCode {
OVER_LIMIT_CHARACTER,
OVER_LIMIT_CHATBOT_REQUEST,
OTHER_REQUEST_IN_PROGRESS
OTHER_REQUEST_IN_PROGRESS,
SERVICE_UNAVAILABLE
}
106 changes: 67 additions & 39 deletions src/main/java/com/backend/coapp/service/ApplicationService.java
Original file line number Diff line number Diff line change
Expand Up @@ -165,49 +165,29 @@ public ApplicationResponse updateApplication(
"You do not have permission to edit this application.");
}

boolean companyChanged = !Objects.equals(existingApp.getCompanyId(), newCompanyId);
boolean titleChanged = !Objects.equals(existingApp.getJobTitle(), newJobTitle);
boolean statusChanged = !Objects.equals(existingApp.getStatus(), newStatus);
boolean deadlineChanged =
!Objects.equals(existingApp.getApplicationDeadline(), newApplicationDeadline);
boolean descChanged = !Objects.equals(existingApp.getJobDescription(), newJobDescription);
boolean positionsChanged = !Objects.equals(existingApp.getNumPositions(), newNumPositions);
boolean linkChanged = !Objects.equals(existingApp.getSourceLink(), newSourceLink);
boolean dateAppliedChanged = !Objects.equals(existingApp.getDateApplied(), newDateApplied);
boolean notesChanged = !Objects.equals(existingApp.getNotes(), newNotes);
boolean interviewDateChanged =
!Objects.equals(existingApp.getInterviewDateTime(), newInterviewDateTime);

if (!companyChanged
&& !titleChanged
&& !descChanged
&& !linkChanged
&& !dateAppliedChanged
&& !notesChanged
&& !statusChanged
&& !deadlineChanged
&& !positionsChanged
&& !interviewDateChanged) {
boolean hasChanges =
!Objects.equals(existingApp.getCompanyId(), newCompanyId)
|| !Objects.equals(existingApp.getJobTitle(), newJobTitle)
|| !Objects.equals(existingApp.getStatus(), newStatus)
|| !Objects.equals(existingApp.getApplicationDeadline(), newApplicationDeadline)
|| !Objects.equals(existingApp.getJobDescription(), newJobDescription)
|| !Objects.equals(existingApp.getNumPositions(), newNumPositions)
|| !Objects.equals(existingApp.getSourceLink(), newSourceLink)
|| !Objects.equals(existingApp.getDateApplied(), newDateApplied)
|| !Objects.equals(existingApp.getNotes(), newNotes)
|| !Objects.equals(existingApp.getInterviewDateTime(), newInterviewDateTime);

if (!hasChanges) {
throw new NoChangesDetectedException("No fields were changed.");
}

if (companyChanged && this.companyRepository.findById(newCompanyId).isEmpty()) {
if (!Objects.equals(existingApp.getCompanyId(), newCompanyId)
&& this.companyRepository.findById(newCompanyId).isEmpty()) {
throw new CompanyNotFoundException();
}

if (statusChanged && newStatus == ApplicationStatus.APPLIED) {
newDateApplied = LocalDate.now();
}

if (newDateApplied != null
&& !newDateApplied.isBefore(existingApp.getApplicationDeadline())
&& !newDateApplied.isEqual(existingApp.getApplicationDeadline())) {
throw new InvalidRequestException(
"The applied date must be before the application deadline."
+ existingApp.getApplicationDeadline()
+ " "
+ newDateApplied);
}
// Process logic for status transitions and date constraints
validateAndSyncStatusDates(existingApp, newStatus, newDateApplied, newInterviewDateTime);

existingApp.setCompanyId(newCompanyId);
existingApp.setJobTitle(newJobTitle);
Expand All @@ -216,14 +196,62 @@ public ApplicationResponse updateApplication(
existingApp.setJobDescription(newJobDescription);
existingApp.setNumPositions(newNumPositions);
existingApp.setSourceLink(newSourceLink);
existingApp.setDateApplied(newDateApplied);
existingApp.setNotes(newNotes);
existingApp.setInterviewDateTime(newInterviewDateTime);

ApplicationModel updatedApp = this.applicationRepository.save(existingApp);
return ApplicationResponse.fromModel(updatedApp);
}

/**
* Validates and synchronizes application dates based on status changes
*
* @param existingApp The existing application model
* @param newStatus The proposed new status
* @param newDateApplied The proposed applied date
* @param newInterviewDateTime The proposed interview date
* @throws InvalidRequestException If dates violate business logic
*/
private void validateAndSyncStatusDates(
ApplicationModel existingApp,
ApplicationStatus newStatus,
LocalDate newDateApplied,
LocalDateTime newInterviewDateTime) {

boolean statusChanged = !Objects.equals(existingApp.getStatus(), newStatus);

if (statusChanged) {
if (newStatus == ApplicationStatus.APPLIED) {
newDateApplied = LocalDate.now();
} else if (newStatus == ApplicationStatus.NOT_APPLIED) {
newDateApplied = null;
}

boolean wasInterviewing =
existingApp.getStatus() == ApplicationStatus.INTERVIEWING
|| existingApp.getStatus() == ApplicationStatus.INTERVIEW_SCHEDULED;
boolean isReverting =
newStatus == ApplicationStatus.NOT_APPLIED || newStatus == ApplicationStatus.APPLIED;

if (wasInterviewing && isReverting) {
newInterviewDateTime = null;
}
}

if (newDateApplied != null) {
LocalDate deadline = existingApp.getApplicationDeadline();
if (deadline != null && newDateApplied.isAfter(deadline)) {
throw new InvalidRequestException(
"The applied date must be before the application deadline."
+ deadline
+ " "
+ newDateApplied);
}
}

existingApp.setDateApplied(newDateApplied);
existingApp.setInterviewDateTime(newInterviewDateTime);
}

/**
* Delete a job application
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,6 +54,8 @@ public GenAIResumeAdvisorService(
* @throws UserNotFoundException when user doesn't exist
* @throws GenAIQuotaExceededException when user exceed GenAI usage limit
* @throws ConcurrencyException when the same user make a request twice
* @throws GenAIOutOfServiceException when we reach usage limit (internally)
* @throws GenAIServiceException when there is something wrong for GenAI service (internally)
*/
public String getAdvice(String userId, String applicationId, String prompt)
throws OverCharacterLimitException,
Expand All @@ -65,7 +64,9 @@ public String getAdvice(String userId, String applicationId, String prompt)
GenAIUsageManagementServiceException,
UserNotFoundException,
GenAIQuotaExceededException,
ConcurrencyException {
ConcurrencyException,
GenAIOutOfServiceException,
GenAIServiceException {
String applicationJobDescription = null;
String applicationJobTitle = null;
if (prompt.length() > GenAIConstants.MAX_PROMPT_CHARACTERS) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package com.backend.coapp.service.genAI;

import com.backend.coapp.exception.genai.GenAIOutOfServiceException;
import com.backend.coapp.exception.genai.GenAIServiceException;
import com.backend.coapp.exception.genai.OverCharacterLimitException;
import com.backend.coapp.util.GenAIConstants;
import com.google.genai.Client;
import com.google.genai.errors.ApiException;
import com.google.genai.types.GenerateContentResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;

@Service
@Slf4j
@ConditionalOnProperty(name = "gen-ai.provider", havingValue = "gemini")
public class GeminiGenAIService implements GenAIService {
private final Client geminiClient;
Expand All @@ -29,11 +34,12 @@ public GeminiGenAIService(Client geminiClient) {
* @param prompt client's prompt
* @return GenAI response
* @throws IllegalArgumentException when invalid input
* @throws GenAIServiceException when there is something wrong with GenAI provider.
* @throws GenAIServiceException when there is something wrong with GenAI provider
* @throws GenAIOutOfServiceException when we reach usage limit (internally)
*/
@Override
public String generateResponse(String prompt)
throws IllegalArgumentException, GenAIServiceException {
throws IllegalArgumentException, GenAIServiceException, GenAIOutOfServiceException {
if (prompt == null || prompt.isBlank()) {
throw new IllegalArgumentException("Prompt can't be null or blank");
}
Expand All @@ -47,6 +53,12 @@ public String generateResponse(String prompt)
GenerateContentResponse response =
geminiClient.models.generateContent(this.model, prompt, null);
return response.text();
} catch (ApiException e) {
if (e.code() == HttpStatus.TOO_MANY_REQUESTS.value() || (e.code() >= 500 && e.code() < 600)) {
// This includes 503 - SERVICE UNAVAILABLE and 500 - INTERNAL ERROR
throw new GenAIOutOfServiceException(e.getMessage());
}
throw new GenAIServiceException(e.getMessage());
} catch (Exception e) {
throw new GenAIServiceException(e.getMessage());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ void interviewFilterFlow_whenUserSchedulesAndFiltersByDate_expectCorrectApplicat
.status(ApplicationStatus.INTERVIEWING)
.applicationDeadline(LocalDate.now().plusDays(10))
.interviewDateTime(interviewDateA)
.status(ApplicationStatus.INTERVIEWING)
.build();
mockMvc
.perform(
Expand Down Expand Up @@ -174,6 +175,7 @@ void interviewFilterFlow_whenUserSchedulesAndFiltersByDate_expectCorrectApplicat
UpdateApplicationRequest.builder()
.companyId(testCompanyId)
.jobTitle("Job B")
.status(ApplicationStatus.INTERVIEWING)
.interviewDateTime(newDateB)
.build();

Expand Down
Loading
Loading