From 13bce582e17d0ab65d616ff8ed20860811c4947f Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:54:12 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Add tests for upload controller, service, MetaMode, Keyword entities --- .../LectureUploadControllerTest.java | 199 ++++++++++++++++++ .../domain/lecture/dto/MetaModeTest.java | 143 +++++++++++++ .../domain/lecture/entity/KeywordTest.java | 186 ++++++++++++++++ .../lecture/entity/LectureKeywordTest.java | 108 ++++++++++ .../service/LectureUploadServiceTest.java | 182 ++++++++++++++++ .../LectureUploadServiceTest_JUnit4.java | 79 +++++++ 6 files changed, 897 insertions(+) create mode 100644 src/test/java/inu/codin/codin/domain/lecture/controller/LectureUploadControllerTest.java create mode 100644 src/test/java/inu/codin/codin/domain/lecture/dto/MetaModeTest.java create mode 100644 src/test/java/inu/codin/codin/domain/lecture/entity/KeywordTest.java create mode 100644 src/test/java/inu/codin/codin/domain/lecture/entity/LectureKeywordTest.java create mode 100644 src/test/java/inu/codin/codin/domain/lecture/service/LectureUploadServiceTest.java create mode 100644 src/test/java/inu/codin/codin/domain/lecture/service/LectureUploadServiceTest_JUnit4.java diff --git a/src/test/java/inu/codin/codin/domain/lecture/controller/LectureUploadControllerTest.java b/src/test/java/inu/codin/codin/domain/lecture/controller/LectureUploadControllerTest.java new file mode 100644 index 0000000..3f406c9 --- /dev/null +++ b/src/test/java/inu/codin/codin/domain/lecture/controller/LectureUploadControllerTest.java @@ -0,0 +1,199 @@ +package inu.codin.codin.domain.lecture.controller; + +import inu.codin.codin.domain.lecture.dto.MetaMode; +import inu.codin.codin.domain.lecture.service.LectureUploadService; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Test stack: JUnit 5 (Jupiter) + Spring Boot Test (@WebMvcTest) + MockMvc + Mockito + spring-security-test. + * Focus: LectureUploadController endpoints and behavior added in the PR. + */ +@WebMvcTest(controllers = LectureUploadController.class) +@Import(LectureUploadControllerTest.MethodSecurityConfig.class) +class LectureUploadControllerTest { + + @TestConfiguration + @EnableMethodSecurity(prePostEnabled = true) + static class MethodSecurityConfig { + // Uses default ROLE_ prefix; note controller uses hasAnyRole('ROLE_ADMIN','ROLE_MANAGER'), + // hence tests use roles="ROLE_ADMIN"/"ROLE_MANAGER" to match (Spring adds ROLE_ prefix). + } + + @Autowired + private MockMvc mockMvc; + + @MockBean + private LectureUploadService lectureUploadService; + + @Test + @DisplayName("POST /upload as ROLE_ADMIN: returns 201 and calls service with uploaded file") + @WithMockUser(roles = "ROLE_ADMIN") + void uploadNewSemesterLectures_asAdmin_returns201_andCallsService() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "excelFile", + "24-1.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + new byte[]{1, 2, 3} + ); + + doNothing().when(lectureUploadService).uploadNewSemesterLectures(any()); + + mockMvc.perform(multipart("/upload").file(file).contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isCreated()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.code").value(201)) + .andExpect(jsonPath("$.message", containsString("24-1.xlsx"))) + .andExpect(jsonPath("$.message", containsString("강의 내역 업로드"))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(org.springframework.web.multipart.MultipartFile.class); + verify(lectureUploadService, times(1)).uploadNewSemesterLectures(captor.capture()); + org.springframework.web.multipart.MultipartFile passed = captor.getValue(); + // Verify the same filename reaches the service + org.junit.jupiter.api.Assertions.assertEquals("24-1.xlsx", passed.getOriginalFilename()); + } + + @Test + @DisplayName("POST /upload/rooms as ROLE_MANAGER: returns 201 and calls service with uploaded file") + @WithMockUser(roles = "ROLE_MANAGER") + void uploadNewSemesterRooms_asManager_returns201_andCallsService() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "excelFile", + "24-2.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + new byte[]{9, 8, 7} + ); + + doNothing().when(lectureUploadService).uploadNewSemesterRooms(any()); + + mockMvc.perform(multipart("/upload/rooms").file(file).contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isCreated()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.code").value(201)) + .andExpect(jsonPath("$.message", containsString("24-2.xlsx"))) + .andExpect(jsonPath("$.message", containsString("강의실 현황 업데이트"))); + + verify(lectureUploadService, times(1)).uploadNewSemesterRooms(any()); + } + + @Test + @DisplayName("POST /upload/meta default mode: ALL (no mode param) -> 201 and service called with MetaMode.ALL") + @WithMockUser(roles = "ROLE_ADMIN") + void uploadLectureMeta_defaultMode_callsServiceWithALL() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "excelFile", + "info_25_2_meta.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "dummy".getBytes() + ); + + doNothing().when(lectureUploadService).uploadLectureMeta(any(), any()); + + mockMvc.perform(multipart("/upload/meta").file(file).contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isCreated()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.code").value(201)) + .andExpect(jsonPath("$.message", containsString("info_25_2_meta.xlsx"))) + .andExpect(jsonPath("$.message", containsString("메타데이터"))); + + verify(lectureUploadService, times(1)).uploadLectureMeta(any(), eq(MetaMode.ALL)); + } + + @Test + @DisplayName("POST /upload/meta with mode=TAGS -> 201 and service called with MetaMode.TAGS") + @WithMockUser(roles = "ROLE_ADMIN") + void uploadLectureMeta_withTags_callsServiceWithTAGS() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "excelFile", + "info_25_2_meta.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + new byte[]{0x1} + ); + + doNothing().when(lectureUploadService).uploadLectureMeta(any(), any()); + + mockMvc.perform( + multipart("/upload/meta") + .file(file) + .param("mode", "TAGS") + .contentType(MediaType.MULTIPART_FORM_DATA) + ) + .andExpect(status().isCreated()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.code").value(201)) + .andExpect(jsonPath("$.message", containsString("메타데이터"))); + + verify(lectureUploadService, times(1)).uploadLectureMeta(any(), eq(MetaMode.TAGS)); + } + + @Test + @DisplayName("POST /upload without authentication -> 401 Unauthorized") + void uploadNewSemesterLectures_unauthenticated_returns401() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "excelFile", "24-1.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1} + ); + + mockMvc.perform(multipart("/upload").file(file).contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("POST /upload as ROLE_USER (insufficient) -> 403 Forbidden") + @WithMockUser(roles = "USER") + void uploadNewSemesterLectures_insufficientRole_returns403() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "excelFile", "24-1.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1} + ); + + mockMvc.perform(multipart("/upload").file(file).contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("POST /upload/rooms missing file parameter -> 400 Bad Request and service not called") + @WithMockUser(roles = "ROLE_ADMIN") + void uploadNewSemesterRooms_missingFile_returns400() throws Exception { + mockMvc.perform(multipart("/upload/rooms").contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isBadRequest()); + + verify(lectureUploadService, never()).uploadNewSemesterRooms(any()); + } + + @Test + @DisplayName("POST /upload/meta with invalid mode value -> 400 Bad Request and service not called") + @WithMockUser(roles = "ROLE_ADMIN") + void uploadLectureMeta_invalidMode_returns400() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "excelFile", "info_25_2_meta.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{2,3} + ); + + mockMvc.perform( + multipart("/upload/meta") + .file(file) + .param("mode", "INVALID") + .contentType(MediaType.MULTIPART_FORM_DATA) + ) + .andExpect(status().isBadRequest()); + + verify(lectureUploadService, never()).uploadLectureMeta(any(), any()); + } +} \ No newline at end of file diff --git a/src/test/java/inu/codin/codin/domain/lecture/dto/MetaModeTest.java b/src/test/java/inu/codin/codin/domain/lecture/dto/MetaModeTest.java new file mode 100644 index 0000000..8e3fede --- /dev/null +++ b/src/test/java/inu/codin/codin/domain/lecture/dto/MetaModeTest.java @@ -0,0 +1,143 @@ +package inu.codin.codin.domain.lecture.dto; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Test framework: JUnit Jupiter (JUnit 5) provided by spring-boot-starter-test. + * Scope: Thorough unit tests for MetaMode enum focusing on public behavior: + * - values() ordering and size + * - valueOf(String) happy paths and failure conditions + * - toString(), name(), ordinal stability + * - EnumSet interoperability and membership checks + */ +class MetaModeTest { + + @Test + @DisplayName("values() should include all defined constants in declared order") + void values_shouldContainAllConstantsInOrder() { + MetaMode[] values = MetaMode.values(); + assertAll( + () -> assertEquals(4, values.length, "Unexpected enum constant count"), + () -> assertEquals(MetaMode.ALL, values[0]), + () -> assertEquals(MetaMode.KEYWORDS, values[1]), + () -> assertEquals(MetaMode.TAGS, values[2]), + () -> assertEquals(MetaMode.PRE_COURSES, values[3]) + ); + } + + @Test + @DisplayName("EnumSet.allOf should return exactly the 4 MetaMode values") + void enumSet_allOf_hasAllFour() { + Set set = EnumSet.allOf(MetaMode.class); + assertEquals(EnumSet.of(MetaMode.ALL, MetaMode.KEYWORDS, MetaMode.TAGS, MetaMode.PRE_COURSES), set); + } + + @ParameterizedTest(name = "valueOf should resolve \"{0}\"") + @EnumSource(MetaMode.class) + void valueOf_shouldResolveEveryConstant(MetaMode mode) { + MetaMode resolved = MetaMode.valueOf(mode.name()); + assertSame(mode, resolved); + } + + @ParameterizedTest(name = "toString should equal name for {0}") + @EnumSource(MetaMode.class) + void toString_shouldEqualName(MetaMode mode) { + assertEquals(mode.name(), mode.toString()); + } + + @Nested + @DisplayName("Failure conditions for valueOf(String)") + class ValueOfFailures { + + @Test + @DisplayName("Invalid names should throw IllegalArgumentException") + void invalidNames_throwIllegalArgumentException() { + for (String bad : Arrays.asList( + "", " ", "ALL ", " ALL", "all", "All", + "KEYWORD", "TAGS_", "PRE-COURSES", "PRE_COURSE", "COURSES" + )) { + String msg = "Expected IllegalArgumentException for input: '" + bad + "'"; + assertThrows(IllegalArgumentException.class, () -> MetaMode.valueOf(bad), msg); + } + } + + @ParameterizedTest + @NullSource + @DisplayName("Passing null should throw NullPointerException") + void nullName_throwsNullPointerException(String input) { + assertThrows(NullPointerException.class, () -> MetaMode.valueOf(input)); + } + } + + @Test + @DisplayName("Ordinals remain stable with the declared order") + void ordinals_shouldMatchDeclaredOrder() { + assertAll( + () -> assertEquals(0, MetaMode.ALL.ordinal()), + () -> assertEquals(1, MetaMode.KEYWORDS.ordinal()), + () -> assertEquals(2, MetaMode.TAGS.ordinal()), + () -> assertEquals(3, MetaMode.PRE_COURSES.ordinal()) + ); + } + + @ParameterizedTest(name = "name() should be uppercase for {0}") + @EnumSource(MetaMode.class) + void name_isUppercase(MetaMode mode) { + assertEquals(mode.name().toUpperCase(), mode.name()); + } + + @ParameterizedTest(name = "Enum membership should include {0}") + @EnumSource(MetaMode.class) + void membership_containsEachConstant(MetaMode mode) { + assertTrue(EnumSet.allOf(MetaMode.class).contains(mode)); + } + + @ParameterizedTest(name = "valueOf should be case-sensitive; lower-case of {0} must fail") + @EnumSource(MetaMode.class) + void valueOf_isCaseSensitive(MetaMode mode) { + String lower = mode.name().toLowerCase(); + assertThrows(IllegalArgumentException.class, () -> MetaMode.valueOf(lower)); + } + + @ParameterizedTest(name = "valueOf should not accept extra whitespace around {0}") + @EnumSource(MetaMode.class) + void valueOf_doesNotAllowWhitespace(MetaMode mode) { + for (String s : Arrays.asList(" " + mode.name(), mode.name() + " ", " " + mode.name() + " ")) { + assertThrows(IllegalArgumentException.class, () -> MetaMode.valueOf(s)); + } + } + + @Test + @DisplayName("Duplicate constants do not exist (EnumSet.copyOf size equals array length)") + void noDuplicates() { + MetaMode[] values = MetaMode.values(); + assertEquals(values.length, EnumSet.copyOf(Arrays.asList(values)).size()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALL", "KEYWORDS", "TAGS", "PRE_COURSES"}) + @DisplayName("Basic sanity: valueOf returns a MetaMode for all expected literals") + void sanity_valueOfRecognizesLiterals(String literal) { + assertNotNull(MetaMode.valueOf(literal)); + } + + @Test + @Tag("regression") + @DisplayName("Regression: contents of values() remain unchanged (snapshot)") + void regression_valuesSnapshot() { + assertEquals("[ALL, KEYWORDS, TAGS, PRE_COURSES]", Arrays.toString(MetaMode.values())); + } +} \ No newline at end of file diff --git a/src/test/java/inu/codin/codin/domain/lecture/entity/KeywordTest.java b/src/test/java/inu/codin/codin/domain/lecture/entity/KeywordTest.java new file mode 100644 index 0000000..47bb511 --- /dev/null +++ b/src/test/java/inu/codin/codin/domain/lecture/entity/KeywordTest.java @@ -0,0 +1,186 @@ +package inu.codin.codin.domain.lecture.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Testing library/framework: JUnit 5 (Jupiter). + * If AssertJ is used elsewhere in the project, these tests can be trivially upgraded to AssertJ assertions. + */ +class KeywordTest { + + @Nested + @DisplayName("Class-level JPA and Lombok characteristics") + class ClassLevelTests { + @Test + @DisplayName("Keyword is annotated with @Entity") + void keywordHasEntityAnnotation() { + assertTrue(Keyword.class.isAnnotationPresent(Entity.class), + "@Entity should be present on Keyword"); + } + + @Test + @DisplayName("No-args constructor exists and is protected (from Lombok @NoArgsConstructor(PROTECTED))") + void hasProtectedNoArgsConstructor() throws Exception { + Constructor ctor = Keyword.class.getDeclaredConstructor(); + assertNotNull(ctor, "No-args constructor must exist"); + assertTrue(Modifier.isProtected(ctor.getModifiers()), + "No-args constructor should be protected"); + } + } + + @Nested + @DisplayName("Field: id") + class IdFieldTests { + @Test + @DisplayName("id field has @Id and @GeneratedValue(strategy=IDENTITY)") + void idHasIdAndGeneratedValueIdentity() throws Exception { + Field id = Keyword.class.getDeclaredField("id"); + assertNotNull(id, "Field 'id' must exist"); + + assertTrue(id.isAnnotationPresent(Id.class), "'id' must be annotated with @Id"); + + GeneratedValue gv = id.getAnnotation(GeneratedValue.class); + assertNotNull(gv, "'id' must be annotated with @GeneratedValue"); + assertEquals(GenerationType.IDENTITY, gv.strategy(), + "GeneratedValue strategy should be IDENTITY"); + } + + @Test + @DisplayName("getId() getter exists and returns the reflected value") + void getIdGetterReturnsValue() throws Exception { + Keyword keyword = new Keyword(); // protected constructor accessible in same package + Field id = Keyword.class.getDeclaredField("id"); + id.setAccessible(true); + Long expected = 42L; + id.set(keyword, expected); + + Method getter = Keyword.class.getMethod("getId"); + Object value = getter.invoke(keyword); + assertEquals(expected, value, "getId should return the reflected id value"); + } + + @Test + @DisplayName("No public/protected setter for id to keep immutability from outside") + void noSetterForId() { + assertThrows(NoSuchMethodException.class, () -> Keyword.class.getMethod("setId", Long.class)); + } + } + + @Nested + @DisplayName("Field: keywordDescription") + class KeywordDescriptionFieldTests { + @Test + @DisplayName("Getter exists and returns reflected value") + void getterReturnsValue() throws Exception { + Keyword keyword = new Keyword(); + Field f = Keyword.class.getDeclaredField("keywordDescription"); + f.setAccessible(true); + String expected = "Functional Programming"; + f.set(keyword, expected); + + Method getter = Keyword.class.getMethod("getKeywordDescription"); + Object value = getter.invoke(keyword); + assertEquals(expected, value, "Getter should return the reflected keywordDescription"); + } + + @Test + @DisplayName("No public/protected setter for keywordDescription") + void noSetter() { + assertThrows(NoSuchMethodException.class, + () -> Keyword.class.getMethod("setKeywordDescription", String.class)); + } + } + + @Nested + @DisplayName("Field: lectureKeywords mapping") + class LectureKeywordsMappingTests { + @Test + @DisplayName("@OneToMany mapping is configured with mappedBy='keyword', LAZY fetch, and cascade ALL") + void mappingAttributes() throws Exception { + Field f = Keyword.class.getDeclaredField("lectureKeywords"); + assertNotNull(f, "Field 'lectureKeywords' must exist"); + OneToMany otm = f.getAnnotation(OneToMany.class); + assertNotNull(otm, "'lectureKeywords' must be annotated with @OneToMany"); + assertEquals("keyword", otm.mappedBy(), "mappedBy should be 'keyword'"); + assertEquals(FetchType.LAZY, otm.fetch(), "fetch should be LAZY"); + + CascadeType[] cascade = otm.cascade(); + assertNotNull(cascade, "cascade cannot be null"); + // Must contain ALL + boolean hasAll = false; + for (CascadeType ct : cascade) { + if (ct == CascadeType.ALL) { hasAll = true; break; } + } + assertTrue(hasAll, "cascade should include CascadeType.ALL"); + } + + @Test + @DisplayName("Getter exists and returns reflected list instance") + void getterReturnsList() throws Exception { + Keyword keyword = new Keyword(); + Field f = Keyword.class.getDeclaredField("lectureKeywords"); + f.setAccessible(true); + + // Runtime type erasure allows raw ArrayList here; intended element type is LectureKeyword + List expected = new ArrayList<>(); + f.set(keyword, expected); + + Method getter = Keyword.class.getMethod("getLectureKeywords"); + Object value = getter.invoke(keyword); + assertSame(expected, value, "Getter should return the same list instance assigned via reflection"); + } + + @Test + @DisplayName("No public/protected setter for lectureKeywords") + void noSetter() { + assertThrows(NoSuchMethodException.class, + () -> Keyword.class.getMethod("setLectureKeywords", List.class)); + } + } + + @Test + @DisplayName("Only expected fields are present (id, keywordDescription, lectureKeywords)") + void onlyExpectedFieldsPresent() { + Field[] fields = Keyword.class.getDeclaredFields(); + // Collect names + boolean hasId = false, hasDesc = false, hasLectures = false; + for (Field f : fields) { + if (f.getName().equals("id")) hasId = true; + if (f.getName().equals("keywordDescription")) hasDesc = true; + if (f.getName().equals("lectureKeywords")) hasLectures = true; + // Ensure no field is public + assertTrue(Modifier.isPrivate(f.getModifiers()), "All fields should be private"); + } + assertTrue(hasId && hasDesc && hasLectures, "Entity should contain id, keywordDescription, and lectureKeywords fields"); + } + + @Test + @DisplayName("No unexpected public setters exist (encapsulation check)") + void noUnexpectedPublicSetters() { + for (Method m : Keyword.class.getDeclaredMethods()) { + if (m.getName().startsWith("set") && Modifier.isPublic(m.getModifiers())) { + fail("Unexpected public setter found: " + m); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/inu/codin/codin/domain/lecture/entity/LectureKeywordTest.java b/src/test/java/inu/codin/codin/domain/lecture/entity/LectureKeywordTest.java new file mode 100644 index 0000000..e7bf3af --- /dev/null +++ b/src/test/java/inu/codin/codin/domain/lecture/entity/LectureKeywordTest.java @@ -0,0 +1,108 @@ +package inu.codin.codin.domain.lecture.entity; + +/* +Testing library/framework used: JUnit 5 (Jupiter). +- We use built-in JUnit assertions (no new dependencies). +- Tests focus on JPA mapping annotations and Lombok-generated members for LectureKeyword. +- These are unit-level, reflection-based checks (do not require a database or Spring context). +*/ + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import jakarta.persistence.*; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +public class LectureKeywordTest { + + @Test + @DisplayName("LectureKeyword is annotated with @Entity") + void entityAnnotationPresent() { + assertNotNull(LectureKeyword.class.getAnnotation(Entity.class), + "@Entity should be present on LectureKeyword."); + } + + @Test + @DisplayName("id: Long with @Id and @GeneratedValue(strategy = IDENTITY)") + void idFieldMapping() throws NoSuchFieldException { + Field id = LectureKeyword.class.getDeclaredField("id"); + assertEquals(Long.class, id.getType(), "id should be Long"); + assertNotNull(id.getAnnotation(Id.class), "@Id should be present on id"); + GeneratedValue gv = id.getAnnotation(GeneratedValue.class); + assertNotNull(gv, "@GeneratedValue should be present on id"); + assertEquals(GenerationType.IDENTITY, gv.strategy(), "GenerationType should be IDENTITY"); + } + + @Test + @DisplayName("lecture: @ManyToOne(fetch = LAZY) with @JoinColumn(name = 'lecture_id')") + void lectureFieldMapping() throws NoSuchFieldException { + Field lecture = LectureKeyword.class.getDeclaredField("lecture"); + ManyToOne mto = lecture.getAnnotation(ManyToOne.class); + assertNotNull(mto, "@ManyToOne should be present on lecture"); + assertEquals(FetchType.LAZY, mto.fetch(), "lecture fetch type should be LAZY"); + + JoinColumn jc = lecture.getAnnotation(JoinColumn.class); + assertNotNull(jc, "@JoinColumn should be present on lecture"); + assertEquals("lecture_id", jc.name(), "JoinColumn name should be lecture_id"); + } + + @Test + @DisplayName("keyword: @ManyToOne(fetch = LAZY) with @JoinColumn(name = 'keyword_id')") + void keywordFieldMapping() throws NoSuchFieldException { + Field keyword = LectureKeyword.class.getDeclaredField("keyword"); + ManyToOne mto = keyword.getAnnotation(ManyToOne.class); + assertNotNull(mto, "@ManyToOne should be present on keyword"); + assertEquals(FetchType.LAZY, mto.fetch(), "keyword fetch type should be LAZY"); + + JoinColumn jc = keyword.getAnnotation(JoinColumn.class); + assertNotNull(jc, "@JoinColumn should be present on keyword"); + assertEquals("keyword_id", jc.name(), "JoinColumn name should be keyword_id"); + } + + @Test + @DisplayName("Has protected no-args constructor (via Lombok @NoArgsConstructor(PROTECTED))") + void protectedNoArgsConstructor() throws Exception { + Constructor ctor = LectureKeyword.class.getDeclaredConstructor(); + assertTrue(Modifier.isProtected(ctor.getModifiers()), + "No-args constructor should be protected"); + ctor.setAccessible(true); + LectureKeyword instance = ctor.newInstance(); + assertNotNull(instance, "Instance should be constructible via reflection"); + } + + @Test + @DisplayName("Getter for id returns value set via reflection") + void gettersReturnValuesForId() throws Exception { + Constructor ctor = LectureKeyword.class.getDeclaredConstructor(); + ctor.setAccessible(true); + LectureKeyword instance = ctor.newInstance(); + + Field id = LectureKeyword.class.getDeclaredField("id"); + id.setAccessible(true); + id.set(instance, 123L); + + assertEquals(123L, instance.getId(), "getId should return the reflected value"); + // lecture and keyword are reference associations; by default they are null (uninitialized) + assertNull(instance.getLecture(), "getLecture should be null by default (uninitialized)"); + assertNull(instance.getKeyword(), "getKeyword should be null by default (uninitialized)"); + } + + @Test + @DisplayName("No public setter methods (setId, setLecture, setKeyword) are exposed") + void noSetterMethodsExposed() { + Method[] methods = LectureKeyword.class.getDeclaredMethods(); + assertTrue(Arrays.stream(methods).noneMatch(m -> m.getName().equals("setId")), + "setId should not be present"); + assertTrue(Arrays.stream(methods).noneMatch(m -> m.getName().equals("setLecture")), + "setLecture should not be present"); + assertTrue(Arrays.stream(methods).noneMatch(m -> m.getName().equals("setKeyword")), + "setKeyword should not be present"); + } +} \ No newline at end of file diff --git a/src/test/java/inu/codin/codin/domain/lecture/service/LectureUploadServiceTest.java b/src/test/java/inu/codin/codin/domain/lecture/service/LectureUploadServiceTest.java new file mode 100644 index 0000000..5a76968 --- /dev/null +++ b/src/test/java/inu/codin/codin/domain/lecture/service/LectureUploadServiceTest.java @@ -0,0 +1,182 @@ +package inu.codin.codin.domain.lecture.service; + +import inu.codin.codin.domain.elasticsearch.indexer.LectureStartupIndexer; +import inu.codin.codin.domain.lecture.dto.MetaMode; +import inu.codin.codin.domain.lecture.exception.LectureUploadException; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Note: Using JUnit 5 (Jupiter) + Mockito based on typical Spring Boot setups. + * If this project uses JUnit 4, replace imports with org.junit.* and use @Rule TemporaryFolder. + */ +class LectureUploadServiceTest { + + private LectureUploadService service; + private LectureStartupIndexer indexer; + + @TempDir + Path tempDir; + + private void setField(Object target, String name, Object value) { + try { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private MultipartFile mockFile(String originalName, byte[] content) throws IOException { + MultipartFile mf = mock(MultipartFile.class); + when(mf.getOriginalFilename()).thenReturn(originalName); + when(mf.getName()).thenReturn(originalName); + when(mf.getBytes()).thenReturn(content); + return mf; + } + + private void writeExecutable(Path path, String body) throws IOException { + Files.writeString(path, body, StandardCharsets.UTF_8); + path.toFile().setExecutable(true, true); + } + + @BeforeEach + void setup() { + indexer = mock(LectureStartupIndexer.class); + service = new LectureUploadService(indexer); + + // Ensure UPLOAD_DIR ends with separator because service uses simple string concat in saveFile() + setField(service, "UPLOAD_DIR", tempDir.toAbsolutePath().toString() + "/"); + // Use /bin/sh to execute our test scripts regardless of .py extension + setField(service, "PYTHON_DIR", "/bin/sh"); + } + + @Test + void uploadNewSemesterLectures_success_executesScriptAndIndexes() throws Exception { + // Arrange + MultipartFile excel = mockFile("lectures.xlsx", "hello".getBytes(StandardCharsets.UTF_8)); + // script expected by service + Path script = tempDir.resolve("infoOfLecture.py"); + writeExecutable(script, "#\!/bin/sh\n# simulate success\nexit 0\n"); + + // Act & Assert + assertDoesNotThrow(() -> service.uploadNewSemesterLectures(excel)); + + // Saved file exists + assertTrue(Files.exists(tempDir.resolve("lectures.xlsx"))); + // Index invoked + verify(indexer, times(1)).lectureIndex(); + } + + @Test + void uploadNewSemesterLectures_failure_exitNonZero_throws() throws Exception { + MultipartFile excel = mockFile("lectures.xlsx", "bytes".getBytes(StandardCharsets.UTF_8)); + Path script = tempDir.resolve("infoOfLecture.py"); + writeExecutable(script, "#\!/bin/sh\n# simulate failure\necho 'failure on purpose' 1>&2\nexit 7\n"); + + LectureUploadException ex = assertThrows(LectureUploadException.class, + () -> service.uploadNewSemesterLectures(excel)); + // We can't rely on custom getters, but at least message should exist + assertNotNull(ex.getMessage()); + // File was written before exec attempt + assertTrue(Files.exists(tempDir.resolve("lectures.xlsx"))); + // No indexing on failure + verify(indexer, never()).lectureIndex(); + } + + @Test + void uploadNewSemesterRooms_success_executesScript() throws Exception { + MultipartFile excel = mockFile("rooms.xlsx", "data".getBytes(StandardCharsets.UTF_8)); + Path script = tempDir.resolve("dayTimeOfRoom.py"); + writeExecutable(script, "#\!/bin/sh\nexit 0\n"); + + assertDoesNotThrow(() -> service.uploadNewSemesterRooms(excel)); + assertTrue(Files.exists(tempDir.resolve("rooms.xlsx"))); + verify(indexer, never()).lectureIndex(); // rooms flow does not index + } + + @Test + void uploadNewSemesterRooms_failure_exitNonZero_throws() throws Exception { + MultipartFile excel = mockFile("rooms.xlsx", "data".getBytes(StandardCharsets.UTF_8)); + Path script = tempDir.resolve("dayTimeOfRoom.py"); + writeExecutable(script, "#\!/bin/sh\necho 'bad rooms' >&2\nexit 2\n"); + + LectureUploadException ex = assertThrows(LectureUploadException.class, + () -> service.uploadNewSemesterRooms(excel)); + assertNotNull(ex.getMessage()); + verify(indexer, never()).lectureIndex(); + } + + @Test + void uploadLectureMeta_success_allMode_executesScript_andIndexes() throws Exception { + MultipartFile excel = mockFile("meta.xlsx", "meta".getBytes(StandardCharsets.UTF_8)); + Path script = tempDir.resolve("load_metadata.py"); + writeExecutable(script, "#\!/bin/sh\n# $1 is excel path here\n[ -f \"$1\" ] || exit 1\necho 'ok'\nexit 0\n"); + + assertDoesNotThrow(() -> service.uploadLectureMeta(excel, MetaMode.ALL)); + verify(indexer, times(1)).lectureIndex(); + } + + @Test + void uploadLectureMeta_whenIndexerThrows_warningOnly_noPropagation() throws Exception { + MultipartFile excel = mockFile("meta.xlsx", "meta".getBytes(StandardCharsets.UTF_8)); + Path script = tempDir.resolve("load_metadata.py"); + writeExecutable(script, "#\!/bin/sh\nexit 0\n"); + + doThrow(new RuntimeException("index fail")).when(indexer).lectureIndex(); + + // Should not propagate, method logs a warning and completes + assertDoesNotThrow(() -> service.uploadLectureMeta(excel, MetaMode.KEYWORDS)); + verify(indexer, times(1)).lectureIndex(); + } + + @Test + void uploadLectureMeta_failure_exitNonZero_throws() throws Exception { + MultipartFile excel = mockFile("meta.xlsx", "meta".getBytes(StandardCharsets.UTF_8)); + Path script = tempDir.resolve("load_metadata.py"); + writeExecutable(script, "#\!/bin/sh\necho 'script err' >&2\nexit 3\n"); + + LectureUploadException ex = assertThrows(LectureUploadException.class, + () -> service.uploadLectureMeta(excel, MetaMode.TAGS)); + assertTrue(ex.getMessage() \!= null && \!ex.getMessage().isBlank()); + verify(indexer, never()).lectureIndex(); + } + + @Test + void saveFile_whenOriginalFilenameNull_throwsFileReadFail() throws Exception { + MultipartFile mf = mock(MultipartFile.class); + when(mf.getOriginalFilename()).thenReturn(null); + + LectureUploadException ex = assertThrows(LectureUploadException.class, + () -> service.uploadLectureMeta(mf, MetaMode.ALL)); + assertNotNull(ex.getMessage()); + verify(indexer, never()).lectureIndex(); + } + + @Test + void saveFile_whenGetBytesIOException_throwsFileReadFail() throws Exception { + MultipartFile mf = mock(MultipartFile.class); + when(mf.getOriginalFilename()).thenReturn("bad.xlsx"); + when(mf.getName()).thenReturn("bad.xlsx"); + when(mf.getBytes()).thenThrow(new IOException("boom")); + + LectureUploadException ex = assertThrows(LectureUploadException.class, + () -> service.uploadLectureMeta(mf, MetaMode.ALL)); + assertTrue(ex.getMessage().contains("boom")); + verify(indexer, never()).lectureIndex(); + } +} \ No newline at end of file diff --git a/src/test/java/inu/codin/codin/domain/lecture/service/LectureUploadServiceTest_JUnit4.java b/src/test/java/inu/codin/codin/domain/lecture/service/LectureUploadServiceTest_JUnit4.java new file mode 100644 index 0000000..2261298 --- /dev/null +++ b/src/test/java/inu/codin/codin/domain/lecture/service/LectureUploadServiceTest_JUnit4.java @@ -0,0 +1,79 @@ +package inu.codin.codin.domain.lecture.service; + +import inu.codin.codin.domain.elasticsearch.indexer.LectureStartupIndexer; +import inu.codin.codin.domain.lecture.dto.MetaMode; +import inu.codin.codin.domain.lecture.exception.LectureUploadException; +import org.junit.*; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mockito; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +/** + * Fallback variant for JUnit 4 + Mockito. + * Prefer the JUnit 5 version if repository uses Jupiter. + */ +public class LectureUploadServiceTest_JUnit4 { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private LectureUploadService service; + private LectureStartupIndexer indexer; + + private void setField(Object target, String name, Object value) { + try { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private MultipartFile mockFile(String originalName, byte[] content) throws IOException { + MultipartFile mf = mock(MultipartFile.class); + when(mf.getOriginalFilename()).thenReturn(originalName); + when(mf.getName()).thenReturn(originalName); + when(mf.getBytes()).thenReturn(content); + return mf; + } + + @Before + public void setUp() { + indexer = mock(LectureStartupIndexer.class); + service = new LectureUploadService(indexer); + setField(service, "UPLOAD_DIR", tmp.getRoot().getAbsolutePath() + File.separator); + setField(service, "PYTHON_DIR", "/bin/sh"); + } + + @Test + public void uploadLectureMeta_success() throws Exception { + MultipartFile excel = mockFile("meta.xlsx", "meta".getBytes(StandardCharsets.UTF_8)); + File script = new File(tmp.getRoot(), "load_metadata.py"); + assertTrue(script.createNewFile()); + script.setExecutable(true, true); + org.apache.commons.io.FileUtils.writeStringToFile(script, "#\!/bin/sh\nexit 0\n", StandardCharsets.UTF_8); + + service.uploadLectureMeta(excel, MetaMode.ALL); + verify(indexer, times(1)).lectureIndex(); + } + + @Test(expected = LectureUploadException.class) + public void uploadLectureMeta_failure_exitNonZero() throws Exception { + MultipartFile excel = mockFile("meta.xlsx", "meta".getBytes(StandardCharsets.UTF_8)); + File script = new File(tmp.getRoot(), "load_metadata.py"); + assertTrue(script.createNewFile()); + script.setExecutable(true, true); + org.apache.commons.io.FileUtils.writeStringToFile(script, "#\!/bin/sh\nexit 5\n", StandardCharsets.UTF_8); + + service.uploadLectureMeta(excel, MetaMode.KEYWORDS); + } +} \ No newline at end of file