Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ out/
### VS Code ###
.vscode/

src/main/resources/application.properties
src/test/resources/application-test.properties

# Spring Boot
*.yml
*.yaml

src/main/resources/application.properties
18 changes: 18 additions & 0 deletions src/main/java/com/example/issueDive/config/QueryDslConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.example.issueDive.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager em;

@Bean
public JPAQueryFactory queryFactory() {
return new JPAQueryFactory(em);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package com.example.issueDive.controller;

import com.example.issueDive.dto.CreateIssueRequest;
import com.example.issueDive.dto.IssueResponse;
import com.example.issueDive.dto.UpdateIssueRequest;
import com.example.issueDive.dto.*;
import com.example.issueDive.service.IssueService;
import com.example.issueDive.dto.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
Expand All @@ -32,7 +31,28 @@ public ResponseEntity<ApiResponse<IssueResponse>> createIssue(@RequestBody Creat
}

/**
* Read
* Read: 다중 조회(필터링, 페이징)
* @param filter status, authorId, labelIds, page, size, sort, order
* @return 공통 응답 포맷 + 조회한 이슈 dto 리스트(페이지)
*/
@GetMapping
public ResponseEntity<ApiResponse<Page<IssueResponse>>> getIssues(@Valid IssueFilterRequest filter) {
Page<IssueResponse> issue = issueService.getFilteredIssues(
new IssueFilterRequest(
filter.status(),
filter.authorId(),
filter.assigneeId(),
filter.labelIds(),
filter.page() != null ? filter.page() : 0,
filter.size() != null ? filter.size() : 10,
filter.sort() != null ? filter.sort() : "createdAt",
filter.order() != null ? filter.order() : "desc"
));
return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.ok(issue));
}

/**
* Read: 단건 조회
* @param id 조회할 이슈
* @return 공통 응답 포맷 + 해당 이슈 dto
*/
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/com/example/issueDive/dto/IssueFilterRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.example.issueDive.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;

import java.util.List;

public record IssueFilterRequest(
@Pattern(regexp = "open|closed|OPEN|CLOSED") String status,
Long authorId,
Long assigneeId,
List<Long> labelIds,
@Min(0) Integer page,
@Min(1) Integer size,
@Pattern(regexp = "createdAt|updatedAt") String sort,
@Pattern(regexp = "asc|desc|ASC|DESC") String order
) {}
2 changes: 2 additions & 0 deletions src/main/java/com/example/issueDive/dto/IssueResponse.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.issueDive.dto;

import java.time.LocalDateTime;
import java.util.List;

