diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/ResumeProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/ResumeProperties.java new file mode 100644 index 00000000..58321fc7 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/ResumeProperties.java @@ -0,0 +1,13 @@ +package com.stackup.stackup.common.config.properties; + +import jakarta.validation.constraints.NotNull; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.unit.DataSize; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "app.resume") +public record ResumeProperties( + @NotNull DataSize maxUploadSize +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/common/entity/BaseSoftDeleteEntity.java b/backend/src/main/java/com/stackup/stackup/common/entity/BaseSoftDeleteEntity.java index 92d6fc17..9e11665c 100644 --- a/backend/src/main/java/com/stackup/stackup/common/entity/BaseSoftDeleteEntity.java +++ b/backend/src/main/java/com/stackup/stackup/common/entity/BaseSoftDeleteEntity.java @@ -10,4 +10,8 @@ public abstract class BaseSoftDeleteEntity extends BaseTimeAndUpdateEntity { @Column(name = "is_deleted", nullable = false) protected boolean deleted = false; + + public void softDelete() { + this.deleted = true; + } } diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java index aa0729b8..23b1d06c 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java @@ -19,6 +19,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; @RestControllerAdvice public class GlobalExceptionHandler { @@ -80,6 +81,18 @@ public ResponseEntity handleStorageException(StorageException ); } + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxUploadSizeExceeded( + MaxUploadSizeExceededException exception + ) { + ApiErrorCode errorCode = ApiErrorCode.RESUME_FILE_TOO_LARGE; + log.warn("Upload size exceeded. code={}, traceId={}, maxBytes={}", + errorCode.name(), TraceContext.getTraceId(), exception.getMaxUploadSize()); + return buildResponse(errorCode, errorCode.getDefaultMessage(), Map.of( + "maxBytes", exception.getMaxUploadSize() + )); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception exception) { ApiErrorCode errorCode = ApiErrorCode.SYS_INTERNAL_ERROR; diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/MessageContext.java b/backend/src/main/java/com/stackup/stackup/common/messaging/MessageContext.java deleted file mode 100644 index 9ff13749..00000000 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/MessageContext.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.stackup.stackup.common.messaging; - -public record MessageContext( - Long userId, - Long sessionId, - Long documentId, - Long repositoryId -) { - public static MessageContext empty() { - return new MessageContext(null, null, null, null); - } - - public static MessageContext ofUser(Long userId) { - return new MessageContext(userId, null, null, null); - } - - public MessageContext withSession(Long newSessionId) { - return new MessageContext(userId, newSessionId, documentId, repositoryId); - } - - public MessageContext withDocument(Long newDocumentId) { - return new MessageContext(userId, sessionId, newDocumentId, repositoryId); - } - - public MessageContext withRepository(Long newRepositoryId) { - return new MessageContext(userId, sessionId, documentId, newRepositoryId); - } -} diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/MessageEnvelope.java b/backend/src/main/java/com/stackup/stackup/common/messaging/MessageEnvelope.java index f319a827..33f5045d 100644 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/MessageEnvelope.java +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/MessageEnvelope.java @@ -1,15 +1,21 @@ package com.stackup.stackup.common.messaging; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import java.time.Instant; +import java.util.Map; -public record MessageEnvelope( +@JsonInclude(JsonInclude.Include.NON_NULL) +public record MessageEnvelope( String messageId, String messageType, String version, String traceId, + @JsonSerialize(using = ToStringSerializer.class) Instant publishedAt, String publisher, - T payload, - MessageContext context + Object payload, + Map context ) { } diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMessagePublisher.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMessagePublisher.java index 56876f4d..1bac3cd4 100644 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMessagePublisher.java +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMessagePublisher.java @@ -3,24 +3,22 @@ import com.stackup.stackup.common.config.properties.RabbitMqProperties; import com.stackup.stackup.common.trace.TraceContext; import java.time.Instant; +import java.util.Map; import java.util.UUID; +import lombok.RequiredArgsConstructor; import org.springframework.amqp.core.MessagePostProcessor; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class RabbitMessagePublisher { private final RabbitMqProperties properties; private final RabbitTemplate rabbitTemplate; - public RabbitMessagePublisher(RabbitMqProperties properties, RabbitTemplate rabbitTemplate) { - this.properties = properties; - this.rabbitTemplate = rabbitTemplate; - } - - public MessageEnvelope publishToAi(String routingKey, T payload, MessageContext context) { - MessageEnvelope envelope = new MessageEnvelope<>( + public MessageEnvelope publishToAi(String routingKey, Object payload, Map context) { + MessageEnvelope envelope = new MessageEnvelope( UUID.randomUUID().toString(), routingKey, properties.version(), @@ -28,7 +26,7 @@ public MessageEnvelope publishToAi(String routingKey, T payload, MessageC Instant.now(), properties.publisher(), payload, - context == null ? MessageContext.empty() : context + context == null ? Map.of() : context ); rabbitTemplate.convertAndSend( @@ -40,7 +38,7 @@ public MessageEnvelope publishToAi(String routingKey, T payload, MessageC return envelope; } - private MessagePostProcessor withEnvelopeHeaders(MessageEnvelope envelope) { + private MessagePostProcessor withEnvelopeHeaders(MessageEnvelope envelope) { return message -> { message.getMessageProperties().setContentType(properties.message().contentType()); message.getMessageProperties().setContentEncoding(properties.message().contentEncoding()); diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java index 981382f6..fcfa2d43 100644 --- a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java +++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java @@ -3,6 +3,8 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface AnalyzedDocumentRepository extends JpaRepository { @@ -14,4 +16,18 @@ Optional findByIdAndResume_User_IdOrIdAndRepository_User_Id( Long repositoryDocumentId, Long repositoryUserId ); + + @Query(value = """ + SELECT EXISTS ( + SELECT 1 + FROM analyzed_documents d + JOIN session_contexts sc ON sc.document_id = d.id + JOIN interview_sessions s ON s.id = sc.session_id + WHERE d.resume_id = :resumeId + AND d.is_deleted = FALSE + AND s.is_deleted = FALSE + AND s.status IN ('READY', 'IN_PROGRESS') + ) + """, nativeQuery = true) + boolean existsActiveSessionContextForResume(@Param("resumeId") Long resumeId); } diff --git a/backend/src/main/java/com/stackup/stackup/document/infrastructure/AnalyzedDocumentBasedResumeUsageChecker.java b/backend/src/main/java/com/stackup/stackup/document/infrastructure/AnalyzedDocumentBasedResumeUsageChecker.java new file mode 100644 index 00000000..8cdb10cb --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/infrastructure/AnalyzedDocumentBasedResumeUsageChecker.java @@ -0,0 +1,18 @@ +package com.stackup.stackup.document.infrastructure; + +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.resume.domain.ResumeUsageChecker; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AnalyzedDocumentBasedResumeUsageChecker implements ResumeUsageChecker { + + private final AnalyzedDocumentRepository documentRepository; + + @Override + public boolean isInUse(Long resumeId) { + return documentRepository.existsActiveSessionContextForResume(resumeId); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java b/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java new file mode 100644 index 00000000..1f23cf86 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java @@ -0,0 +1,121 @@ +package com.stackup.stackup.resume.application; + +import com.stackup.stackup.common.config.properties.ResumeProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.storage.ObjectStorageClient; +import com.stackup.stackup.resume.domain.ResumeUsageChecker; +import com.stackup.stackup.resume.application.dto.ResumeResult; +import com.stackup.stackup.resume.application.dto.ResumeUploadCommand; +import com.stackup.stackup.resume.application.event.ResumeUploadedEvent; +import com.stackup.stackup.resume.domain.Resume; +import com.stackup.stackup.resume.domain.ResumeFileType; +import com.stackup.stackup.resume.domain.ResumeRepository; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ResumeService { + + private static final byte[] PDF_MAGIC = {'%', 'P', 'D', 'F', '-'}; + + private final ResumeRepository resumeRepository; + private final UserRepository userRepository; + private final ResumeUsageChecker resumeUsageChecker; + private final ObjectStorageClient storage; + private final ApplicationEventPublisher events; + private final ResumeProperties resumeProperties; + + @Transactional + public ResumeResult upload(Long userId, ResumeUploadCommand command) { + MultipartFile file = command.file(); + if (file == null || file.isEmpty()) { + throw new DomainException(ApiErrorCode.RESUME_EMPTY_FILE); + } + if (file.getSize() > resumeProperties.maxUploadSize().toBytes()) { + throw new DomainException(ApiErrorCode.RESUME_FILE_TOO_LARGE); + } + if (!isPdfContentType(file.getContentType()) || !hasPdfExtension(file.getOriginalFilename())) { + throw new DomainException(ApiErrorCode.RESUME_INVALID_FILE_TYPE); + } + if (!hasPdfMagicBytes(file)) { + throw new DomainException(ApiErrorCode.RESUME_INVALID_FILE_TYPE); + } + + String key = "resumes/raw/" + userId + "/" + UUID.randomUUID() + ".pdf"; + long size = file.getSize(); + + try (var in = file.getInputStream()) { + storage.put(key, in, size, "application/pdf"); + } catch (java.io.IOException e) { + throw new DomainException(ApiErrorCode.SYS_INTERNAL_ERROR, "Failed to read uploaded file", e); + } + + Resume resume; + try { + User user = userRepository.getReferenceById(userId); + resume = resumeRepository.save(Resume.create( + user, file.getOriginalFilename(), key, size, ResumeFileType.PDF + )); + } catch (RuntimeException dbError) { + safeDelete(key); + throw dbError; + } + + events.publishEvent(new ResumeUploadedEvent(resume.getId(), userId, key)); + return ResumeResult.from(resume); + } + + public org.springframework.data.domain.Page list(Long userId, org.springframework.data.domain.Pageable pageable) { + return resumeRepository.findByUser_IdAndDeletedFalse(userId, pageable) + .map(ResumeResult::from); + } + + public ResumeResult get(Long userId, Long resumeId) { + return resumeRepository.findByIdAndUser_IdAndDeletedFalse(resumeId, userId) + .map(ResumeResult::from) + .orElseThrow(() -> new DomainException(ApiErrorCode.RESUME_NOT_FOUND)); + } + + @Transactional + public void delete(Long userId, Long resumeId) { + Resume resume = resumeRepository.findByIdAndUser_IdAndDeletedFalse(resumeId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.RESUME_NOT_FOUND)); + if (resumeUsageChecker.isInUse(resumeId)) { + throw new DomainException(ApiErrorCode.RESUME_IN_USE); + } + resume.softDelete(); + } + + private void safeDelete(String key) { + try { + storage.delete(key); + } catch (RuntimeException ignored) { + } + } + + private static boolean isPdfContentType(String contentType) { + return contentType != null && contentType.equalsIgnoreCase("application/pdf"); + } + + private static boolean hasPdfExtension(String filename) { + return filename != null && filename.toLowerCase(java.util.Locale.ROOT).endsWith(".pdf"); + } + + private static boolean hasPdfMagicBytes(MultipartFile file) { + try (var in = file.getInputStream()) { + byte[] head = in.readNBytes(PDF_MAGIC.length); + return java.util.Arrays.equals(head, PDF_MAGIC); + } catch (java.io.IOException e) { + throw new DomainException(ApiErrorCode.RESUME_INVALID_FILE_TYPE, "Failed to read uploaded file", e); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeResult.java b/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeResult.java new file mode 100644 index 00000000..83b2b692 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeResult.java @@ -0,0 +1,25 @@ +package com.stackup.stackup.resume.application.dto; + +import com.stackup.stackup.resume.domain.Resume; +import com.stackup.stackup.resume.domain.ResumeStatus; +import java.time.Instant; + +public record ResumeResult( + Long id, + String originalFilename, + Long fileSize, + ResumeStatus status, + Instant createdAt, + Instant updatedAt +) { + public static ResumeResult from(Resume resume) { + return new ResumeResult( + resume.getId(), + resume.getOriginalFilename(), + resume.getFileSize(), + resume.getStatus(), + resume.getCreatedAt(), + resume.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeUploadCommand.java b/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeUploadCommand.java new file mode 100644 index 00000000..ec3d432f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeUploadCommand.java @@ -0,0 +1,6 @@ +package com.stackup.stackup.resume.application.dto; + +import org.springframework.web.multipart.MultipartFile; + +public record ResumeUploadCommand(MultipartFile file) { +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeUploadedEvent.java b/backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeUploadedEvent.java new file mode 100644 index 00000000..5e56a192 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeUploadedEvent.java @@ -0,0 +1,4 @@ +package com.stackup.stackup.resume.application.event; + +public record ResumeUploadedEvent(Long resumeId, Long userId, String s3Key) { +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java index bd07ad0a..378f2f12 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java @@ -53,4 +53,22 @@ public class Resume extends BaseSoftDeleteEntity { @Column(nullable = false, length = 20) @Enumerated(EnumType.STRING) private ResumeStatus status = ResumeStatus.PENDING; + + public static Resume create( + User user, + String originalFilename, + String filePath, + Long fileSize, + ResumeFileType fileType + ) { + Resume resume = new Resume(); + resume.user = user; + resume.originalFilename = originalFilename; + resume.filePath = filePath; + resume.fileSize = fileSize; + resume.fileType = fileType; + resume.status = ResumeStatus.PENDING; + return resume; + } + } diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java index 321a2d2e..d438e342 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java @@ -1,12 +1,13 @@ package com.stackup.stackup.resume.domain; -import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface ResumeRepository extends JpaRepository { - List findByUser_IdAndDeletedFalse(Long userId); + Page findByUser_IdAndDeletedFalse(Long userId, Pageable pageable); Optional findByIdAndUser_IdAndDeletedFalse(Long id, Long userId); } diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeUsageChecker.java b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeUsageChecker.java new file mode 100644 index 00000000..a13ec8fc --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeUsageChecker.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.resume.domain; + +public interface ResumeUsageChecker { + + /** + * Returns true when the resume's analyzed document is referenced by an active + * (READY or IN_PROGRESS) interview session. + */ + boolean isInUse(Long resumeId); +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisher.java b/backend/src/main/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisher.java new file mode 100644 index 00000000..b7261a69 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisher.java @@ -0,0 +1,34 @@ +package com.stackup.stackup.resume.infrastructure; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.resume.application.event.ResumeUploadedEvent; +import com.stackup.stackup.resume.infrastructure.dto.AnalyzeResumePayload; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ResumeAnalysisPublisher { + + private final RabbitMessagePublisher rabbitPublisher; + private final RabbitMqProperties properties; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ResumeUploadedEvent event) { + try { + rabbitPublisher.publishToAi( + properties.routingKeys().analyzeResume(), + new AnalyzeResumePayload(event.resumeId(), event.s3Key()), + Map.of("userId", event.userId()) + ); + } catch (RuntimeException publishError) { + log.warn("Failed to publish analyze.resume for resumeId={}", event.resumeId(), publishError); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/infrastructure/dto/AnalyzeResumePayload.java b/backend/src/main/java/com/stackup/stackup/resume/infrastructure/dto/AnalyzeResumePayload.java new file mode 100644 index 00000000..cc4e97bc --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/infrastructure/dto/AnalyzeResumePayload.java @@ -0,0 +1,4 @@ +package com.stackup.stackup.resume.infrastructure.dto; + +public record AnalyzeResumePayload(Long resumeId, String s3Key) { +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java b/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java new file mode 100644 index 00000000..115e2b96 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java @@ -0,0 +1,78 @@ +package com.stackup.stackup.resume.presentation; + +import com.stackup.stackup.common.response.PageResponse; +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.resume.application.ResumeService; +import com.stackup.stackup.resume.application.dto.ResumeResult; +import com.stackup.stackup.resume.application.dto.ResumeUploadCommand; +import com.stackup.stackup.resume.presentation.dto.ResumeResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.net.URI; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/resumes") +@Tag(name = "Resumes", description = "이력서 업로드 및 관리") +public class ResumeController { + + private final ResumeService resumeService; + + public ResumeController(ResumeService resumeService) { + this.resumeService = resumeService; + } + + @PostMapping + @Operation(summary = "이력서 PDF 업로드") + public ResponseEntity upload( + @AuthenticationPrincipal UserPrincipal principal, + @RequestPart("file") MultipartFile file + ) { + ResumeResult result = resumeService.upload(principal.userId(), new ResumeUploadCommand(file)); + return ResponseEntity + .created(URI.create("/api/resumes/" + result.id())) + .body(ResumeResponse.from(result)); + } + + @GetMapping + @Operation(summary = "내 이력서 목록", description = "페이지 기본값: size=20, sort=createdAt DESC") + public PageResponse list( + @AuthenticationPrincipal UserPrincipal principal, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + return PageResponse.from(resumeService.list(principal.userId(), pageable).map(ResumeResponse::from)); + } + + @GetMapping("/{id}") + @Operation(summary = "이력서 단건 조회") + public ResumeResponse get( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long id + ) { + return ResumeResponse.from(resumeService.get(principal.userId(), id)); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "이력서 소프트 삭제") + public void delete( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long id + ) { + resumeService.delete(principal.userId(), id); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/ResumeResponse.java b/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/ResumeResponse.java new file mode 100644 index 00000000..fa793fd0 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/ResumeResponse.java @@ -0,0 +1,18 @@ +package com.stackup.stackup.resume.presentation.dto; + +import com.stackup.stackup.resume.application.dto.ResumeResult; +import com.stackup.stackup.resume.domain.ResumeStatus; +import java.time.Instant; + +public record ResumeResponse( + Long id, + String originalFilename, + Long fileSize, + ResumeStatus status, + Instant createdAt, + Instant updatedAt +) { + public static ResumeResponse from(ResumeResult r) { + return new ResumeResponse(r.id(), r.originalFilename(), r.fileSize(), r.status(), r.createdAt(), r.updatedAt()); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 323c497d..1d217e10 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -12,8 +12,8 @@ spring: locations: classpath:db/migration servlet: multipart: - max-file-size: 20MB - max-request-size: 20MB + max-file-size: ${app.resume.max-upload-size:20MB} + max-request-size: ${app.resume.max-upload-size:20MB} server: port: ${SERVER_PORT:38010} @@ -35,6 +35,8 @@ springdoc: path: /api/swagger-ui.html app: + resume: + max-upload-size: 20MB security: jwt-secret: ${JWT_SECRET:} access-token-ttl-seconds: ${JWT_ACCESS_TTL_SECONDS:900} diff --git a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java index e1eea7e1..19421b04 100644 --- a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java +++ b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java @@ -2,6 +2,8 @@ import com.stackup.stackup.auth.domain.OAuthStateRepository; import com.stackup.stackup.auth.domain.RefreshTokenRepository; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.resume.domain.ResumeRepository; import com.stackup.stackup.user.domain.UserRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @@ -19,6 +21,12 @@ class StackupApplicationTests { @MockitoBean private UserRepository userRepository; + @MockitoBean + private ResumeRepository resumeRepository; + + @MockitoBean + private AnalyzedDocumentRepository analyzedDocumentRepository; + @Test void contextLoads() { } diff --git a/backend/src/test/java/com/stackup/stackup/common/entity/BaseSoftDeleteEntityTest.java b/backend/src/test/java/com/stackup/stackup/common/entity/BaseSoftDeleteEntityTest.java new file mode 100644 index 00000000..0525e8fb --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/entity/BaseSoftDeleteEntityTest.java @@ -0,0 +1,23 @@ +package com.stackup.stackup.common.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.Entity; +import org.junit.jupiter.api.Test; + +class BaseSoftDeleteEntityTest { + + @Entity + static class Dummy extends BaseSoftDeleteEntity { + } + + @Test + void softDelete_sets_deleted_true() { + Dummy dummy = new Dummy(); + assertThat(dummy.isDeleted()).isFalse(); + + dummy.softDelete(); + + assertThat(dummy.isDeleted()).isTrue(); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/common/exception/GlobalExceptionHandlerMaxUploadSizeTest.java b/backend/src/test/java/com/stackup/stackup/common/exception/GlobalExceptionHandlerMaxUploadSizeTest.java new file mode 100644 index 00000000..708ac9c5 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/exception/GlobalExceptionHandlerMaxUploadSizeTest.java @@ -0,0 +1,27 @@ +package com.stackup.stackup.common.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.common.response.ApiErrorResponse; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +class GlobalExceptionHandlerMaxUploadSizeTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void max_upload_size_exception_maps_to_resume_file_too_large() { + MaxUploadSizeExceededException exception = new MaxUploadSizeExceededException(20L * 1024 * 1024); + + ResponseEntity response = handler.handleMaxUploadSizeExceeded(exception); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + ApiErrorResponse body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body.code()).isEqualTo(ApiErrorCode.RESUME_FILE_TOO_LARGE.name()); + assertThat(body.details()).containsEntry("maxBytes", 20L * 1024 * 1024); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/MessageEnvelopeTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/MessageEnvelopeTest.java new file mode 100644 index 00000000..79526c4a --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/MessageEnvelopeTest.java @@ -0,0 +1,37 @@ +package com.stackup.stackup.common.messaging; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.time.Instant; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class MessageEnvelopeTest { + + @Test + void serialized_envelope_has_required_fields() throws Exception { + ObjectMapper mapper = JsonMapper.builder().addModule(new JavaTimeModule()).build(); + MessageEnvelope envelope = new MessageEnvelope( + "msg-1", + "analyze.resume", + "v1", + "trace-1", + Instant.parse("2026-05-15T10:00:00Z"), + "core-server", + Map.of("resumeId", 42, "s3Key", "resumes/raw/1/abc.pdf"), + Map.of("userId", 1) + ); + + String json = mapper.writeValueAsString(envelope); + + assertThat(json).contains("\"messageId\":\"msg-1\""); + assertThat(json).contains("\"messageType\":\"analyze.resume\""); + assertThat(json).contains("\"version\":\"v1\""); + assertThat(json).contains("\"traceId\":\"trace-1\""); + assertThat(json).contains("\"publishedAt\":\"2026-05-15T10:00:00Z\""); + assertThat(json).contains("\"publisher\":\"core-server\""); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java index 8d602d07..d266173c 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java @@ -36,10 +36,10 @@ void setUp() { void publishToAi_setsEnvelopeMetadataAndRabbitHeaders() { TraceContext.setTraceId("trace-123"); - MessageEnvelope> envelope = rabbitMessagePublisher.publishToAi( + MessageEnvelope envelope = rabbitMessagePublisher.publishToAi( "analyze.resume", Map.of("resumeId", 1L), - MessageContext.ofUser(99L) + Map.of("userId", 99L) ); ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(Object.class); @@ -60,7 +60,7 @@ void publishToAi_setsEnvelopeMetadataAndRabbitHeaders() { assertThat(envelope.traceId()).isEqualTo("trace-123"); assertThat(envelope.publishedAt()).isNotNull(); assertThat(envelope.publisher()).isEqualTo("core-server"); - assertThat(envelope.context().userId()).isEqualTo(99L); + assertThat(envelope.context()).containsEntry("userId", 99L); Message message = postProcessorCaptor.getValue().postProcessMessage( new Message("{}".getBytes(StandardCharsets.UTF_8), new MessageProperties()) diff --git a/backend/src/test/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepositoryTest.java b/backend/src/test/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepositoryTest.java new file mode 100644 index 00000000..6c30f737 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepositoryTest.java @@ -0,0 +1,104 @@ +package com.stackup.stackup.document.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.resume.domain.Resume; +import com.stackup.stackup.resume.domain.ResumeFileType; +import com.stackup.stackup.resume.domain.ResumeRepository; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.amqp.autoconfigure.RabbitAutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest( + classes = AnalyzedDocumentRepositoryTest.TestConfig.class, + webEnvironment = WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.autoconfigure.exclude=", + "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///testdb", + "spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration", + "spring.jpa.open-in-view=false", + "app.security.jwt-secret=test-secret", + "app.security.encryption-key=test-encryption-key", + "app.github.client-id=test", + "app.github.client-secret=test", + "app.github.redirect-uri=http://localhost" +}) +@Transactional +class AnalyzedDocumentRepositoryTest { + + @Configuration + @EnableAutoConfiguration(exclude = { + RabbitAutoConfiguration.class, + SecurityAutoConfiguration.class, + SecurityFilterAutoConfiguration.class, + ServletWebSecurityAutoConfiguration.class + }) + @EntityScan({ + "com.stackup.stackup.user.domain", + "com.stackup.stackup.auth.domain", + "com.stackup.stackup.resume.domain", + "com.stackup.stackup.github.domain", + "com.stackup.stackup.document.domain", + "com.stackup.stackup.session.domain", + "com.stackup.stackup.log.activity.domain", + "com.stackup.stackup.log.ai.domain" + }) + @EnableJpaRepositories(basePackages = "com.stackup.stackup") + static class TestConfig {} + + @Autowired AnalyzedDocumentRepository documentRepository; + @Autowired ResumeRepository resumeRepository; + @Autowired UserRepository userRepository; + @Autowired JdbcTemplate jdbcTemplate; + + @Test + void exists_active_session_context_returns_false_when_no_context() { + User u = userRepository.save(User.createGithubUser(2001L, "u", "u@x", null, "tok")); + Resume r = resumeRepository.save(Resume.create(u, "f.pdf", "k", 1L, ResumeFileType.PDF)); + + assertThat(documentRepository.existsActiveSessionContextForResume(r.getId())).isFalse(); + } + + @Test + void exists_active_session_context_returns_true_when_in_progress_session_references_resume() { + User u = userRepository.save(User.createGithubUser(2002L, "v", "v@x", null, "tok")); + Resume r = resumeRepository.save(Resume.create(u, "f.pdf", "k", 1L, ResumeFileType.PDF)); + + Long docId = jdbcTemplate.queryForObject( + "INSERT INTO analyzed_documents (resume_id, document_path, status, created_at, updated_at, is_deleted) " + + "VALUES (?, 'analyzed/resume/x/summary.md', 'ACTIVE', NOW(), NOW(), FALSE) RETURNING id", + Long.class, r.getId() + ); + Long sessionId = jdbcTemplate.queryForObject( + "INSERT INTO interview_sessions (user_id, mode, interview_type, job_category, max_questions, " + + "max_duration_minutes, status, total_question_count, created_at, updated_at, is_deleted) " + + "VALUES (?, 'ONLINE', 'TECHNICAL', 'BACKEND', 10, 60, 'IN_PROGRESS', 0, NOW(), NOW(), FALSE) " + + "RETURNING id", + Long.class, u.getId() + ); + jdbcTemplate.update( + "INSERT INTO session_contexts (session_id, document_id, created_at) VALUES (?, ?, NOW())", + sessionId, docId + ); + + assertThat(documentRepository.existsActiveSessionContextForResume(r.getId())).isTrue(); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/resume/application/ResumeServiceTest.java b/backend/src/test/java/com/stackup/stackup/resume/application/ResumeServiceTest.java new file mode 100644 index 00000000..67501f49 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/resume/application/ResumeServiceTest.java @@ -0,0 +1,236 @@ +package com.stackup.stackup.resume.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.config.properties.ResumeProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.storage.ObjectStorageClient; +import com.stackup.stackup.resume.domain.ResumeUsageChecker; +import com.stackup.stackup.resume.application.dto.ResumeUploadCommand; +import com.stackup.stackup.resume.domain.ResumeRepository; +import com.stackup.stackup.user.domain.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.util.unit.DataSize; + +class ResumeServiceTest { + + private ResumeRepository resumeRepository; + private UserRepository userRepository; + private ResumeUsageChecker resumeUsageChecker; + private ObjectStorageClient storage; + private ApplicationEventPublisher events; + private ResumeProperties resumeProperties; + private ResumeService service; + + @BeforeEach + void setUp() { + resumeRepository = Mockito.mock(ResumeRepository.class); + userRepository = Mockito.mock(UserRepository.class); + resumeUsageChecker = Mockito.mock(ResumeUsageChecker.class); + storage = Mockito.mock(ObjectStorageClient.class); + events = Mockito.mock(ApplicationEventPublisher.class); + resumeProperties = new ResumeProperties(DataSize.ofMegabytes(20)); + service = new ResumeService(resumeRepository, userRepository, resumeUsageChecker, storage, events, resumeProperties); + } + + @Test + void upload_rejects_empty_file() { + MockMultipartFile empty = new MockMultipartFile("file", "x.pdf", "application/pdf", new byte[0]); + + assertThatThrownBy(() -> service.upload(1L, new ResumeUploadCommand(empty))) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.RESUME_EMPTY_FILE); + + verify(storage, never()).put(anyString(), any(), anyLong(), anyString()); + verify(resumeRepository, never()).save(any()); + } + + @Test + void upload_rejects_wrong_content_type() { + MockMultipartFile png = new MockMultipartFile("file", "x.pdf", "image/png", new byte[]{1, 2, 3}); + assertThatThrownBy(() -> service.upload(1L, new ResumeUploadCommand(png))) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.RESUME_INVALID_FILE_TYPE); + } + + @Test + void upload_rejects_non_pdf_extension() { + MockMultipartFile doc = new MockMultipartFile("file", "x.doc", "application/pdf", new byte[]{1, 2, 3}); + assertThatThrownBy(() -> service.upload(1L, new ResumeUploadCommand(doc))) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.RESUME_INVALID_FILE_TYPE); + } + + @Test + void upload_rejects_over_20mb_file() { + byte[] big = new byte[20 * 1024 * 1024 + 1]; + big[0]='%'; big[1]='P'; big[2]='D'; big[3]='F'; big[4]='-'; + MockMultipartFile huge = new MockMultipartFile("file", "x.pdf", "application/pdf", big); + assertThatThrownBy(() -> service.upload(1L, new ResumeUploadCommand(huge))) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.RESUME_FILE_TOO_LARGE); + } + + @Test + void upload_rejects_file_without_pdf_magic_bytes() { + byte[] bogus = "not a pdf at all".getBytes(); + MockMultipartFile fake = new MockMultipartFile("file", "x.pdf", "application/pdf", bogus); + assertThatThrownBy(() -> service.upload(1L, new ResumeUploadCommand(fake))) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.RESUME_INVALID_FILE_TYPE); + } + + @Test + void upload_persists_resume_and_publishes_event() { + byte[] pdf = new byte[]{'%', 'P', 'D', 'F', '-', '1', '.', '7'}; + org.springframework.mock.web.MockMultipartFile file = new org.springframework.mock.web.MockMultipartFile( + "file", "resume.pdf", "application/pdf", pdf + ); + + com.stackup.stackup.user.domain.User user = Mockito.mock(com.stackup.stackup.user.domain.User.class); + when(user.getId()).thenReturn(42L); + when(userRepository.getReferenceById(42L)).thenReturn(user); + + when(resumeRepository.save(any())).thenAnswer(inv -> { + com.stackup.stackup.resume.domain.Resume r = inv.getArgument(0); + org.springframework.test.util.ReflectionTestUtils.setField(r, "id", 7L); + org.springframework.test.util.ReflectionTestUtils.setField(r, "createdAt", java.time.Instant.now()); + org.springframework.test.util.ReflectionTestUtils.setField(r, "updatedAt", java.time.Instant.now()); + return r; + }); + + com.stackup.stackup.resume.application.dto.ResumeResult result = + service.upload(42L, new ResumeUploadCommand(file)); + + assertThat(result.id()).isEqualTo(7L); + assertThat(result.status()).isEqualTo(com.stackup.stackup.resume.domain.ResumeStatus.PENDING); + + org.mockito.ArgumentCaptor keyCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(storage).put(keyCaptor.capture(), any(), eq((long) pdf.length), eq("application/pdf")); + assertThat(keyCaptor.getValue()).startsWith("resumes/raw/42/").endsWith(".pdf"); + + org.mockito.ArgumentCaptor eventCaptor = + org.mockito.ArgumentCaptor.forClass(com.stackup.stackup.resume.application.event.ResumeUploadedEvent.class); + verify(events).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().resumeId()).isEqualTo(7L); + assertThat(eventCaptor.getValue().userId()).isEqualTo(42L); + } + + @Test + void list_returns_page_of_user_resumes() { + var pageable = org.springframework.data.domain.PageRequest.of(0, 20); + com.stackup.stackup.resume.domain.Resume r = com.stackup.stackup.resume.domain.Resume.create( + Mockito.mock(com.stackup.stackup.user.domain.User.class), + "r.pdf", "k", 1L, com.stackup.stackup.resume.domain.ResumeFileType.PDF + ); + org.springframework.test.util.ReflectionTestUtils.setField(r, "id", 1L); + var page = new org.springframework.data.domain.PageImpl<>(java.util.List.of(r), pageable, 1); + + when(resumeRepository.findByUser_IdAndDeletedFalse(eq(42L), eq(pageable))).thenReturn(page); + + var result = service.list(42L, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).id()).isEqualTo(1L); + } + + @Test + void get_throws_when_not_found_or_other_user() { + when(resumeRepository.findByIdAndUser_IdAndDeletedFalse(1L, 42L)).thenReturn(java.util.Optional.empty()); + + assertThatThrownBy(() -> service.get(42L, 1L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.RESUME_NOT_FOUND); + } + + @Test + void get_returns_resume_when_owner() { + com.stackup.stackup.resume.domain.Resume r = com.stackup.stackup.resume.domain.Resume.create( + Mockito.mock(com.stackup.stackup.user.domain.User.class), + "r.pdf", "k", 1L, com.stackup.stackup.resume.domain.ResumeFileType.PDF + ); + org.springframework.test.util.ReflectionTestUtils.setField(r, "id", 1L); + when(resumeRepository.findByIdAndUser_IdAndDeletedFalse(1L, 42L)).thenReturn(java.util.Optional.of(r)); + + var result = service.get(42L, 1L); + + assertThat(result.id()).isEqualTo(1L); + } + + @Test + void delete_throws_resume_not_found_for_unknown() { + when(resumeRepository.findByIdAndUser_IdAndDeletedFalse(1L, 42L)).thenReturn(java.util.Optional.empty()); + + assertThatThrownBy(() -> service.delete(42L, 1L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.RESUME_NOT_FOUND); + } + + @Test + void delete_throws_resume_in_use_when_active_session_references_it() { + com.stackup.stackup.resume.domain.Resume r = com.stackup.stackup.resume.domain.Resume.create( + Mockito.mock(com.stackup.stackup.user.domain.User.class), + "r.pdf", "k", 1L, com.stackup.stackup.resume.domain.ResumeFileType.PDF + ); + org.springframework.test.util.ReflectionTestUtils.setField(r, "id", 1L); + when(resumeRepository.findByIdAndUser_IdAndDeletedFalse(1L, 42L)).thenReturn(java.util.Optional.of(r)); + when(resumeUsageChecker.isInUse(1L)).thenReturn(true); + + assertThatThrownBy(() -> service.delete(42L, 1L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.RESUME_IN_USE); + } + + @Test + void delete_marks_resume_deleted_when_no_active_session() { + com.stackup.stackup.resume.domain.Resume r = com.stackup.stackup.resume.domain.Resume.create( + Mockito.mock(com.stackup.stackup.user.domain.User.class), + "r.pdf", "k", 1L, com.stackup.stackup.resume.domain.ResumeFileType.PDF + ); + org.springframework.test.util.ReflectionTestUtils.setField(r, "id", 1L); + when(resumeRepository.findByIdAndUser_IdAndDeletedFalse(1L, 42L)).thenReturn(java.util.Optional.of(r)); + when(resumeUsageChecker.isInUse(1L)).thenReturn(false); + + service.delete(42L, 1L); + + assertThat(r.isDeleted()).isTrue(); + } + + @Test + void upload_accepts_file_exactly_at_20mb_boundary() { + byte[] pdf = new byte[20 * 1024 * 1024]; + pdf[0]='%'; pdf[1]='P'; pdf[2]='D'; pdf[3]='F'; pdf[4]='-'; + org.springframework.mock.web.MockMultipartFile file = + new org.springframework.mock.web.MockMultipartFile("file", "big.pdf", "application/pdf", pdf); + + com.stackup.stackup.user.domain.User user = Mockito.mock(com.stackup.stackup.user.domain.User.class); + Mockito.when(user.getId()).thenReturn(42L); + Mockito.when(userRepository.getReferenceById(42L)).thenReturn(user); + Mockito.when(resumeRepository.save(any())).thenAnswer(inv -> { + com.stackup.stackup.resume.domain.Resume r = inv.getArgument(0); + org.springframework.test.util.ReflectionTestUtils.setField(r, "id", 99L); + org.springframework.test.util.ReflectionTestUtils.setField(r, "createdAt", java.time.Instant.now()); + org.springframework.test.util.ReflectionTestUtils.setField(r, "updatedAt", java.time.Instant.now()); + return r; + }); + + com.stackup.stackup.resume.application.dto.ResumeResult result = + service.upload(42L, new ResumeUploadCommand(file)); + + assertThat(result.id()).isEqualTo(99L); + assertThat(result.status()).isEqualTo(com.stackup.stackup.resume.domain.ResumeStatus.PENDING); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/resume/domain/ResumeRepositoryTest.java b/backend/src/test/java/com/stackup/stackup/resume/domain/ResumeRepositoryTest.java new file mode 100644 index 00000000..384d6247 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/resume/domain/ResumeRepositoryTest.java @@ -0,0 +1,87 @@ +package com.stackup.stackup.resume.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.amqp.autoconfigure.RabbitAutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest( + classes = ResumeRepositoryTest.TestConfig.class, + webEnvironment = WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.autoconfigure.exclude=", + "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///testdb", + "spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration", + "spring.jpa.open-in-view=false", + "app.security.jwt-secret=test-secret", + "app.security.encryption-key=test-encryption-key", + "app.github.client-id=test", + "app.github.client-secret=test", + "app.github.redirect-uri=http://localhost" +}) +@Transactional +class ResumeRepositoryTest { + + @Configuration + @EnableAutoConfiguration(exclude = { + RabbitAutoConfiguration.class, + SecurityAutoConfiguration.class, + SecurityFilterAutoConfiguration.class, + ServletWebSecurityAutoConfiguration.class + }) + @EntityScan({ + "com.stackup.stackup.user.domain", + "com.stackup.stackup.auth.domain", + "com.stackup.stackup.resume.domain", + "com.stackup.stackup.github.domain", + "com.stackup.stackup.document.domain", + "com.stackup.stackup.session.domain", + "com.stackup.stackup.log.activity.domain", + "com.stackup.stackup.log.ai.domain" + }) + @EnableJpaRepositories(basePackages = "com.stackup.stackup") + static class TestConfig {} + + @Autowired ResumeRepository resumeRepository; + @Autowired UserRepository userRepository; + + @Test + void page_finder_excludes_deleted_and_other_users() { + User a = userRepository.save(User.createGithubUser(1001L, "alice", "a@x", null, "encrypted-token-1")); + User b = userRepository.save(User.createGithubUser(1002L, "bob", "b@x", null, "encrypted-token-2")); + resumeRepository.save(Resume.create(a, "a1.pdf", "k1", 1L, ResumeFileType.PDF)); + Resume a2 = resumeRepository.save(Resume.create(a, "a2.pdf", "k2", 1L, ResumeFileType.PDF)); + a2.softDelete(); + resumeRepository.save(a2); + resumeRepository.save(Resume.create(b, "b1.pdf", "k3", 1L, ResumeFileType.PDF)); + + Page page = resumeRepository.findByUser_IdAndDeletedFalse( + a.getId(), + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + + assertThat(page.getTotalElements()).isEqualTo(1); + assertThat(page.getContent()).extracting(Resume::getOriginalFilename).containsExactly("a1.pdf"); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/resume/domain/ResumeTest.java b/backend/src/test/java/com/stackup/stackup/resume/domain/ResumeTest.java new file mode 100644 index 00000000..04ad7cf0 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/resume/domain/ResumeTest.java @@ -0,0 +1,42 @@ +package com.stackup.stackup.resume.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.user.domain.User; +import org.junit.jupiter.api.Test; + +class ResumeTest { + + @Test + void create_initializes_pending_resume() { + User user = mock(User.class); + Resume resume = Resume.create( + user, + "ada-resume.pdf", + "resumes/raw/1/uuid.pdf", + 1_048_576L, + ResumeFileType.PDF + ); + + assertThat(resume.getOriginalFilename()).isEqualTo("ada-resume.pdf"); + assertThat(resume.getFilePath()).isEqualTo("resumes/raw/1/uuid.pdf"); + assertThat(resume.getFileSize()).isEqualTo(1_048_576L); + assertThat(resume.getFileType()).isEqualTo(ResumeFileType.PDF); + assertThat(resume.getStatus()).isEqualTo(ResumeStatus.PENDING); + assertThat(resume.isDeleted()).isFalse(); + } + + @Test + void softDelete_sets_deleted_true() { + User user = mock(User.class); + Resume resume = Resume.create(user, "f.pdf", "k", 1L, ResumeFileType.PDF); + + resume.softDelete(); + + assertThat(resume.isDeleted()).isTrue(); + } + + private static User mock(Class clazz) { + return org.mockito.Mockito.mock(clazz); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisherTest.java b/backend/src/test/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisherTest.java new file mode 100644 index 00000000..c9135976 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisherTest.java @@ -0,0 +1,42 @@ +package com.stackup.stackup.resume.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.resume.application.event.ResumeUploadedEvent; +import com.stackup.stackup.resume.infrastructure.dto.AnalyzeResumePayload; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +class ResumeAnalysisPublisherTest { + + @Test + void delegates_to_rabbit_message_publisher_with_analyze_resume_routing_key() { + RabbitMessagePublisher rabbitPublisher = Mockito.mock(RabbitMessagePublisher.class); + RabbitMqProperties props = Mockito.mock(RabbitMqProperties.class, Mockito.RETURNS_DEEP_STUBS); + Mockito.when(props.routingKeys().analyzeResume()).thenReturn("analyze.resume"); + + ResumeAnalysisPublisher publisher = new ResumeAnalysisPublisher(rabbitPublisher, props); + + publisher.handle(new ResumeUploadedEvent(7L, 42L, "resumes/raw/42/abc.pdf")); + + ArgumentCaptor routingKeyCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(Object.class); + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Map.class); + + verify(rabbitPublisher).publishToAi(routingKeyCaptor.capture(), payloadCaptor.capture(), contextCaptor.capture()); + + assertThat(routingKeyCaptor.getValue()).isEqualTo("analyze.resume"); + assertThat(payloadCaptor.getValue()).isInstanceOf(AnalyzeResumePayload.class); + AnalyzeResumePayload payload = (AnalyzeResumePayload) payloadCaptor.getValue(); + assertThat(payload.resumeId()).isEqualTo(7L); + assertThat(payload.s3Key()).isEqualTo("resumes/raw/42/abc.pdf"); + assertThat(contextCaptor.getValue()).containsEntry("userId", 42L); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/resume/presentation/ResumeControllerTest.java b/backend/src/test/java/com/stackup/stackup/resume/presentation/ResumeControllerTest.java new file mode 100644 index 00000000..d0a4e6cd --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/resume/presentation/ResumeControllerTest.java @@ -0,0 +1,152 @@ +package com.stackup.stackup.resume.presentation; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.exception.GlobalExceptionHandler; +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.resume.application.ResumeService; +import com.stackup.stackup.resume.application.dto.ResumeResult; +import com.stackup.stackup.resume.domain.ResumeStatus; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith(MockitoExtension.class) +class ResumeControllerTest { + + @Mock + ResumeService resumeService; + + @InjectMocks + ResumeController controller; + + MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers( + new AuthenticationPrincipalArgumentResolver(), + new PageableHandlerMethodArgumentResolver() + ) + .build(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private void authenticateAs(long userId) { + UserPrincipal principal = new UserPrincipal(userId, "alice", List.of()); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + @Test + void upload_returns_201_with_location_header() throws Exception { + authenticateAs(42L); + MockMultipartFile file = new MockMultipartFile( + "file", "resume.pdf", "application/pdf", new byte[]{'%', 'P', 'D', 'F'} + ); + ResumeResult result = new ResumeResult( + 7L, "resume.pdf", 4L, ResumeStatus.PENDING, + Instant.parse("2026-05-15T10:00:00Z"), Instant.parse("2026-05-15T10:00:00Z") + ); + when(resumeService.upload(eq(42L), any())).thenReturn(result); + + mockMvc.perform(multipart("/api/resumes") + .file(file) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/resumes/7")) + .andExpect(jsonPath("$.id").value(7)) + .andExpect(jsonPath("$.status").value("PENDING")); + } + + @Test + void list_returns_paged_response() throws Exception { + authenticateAs(42L); + ResumeResult r = new ResumeResult(1L, "a.pdf", 1L, ResumeStatus.ANALYZED, + Instant.parse("2026-05-15T10:00:00Z"), Instant.parse("2026-05-15T10:00:00Z")); + var page = new org.springframework.data.domain.PageImpl<>(java.util.List.of(r), + org.springframework.data.domain.PageRequest.of(0, 20), 1); + when(resumeService.list(eq(42L), any())).thenReturn(page); + + mockMvc.perform(get("/api/resumes?page=0&size=20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.content[0].id").value(1)) + .andExpect(jsonPath("$.first").value(true)) + .andExpect(jsonPath("$.last").value(true)); + } + + @Test + void get_single_returns_resume() throws Exception { + authenticateAs(42L); + ResumeResult r = new ResumeResult(1L, "a.pdf", 1L, ResumeStatus.PENDING, + Instant.now(), Instant.now()); + when(resumeService.get(42L, 1L)).thenReturn(r); + + mockMvc.perform(get("/api/resumes/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.originalFilename").value("a.pdf")); + } + + @Test + void delete_returns_204() throws Exception { + authenticateAs(42L); + mockMvc.perform(delete("/api/resumes/1")) + .andExpect(status().isNoContent()); + org.mockito.Mockito.verify(resumeService).delete(42L, 1L); + } + + @Test + void get_single_returns_404_when_not_owner_or_missing() throws Exception { + authenticateAs(42L); + org.mockito.Mockito.when(resumeService.get(42L, 999L)) + .thenThrow(new DomainException(ApiErrorCode.RESUME_NOT_FOUND)); + + mockMvc.perform(get("/api/resumes/999")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESUME_NOT_FOUND")); + } + + @Test + void delete_returns_409_when_resume_in_use() throws Exception { + authenticateAs(42L); + org.mockito.Mockito.doThrow(new DomainException(ApiErrorCode.RESUME_IN_USE)) + .when(resumeService).delete(42L, 1L); + + mockMvc.perform(delete("/api/resumes/1")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("RESUME_IN_USE")); + } +}