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
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package commonly.commonlybe.certificate.controller;

import commonly.commonlybe.certificate.controller.dto.IssuanceHistoryResponse;
import commonly.commonlybe.certificate.service.QueryIssuanceHistoryService;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/issuance-histories")
@RequiredArgsConstructor
public class IssuanceHistoryController {
private final QueryIssuanceHistoryService queryIssuanceHistoryService;

@GetMapping
public List<IssuanceHistoryResponse> queryIssuanceHistories(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "10") @Min(1) @Max(100) int size,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) String keyword) {
return queryIssuanceHistoryService.execute(page, size, startDate, endDate, keyword);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package commonly.commonlybe.certificate.controller.dto;

import java.time.LocalDateTime;

public record IssuanceHistoryResponse(
Long issuanceHistoryId,
String documentNo,
Long humanId,
String targetName,
String purpose,
int totalMonths,
int totalDays,
LocalDateTime issuedAt
) {
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
package commonly.commonlybe.certificate.repository;

import commonly.commonlybe.certificate.controller.dto.IssuanceHistoryResponse;
import commonly.commonlybe.certificate.entity.CertificateIssuedEntity;
import java.time.LocalDateTime;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface CertificateIssuedRepository extends JpaRepository<CertificateIssuedEntity, Long> {

@Query("""
select new commonly.commonlybe.certificate.controller.dto.IssuanceHistoryResponse(
i.certificateIssuedId, i.documentNo, i.humanId, h.name, i.purpose,
i.totalMonths, i.totalDays, i.issuedAt)
from CertificateIssuedEntity i
join HumanEntity h on h.humanId = i.humanId
where h.name like concat('%', :keyword, '%')
and i.issuedAt >= :start and i.issuedAt < :end
order by i.issuedAt desc
""")
Page<IssuanceHistoryResponse> searchHistories(@Param("keyword") String keyword,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package commonly.commonlybe.certificate.service;

import commonly.commonlybe.certificate.controller.dto.IssuanceHistoryResponse;
import commonly.commonlybe.certificate.repository.CertificateIssuedRepository;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class QueryIssuanceHistoryService {
private static final LocalDate MIN_DATE = LocalDate.of(1970, 1, 1);
private static final LocalDate MAX_DATE = LocalDate.of(9999, 12, 31);

private final CertificateIssuedRepository certificateIssuedRepository;

@Transactional(readOnly = true)
public List<IssuanceHistoryResponse> execute(int page, int size, LocalDate startDate,
LocalDate endDate, String keyword) {
Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size);
LocalDateTime start = (startDate == null ? MIN_DATE : startDate).atStartOfDay();
LocalDateTime end = (endDate == null ? MAX_DATE : endDate.plusDays(1)).atStartOfDay();

return certificateIssuedRepository
.searchHistories(keyword == null ? "" : keyword, start, end, pageable)
.getContent();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
auth
.requestMatchers("/api/auths/login", "/api/auths/signup").permitAll()
.requestMatchers("/api/admin/password").hasAnyAuthority("ADMIN", "USER")
.requestMatchers("/api/admin/**", "/api/admins").hasAuthority("ADMIN");
.requestMatchers("/api/admin/**", "/api/admins").hasAuthority("ADMIN")
.requestMatchers("/api/issuance-histories").hasAnyAuthority("ADMIN", "USER");

// 본인 발급. 담당자는 /api/certificates를 쓴다.
// 신원 검증이 붙기 전까지는 기본 차단이고, 명시적으로 켠 환경에서만 열린다.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package commonly.commonlybe.domain.certificate;

import commonly.commonlybe.certificate.entity.CertificateIssuedEntity;
import commonly.commonlybe.certificate.repository.CertificateIssuedRepository;
import commonly.commonlybe.domain.admin.domain.Admin;
import commonly.commonlybe.domain.admin.domain.AdminRole;
import commonly.commonlybe.domain.admin.domain.repository.AdminRepository;
import commonly.commonlybe.domain.user.domain.User;
import commonly.commonlybe.domain.user.domain.repository.UserRepository;
import commonly.commonlybe.human.entity.Gender;
import commonly.commonlybe.human.entity.HumanEntity;
import commonly.commonlybe.human.repository.HumanRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import tools.jackson.databind.ObjectMapper;

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

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class IssuanceHistoryApiTest {

@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private UserRepository userRepository;
@Autowired private AdminRepository adminRepository;
@Autowired private HumanRepository humanRepository;
@Autowired private CertificateIssuedRepository certificateIssuedRepository;
@Autowired private PasswordEncoder passwordEncoder;

private String adminToken;

@BeforeEach
void setUp() throws Exception {
if (userRepository.findByAccountId("histadmin").isEmpty()) {
User user = userRepository.save(User.builder()
.accountId("histadmin").password(passwordEncoder.encode("password123"))
.name("관리자").build());
adminRepository.save(Admin.builder().user(user).department("미배정").role(AdminRole.ADMIN).build());

HumanEntity hong = humanRepository.save(HumanEntity.builder()
.name("홍길동").gender(Gender.MALE).birthDate(LocalDate.of(1990, 1, 1)).build());
HumanEntity kim = humanRepository.save(HumanEntity.builder()
.name("김철수").gender(Gender.MALE).birthDate(LocalDate.of(1985, 5, 5)).build());

certificateIssuedRepository.save(CertificateIssuedEntity.builder()
.humanId(hong.getHumanId()).documentNo("유성구-2026-000001").purpose("은행 제출")
.totalMonths(12).totalDays(3).issuedAt(LocalDateTime.of(2026, 8, 20, 10, 0))
.certificateIds(List.of(1L)).build());
certificateIssuedRepository.save(CertificateIssuedEntity.builder()
.humanId(kim.getHumanId()).documentNo("유성구-2026-000002").purpose("이직 제출")
.totalMonths(6).totalDays(0).issuedAt(LocalDateTime.of(2026, 8, 25, 14, 0))
.certificateIds(List.of(2L)).build());
}

String body = mockMvc.perform(post("/api/auths/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"accountId": "histadmin", "password": "password123"}
"""))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
adminToken = objectMapper.readTree(body).get("accessToken").asString();
}

@Test
void 발급_이력_전체_조회_최신순() throws Exception {
mockMvc.perform(get("/api/issuance-histories")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].documentNo").value("유성구-2026-000002"))
.andExpect(jsonPath("$[0].targetName").value("김철수"))
.andExpect(jsonPath("$[1].documentNo").value("유성구-2026-000001"));
}

@Test
void 성명_키워드_필터() throws Exception {
mockMvc.perform(get("/api/issuance-histories")
.header("Authorization", "Bearer " + adminToken)
.param("keyword", "홍길"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].targetName").value("홍길동"));
}

@Test
void 발급일_기간_필터() throws Exception {
mockMvc.perform(get("/api/issuance-histories")
.header("Authorization", "Bearer " + adminToken)
.param("startDate", "2026-08-21")
.param("endDate", "2026-08-26"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].documentNo").value("유성구-2026-000002"));
}

@Test
void 민원인은_접근_불가() throws Exception {
mockMvc.perform(post("/api/auths/signup")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"accountId": "histpetit1", "password": "password123", "name": "민원인",
"phoneNumber": "010-1111-2222", "birthDate": "1995-01-01"}
"""))
.andExpect(status().isCreated());
String body = mockMvc.perform(post("/api/auths/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"accountId": "histpetit1", "password": "password123"}
"""))
.andReturn().getResponse().getContentAsString();
String petitionerToken = objectMapper.readTree(body).get("accessToken").asString();

mockMvc.perform(get("/api/issuance-histories")
.header("Authorization", "Bearer " + petitionerToken))
.andExpect(status().isForbidden());
}

@Test
void size가_0이면_400() throws Exception {
mockMvc.perform(get("/api/issuance-histories")
.header("Authorization", "Bearer " + adminToken)
.param("size", "0"))
.andExpect(status().isBadRequest());
}

@Test
void size가_100을_넘으면_400() throws Exception {
mockMvc.perform(get("/api/issuance-histories")
.header("Authorization", "Bearer " + adminToken)
.param("size", "1000000"))
.andExpect(status().isBadRequest());
}

@Test
void endDate_없이_startDate만으로_조회() throws Exception {
mockMvc.perform(get("/api/issuance-histories")
.header("Authorization", "Bearer " + adminToken)
.param("startDate", "2026-08-01"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2));
}
}
Loading