public record IssueResponse(
Long id,
Expand All @@ -9,6 +10,7 @@ public record IssueResponse(
String status,
Long authorId,
Long assigneeId,
List<Long> labelIds,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/com/example/issueDive/entity/Issue.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import jakarta.persistence.*;
import lombok.*;

import java.awt.*;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;

@RequiredArgsConstructor
@AllArgsConstructor
Expand Down Expand Up @@ -45,4 +48,12 @@ public class Issue {
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")
private LocalDateTime updatedAt;

@Builder.Default
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "issue_label",
joinColumns = @JoinColumn(name = "issue_id"),
inverseJoinColumns = @JoinColumn(name = "label_id")
)
private Set<Label> labels = new HashSet<>();
}
20 changes: 12 additions & 8 deletions src/main/java/com/example/issueDive/entity/Label.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import lombok.*;

import java.time.LocalDateTime;

@Entity
Expand All @@ -12,7 +11,9 @@
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "label")
public class Label {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Expand All @@ -28,20 +29,23 @@ public class Label {
@Column(length = 200)
private String description;

@Column(nullable = false, updatable = false)
private LocalDateTime created_at;
@Column(name = "created_at", updatable = false, insertable = false,
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
private LocalDateTime createdAt;

@Column(nullable = false)
private LocalDateTime updated_at;
@Column(name = "updated_at", insertable = false,
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")
private LocalDateTime updatedAt;

@PrePersist
public void prePersist() {
this.created_at = LocalDateTime.now();
this.updated_at = this.created_at;
LocalDateTime now = LocalDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}

@PreUpdate
public void preUpdate() {
this.updated_at = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
}
54 changes: 51 additions & 3 deletions src/main/java/com/example/issueDive/service/IssueService.java
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
package com.example.issueDive.service;

import com.example.issueDive.dto.CreateIssueRequest;
import com.example.issueDive.dto.IssueFilterRequest;
import com.example.issueDive.dto.IssueResponse;
import com.example.issueDive.dto.UpdateIssueRequest;
import com.example.issueDive.entity.Issue;
import com.example.issueDive.entity.IssueStatus;
import com.example.issueDive.entity.User;
import com.example.issueDive.entity.*;
import com.example.issueDive.exception.ErrorCode;
import com.example.issueDive.exception.NotFoundException;
import com.example.issueDive.exception.ValidationException;
import com.example.issueDive.repository.IssueRepository;
import com.example.issueDive.repository.UserRepository;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class IssueService {

private final JPAQueryFactory queryFactory;
private final QIssue qIssue = QIssue.issue;
private final IssueRepository issueRepository;
private final UserRepository userRepository; // 작성자/담당자 유효성 검증용

Expand Down Expand Up @@ -48,6 +55,44 @@ public IssueResponse createIssue(CreateIssueRequest request, Long authorId) {
return toResponse(saved);
}

/**
* 다중 조회 (필터링, 페이징)
* @param filter status, authorId, labelIds, page, size, sort, order
* @return 필터링, 페이징 등 적용된 이슈 dto 리스트(페이지)
*/
public Page<IssueResponse> getFilteredIssues(IssueFilterRequest filter) {
// QueryDSL이나 Criteria API 등으로 동적 쿼리 작성

// 동적 조건
BooleanBuilder builder = new BooleanBuilder();
if (filter.status() != null) builder.and(qIssue.status.eq(IssueStatus.valueOf(filter.status().toUpperCase())));
if (filter.authorId()!=null) builder.and(qIssue.author.id.eq(filter.authorId()));
if (filter.assigneeId()!=null) builder.and(qIssue.assignee.id.eq(filter.assigneeId()));
if (filter.labelIds()!=null && !filter.labelIds().isEmpty()) builder.and(qIssue.labels.any().id.in(filter.labelIds()));

// 페이징 객체
int page = filter.page();
int size = filter.size();
Sort sort = Sort.by(Sort.Direction.fromString(filter.order()), filter.sort());
Pageable pageable = PageRequest.of(page, size, sort);

// 쿼리 실행
List<Issue> issues = queryFactory
.selectFrom(qIssue)
.leftJoin(qIssue.labels).fetchJoin() // .leftJoin(qIssue.labels, QLabel.label).fetchJoin()
.where(builder)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy("asc".equalsIgnoreCase(filter.order())?qIssue.createdAt.asc() : qIssue.createdAt.desc())
.fetch();

long total = queryFactory.selectFrom(qIssue).where(builder).fetchCount(); // 전체 카운트 조회 (페이징 정보용)
List<IssueResponse> dtoList = issues.stream().map(this::toResponse).toList(); // Entity -> DTO 변환

// 페이지 결과 반환
return new PageImpl<>(dtoList, pageable, total);
}

/**
* 단일 조회
* @param id 조회할 이슈 id
Expand Down Expand Up @@ -116,13 +161,16 @@ public void deleteIssue(Long id) {
}

private IssueResponse toResponse(Issue issue) {
List<Long> labelIds = issue.getLabels().stream().map(Label::getId).toList();

return new IssueResponse(
issue.getId(),
issue.getTitle(),
issue.getDescription(),
issue.getStatus().name(),
issue.getAuthor().getId(),
issue.getAssignee() != null ? issue.getAssignee().getId() : null,
labelIds,
issue.getCreatedAt(),
issue.getUpdatedAt()
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,88 @@
package com.example.issueDive;

import com.example.issueDive.entity.Issue;
import com.example.issueDive.entity.IssueStatus;
import com.example.issueDive.entity.User;
import com.example.issueDive.repository.IssueRepository;
import com.example.issueDive.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

@Import(TestcontainersConfiguration.class)
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
* 통합 테스트 (실제 스프링 컨텍스트 + H2 DB/test container)
*/
//@Import(TestcontainersConfiguration.class)
@SpringBootTest
@ActiveProfiles("test") // test 프로파일 적용
@AutoConfigureMockMvc // 통합 테스트 MockMvc (addFilters = false)
class IssueDiveApplicationTests {

@Autowired
private MockMvc mockMvc;

@Autowired
private IssueRepository issueRepository;

@Autowired
private UserRepository userRepository;

@BeforeEach
void setUp() {
// 각 테스트 실행 전에 데이터베이스를 비움
issueRepository.deleteAll();
userRepository.deleteAll();
}

@Test
void contextLoads() {
}

/**
* 다중 조회 (필터링, 페이징) 통합 테스트
*/
@Test
@WithMockUser
void getFilteredIssues_integrationTest() throws Exception {
// given: DB에 테스트용 데이터를 저장
User author = userRepository.save(User.builder().username("author").email("author@example.com").password("password").build());
User assignee = userRepository.save(User.builder().username("assignee").email("assignee@example.com").password("password").build());
issueRepository.save(Issue.builder()
.title("작성자가 지정된 오픈 이슈")
.status(IssueStatus.OPEN)
.author(author)
.assignee(assignee)
.build());
issueRepository.save(Issue.builder()
.title("닫힌 이슈")
.status(IssueStatus.CLOSED)
.author(author)
.build());

// when: API를 호출해 필터링된 결과 요청
mockMvc.perform(get("/issues")
.param("status", "OPEN") // OPEN 상태인 이슈만 필터링
.param("authorId", author.getId().toString())
.param("assigneeId", assignee.getId().toString())
.param("page", "0"))
.andDo(print()) // 응답 내용을 콘솔에 출력
// then: 실제 DB에서 조회된 결과 검증
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.content").isArray())
.andExpect(jsonPath("$.data.totalElements").value(1)) // 1개의 결과만 나와야 함
.andExpect(jsonPath("$.data.content[0].title").value("작성자가 지정된 오픈 이슈"));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration(proxyBeanMethods = false)
// @TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {

@Bean
Expand Down
Loading