package kr.itn.itnhub.org; import kr.itn.itnhub.AbstractDbTest; import kr.itn.itnhub.contact.Contact; import kr.itn.itnhub.contact.ContactMapper; import kr.itn.itnhub.mattermost.MattermostClient; import kr.itn.itnhub.mattermost.MattermostException; 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.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 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.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @AutoConfigureMockMvc @WithMockUser(roles = "ADMIN") class OrganizationControllerTest extends AbstractDbTest { @Autowired MockMvc mvc; @Autowired OrganizationMapper mapper; @Autowired ContactMapper contactMapper; @Autowired JdbcTemplate jdbc; @MockBean MattermostClient mattermost; private Long orgId; @BeforeEach void setUp() { jdbc.update("delete from organization"); jdbc.update("delete from contact"); Organization org = new Organization(); org.setOrgNo("001"); org.setOrgName("국제방송교류재단"); org.setChannelSlug("001"); mapper.upsertBySeed(org); orgId = mapper.findAll().get(0).getId(); } private Long createContact(String category, String name) { Contact contact = new Contact(); contact.setCategory(category); contact.setName(name); contactMapper.insert(contact); return contact.getId(); } @Test void 기관_목록을_상태와_함께_돌려준다() throws Exception { mvc.perform(get("/api/orgs")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].orgNo").value("001")) .andExpect(jsonPath("$[0].orgName").value("국제방송교류재단")) .andExpect(jsonPath("$[0].status").value("INFO_PENDING")); } @Test void 신청기관_담당자를_배정하면_생성가능_상태가_된다() throws Exception { Long applicantId = createContact("APPLICANT", "송민지"); mvc.perform(put("/api/orgs/{id}/assignments", orgId) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"applicantContactId\": " + applicantId + "}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("READY")) .andExpect(jsonPath("$.applicant.name").value("송민지")); assertThat(mapper.findById(orgId).getApplicantContactId()).isEqualTo(applicantId); } @Test void 문정원_담당자와_변호사를_함께_배정하면_응답과_재조회에_모두_반영된다() throws Exception { Long applicantId = createContact("APPLICANT", "송민지"); Long mjId = createContact("MJ", "김문정"); Long itnId = createContact("ITN", "박아이티"); Long lawyerId = createContact("LAWYER", "이변호"); mvc.perform(put("/api/orgs/{id}/assignments", orgId) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(""" { "applicantContactId": %d, "mjContactId": %d, "itnContactId": %d, "lawyerContactId": %d, "lawyerAssignedDate": "2026-07-21" } """.formatted(applicantId, mjId, itnId, lawyerId))) .andExpect(status().isOk()) .andExpect(jsonPath("$.mj.name").value("김문정")) .andExpect(jsonPath("$.itn.name").value("박아이티")) .andExpect(jsonPath("$.lawyer.name").value("이변호")) .andExpect(jsonPath("$.lawyerAssignedDate").value("2026-07-21")); mvc.perform(get("/api/orgs")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].mj.name").value("김문정")) .andExpect(jsonPath("$[0].itn.name").value("박아이티")) .andExpect(jsonPath("$[0].lawyer.name").value("이변호")); } @Test void 신청기관_배정없이_문정원_변호사만_배정해도_저장에_성공하고_정보대기로_남는다() throws Exception { Long mjId = createContact("MJ", "김문정"); mvc.perform(put("/api/orgs/{id}/assignments", orgId) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"mjContactId\": " + mjId + "}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("INFO_PENDING")) .andExpect(jsonPath("$.applicant").value(nullValue())) .andExpect(jsonPath("$.mj.name").value("김문정")); } @Test void 배정_해제는_null을_보내면_반영되어_정보대기로_돌아간다() throws Exception { Long applicantId = createContact("APPLICANT", "송민지"); mapper.updateAssignments(orgId, applicantId, null, null, null, null); mvc.perform(put("/api/orgs/{id}/assignments", orgId) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"applicantContactId\": null}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("INFO_PENDING")) .andExpect(jsonPath("$.applicant").value(nullValue())); } @Test void 아이티앤_담당자를_배정하면_응답과_재조회에_반영된다() throws Exception { Long itnId = createContact("ITN", "박아이티"); mvc.perform(put("/api/orgs/{id}/assignments", orgId) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"itnContactId\": " + itnId + "}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.itn.name").value("박아이티")); mvc.perform(get("/api/orgs")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].itn.name").value("박아이티")); } @Test void 구분이_맞지_않는_담당자를_아이티앤에_배정하면_400이다() throws Exception { Long lawyerId = createContact("LAWYER", "이변호"); mvc.perform(put("/api/orgs/{id}/assignments", orgId) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"itnContactId\": " + lawyerId + "}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value(containsString("아이티앤 담당자"))); } @Test void 아이티앤_담당자_배정_해제는_null을_보내면_반영된다() throws Exception { Long itnId = createContact("ITN", "박아이티"); mapper.updateAssignments(orgId, null, null, itnId, null, null); mvc.perform(put("/api/orgs/{id}/assignments", orgId) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"itnContactId\": null}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.itn").value(nullValue())); assertThat(mapper.findById(orgId).getItnContactId()).isNull(); } @Test void 존재하지_않는_기관ID로_배정을_수정하면_404다() throws Exception { long missingId = orgId + 999999L; mvc.perform(put("/api/orgs/{id}/assignments", missingId) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingId)))); } @Test void 존재하지_않는_담당자ID를_배정하면_400이다() throws Exception { long missingContactId = 999999L; mvc.perform(put("/api/orgs/{id}/assignments", orgId) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"applicantContactId\": " + missingContactId + "}")) .andExpect(status().isBadRequest()); } @Test void 구분이_맞지_않는_담당자를_배정하면_400이다() throws Exception { Long mjContactId = createContact("MJ", "김문정"); mvc.perform(put("/api/orgs/{id}/assignments", orgId) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"applicantContactId\": " + mjContactId + "}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value(containsString("신청기관 담당자"))); } @Test void 채널생성을_요청하면_결과를_돌려준다() throws Exception { Long applicantId = createContact("APPLICANT", "송민지"); mapper.updateAssignments(orgId, applicantId, null, null, null, null); when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty()); when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty()); when(mattermost.createPrivateChannel(anyString(), anyString())).thenReturn("chan"); mvc.perform(post("/api/orgs/{id}/channels", orgId).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.mj").value("CREATED")) .andExpect(jsonPath("$.law").value("CREATED")); } @Test void 담당자배정없이_채널생성하면_실패결과를_돌려준다() throws Exception { mvc.perform(post("/api/orgs/{id}/channels", orgId).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.mj").value("FAILED")) .andExpect(jsonPath("$.law").value("FAILED")) .andExpect(jsonPath("$.message").value(containsString("신청기관 담당자"))); } /** * Finding 2 회귀 테스트: 문정원 채널은 성공하고 법률검토 채널만 실패하는 실제 혼합 * 결과를 컨트롤러 경계에서 검증한다. 기존 테스트는 전부 성공 아니면(둘 다 성공) * 담당자 정보 누락으로 둘 다 강제 FAILED인 경로만 다뤄, 진짜 부분 실패가 HTTP * 200으로 내려가는지는 한 번도 실행되지 않았다. */ @Test void 법률검토만_실패해도_200과_부분실패_결과를_돌려준다() throws Exception { Long applicantId = createContact("APPLICANT", "송민지"); mapper.updateAssignments(orgId, applicantId, null, null, null, null); when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty()); when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty()); when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString())).thenReturn("id-mj"); when(mattermost.createPrivateChannel(eq("org-001-law"), anyString())) .thenThrow(new MattermostException("법률검토 서버 오류")); mvc.perform(post("/api/orgs/{id}/channels", orgId).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.mj").value("CREATED")) .andExpect(jsonPath("$.law").value("FAILED")) .andExpect(jsonPath("$.message").value(containsString("법률검토"))); } }