package kr.itn.itnhub.project; import kr.itn.itnhub.AbstractDbTest; import kr.itn.itnhub.mattermost.MattermostClient; import kr.itn.itnhub.org.Organization; import kr.itn.itnhub.org.OrganizationMapper; 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.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 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.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; 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; @AutoConfigureMockMvc @WithMockUser(username = "admin", roles = "ADMIN") class ProjectControllerTest extends AbstractDbTest { @Autowired MockMvc mvc; @Autowired OrganizationMapper orgMapper; @Autowired JdbcTemplate jdbc; @MockBean MattermostClient mattermost; private Long orgId; @BeforeEach void setUp() { jdbc.update("delete from organization"); Organization org = new Organization(); org.setOrgNo("001"); org.setOrgName("국제방송교류재단"); org.setChannelSlug("001"); orgMapper.upsertBySeed(org); orgId = orgMapper.findByOrgNoAndOrgName("001", "국제방송교류재단").getId(); } @Test void 목록은_기관현황에_연락이력_보고서_건수를_붙여_내려준다() throws Exception { mvc.perform(get("/api/projects")) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(1)) .andExpect(jsonPath("$[0].org.orgNo").value("001")) .andExpect(jsonPath("$[0].contactCount").value(0)) .andExpect(jsonPath("$[0].reportCount").value(0)) .andExpect(jsonPath("$[0].lastContactedOn").doesNotExist()); } @Test void 연락이력을_등록하면_최근순으로_돌려주고_작성자는_로그인_사용자다() throws Exception { mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(""" {"contactedOn": "2026-07-20", "method": "전화", "summary": "신청 확인"} """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(1)) .andExpect(jsonPath("$[0].author").value("관리자")) .andExpect(jsonPath("$[0].contactedOn").value("2026-07-20")); mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(""" {"contactedOn": "2026-07-25", "method": "메일", "summary": "자료 요청"} """)) .andExpect(status().isOk()) // 최근 연락일이 위로 온다 .andExpect(jsonPath("$[0].contactedOn").value("2026-07-25")) .andExpect(jsonPath("$[1].contactedOn").value("2026-07-20")); mvc.perform(get("/api/projects")) .andExpect(jsonPath("$[0].contactCount").value(2)) .andExpect(jsonPath("$[0].lastContactedOn").value("2026-07-25")); } @Test void 연락이력_수정은_내용만_바꾸고_작성자는_유지한다() throws Exception { String created = mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(""" {"contactedOn": "2026-07-20", "method": "전화", "summary": "처음"} """)) .andReturn().getResponse().getContentAsString(); int logId = com.jayway.jsonpath.JsonPath.parse(created).read("$[0].id", Integer.class); mvc.perform(put("/api/orgs/{id}/contact-logs/{logId}", orgId, logId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(""" {"contactedOn": "2026-07-21", "method": "방문", "summary": "고침"} """)) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].summary").value("고침")) .andExpect(jsonPath("$[0].method").value("방문")) .andExpect(jsonPath("$[0].author").value("관리자")); } @Test void 연락이력을_삭제하면_목록에서_빠진다() throws Exception { String created = mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(""" {"contactedOn": "2026-07-20", "method": "전화", "summary": "지울 것"} """)) .andReturn().getResponse().getContentAsString(); int logId = com.jayway.jsonpath.JsonPath.parse(created).read("$[0].id", Integer.class); mvc.perform(delete("/api/orgs/{id}/contact-logs/{logId}", orgId, logId).with(csrf())) .andExpect(status().isNoContent()); mvc.perform(get("/api/orgs/{id}/contact-logs", orgId)) .andExpect(jsonPath("$.length()").value(0)); } @Test void 내용이_비면_400이다() throws Exception { mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(""" {"contactedOn": "2026-07-20", "method": "전화", "summary": ""} """)) .andExpect(status().isBadRequest()); } @Test void 보고서를_올리면_목록에_쌓이고_다운로드된다() throws Exception { MockMultipartFile file = new MockMultipartFile("file", "최종보고서.pdf", "application/pdf", "보고서 내용".getBytes(java.nio.charset.StandardCharsets.UTF_8)); String uploaded = mvc.perform(multipart("/api/orgs/{id}/reports", orgId).file(file).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(1)) .andExpect(jsonPath("$[0].fileName").value("최종보고서.pdf")) .andExpect(jsonPath("$[0].uploadedBy").value("관리자")) .andReturn().getResponse().getContentAsString(); int reportId = com.jayway.jsonpath.JsonPath.parse(uploaded).read("$[0].id", Integer.class); // 한글 파일명은 RFC 5987로 인코딩돼 내려간다 mvc.perform(get("/api/orgs/{id}/reports/{reportId}", orgId, reportId)) .andExpect(status().isOk()) .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("filename*=UTF-8''"))); mvc.perform(get("/api/projects")) .andExpect(jsonPath("$[0].reportCount").value(1)); } @Test void 같은_기관에_여러_번_올리면_최신순으로_쌓인다() throws Exception { mvc.perform(multipart("/api/orgs/{id}/reports", orgId) .file(new MockMultipartFile("file", "1차.pdf", "application/pdf", "a".getBytes())) .with(csrf())) .andExpect(status().isOk()); mvc.perform(multipart("/api/orgs/{id}/reports", orgId) .file(new MockMultipartFile("file", "2차.pdf", "application/pdf", "b".getBytes())) .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(2)) .andExpect(jsonPath("$[0].fileName").value("2차.pdf")); } @Test void 빈_파일은_400이다() throws Exception { mvc.perform(multipart("/api/orgs/{id}/reports", orgId) .file(new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0])) .with(csrf())) .andExpect(status().isBadRequest()); } @Test void 보고서를_삭제하면_목록에서_빠진다() throws Exception { String uploaded = mvc.perform(multipart("/api/orgs/{id}/reports", orgId) .file(new MockMultipartFile("file", "지울보고서.pdf", "application/pdf", "x".getBytes())) .with(csrf())) .andReturn().getResponse().getContentAsString(); int reportId = com.jayway.jsonpath.JsonPath.parse(uploaded).read("$[0].id", Integer.class); mvc.perform(delete("/api/orgs/{id}/reports/{reportId}", orgId, reportId).with(csrf())) .andExpect(status().isNoContent()); mvc.perform(get("/api/orgs/{id}/reports", orgId)) .andExpect(jsonPath("$.length()").value(0)); assertThat(jdbc.queryForObject("select count(*) from org_report", Integer.class)).isZero(); } @Test void 없는_기관이면_404다() throws Exception { mvc.perform(get("/api/orgs/{id}/contact-logs", orgId + 99999L)) .andExpect(status().isNotFound()); } }