diff --git a/src/main/kotlin/com/mjucow/eatda/domain/curation/entity/Curation.kt b/src/main/kotlin/com/mjucow/eatda/domain/curation/entity/Curation.kt new file mode 100644 index 0000000..9ec86d4 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/domain/curation/entity/Curation.kt @@ -0,0 +1,65 @@ +package com.mjucow.eatda.domain.curation.entity + +import com.mjucow.eatda.domain.common.BaseEntity +import com.mjucow.eatda.domain.store.entity.Store +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.JoinColumn +import jakarta.persistence.JoinTable +import jakarta.persistence.ManyToMany +import jakarta.persistence.Table + +@Entity +@Table(name = "curation") +class Curation() : BaseEntity() { + constructor( + title: String, + description: String, + ) : this() { + this.title = title + this.description = description + } + + @Column(nullable = false) + var title: String = "" + set(value) { + validateTitle(value) + field = value + } + + @Column(nullable = false) + var description: String = "" + set(value) { + validateDescription(value) + field = value + } + + @ManyToMany(fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST, CascadeType.MERGE]) + @JoinTable( + name = "curation_store", + joinColumns = [JoinColumn(name = "store_id")], + inverseJoinColumns = [JoinColumn(name = "curation_id")] + ) + val mutableStores: MutableSet = mutableSetOf() + + fun getStores(): Set = mutableStores.toSet() + + fun addStore(store: Store) { + mutableStores.add(store) + } + + private fun validateTitle(title: String) { + require(title.isNotBlank() && title.length <= MAX_TITLE_LENGTH) + } + + private fun validateDescription(description: String) { + require(description.isNotBlank() && description.length <= MAX_DESCRIPTION_LENGTH) + } + + companion object { + const val MAX_TITLE_LENGTH = 255 + const val MAX_DESCRIPTION_LENGTH = 255 + } +} diff --git a/src/main/kotlin/com/mjucow/eatda/domain/curation/service/command/CurationCommandService.kt b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/command/CurationCommandService.kt new file mode 100644 index 0000000..18a5cc0 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/command/CurationCommandService.kt @@ -0,0 +1,49 @@ +package com.mjucow.eatda.domain.curation.service.command + +import com.mjucow.eatda.domain.curation.entity.Curation +import com.mjucow.eatda.domain.curation.service.command.dto.AddStoreCommand +import com.mjucow.eatda.domain.curation.service.command.dto.CreateCurationCommand +import com.mjucow.eatda.domain.curation.service.command.dto.UpdateCurationCommand +import com.mjucow.eatda.persistence.curation.CurationRepository +import com.mjucow.eatda.persistence.store.StoreRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional +class CurationCommandService( + private val repository: CurationRepository, + private val storeRepository: StoreRepository, + +) { + fun create(command: CreateCurationCommand): Long { + return repository.save(Curation(command.title, command.description)).id + } + + fun update(id: Long, command: UpdateCurationCommand) { + val (newTitle, newDescription) = command + val updatedCuration = repository.getReferenceById(id).apply { + title = newTitle + description = newDescription + } + + repository.save(updatedCuration) + } + + fun delete(id: Long) { + val curation = repository.findByIdOrNull(id) ?: return + + repository.delete(curation) + } + + fun addStore(id: Long, command: AddStoreCommand): Long { + val (storeId) = command + val curation = repository.getReferenceById(id) + val store = storeRepository.getReferenceById(storeId) + + curation.addStore(store) + + return repository.save(curation).id + } +} diff --git a/src/main/kotlin/com/mjucow/eatda/domain/curation/service/command/dto/AddStoreCommand.kt b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/command/dto/AddStoreCommand.kt new file mode 100644 index 0000000..a60e557 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/command/dto/AddStoreCommand.kt @@ -0,0 +1,8 @@ +package com.mjucow.eatda.domain.curation.service.command.dto + +import io.swagger.v3.oas.annotations.media.Schema + +data class AddStoreCommand( + @Schema(name = "storeId", example = "1") + val storeId: Long, +) diff --git a/src/main/kotlin/com/mjucow/eatda/domain/curation/service/command/dto/CreateCurationCommand.kt b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/command/dto/CreateCurationCommand.kt new file mode 100644 index 0000000..0e7d1f4 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/command/dto/CreateCurationCommand.kt @@ -0,0 +1,10 @@ +package com.mjucow.eatda.domain.curation.service.command.dto + +import io.swagger.v3.oas.annotations.media.Schema + +data class CreateCurationCommand( + @Schema(name = "title", example = "큐레이션 제목") + val title: String, + @Schema(name = "description", example = "큐레이션 설명") + val description: String, +) diff --git a/src/main/kotlin/com/mjucow/eatda/domain/curation/service/command/dto/UpdateCurationCommand.kt b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/command/dto/UpdateCurationCommand.kt new file mode 100644 index 0000000..8ec7a2f --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/command/dto/UpdateCurationCommand.kt @@ -0,0 +1,10 @@ +package com.mjucow.eatda.domain.curation.service.command.dto + +import io.swagger.v3.oas.annotations.media.Schema + +data class UpdateCurationCommand( + @Schema(name = "title", example = "수정할 큐레이션 제목") + val title: String, + @Schema(name = "description", example = "수정할 큐레이션 설명") + val description: String, +) diff --git a/src/main/kotlin/com/mjucow/eatda/domain/curation/service/query/CurationQueryService.kt b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/query/CurationQueryService.kt new file mode 100644 index 0000000..c128af5 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/query/CurationQueryService.kt @@ -0,0 +1,17 @@ +package com.mjucow.eatda.domain.curation.service.query + +import com.mjucow.eatda.domain.curation.service.query.dto.CurationDto +import com.mjucow.eatda.domain.curation.service.query.dto.Curations +import com.mjucow.eatda.persistence.curation.CurationRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class CurationQueryService( + private val repository: CurationRepository, +) { + fun findAll(): Curations { + return Curations(repository.findAll().map(CurationDto::from)) + } +} diff --git a/src/main/kotlin/com/mjucow/eatda/domain/curation/service/query/dto/CurationDto.kt b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/query/dto/CurationDto.kt new file mode 100644 index 0000000..f0913f8 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/query/dto/CurationDto.kt @@ -0,0 +1,29 @@ +package com.mjucow.eatda.domain.curation.service.query.dto + +import com.fasterxml.jackson.annotation.JsonUnwrapped +import com.mjucow.eatda.domain.curation.entity.Curation +import com.mjucow.eatda.domain.store.service.query.dto.StoreDto +import com.mjucow.eatda.domain.store.service.query.dto.Stores +import io.swagger.v3.oas.annotations.media.Schema + +data class CurationDto( + @Schema(name = "id", example = "1") + val id: Long, + @Schema(name = "title", example = "명지대 점심 특선") + val title: String, + @Schema(name = "description", example = "점심 특선 메뉴를 판매하는 음식점들이에요.") + val description: String, + @JsonUnwrapped val stores: Stores? = null, +) { + + companion object { + fun from(domain: Curation): CurationDto { + return CurationDto( + id = domain.id, + title = domain.title, + description = domain.description, + stores = Stores(domain.getStores().map(StoreDto::from)) + ) + } + } +} diff --git a/src/main/kotlin/com/mjucow/eatda/domain/curation/service/query/dto/Curations.kt b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/query/dto/Curations.kt new file mode 100644 index 0000000..0054c9c --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/domain/curation/service/query/dto/Curations.kt @@ -0,0 +1,5 @@ +package com.mjucow.eatda.domain.curation.service.query.dto + +data class Curations( + val curationList: List, +) : ArrayList(curationList) diff --git a/src/main/kotlin/com/mjucow/eatda/domain/store/service/query/dto/Stores.kt b/src/main/kotlin/com/mjucow/eatda/domain/store/service/query/dto/Stores.kt new file mode 100644 index 0000000..e718b17 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/domain/store/service/query/dto/Stores.kt @@ -0,0 +1,5 @@ +package com.mjucow.eatda.domain.store.service.query.dto + +data class Stores( + val storeList: List, +) : ArrayList(storeList) diff --git a/src/main/kotlin/com/mjucow/eatda/persistence/curation/CurationRepository.kt b/src/main/kotlin/com/mjucow/eatda/persistence/curation/CurationRepository.kt new file mode 100644 index 0000000..027277f --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/persistence/curation/CurationRepository.kt @@ -0,0 +1,6 @@ +package com.mjucow.eatda.persistence.curation + +import com.mjucow.eatda.domain.curation.entity.Curation +import org.springframework.data.jpa.repository.JpaRepository + +interface CurationRepository : JpaRepository diff --git a/src/main/kotlin/com/mjucow/eatda/presentation/curation/CurationApiPresentation.kt b/src/main/kotlin/com/mjucow/eatda/presentation/curation/CurationApiPresentation.kt new file mode 100644 index 0000000..4d2b6a5 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/presentation/curation/CurationApiPresentation.kt @@ -0,0 +1,31 @@ +package com.mjucow.eatda.presentation.curation + +import com.mjucow.eatda.domain.curation.service.command.dto.AddStoreCommand +import com.mjucow.eatda.domain.curation.service.command.dto.CreateCurationCommand +import com.mjucow.eatda.domain.curation.service.command.dto.UpdateCurationCommand +import com.mjucow.eatda.domain.curation.service.query.dto.Curations +import com.mjucow.eatda.presentation.common.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag + +@Tag(name = "큐레이션 API", description = "큐레이션을 관리해주는 API") +interface CurationApiPresentation { + @Operation(summary = "큐레이션 생성", description = "큐레이션를 생성합니다.") + fun create(command: CreateCurationCommand): ApiResponse + + @Operation(summary = "큐레이션 전체 조회", description = "모든 큐레이션를 조회합니다.") + fun findAll(): ApiResponse + + @Operation(summary = "큐레이션 수정", description = "큐레이션의 내용을 수정합니다.") + @Parameter(name = "curationId", description = "수정할 큐레이션의 ID") + fun update(id: Long, command: UpdateCurationCommand) + + @Operation(summary = "큐레이션 삭제", description = "큐레이션을 삭제합니다.") + @Parameter(name = "curationId", description = "삭제할 큐레이션의 ID") + fun delete(id: Long) + + @Operation(summary = "큐레이션 가게 추가", description = "큐레이션에 가게를 추가합니다.") + @Parameter(name = "curationId", description = "큐레이션의 ID") + fun addStore(id: Long, command: AddStoreCommand): ApiResponse +} diff --git a/src/main/kotlin/com/mjucow/eatda/presentation/curation/CurationController.kt b/src/main/kotlin/com/mjucow/eatda/presentation/curation/CurationController.kt new file mode 100644 index 0000000..aa2ece5 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/presentation/curation/CurationController.kt @@ -0,0 +1,63 @@ +package com.mjucow.eatda.presentation.curation + +import com.mjucow.eatda.domain.curation.service.command.CurationCommandService +import com.mjucow.eatda.domain.curation.service.command.dto.AddStoreCommand +import com.mjucow.eatda.domain.curation.service.command.dto.CreateCurationCommand +import com.mjucow.eatda.domain.curation.service.command.dto.UpdateCurationCommand +import com.mjucow.eatda.domain.curation.service.query.CurationQueryService +import com.mjucow.eatda.domain.curation.service.query.dto.Curations +import com.mjucow.eatda.presentation.common.ApiResponse +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RequestMapping("/api/v1/curations") +@RestController +class CurationController( + private val curationQueryService: CurationQueryService, + private val curationCommandService: CurationCommandService, +) : CurationApiPresentation { + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + override fun create(@RequestBody command: CreateCurationCommand): ApiResponse { + return ApiResponse.success(curationCommandService.create(command)) + } + + @GetMapping + @ResponseStatus(HttpStatus.OK) + override fun findAll(): ApiResponse { + return ApiResponse.success(curationQueryService.findAll()) + } + + @PatchMapping("/{curationId}") + @ResponseStatus(HttpStatus.OK) + override fun update( + @PathVariable("curationId") id: Long, + @RequestBody command: UpdateCurationCommand, + ) { + return curationCommandService.update(id, command) + } + + @DeleteMapping("/{curationId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + override fun delete(@PathVariable("curationId") id: Long) { + return curationCommandService.delete(id) + } + + @PostMapping("/{curationId}/store") + @ResponseStatus(HttpStatus.CREATED) + override fun addStore( + @PathVariable("curationId") id: Long, + @RequestBody command: AddStoreCommand, + ): ApiResponse { + return ApiResponse.success(curationCommandService.addStore(id, command)) + } +} diff --git a/src/main/resources/db/changelog/231105-expired_banner.sql b/src/main/resources/db/changelog/231105-expired_banner.sql index fe17fe6..e8b1508 100644 --- a/src/main/resources/db/changelog/231105-expired_banner.sql +++ b/src/main/resources/db/changelog/231105-expired_banner.sql @@ -7,4 +7,4 @@ CREATE TABLE expired_banner link varchar(255) NOT NULL, image_address varchar(255) NOT NULL, expired_at timestamp -); \ No newline at end of file +); diff --git a/src/main/resources/db/changelog/240129-curation.sql b/src/main/resources/db/changelog/240129-curation.sql new file mode 100644 index 0000000..8908056 --- /dev/null +++ b/src/main/resources/db/changelog/240129-curation.sql @@ -0,0 +1,18 @@ +-- liquibase formatted sql + +-- changeset liquibase:8 +CREATE TABLE curation ( + id bigserial NOT NULL PRIMARY KEY, + title varchar(255) NOT NULL, + description varchar(255) NOT NULL, + created_at timestamp, + updated_at timestamp NOT NULL +); + +CREATE TABLE curation_store ( + id bigserial NOT NULL PRIMARY KEY, + curation_id bigint NOT NULL REFERENCES curation, + store_id bigint NOT NULL REFERENCES store, + created_at timestamp NOT NULL DEFAULT NOW(), + updated_at timestamp NOT NULL +); diff --git a/src/test/kotlin/com/mjucow/eatda/domain/banner/service/query/BannerQueryServiceTest.kt b/src/test/kotlin/com/mjucow/eatda/domain/banner/service/query/BannerQueryServiceTest.kt index dde1f0c..1eafc67 100644 --- a/src/test/kotlin/com/mjucow/eatda/domain/banner/service/query/BannerQueryServiceTest.kt +++ b/src/test/kotlin/com/mjucow/eatda/domain/banner/service/query/BannerQueryServiceTest.kt @@ -31,7 +31,7 @@ class BannerQueryServiceTest : AbstractDataTest() { @DisplayName("전체 배너를 반환한다") @Test - fun returnCategories() { + fun task2() { // given repository.save(BannerMother.create()) diff --git a/src/test/kotlin/com/mjucow/eatda/domain/curation/entity/CurationTest.kt b/src/test/kotlin/com/mjucow/eatda/domain/curation/entity/CurationTest.kt new file mode 100644 index 0000000..402bf1d --- /dev/null +++ b/src/test/kotlin/com/mjucow/eatda/domain/curation/entity/CurationTest.kt @@ -0,0 +1,129 @@ +package com.mjucow.eatda.domain.curation.entity + +import com.mjucow.eatda.domain.curation.entity.objectmother.CurationMother +import com.mjucow.eatda.domain.store.entity.objectmother.StoreMother +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EmptySource +import org.springframework.test.util.ReflectionTestUtils + +class CurationTest { + @DisplayName("제목이 빈 값일 경우 예외를 던진다") + @ParameterizedTest + @EmptySource + fun task1(title: String) { + // given + + // when + val throwable = Assertions.catchThrowable { + Curation( + title = title, + description = CurationMother.DESCRIPTION + ) + } + + // then + Assertions.assertThat(throwable).isInstanceOf(RuntimeException::class.java) + } + + @DisplayName("제목이 최대 길이보다 길 경우 예외를 던진다") + @Test + fun task2() { + // given + val title = "x".repeat(Curation.MAX_TITLE_LENGTH + 1) + + // when + val throwable = Assertions.catchThrowable { + Curation( + title = title, + description = CurationMother.DESCRIPTION + ) + } + + // then + Assertions.assertThat(throwable).isInstanceOf(RuntimeException::class.java) + } + + @DisplayName("설명이 빈 값일 경우 예외를 던진다") + @ParameterizedTest + @EmptySource + fun task3(description: String) { + // given + + // when + val throwable = Assertions.catchThrowable { + Curation( + title = CurationMother.TITLE, + description = description + ) + } + + // then + Assertions.assertThat(throwable).isInstanceOf(RuntimeException::class.java) + } + + @DisplayName("설명이 최대 길이보다 길 경우 예외를 던진다") + @Test + fun task4() { + // given + val description = "x".repeat(Curation.MAX_DESCRIPTION_LENGTH + 1) + + // when + val throwable = Assertions.catchThrowable { + Curation( + title = CurationMother.TITLE, + description = description + ) + } + + // then + Assertions.assertThat(throwable).isInstanceOf(RuntimeException::class.java) + } + + @DisplayName("새로운 가게를 추가할 수 있다") + @Test + fun task5() { + // given + val store = StoreMother.create() + val curation = CurationMother.create() + ReflectionTestUtils.setField(store, "id", 1L) + + // when + curation.addStore(store) + + // then + Assertions.assertThat(curation.getStores()).contains(store) + } + + @DisplayName("이미 존재하는 가게는 중복 저장되지 않는다") + @Test + fun task6() { + // given + val store = StoreMother.create() + val curation = CurationMother.create { it.addStore(store) } + val prevStoreCount = curation.getStores().size + + // when + curation.addStore(store) + + // then + Assertions.assertThat(curation.getStores().size).isEqualTo(prevStoreCount) + } + + @DisplayName("정상적인 값일 경우 객체가 생성된다") + @Test + fun task7() { + // given + + // when + val curation = Curation( + title = CurationMother.TITLE, + description = CurationMother.DESCRIPTION + ) + + // then + Assertions.assertThat(curation).isNotNull() + } +} diff --git a/src/test/kotlin/com/mjucow/eatda/domain/curation/entity/objectmother/CurationMother.kt b/src/test/kotlin/com/mjucow/eatda/domain/curation/entity/objectmother/CurationMother.kt new file mode 100644 index 0000000..307dda8 --- /dev/null +++ b/src/test/kotlin/com/mjucow/eatda/domain/curation/entity/objectmother/CurationMother.kt @@ -0,0 +1,13 @@ +package com.mjucow.eatda.domain.curation.entity.objectmother + +import com.mjucow.eatda.common.objectmother.EntityMother +import com.mjucow.eatda.domain.curation.entity.Curation + +object CurationMother : EntityMother() { + const val TITLE = "명지대 점심 특선" + const val DESCRIPTION = "점심 특선 메뉴를 판매하는 음식점들이에요." + + override fun createFillInstance() = Curation(TITLE, DESCRIPTION) + + override fun createDefaultInstance() = Curation(TITLE, DESCRIPTION) +} diff --git a/src/test/kotlin/com/mjucow/eatda/domain/curation/service/command/CurationCommandServiceTest.kt b/src/test/kotlin/com/mjucow/eatda/domain/curation/service/command/CurationCommandServiceTest.kt new file mode 100644 index 0000000..a13a8cb --- /dev/null +++ b/src/test/kotlin/com/mjucow/eatda/domain/curation/service/command/CurationCommandServiceTest.kt @@ -0,0 +1,152 @@ +package com.mjucow.eatda.domain.curation.service.command + +import autoparams.kotlin.AutoKotlinSource +import com.mjucow.eatda.domain.AbstractDataTest +import com.mjucow.eatda.domain.curation.entity.objectmother.CurationMother +import com.mjucow.eatda.domain.curation.service.command.dto.AddStoreCommand +import com.mjucow.eatda.domain.curation.service.command.dto.CreateCurationCommand +import com.mjucow.eatda.domain.curation.service.command.dto.UpdateCurationCommand +import com.mjucow.eatda.domain.store.entity.objectmother.StoreMother +import com.mjucow.eatda.persistence.curation.CurationRepository +import com.mjucow.eatda.persistence.store.StoreRepository +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.params.ParameterizedTest +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Import + +@Import(CurationCommandService::class) +class CurationCommandServiceTest : AbstractDataTest() { + @Autowired + lateinit var curationCommandService: CurationCommandService + + @Autowired + lateinit var repository: CurationRepository + + @Autowired + lateinit var storeRepository: StoreRepository + + @DisplayName("정상적인 값이 들어오면 큐레이션이 생성된다") + @Test + fun task1() { + // given + val command = CreateCurationCommand( + title = CurationMother.TITLE, + description = CurationMother.DESCRIPTION + ) + + // when + val id = curationCommandService.create(command) + + // then + Assertions.assertThat(repository.getReferenceById(id)).isNotNull() + } + + @DisplayName("정상적인 값이 들어오면 큐레이션 정보가 수정된다") + @Test + fun task2() { + // given + val curation = repository.save(CurationMother.create()) + val updatedTitle = CurationMother.TITLE + "x" + val updatedDescription = CurationMother.DESCRIPTION + "x" + val command = UpdateCurationCommand( + title = updatedTitle, + description = updatedDescription + ) + + // when + curationCommandService.update(curation.id, command) + + // then + val updatedCuration = repository.getReferenceById(curation.id) + assertAll( + { Assertions.assertThat(updatedCuration.title).isEqualTo(updatedTitle) }, + { Assertions.assertThat(updatedCuration.description).isEqualTo(updatedDescription) } + ) + } + + @DisplayName("삭제하려는 대상이 없어도 예외를 던지지 않는다") + @ParameterizedTest + @AutoKotlinSource + fun task3(id: Long) { + // given + + // when + val throwable = Assertions.catchThrowable { curationCommandService.delete(id) } + + // then + Assertions.assertThat(throwable).isNull() + } + + @DisplayName("삭제하려는 대상을 삭제한다") + @Test + fun task4() { + // given + val curation = CurationMother.create() + repository.save(curation) + + // when + curationCommandService.delete(curation.id) + + // then + Assertions.assertThat(repository.existsById(curation.id)).isFalse() + } + + @DisplayName("id에 해당하는 큐레이션이 없다면 예외를 던진다") + @ParameterizedTest + @AutoKotlinSource + fun test5(id: Long) { + // given + val storeId = storeRepository.save(StoreMother.create()).id + val command = AddStoreCommand(storeId) + + // when + val throwable = Assertions.catchThrowable { curationCommandService.addStore(id, command) } + + // then + Assertions.assertThat(throwable).isNotNull() + } + + @DisplayName("storeId에 해당하는 가게가 없다면 예외를 던진다") + @ParameterizedTest + @AutoKotlinSource + fun test6(storeId: Long) { + // given + val id = curationCommandService.create( + CreateCurationCommand( + title = CurationMother.TITLE, + description = CurationMother.DESCRIPTION + ) + ) + val command = AddStoreCommand(storeId) + + // when + val throwable = Assertions.catchThrowable { curationCommandService.addStore(id, command) } + + // then + Assertions.assertThat(throwable).isNotNull() + } + + @DisplayName("가게가 추가된다") + @Test + fun test7() { + // given + val id = curationCommandService.create( + CreateCurationCommand( + title = CurationMother.TITLE, + description = CurationMother.DESCRIPTION + ) + ) + val storeId = storeRepository.save(StoreMother.create()).id + val command = AddStoreCommand(storeId) + curationCommandService.addStore(id, command) + + // when + val curation = repository.getReferenceById(id) + + // then + Assertions.assertThat(curation.getStores().size).isEqualTo(1) + } +} diff --git a/src/test/kotlin/com/mjucow/eatda/domain/curation/service/query/CurationQueryServiceTest.kt b/src/test/kotlin/com/mjucow/eatda/domain/curation/service/query/CurationQueryServiceTest.kt new file mode 100644 index 0000000..c3ad6ca --- /dev/null +++ b/src/test/kotlin/com/mjucow/eatda/domain/curation/service/query/CurationQueryServiceTest.kt @@ -0,0 +1,44 @@ +package com.mjucow.eatda.domain.curation.service.query + +import com.mjucow.eatda.domain.AbstractDataTest +import com.mjucow.eatda.domain.curation.entity.objectmother.CurationMother +import com.mjucow.eatda.persistence.curation.CurationRepository +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Import + +@Import(CurationQueryService::class) +class CurationQueryServiceTest : AbstractDataTest() { + @Autowired + lateinit var curationQueryService: CurationQueryService + + @Autowired + lateinit var repository: CurationRepository + + @DisplayName("큐레이션이 없을 경우 빈 배열을 반환한다") + @Test + fun task1() { + // given + + // when + val curations = curationQueryService.findAll() + + // then + Assertions.assertThat(curations).isEmpty() + } + + @DisplayName("전체 큐레이션을 반환한다") + @Test + fun returnCategories() { + // given + repository.save(CurationMother.create()) + + // when + val curations = curationQueryService.findAll() + + // then + Assertions.assertThat(curations).isNotEmpty + } +} diff --git a/src/test/kotlin/com/mjucow/eatda/domain/store/entity/StoreTest.kt b/src/test/kotlin/com/mjucow/eatda/domain/store/entity/StoreTest.kt index 5415b8f..d800756 100644 --- a/src/test/kotlin/com/mjucow/eatda/domain/store/entity/StoreTest.kt +++ b/src/test/kotlin/com/mjucow/eatda/domain/store/entity/StoreTest.kt @@ -25,7 +25,7 @@ class StoreTest { assertThat(throwable).isInstanceOf(RuntimeException::class.java) } - @DisplayName("이름이 최소 길이 이상일 경우 에외를 던진다") + @DisplayName("이름이 최소 길이 이상일 경우 예외를 던진다") @Test fun throwExceptionWhenNameLengthGreaterThanMaxLength() { // given @@ -38,7 +38,7 @@ class StoreTest { assertThat(throwable).isInstanceOf(RuntimeException::class.java) } - @DisplayName("보이는 이름이 최소 길이 이상일 경우 에외를 던진다") + @DisplayName("보이는 이름이 최소 길이 이상일 경우 예외를 던진다") @Test fun throwExceptionWhenDisplayNameLengthGreaterThanMaxLength() { // given @@ -68,7 +68,7 @@ class StoreTest { assertThat(throwable).isInstanceOf(RuntimeException::class.java) } - @DisplayName("새로운 이름이 최소 길이 이상일 경우 에외를 던진다") + @DisplayName("새로운 이름이 최소 길이 이상일 경우 예외를 던진다") @Test fun throwExceptionWhenNewNameLengthGreaterThanMaxLength() { // given @@ -82,7 +82,7 @@ class StoreTest { assertThat(throwable).isInstanceOf(RuntimeException::class.java) } - @DisplayName("새로운 보이는 이름이 최소 길이 이상일 경우 에외를 던진다") + @DisplayName("새로운 보이는 이름이 최소 길이 이상일 경우 예외를 던진다") @Test fun throwExceptionWhenNewDisplayNameLengthGreaterThanMaxLength() { // given @@ -134,7 +134,7 @@ class StoreTest { assertThat(throwable).isInstanceOf(RuntimeException::class.java) } - @DisplayName("새로운 주소가 최소 길이 이상일 경우 에외를 던진다") + @DisplayName("새로운 주소가 최소 길이 이상일 경우 예외를 던진다") @Test fun throwExceptionWhenNewAddressLengthGreaterThanMaxLength() { // given @@ -225,7 +225,7 @@ class StoreTest { assertThat(throwable).isInstanceOf(RuntimeException::class.java) } - @DisplayName("새로운 주소가 최소 길이 이상일 경우 에외를 던진다") + @DisplayName("새로운 주소가 최소 길이 이상일 경우 예외를 던진다") @Test fun throwExceptionWhenNewImageAddressLengthGreaterThanMaxLength() { // given diff --git a/src/test/kotlin/com/mjucow/eatda/domain/store/service/command/MenuCommandServiceTest.kt b/src/test/kotlin/com/mjucow/eatda/domain/store/service/command/MenuCommandServiceTest.kt index 4ce4d1e..6660e3f 100644 --- a/src/test/kotlin/com/mjucow/eatda/domain/store/service/command/MenuCommandServiceTest.kt +++ b/src/test/kotlin/com/mjucow/eatda/domain/store/service/command/MenuCommandServiceTest.kt @@ -33,7 +33,7 @@ class MenuCommandServiceTest : AbstractDataTest() { MenuMother.store = storeRepository.save(StoreMother.create { it.name = "default" }) } - @DisplayName("storeId에 해당하는 store가 없다면 에외를 던진다") + @DisplayName("storeId에 해당하는 store가 없다면 예외를 던진다") @Test fun test1() { // given @@ -67,7 +67,7 @@ class MenuCommandServiceTest : AbstractDataTest() { assertThat(repository.getReferenceById(menuId)).isNotNull() } - @DisplayName("id에 해당하는 menu가 없다면 에외를 던진다") + @DisplayName("id에 해당하는 menu가 없다면 예외를 던진다") @Test fun test3() { // given @@ -84,7 +84,7 @@ class MenuCommandServiceTest : AbstractDataTest() { assertThat(throwable).isNotNull() } - @DisplayName("정상적인 값이 들어오면 메뉴의 정보가 변경 된다") + @DisplayName("정상적인 값이 들어오면 메뉴의 정보가 변경된다") @Test fun test4() { // given diff --git a/src/test/kotlin/com/mjucow/eatda/presentation/banner/BannerMvcTest.kt b/src/test/kotlin/com/mjucow/eatda/presentation/banner/BannerMvcTest.kt index 7331c86..a39190e 100644 --- a/src/test/kotlin/com/mjucow/eatda/presentation/banner/BannerMvcTest.kt +++ b/src/test/kotlin/com/mjucow/eatda/presentation/banner/BannerMvcTest.kt @@ -33,7 +33,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @WebMvcTest(BannerController::class) class BannerMvcTest : AbstractMockMvcTest() { @MockkBean(relaxUnitFun = true) - lateinit var banneerQueryService: BannerQueryService + lateinit var bannerQueryService: BannerQueryService @MockkBean(relaxUnitFun = true) lateinit var bannerCommandService: BannerCommandService @@ -43,7 +43,7 @@ class BannerMvcTest : AbstractMockMvcTest() { // given val entityId = 1L val banners = Banners(listOf(BannerDto.from(BannerMother.createWithId(id = entityId)))) - every { banneerQueryService.findAll() } returns banners + every { bannerQueryService.findAll() } returns banners // when & then mockMvc.perform( @@ -59,12 +59,17 @@ class BannerMvcTest : AbstractMockMvcTest() { .tag("banner") .description("배너 전체 조회") .responseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메세지"), + fieldWithPath("message").type(JsonFieldType.STRING) + .description("에러 메세지"), fieldWithPath("body").type(JsonFieldType.ARRAY).description("배너 데이터"), - fieldWithPath("body[].id").type(JsonFieldType.NUMBER).description("배너 식별자"), - fieldWithPath("body[].link").type(JsonFieldType.STRING).description("배너 링크 주소"), - fieldWithPath("body[].imageAddress").type(JsonFieldType.STRING).description("배너 이미지"), - fieldWithPath("body[].expiredAt").type(JsonFieldType.STRING).description("배너 만료일자") + fieldWithPath("body[].id").type(JsonFieldType.NUMBER) + .description("배너 식별자"), + fieldWithPath("body[].link").type(JsonFieldType.STRING) + .description("배너 링크 주소"), + fieldWithPath("body[].imageAddress").type(JsonFieldType.STRING) + .description("배너 이미지"), + fieldWithPath("body[].expiredAt").type(JsonFieldType.STRING) + .description("배너 만료일자") ) ) ) @@ -97,13 +102,18 @@ class BannerMvcTest : AbstractMockMvcTest() { .tag("banner") .description("배너 생성") .requestFields( - fieldWithPath("link").type(JsonFieldType.STRING).description("배너 링크 주소"), - fieldWithPath("imageAddress").type(JsonFieldType.STRING).description("배너 이미지"), - fieldWithPath("expiredAt").type(JsonFieldType.STRING).description("배너 만료일자") + fieldWithPath("link").type(JsonFieldType.STRING) + .description("배너 링크 주소"), + fieldWithPath("imageAddress").type(JsonFieldType.STRING) + .description("배너 이미지"), + fieldWithPath("expiredAt").type(JsonFieldType.STRING) + .description("배너 만료일자") ) .responseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메세지"), - fieldWithPath("body").type(JsonFieldType.NUMBER).description("생성된 배너 식별자") + fieldWithPath("message").type(JsonFieldType.STRING) + .description("에러 메세지"), + fieldWithPath("body").type(JsonFieldType.NUMBER) + .description("생성된 배너 식별자") ) ) ) @@ -134,12 +144,16 @@ class BannerMvcTest : AbstractMockMvcTest() { .tag("banner") .description("배너 수정") .pathParameters( - ResourceDocumentation.parameterWithName("baennrId").description("배너 식별자") + ResourceDocumentation.parameterWithName("baennrId") + .description("배너 식별자") ) .requestFields( - fieldWithPath("link").type(JsonFieldType.STRING).description("배너 링크 주소"), - fieldWithPath("imageAddress").type(JsonFieldType.STRING).description("배너 이미지"), - fieldWithPath("expiredAt").type(JsonFieldType.STRING).description("배너 만료일자") + fieldWithPath("link").type(JsonFieldType.STRING) + .description("배너 링크 주소"), + fieldWithPath("imageAddress").type(JsonFieldType.STRING) + .description("배너 이미지"), + fieldWithPath("expiredAt").type(JsonFieldType.STRING) + .description("배너 만료일자") ) ) ) diff --git a/src/test/kotlin/com/mjucow/eatda/presentation/curation/CurationMvcTest.kt b/src/test/kotlin/com/mjucow/eatda/presentation/curation/CurationMvcTest.kt new file mode 100644 index 0000000..22a7e1e --- /dev/null +++ b/src/test/kotlin/com/mjucow/eatda/presentation/curation/CurationMvcTest.kt @@ -0,0 +1,227 @@ +package com.mjucow.eatda.presentation.curation + +import autoparams.kotlin.AutoKotlinSource +import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper +import com.epages.restdocs.apispec.ResourceDocumentation +import com.epages.restdocs.apispec.ResourceSnippetParametersBuilder +import com.mjucow.eatda.domain.curation.entity.objectmother.CurationMother +import com.mjucow.eatda.domain.curation.service.command.CurationCommandService +import com.mjucow.eatda.domain.curation.service.command.dto.AddStoreCommand +import com.mjucow.eatda.domain.curation.service.command.dto.CreateCurationCommand +import com.mjucow.eatda.domain.curation.service.command.dto.UpdateCurationCommand +import com.mjucow.eatda.domain.curation.service.query.CurationQueryService +import com.mjucow.eatda.domain.curation.service.query.dto.CurationDto +import com.mjucow.eatda.domain.curation.service.query.dto.Curations +import com.mjucow.eatda.presentation.AbstractMockMvcTest +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import org.hamcrest.core.Is +import org.hamcrest.core.IsNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.http.MediaType +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.restdocs.payload.PayloadDocumentation +import org.springframework.restdocs.request.RequestDocumentation +import org.springframework.test.web.servlet.result.MockMvcResultMatchers + +@WebMvcTest(CurationController::class) +class CurationMvcTest : AbstractMockMvcTest() { + @MockkBean(relaxUnitFun = true) + lateinit var curationQueryService: CurationQueryService + + @MockkBean(relaxUnitFun = true) + lateinit var curationCommandService: CurationCommandService + + @Test + fun findAll() { + // given + val entityId = 1L + val curations = + Curations(listOf(CurationDto.from(CurationMother.createWithId(id = entityId)))) + every { curationQueryService.findAll() } returns curations + + // when & then + mockMvc.perform( + RestDocumentationRequestBuilders.get(BASE_URI) + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andExpect(MockMvcResultMatchers.jsonPath("message", Is.`is`(IsNull.nullValue()))) + .andExpect(MockMvcResultMatchers.jsonPath("body").exists()) + .andDo( + MockMvcRestDocumentationWrapper.document( + identifier = "curation-findAll", + resourceDetails = ResourceSnippetParametersBuilder() + .tag("curation") + .description("큐레이션 전체 조회") + .responseFields( + PayloadDocumentation.fieldWithPath("message").type(JsonFieldType.STRING) + .description("에러 메세지"), + PayloadDocumentation.fieldWithPath("body").type(JsonFieldType.ARRAY) + .description("큐레이션 데이터"), + PayloadDocumentation.fieldWithPath("body[].id") + .type(JsonFieldType.NUMBER) + .description("큐레이션 식별자"), + PayloadDocumentation.fieldWithPath("body[].title") + .type(JsonFieldType.STRING) + .description("큐레이션 제목"), + PayloadDocumentation.fieldWithPath("body[].description") + .type(JsonFieldType.STRING) + .description("큐레이션 설명"), + PayloadDocumentation.fieldWithPath("body[].stores") + .type(JsonFieldType.ARRAY) + .description("큐레이션 가게들") + ) + ) + ) + } + + @ParameterizedTest + @AutoKotlinSource + fun create(id: Long) { + // given + val createCurationCommand = CreateCurationCommand( + title = CurationMother.TITLE, + description = CurationMother.DESCRIPTION + ) + val content = objectMapper.writeValueAsString(createCurationCommand) + every { curationCommandService.create(any()) } returns id + + // when & then + mockMvc.perform( + RestDocumentationRequestBuilders.post(BASE_URI) + .contentType(MediaType.APPLICATION_JSON) + .content(content) + ) + .andExpect(MockMvcResultMatchers.status().isCreated) + .andExpect(MockMvcResultMatchers.jsonPath("body", Is.`is`(id))) + .andDo( + MockMvcRestDocumentationWrapper.document( + identifier = "curation-create", + resourceDetails = ResourceSnippetParametersBuilder() + .tag("curation") + .description("큐레이션 생성") + .requestFields( + PayloadDocumentation.fieldWithPath("title").type(JsonFieldType.STRING) + .description("큐레이션 제목"), + PayloadDocumentation.fieldWithPath("description") + .type(JsonFieldType.STRING) + .description("큐레이션 설명") + ) + .responseFields( + PayloadDocumentation.fieldWithPath("message").type(JsonFieldType.STRING) + .description("에러 메세지"), + PayloadDocumentation.fieldWithPath("body").type(JsonFieldType.NUMBER) + .description("생성된 큐레이션 식별자") + ) + ) + ) + } + + @Test + fun update() { + // given + val id = 1L + val updateCurationCommand = UpdateCurationCommand( + title = CurationMother.TITLE, + description = CurationMother.DESCRIPTION + ) + val content = objectMapper.writeValueAsString(updateCurationCommand) + + // when & then + mockMvc.perform( + RestDocumentationRequestBuilders.patch("$BASE_URI/{curationId}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(content) + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andDo( + MockMvcRestDocumentationWrapper.document( + identifier = "curation-update", + resourceDetails = ResourceSnippetParametersBuilder() + .tag("curation") + .description("큐레이션 수정") + .pathParameters( + ResourceDocumentation.parameterWithName("curationId") + .description("큐레이션 식별자") + ) + .requestFields( + PayloadDocumentation.fieldWithPath("title").type(JsonFieldType.STRING) + .description("수정할 큐레이션 제목"), + PayloadDocumentation.fieldWithPath("description") + .type(JsonFieldType.STRING) + .description("수정할 큐레이션 설명") + ) + ) + ) + } + + @Test + fun delete() { + // given + + // when & then + mockMvc.perform( + RestDocumentationRequestBuilders.delete("$BASE_URI/{curationId}", 1) + ) + .andExpect(MockMvcResultMatchers.status().isNoContent) + .andDo( + MockMvcRestDocumentationWrapper.document( + identifier = "curation-delete", + resourceDetails = ResourceSnippetParametersBuilder() + .tag("curation") + .description("큐레이션 삭제") + .pathParameters( + RequestDocumentation.parameterWithName("curationId") + .description("큐레이션 식별자") + ) + ) + ) + } + + @Test + fun addStore() { + // given + val id = 1L + val addStoreCommand = AddStoreCommand(Long.MAX_VALUE) + val content = objectMapper.writeValueAsString(addStoreCommand) + every { curationCommandService.addStore(id, any()) } returns id + + // when & then + mockMvc.perform( + RestDocumentationRequestBuilders.post("$BASE_URI/{curationId}/store", id) + .contentType(MediaType.APPLICATION_JSON) + .content(content) + ) + .andExpect(MockMvcResultMatchers.status().isCreated) + .andExpect(MockMvcResultMatchers.jsonPath("body", Is.`is`(id.toInt()))) + .andDo( + MockMvcRestDocumentationWrapper.document( + identifier = "curation-create", + resourceDetails = ResourceSnippetParametersBuilder() + .tag("curation") + .description("큐레이션 생성") + .pathParameters( + RequestDocumentation.parameterWithName("curationId") + .description("큐레이션 식별자") + ) + .requestFields( + PayloadDocumentation.fieldWithPath("storeId").type(JsonFieldType.NUMBER) + .description("가게 식별자") + ) + .responseFields( + PayloadDocumentation.fieldWithPath("message").type(JsonFieldType.STRING) + .description("에러 메세지"), + PayloadDocumentation.fieldWithPath("body").type(JsonFieldType.NUMBER) + .description("가게를 추가한 큐레이션 식별자") + ) + ) + ) + } + + companion object { + const val BASE_URI = "/api/v1/curations" + } +} diff --git a/src/test/kotlin/com/mjucow/eatda/presentation/notice/NoticeMvcTest.kt b/src/test/kotlin/com/mjucow/eatda/presentation/notice/NoticeMvcTest.kt index 9199132..00a5c5c 100644 --- a/src/test/kotlin/com/mjucow/eatda/presentation/notice/NoticeMvcTest.kt +++ b/src/test/kotlin/com/mjucow/eatda/presentation/notice/NoticeMvcTest.kt @@ -58,11 +58,15 @@ class NoticeMvcTest : AbstractMockMvcTest() { .tag("notice") .description("공지사항 전체조회") .responseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메세지"), + fieldWithPath("message").type(JsonFieldType.STRING) + .description("에러 메세지"), fieldWithPath("body").type(JsonFieldType.ARRAY).description("공지사항 데이터"), - fieldWithPath("body[].id").type(JsonFieldType.NUMBER).description("공지사항 식별자"), - fieldWithPath("body[].title").type(JsonFieldType.STRING).description("공지사항 제목"), - fieldWithPath("body[].content").type(JsonFieldType.STRING).description("공지사항 내용") + fieldWithPath("body[].id").type(JsonFieldType.NUMBER) + .description("공지사항 식별자"), + fieldWithPath("body[].title").type(JsonFieldType.STRING) + .description("공지사항 제목"), + fieldWithPath("body[].content").type(JsonFieldType.STRING) + .description("공지사항 내용") ) ) ) @@ -92,11 +96,16 @@ class NoticeMvcTest : AbstractMockMvcTest() { parameterWithName("noticeId").description("공지사항 식별자") ) .responseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메세지"), - fieldWithPath("body").type(JsonFieldType.OBJECT).description("공지사항 데이터"), - fieldWithPath("body.id").type(JsonFieldType.NUMBER).description("공지사항 식별자"), - fieldWithPath("body.title").type(JsonFieldType.STRING).description("공지사항 제목"), - fieldWithPath("body.content").type(JsonFieldType.STRING).description("공지사항 내용") + fieldWithPath("message").type(JsonFieldType.STRING) + .description("에러 메세지"), + fieldWithPath("body").type(JsonFieldType.OBJECT) + .description("공지사항 데이터"), + fieldWithPath("body.id").type(JsonFieldType.NUMBER) + .description("공지사항 식별자"), + fieldWithPath("body.title").type(JsonFieldType.STRING) + .description("공지사항 제목"), + fieldWithPath("body.content").type(JsonFieldType.STRING) + .description("공지사항 내용") ) ) ) @@ -125,12 +134,16 @@ class NoticeMvcTest : AbstractMockMvcTest() { .tag("notice") .description("공지사항 생성") .requestFields( - fieldWithPath("title").type(JsonFieldType.STRING).description("공지사항 제목"), - fieldWithPath("content").type(JsonFieldType.STRING).description("공지사항 내용") + fieldWithPath("title").type(JsonFieldType.STRING) + .description("공지사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING) + .description("공지사항 내용") ) .responseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메세지"), - fieldWithPath("body").type(JsonFieldType.NUMBER).description("생성된 공지사항 식별자") + fieldWithPath("message").type(JsonFieldType.STRING) + .description("에러 메세지"), + fieldWithPath("body").type(JsonFieldType.NUMBER) + .description("생성된 공지사항 식별자") ) ) ) @@ -162,8 +175,10 @@ class NoticeMvcTest : AbstractMockMvcTest() { parameterWithName("noticeId").description("공지사항 식별자") ) .requestFields( - fieldWithPath("title").type(JsonFieldType.STRING).description("공지사항 제목"), - fieldWithPath("content").type(JsonFieldType.STRING).description("공지사항 내용") + fieldWithPath("title").type(JsonFieldType.STRING) + .description("공지사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING) + .description("공지사항 내용") ) ) )