diff --git a/.github/workflows/cd-back-dev.yml b/.github/workflows/cd-back-dev.yml index ee3c7402c..a91a8327f 100644 --- a/.github/workflows/cd-back-dev.yml +++ b/.github/workflows/cd-back-dev.yml @@ -4,7 +4,8 @@ on: push: branches: - dev - paths: 'backend/**' + paths: + - 'backend/**' workflow_dispatch: concurrency: @@ -37,22 +38,23 @@ jobs: - name: bootJar with gradle run: ./gradlew bootJar - # 2023-11-23 기준 EC2 프리티어 사용으로 인해 DEV 환경을 PROD 환경에서 실행함 - - name: deploy use scp - uses: appleboy/scp-action@master + - name: Docker Login + uses: docker/login-action@v3.1.0 with: - host: ${{secrets.FESTAGO_PROD_IP}} - username: ${{secrets.FESTAGO_PROD_USERNAME}} - key: ${{secrets.FESTAGO_SSH_KEY}} - source: "./backend/build/libs/*.jar" - target: ${{ vars.FESTAGO_PROD_JAR_DIR }} - strip_components: 3 + username: ${{ vars.DOCKER_HUB_DEV_USERNAME }} + password: ${{ secrets.DOCKER_HUB_DEV_LOGIN_TOKEN }} + + - name: Build Docker images + run: docker build -t ${{ vars.DOCKER_DEV_TAG }} . + + - name: Push Docker images + run: docker push ${{ vars.DOCKER_DEV_TAG }} - name: run application use ssh uses: appleboy/ssh-action@master with: - host: ${{secrets.FESTAGO_PROD_IP}} - username: ${{secrets.FESTAGO_PROD_USERNAME}} - key: ${{secrets.FESTAGO_SSH_KEY}} + host: ${{ vars.FESTAGO_DEV_IP }} + username: ${{ vars.FESTAGO_DEV_USERNAME }} + key: ${{secrets.FESTAGO_DEV_SSH_KEY}} script_stop: true script: ${{ vars.FESTAGO_DEV_DEPLOY_COMMAND }} diff --git a/.gitmodules b/.gitmodules index 550533915..76d6e720b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "backend/src/main/resources/festago-config"] - path = backend/src/main/resources/festago-config +[submodule "backend/src/main/resources/config"] + path = backend/src/main/resources/config url = https://github.com/festago/festago-config.git diff --git a/android/festago/data/build.gradle.kts b/android/festago/data/build.gradle.kts index 03e2336ec..912a80fbb 100644 --- a/android/festago/data/build.gradle.kts +++ b/android/festago/data/build.gradle.kts @@ -78,6 +78,13 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") + + // room + val room_version = "2.6.1" + implementation("androidx.room:room-runtime:$room_version") + annotationProcessor("androidx.room:room-compiler:$room_version") + kapt("androidx.room:room-compiler:$room_version") + implementation("androidx.room:room-ktx:$room_version") } fun getSecretKey(propertyKey: String): String { diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dao/RecentSearchQueryDao.kt b/android/festago/data/src/main/java/com/festago/festago/data/dao/RecentSearchQueryDao.kt new file mode 100644 index 000000000..611889f8c --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dao/RecentSearchQueryDao.kt @@ -0,0 +1,22 @@ +package com.festago.festago.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Query +import androidx.room.Upsert +import com.festago.festago.data.model.RecentSearchQueryEntity + +@Dao +interface RecentSearchQueryDao { + @Query(value = "SELECT * FROM recentSearchQueries ORDER BY created_at DESC LIMIT :limit") + suspend fun getRecentSearchQueryEntities(limit: Int): List + + @Upsert + suspend fun insertOrReplaceRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity) + + @Delete + suspend fun deleteRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity) + + @Query(value = "DELETE FROM recentSearchQueries") + suspend fun clearRecentSearchQueries() +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/database/FestagoDatabase.kt b/android/festago/data/src/main/java/com/festago/festago/data/database/FestagoDatabase.kt new file mode 100644 index 000000000..6db6158bf --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/database/FestagoDatabase.kt @@ -0,0 +1,11 @@ +package com.festago.festago.data.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.festago.festago.data.dao.RecentSearchQueryDao +import com.festago.festago.data.model.RecentSearchQueryEntity + +@Database(entities = [RecentSearchQueryEntity::class], version = 1) +abstract class FestagoDatabase : RoomDatabase() { + abstract fun recentSearchQueryDao(): RecentSearchQueryDao +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DaosModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DaosModule.kt new file mode 100644 index 000000000..6ad5ca163 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DaosModule.kt @@ -0,0 +1,17 @@ +package com.festago.festago.data.di.singletonscope + +import com.festago.festago.data.dao.RecentSearchQueryDao +import com.festago.festago.data.database.FestagoDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object DaosModule { + + @Provides + fun providesRecentSearchQueryDao(database: FestagoDatabase): RecentSearchQueryDao = + database.recentSearchQueryDao() +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DatabaseModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DatabaseModule.kt new file mode 100644 index 000000000..2c4e9c218 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DatabaseModule.kt @@ -0,0 +1,25 @@ +package com.festago.festago.data.di.singletonscope + +import android.content.Context +import androidx.room.Room +import com.festago.festago.data.database.FestagoDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + @Provides + @Singleton + fun providesFestagoDatabase( + @ApplicationContext context: Context, + ): FestagoDatabase = Room.databaseBuilder( + context, + FestagoDatabase::class.java, + "festago-database", + ).build() +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt index 2ca7f616d..556bcf3da 100644 --- a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt @@ -1,11 +1,15 @@ package com.festago.festago.data.di.singletonscope +import com.festago.festago.data.repository.DefaultRecentSearchRepository import com.festago.festago.data.repository.FakeArtistRepository import com.festago.festago.data.repository.FakeFestivalRepository import com.festago.festago.data.repository.FakeSchoolRepository +import com.festago.festago.data.repository.FakeSearchRepository import com.festago.festago.domain.repository.ArtistRepository import com.festago.festago.domain.repository.FestivalRepository +import com.festago.festago.domain.repository.RecentSearchRepository import com.festago.festago.domain.repository.SchoolRepository +import com.festago.festago.domain.repository.SearchRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -27,4 +31,12 @@ interface RepositoryModule { @Binds @Singleton fun bindsSchoolRepository(schoolRepository: FakeSchoolRepository): SchoolRepository + + @Binds + @Singleton + fun bindsRecentSearchRepository(recentSearchRepository: DefaultRecentSearchRepository): RecentSearchRepository + + @Binds + @Singleton + fun bindsSearchRepository(searchRepository: FakeSearchRepository): SearchRepository } diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/artist/ArtistSearchResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/artist/ArtistSearchResponse.kt new file mode 100644 index 000000000..098e28470 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/artist/ArtistSearchResponse.kt @@ -0,0 +1,21 @@ +package com.festago.festago.data.dto.artist + +import com.festago.festago.domain.model.search.ArtistSearch +import kotlinx.serialization.Serializable + +@Serializable +data class ArtistSearchResponse( + val id: Long, + val name: String, + val profileImageUrl: String, + val todayStage: Int, + val upcomingStage: Int, +) { + fun toDomain() = ArtistSearch( + id = id, + name = name, + profileImageUrl = profileImageUrl, + todayStage = todayStage, + upcomingStage = upcomingStage, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolSearchResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolSearchResponse.kt new file mode 100644 index 000000000..a8ab759a7 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolSearchResponse.kt @@ -0,0 +1,20 @@ +package com.festago.festago.data.dto.school + +import com.festago.festago.domain.model.search.SchoolSearch +import kotlinx.serialization.Serializable +import java.time.LocalDate + +@Serializable +data class SchoolSearchResponse( + val id: Long, + val name: String, + val logoUrl: String, + val upcomingFestivalStartDate: String?, +) { + fun toDomain() = SchoolSearch( + id = id, + name = name, + logoUrl = logoUrl, + upcomingFestivalStartDate = upcomingFestivalStartDate?.let { LocalDate.parse(it) }, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/model/RecentSearchQueryEntity.kt b/android/festago/data/src/main/java/com/festago/festago/data/model/RecentSearchQueryEntity.kt new file mode 100644 index 000000000..2076f4bbe --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/model/RecentSearchQueryEntity.kt @@ -0,0 +1,18 @@ +package com.festago.festago.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.festago.festago.domain.model.recentsearch.RecentSearchQuery + +@Entity( + tableName = "recentSearchQueries", +) +data class RecentSearchQueryEntity( + @PrimaryKey + val query: String, + @ColumnInfo(name = "created_at") + val createdAt: Long, +) { + fun toDomain() = RecentSearchQuery(query = query, queriedDate = createdAt) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultRecentSearchRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultRecentSearchRepository.kt new file mode 100644 index 000000000..f6506e01c --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultRecentSearchRepository.kt @@ -0,0 +1,39 @@ +package com.festago.festago.data.repository + +import com.festago.festago.data.dao.RecentSearchQueryDao +import com.festago.festago.data.model.RecentSearchQueryEntity +import com.festago.festago.domain.model.recentsearch.RecentSearchQuery +import com.festago.festago.domain.repository.RecentSearchRepository +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class DefaultRecentSearchRepository @Inject constructor( + private val recentSearchQueryDao: RecentSearchQueryDao, +) : RecentSearchRepository { + + override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { + recentSearchQueryDao.insertOrReplaceRecentSearchQuery( + RecentSearchQueryEntity( + query = searchQuery, + createdAt = System.currentTimeMillis(), + ), + ) + } + + override suspend fun deleteRecentSearch(searchQuery: String) { + recentSearchQueryDao.deleteRecentSearchQuery( + RecentSearchQueryEntity( + query = searchQuery, + createdAt = System.currentTimeMillis(), + ), + ) + } + + override suspend fun getRecentSearchQueries(limit: Int): List { + return recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { recentSearchQueries -> + recentSearchQueries.toDomain() + } + } + + override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries() +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultSearchRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultSearchRepository.kt new file mode 100644 index 000000000..322fbcb23 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultSearchRepository.kt @@ -0,0 +1,33 @@ +package com.festago.festago.data.repository + +import com.festago.festago.data.service.SearchRetrofitService +import com.festago.festago.data.util.onSuccessOrCatch +import com.festago.festago.data.util.runCatchingResponse +import com.festago.festago.domain.model.festival.Festival +import com.festago.festago.domain.model.search.ArtistSearch +import com.festago.festago.domain.model.search.SchoolSearch +import com.festago.festago.domain.repository.SearchRepository +import javax.inject.Inject + +class DefaultSearchRepository @Inject constructor( + private val searchRetrofitService: SearchRetrofitService, +) : SearchRepository { + + override suspend fun searchFestivals(searchQuery: String): Result> { + return runCatchingResponse { searchRetrofitService.searchFestivals(searchQuery) }.onSuccessOrCatch { festivalResponses -> + festivalResponses.map { it.toDomain() } + } + } + + override suspend fun searchArtists(searchQuery: String): Result> { + return runCatchingResponse { + searchRetrofitService.searchArtists(searchQuery) + }.onSuccessOrCatch { artistSearchResponses -> artistSearchResponses.map { it.toDomain() } } + } + + override suspend fun searchSchools(searchQuery: String): Result> { + return runCatchingResponse { + searchRetrofitService.searchSchools(searchQuery) + }.onSuccessOrCatch { schoolSearchResponses -> schoolSearchResponses.map { it.toDomain() } } + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeFestivals.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeFestivals.kt index 382516c38..fe695f2ee 100644 --- a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeFestivals.kt +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeFestivals.kt @@ -391,8 +391,8 @@ object FakeFestivals { Festival( id = 1, name = "뉴진스 콘서트", - startDate = LocalDate.MIN, - endDate = LocalDate.MAX, + startDate = LocalDate.now().minusDays(5L), + endDate = LocalDate.now().minusDays(3L), imageUrl = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAoHCBcWFRgWFRYZGBgZHBweHRwcHBwaHBwkHRoaIRwaHhocIS4lHB4rIRwcJjgmKy8xNTU1GiQ7QDszPy40NTEBDAwMEA8QHxISHjcsJCs0NDQ/PTQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NP/AABEIALcBEwMBIgACEQEDEQH/xAAcAAACAwEBAQEAAAAAAAAAAAAFBgMEBwACAQj/xABEEAACAAQDBQUGAwYFAwQDAAABAgADESEEEjEFQVFhcQYigZGhEzJCscHwUmLRFCNygpLhB6KywtIzU/EVY7PyFiQ0/8QAGQEAAwEBAQAAAAAAAAAAAAAAAQIDBAAF/8QAKREAAwACAgIBAwQCAwAAAAAAAAECESEDEjFBUQQTIjJhcaGB8CNCkf/aAAwDAQACEQMRAD8AVxtFh+7qWVtALkMOFNYkxUgsoZGIK3HjS8XpmykTKaOHDk5wwX3b91Retd9d++Ks1yliFA3G4NLUBO/T1jGpZG9Pb2CBjDmIfutqeDUO4fCeY/tBlMaJpBykhQLAEljwAgPiVVr2J51AF+lzEuGmNJBdbm6WJGoJBHDQdYok0BpVjYY2pPVFKFQxcEUvoaWtpcn+mKEl+8agCvU7qam+4GPaoZjgOwUAX8NaeNYqSSWL5b1JVRwoRT6RXAuNYCEvFIjK4LZiXqLZaiWAL0qPdpTS8Wnnh5C5SSrOutyFpoSNTaAb4dhMAmpmSgGZTdLnvA9DWhB3cIKyZTSc0slGALZSDUMCpowobG4PKEdKSlS6lP8A1FnCrRG0qWPkPsxXxErum1XYAVPARPsNWmUVRU6DiSxJ8qAx219sYfDEhV/apu+pyyUO8Cl5nyikz22SUtgPa7Ay1FRUsK8bA+PCK8vETFp7FyDQVAagNrg3glM7TTzQqyywVFElqEUW3bz4xG23pjikzJNXhMRW9aBgfGKdVjyWmal5TPK7UNGcgs9DUWUC/wANflfSBq49iNTQmpGtAdaeMWpqISGlgpxQktT86ObsugINxzF4H4mVQ1GulNK9OMI110ylfUctab8Fqa4cLQUFSda1/Ma6dOUfcEwqaXqQFANyKWrwNSbRVrUBEFWF91uVTaLOFlqmfvUcKQDQhVJoKk0rXvU3U1pvhW2lhE+rp5PWL7ikrqRfQ0B1J5XA8RFJsYcmUWBued6U9I94aSVcM5qtDowNbZcp13ct0c+Bd1zIoIzEAA3FbgX4X84PsouJudbJsFPohbeFP1/SLm1u1bz1RAuREQDKGpUqBS4+EEVpv3wADtTJS/DpHTZeWnEwUvIJqoyl7C2JZHspZQcuVSKk11GZTSo58IqqL5EvrU/XpHhzlloALsY9JOZFotATwAqfvlAUi0+zzgjnJQ053PGLmy5dVcHgfkInl7dnyaKroCbmkqTblXJUniTBaV2kV+7iZEuYv4lUI45gi3gKdYr0yvIrl4wA8Xh+8aWNAdaViDDzGulTVteMMe0NjIU/acK5eWPeU3dKajiQK6G4F7i8AUIDFhwidS5AnrBN7c3VdafO2U+fpHuSjCaFUFu6BZWuSBu11BiEzPiFjqYqM7uS5JAFhr9IRTnbKJfjgc5UicUWWolKwFw8xA2tfcBqPGJjsvGpdpauOCsvpWkZ+kmkX8DtKbKNZcx0INaKxAbkV0PiIdcHH7RRc3IvD8DOdpZXCTJby3O57A9DFidiSFY5BYH4uXSLeA2gmPleynhc7A5WAAuOHBhw0PlVamO6Z8O9ypoCTelRpxsa9DyiPN9Kp/KfBo4vq201XkIYfFF60AFKbz+kWUVjvXyJ+sVdnYU5agqK3vWvL0v4xeVCCFLLU1Isd1K7+cZbSTaRr4rblNs+ZW4r/Sf+UdE2RuK/0n/lHRMr2L2y8KHIaaMtAfepehrVToylb1vvgVi8VKMxyjiU1R7M2VRlrUsRvOmh1pBLF4kTkRVAFUV3OuWu4V0P3xgTtQoRLTIGoc6FgAQbhu8vvITuNbr5a5TpJ/J49uU2l6/soYbFBhWhAv8AzcfA3ED5IfEOsnDpncnUaW1N7BR+I2i7twnKqL7z604bwOv3rDllTZGDByq2JmD1G7jkSviettfHCxlmZLOwM3ZCThlD4/GFWPwI2UHiBUFn8AIH4rauzFPcTEORowZx/rcfKFTaWNec7TJjl3bUn0A4AcBaKJirQ2PkZMXi8PNbMk2ah/DMAAPLMlfUeMXsJShRRnotXuN4F1YGhAFOdzCZF3B4oratt440P36xK+PstaHnqnscNnzAkl8lnnEoN+VFGZ6dcwHQNC7tHZxZqJfQUi1Lx9M5qKZDlF7ZqZtfARf7FAzZ7M1wtPlCS3MlFKbLGB7JOZQL2YaQvbQkNLcowow+wY2tUGWkJPbfYodC6++gqOY3r+kHa2O5TWhGkEOMh1N1O8H++ke9npnqGzFkrZSFNhrmP3YwNlzTYjURcd8kzOtO8K06g13aboatolOE8tZLUhTMYS5Mu7HRSanhbz1ra+6GBOysmUK4zEpLOvs0YZh1NyT0B6xBKxTSZOde67rcixVTYIp+EsQSSL5VFKVhQxEwsSTvv/eDCSR1Zex6EvZGntHPP999EpH0dnMJN/8A5cSQ+oGYMfKiuIz2LMpbAjWKJ/KEw14YU2pgJmGmEz1zFhRXF1Ygcaa8jQ2gNlLHMYbtlbUM1PYYnvo/dDH3lO6p60o2oPovYqQZTtJa5VqA8RuPkR5xOkltHPPl+QjsnZ8yeVkShSgBdyD3AScq131FGtx5Q54TsKiULEMRx/SLnYbCokhbgu9XatM1WuARyWgpyhuKwPJaZSRjvaXYhlvmOkLrON0bbtjZ6TVKuKg+Y6RkPaHYDyJlFBdHYBCBepNApG4/OCqa0C59ok7KbUeViFVAXVzlZBTvcKVNMw3eI3x929IEme6KKKe8otodwpu1p0EGcHKlYGQJk5ZbTwbKD32zZe7UrVcqk5qUFOOeFnam0TOEt2NXC5WO85TY132MO9LDI4yfcBhXxExZSGm8ncBvJjT9m9mJSSwrLnPEwmf4fr33bfYfX6xqUkWiDeWaZlKRA7T9m1RS8paU1A9aQlKt42fHzpYqruoPDU+QjKNvYYS5zZPcapFiOdL9aeEGaa0Lcp7R72VMKuUBpmOZCNzLcU6geYWLXaDHh3lYigOZRnXS6Ehx5FfAiAsvEUKsNVII8DX9YI4rZhmTnRGAABdahjXNk3KDuC+UUqvx2TU5ei5L2i5VVlkG1bChqb3JtHmVOmo4ZgC17V42vc/YgbhiUVkYUdWynkBw+90XvanMWJrXnpbwjDSSeMF8PqsN5/pFyY7kkk6+EdFP2jDRa+UdFM/x/wCEel/LGZJqSkehJNAVBvUgGgqBQ3PKPuPwLGYk8igmLULWympNBwrmB6kmKeKwLIxAIaihnGlNMxUcAT4CPkvHTCgQFCijMuYkEAe8Ad5ArY8I5JacidnlxSw/X+Cm8xTilJ0V06e8tfD9I7tNtg4vEO6+4gypyArlPUm/jygHjpxGe9yAfPNEOCm0Q86ny/8ArGnOFgMolbCrULxzekV2wdvvgTFue9HT71C0iuiTJhZJaM1K1oNLUhu2juu8FZ5AvePKyu8Rx062gjh+z89zcBa0sTfyj2dizker5VAbUkU1pA7IZQ/gEBzcHgRDP2FxuRplFq3dIuBx4wuY2WBNYBgwJsRoaiPmz2AmJmNFJAY8id/LjCtZQ0vFGt4TbTu1GC/ysGp1irtqQ7vpm0oDXLc6kbwIn2VsZJVWAF72rv4V3coKY56Ijjp1hcfJfwZJtvY02V+8dRlZjXL7qkk0HjFeQM4TkaeRNo07tZLV8FMAFapmHVe8PlGY7KFacjbxFvkYPolUpUg5tdKoiV3VJ6gAf5csD02cCpPG31+kWe0OJpMKjccvgBQeoEeZd5LCjhgoIqLGnvUI5QnZpDdU2DjgxTXcT8o94zDBNOA+VYrzGbMdaU+d4Iz3LoMqOxCpelqgX1g92DqiLYk4PmlNq2h690+VQ38sQ7Vnl3lt8ZQI3MqzCvjYxUkIyzVyggg1pobCtIvHCs2IDKKoroTcVFwa0rUi+4QewvXKHLAY4rkV5VCSQrJUlcpp3qC3nvhunzWyAkkAjWBmCkD3gO8313wXdhnCHSlKfOAkXFqbteSjBWM2rCoJzafi1050iSZLWaupZaggmh0011g5P2alczCv3p0gbjHVBRRQQvgOMrZmfacO2JZWYuTlCipOoAoATYlqkgWqxihitnzJVBMTLXS6tpqKqSAbixh0w2CEyYz93NU0qK2Glet4G9sMShdJSgZhdqaLVaAfM+A4wytt4I1xpJsg7HO4L5HRLipa503bqRpWzMQzqVYgsBWo0I4iMp7MYQPNoaVW9DodQaiNT7PbOWSjBbd07yfe69I5rY0r8SniZplVMuXnajNwrSlgd7GunIwq9plnTZXtJiBctGsTUCtKEHfRuMaTJlqy0IgX2nkKZLqBQZWHH4T/AGjsYGxnRjSm/pDTs92E7DMpoWQKedBQg+UKyjd92rBmc59jKZDRlLLUaipP0MO1lNGdaeT7tLFg4nEBT3XKgEfkygkdcrece5HfYXoLaXPQCK2IwGRQ+ZaqqsVvW593SxpEiT8orlIVvdJHA36xHkjA8VmsvwFGwCjV/Mk/WPseJGGYqCJrUItcf8o6M3+TXr4CLMtEVAQGLZqnMaAgtfduty1iPbWGyOCoopTNTmKgkcK0BpziPAJQM53Cn948Tl7pYsSGpSpJpcBhff8ApFozlI87sqzlfAHQKxmEgHI6A1v3RbyNT/SIi2giBysoELSlCSbmtaVvS8Wl2Qy5XeYB7dSVVUZ8wahCE2yuLGlDSg1ipjnBcMvBai9mCgNruqPWNPVpjqk0RY9jnU/kQ+YBhs7L7DE3DsxZgHdjY00oK8/GFjaqZStP+3K9EWNG7FUGGQdfnHawUhZbO2VsII600UU/ipvPEx67QbHVnBYWJDLrQMvIa+PODqvRqgV5VpArauLmNlV1UUNajToPCFyh8GY9psIJU+3xAOeuY1+QgW6UcjmfnBLtXiM+Jf8AKAnkKn1Y+UVp5UOzEmvcIG42qa/e+H9EH+o0TsrtTPJCMalABXiPhPl8o94+bJJKs7veuQGoruFoR+zuPZHJGm8cjw6GHjC5XBKTMldQANeh0MSrOcGrjcvbKuM2lnlOmQoApFG1pThuhN7MS801VPFD5NT5MYOdpZySkKIxd3PeJNTzJgR2WtPT7+IR28E6w60W3wLTsSVU0JLmtK6m0NeC7PzQ477FQBYgU/MTx5dYAbKmZMWhO80PiBT1Maej2FIE7Q+MAHbOyE7gVVDEXNBC9itgTQe47La4AFM3L8vrDNtjGrnQKbjdQ18osYmeAleUNhCpNmbHClcUFe5owJ49wwKfFlJj/mVR45Fv6mD7PmxaN+IOfRqfOFnHrWa28AgmnAKtY5LOmCn12jQ8BtJDKQu+WtKEGhrBvZ4QtmE0ua1oSNYRuyU/2paVoUoy1vXcajqfWHNNl5SGcJbTKKf3gNNMeXLnLewniZ9oW9pTtYkx+PCamFjaW0mylwLDcfivp4wHs7thEeP21NlpkSRlO56FgAa0NKUDU4neLQrrKcsCwapvVgannU69Y0fAykdGnFw3ABAcxNwe93cvdIJtqAbwH7SYnP7IZACFZ1YWOU0BXLUgDNpSgsRGhcSS0ZHyVT2LGzMWZU5X3VoehMauk9mRWRwgYd6txy8YyTESSHUDfQjxNvlDPsLaCTCMO1QVpkqTQkC+lK77c+URpey3HSz1Y84XGSpfvzszU+JhH3a80Mh6H5RXwuxiO8wRV1oq68yaRS7QYpUQ3udBCNvBe+qf4sznDyx7cIfxkfOLuFQmVl0ImeIqoH0igrET0b86n1qYuj3Z68H+rxaTK1sI7UlvLkuj0rXKNzWyrfj3Qp1pHbMCzcMoagAGU8stq13HfXnA6VmaWwZ3fuoBmZmpmOgqbeEeez05iry1pUiorpwbTwif1K7SmvRT6dqXh+yWZsuaCQBUDfpWOj5I23kUJV+7bX+8dEv+T4QccX7jGuFqhQg+8F5mlTu3W3QL2lLCIaVvxO8ilelYNzZ6gihNSwII7tDlvvqNYH7ewtJCvUkMXAPJaX53rDcT7NMzSkm0iFJzOqmUQwyMPeHdIQhSTXulQRwsQICYrKQAprYZvysQMy133BvzihImPlKIzZXIqo+Lh/4hlTs46Skcg5mrmWlOYI5iNdPSDHG3loEbRfMks78gB/lZl/2iHPsPjwZIWt1tCdi07lPwk+TbvMHziTs/PdMxQ0IIt1/8GJUtFY/UafiZr6o2UbyAGbwrYQq48OhaY7zGy1PeoKmlhpvjv/zcSjkeWWalypFvOBW1e0LYgVC5VFTe50heuS33cS5WBWZyxLHUksepN/nE2N+A8UX0t9Iiy92v3qIndKylP4WZfPvD0r5RUyFvs5/1eRFDw5Vh1bAqw0vxhc7BS808gioyGvmLxpv/AKctCT3SNTu603R32HS7JjrmU6YgbU2TRGIG4wG2HMCTZZP42U+IBHqPWNF2lIX2DubjKSLU++NIyx3oKj4XVh99aQjip0xu0vaGTbCMk/u/iSniAR6rD1gdpFpKvLysSAbm3W2sKWPlLiJKzBfMlD1Sv6tEXYvaTSnaW/fl69CTqBwNjTnE8YK52mMOL2jPrVllHo1Sf8sedo4s+zvY0ghiMZIoSqqDyW/yhYx81nYWogPnHeF5HqlT0sFGSP8A9iv4E/2wuYl8rzDvOYCnPTdwHKGU9yXMmH3mrl8P1ovgTCfiAaLXfUw05I8jWMHYLGPKcTEbK438a6gjeDDlhO0eJmqASoqNVB+pMIpEN3ZcVQV3Ej1hqEh7CCYMsasSTzgd2mTKiqN5htlywBC32rTuV6/KES2UrwxdwG1ZkpHRCMrUIqAcjAg5lBBFaCnjXjWfZjlnzOSzFWufE09dIGJpF3ZT0YHkT/laLJvwZ8LySqAWklt2YHdZRX9YEymYUcEggihGoOsENoqVSXXUiv8AlFfUnyjxKkdxB+Jj9+kdWPB2N5D2zu1OJdchZbWrlv8AOlYtthWc5nJYneYDdncGxJaljaHbD4e0Qrzo0T42Iu1cKVmVAsq35VqBHlSSZ54lD5sf1hln4AsJrnRiafwoKeVcxgDLQDPvqZd+N1MPDyJSxs84Y9w/xyR8v1gTh5rSplVsQSv0++kF0FJCnjNB8FCj6GC3aDs5mkidLFwO+OPExbp2kl26tClOVcxzChqa1F9d8dFuSkxhUOL11pWtTWtuMfIhgpsbMNhX7tHUhwVApu18Cb+UGu2uEEvByk+IW8wS3qRFLsvhWbEd+hVAW+iinMkecMG1ML+1Y6XIN0lrmf0JHiAg/mjuCPZKV+OSj/h32TVUGImqSx9wEbt7deENj4RZlWyAjQXPnrBaancCLaoAtuG+n3viVZQAoBpGxVgVpmW9p+zOQGYiNp3wL1HED8QoDTfSELCqZc0ruIN93EHyj9FzJQIhS7RdlJM8FsoR799beY0Pz5x1SrOVOfJiAbO5PEk/ODuEwJeTlT3mpXpS/wCnjFfauxHw03I4tRirDRrH1HCDmxCAKRC5c+S0YoWto4bIMvCg9XJ+kdhbl0PxCo6rf5Zh4xe7TrRyN+Yf6f1rFFxldHGuUMOuUfWO/kVoY/8ADdaYh7V7h5alf0jQcc9EJmMElrc6kngCaekJfYNAJzsNHVSOmtPCtPCGDasxsQURAfZknM3HhQHX+/hGmWpnJGk3WEdjdrI8mYoRwoWzMBlPKxjKVNbcVp4jT1jSMRs3LIZMrFySoYGmZdQKaWrSkU9ldg+77TETEVEqSqHMba5m0WlN1YlSdPRSfxTyL2E2s8oLKUWYA33HU26QQ7GIWnPmGqk+ZFvSAmIwoeZnQkgzSAN4FQRbdYw/dl9jtKYs4PeFVPEH7vGek8F5YUbZ44RTxmCAFhDIqg3iA4XOaAeMKpb0hnWNsy/bSEhg4soZh4K27gLecVdkbHGKTJnKTJeaoItRmqLcKU049Iee3OxV/Z8wqGU0JGpDmhB43C2hV7FgviUDnKVTKCLFgKUB50oOgEaInFJUQuuybQMxnZLFSz7hmIPiTvafl970hj7PbOyS0DWYipHAm5EaQ0sBe4brqBr1odb/AFgXiZQe5UBxS441+R05Wg8kT6Z3E37KEvC2hN7czMrpLG9XY+AIUedfSNGwUtaXqSNQbUjLe287Pj2pouRB4AE+rGE+00ssauRN4QtyTY+PyMGtj4EvMyUr8JpzsfrAjADvCtxUV6VFYf8A/D/BF3eYdS5+/OsPxzmidPCAPbLZTy6OwqgNFI0AI0PA1HjWBeASuThc04bo27amyEnIyOoIYUPP747oy/auwHwzit0oQrU8cp4MLdfMB+SF5QJv0w92bwg/Zkal718zT76QyYPZpYXGUHjr5RV7Jplw8uv4QfO8FtpTzkyIaM9qjUDeRwP1IhHwznIVytLAv7VkI6uAP3ctT0dlGv8AApsOJ6CqTNl0yHi6D/KkaHtjDhMK6qLZQo8SB9TGfYle6h4uD5ZRHOUtIMtvyVZiUlSl41Y+LgD6xqGzcOvsXD2Wx0rranM13RnKIGzfk9mo5mtTTxaNP2UlSpYWCgKDvNT3yPGg5dYrxvGSdrJnmJ7Guzsy90E2FdBuGkdGq/sojoP4fAM38i92ekBQgcAO9Ham4IDTzNT/ACiCfY3DZjOxJ1muQv8ACD+tv5RAmVNLHFMvwIUXx7v+35w57Hw3s5MtPwqPPU+sZ50i9JTiUWUFhXpH1zSOY/OIye8eVvqYZCM6Y26KWIMTs9i3E0EQThcDlFJEoWe0ezFxEtkIGbVG4MNPA6eMZ1sqdlcA8dOm6NXxC3I8oyTaIyY2cptR2I5Z+9/uh7lU02dFNJoq9oTWc3T/AG1+sUphrLQ8KqfX9YtbZNXB4j/bT6RTwneVl8R9+EZ7WKaKy9D52Aw59jn4uwHIUH1r5w2fsuXTQCgHCAfYyWVkSxxBb+pjDQTAqm9fA8ylsozEDFBuUFj1H9zXwgRtvGZcMw0Dk+NbKqjeSQT0g5MUsQgtm1PAbz4CE/tviQzpJlmgljvdWpQc2Cj1PCGjKTBe2il2L2eJmIL65O9TdUVC/L0jWEwwKBdKAU5Qs9htj+xw4LLRnOY11p8I5WJ84bVh+uJwSdZrJSl4Y1ykUpv3HpFxUC6R7JjzAmUjqpsXO07d0KTRfeY9PdryBvCP2EwqvOBLAkL6k2r5Q+doJBeXMUaupUeIP0rCB2PnCXigoBDMWR1PAaOOG9acucNSw0GXpmm4mRYOp/S45ag8Io4k565bEfZA5RLOGQmptUGm405bjFNwSMy0zC9POx5RK2WhFScWZC6mjqaHw0Pl9YzLbM+uMdmFDnrfoprz0jWZ0tUbPucUIjOe3GA7wnoKD3G5AnunzJHiIVXrAKj2LGCFD5DzjZOwGGC4ZGp71/Mk09YxqW1uZP6RvPZ6VkkSkG5QPJan1i3H4bIV6Cy38L+sAMfhlnq8s6OD4VHdPUWMFcXiAqlRq3oN5gergX38IvKJ0wX2ZxgeWFoVMvuMOagQTkjO5bcLDw1Pia/0CAex8Ree28TXHlQKPl5wy7Pk0UQtLCOllPtMn7in5l/1CM52kcrIPwhT/U6mNI7Tf9KnEj5GMv21OBnP+UIvrb5iI0i0+C1svDnI5/8Ad+UxQPSNW2eot/AIzrBJ+5H5phbzmAD5xoOBfugDUqPADfBnwBl+OjzkMdBALHZfD5jNU3H7pDzOYs3qTD6IRuxUwMHb8c8+gJH0h3LRFLRW3lnmZFZ294cT88sTO0VM3er0+6Q8om2SuKsFGg+Z/tEIu7HcLRKgNC29q08f7RGzBRQX4w6FYNxa3jNf8RMJSZLnLYkZW5090+VfKNNnEndCl2twftpTKKFgKr1Fx+njFmu04EWnkzfHv3UNYq7OmUcc/s+lYmntmlKfu1j8ol7ObMbETgi1oBmY8Bpv3nTz4RmtN0sey0vCNL7MKRKTkAPKDhffEeCwWVQFIoAB0intGaQci+8TTpxPlfygPjrtgpNy5LLYtUlzJzaKDTnTcOrUH8sL3Z3ZrzpvtHA98u4GgJay9RT/ADHhBfbiKstJABYM6AjWgDZjXxAHVoYtlYIS0AoATdqcTcxbrgm6yi2qUEVm2igYi9BSrWoKmgreovxET4mXmQrWlQRxijIWW4KhVKq1LWWq8B0I6xwmAhJnK4qjBhxBrEjaQMw+DWW6lAQDVHFT1Vtd3e/q5QTeAEo4pO74E+VIy+bhsmMHstQwbW5zu2bwFdOEalid0ZltruY9GJIAdctN4JAI6XPrBpaT/c6X5Q+TRnYV3W9LmI2ojVax4daRJMQoUJNc3epwG+PkyRmVnOpPpC8k5WUUmurSYOnPm18OUL/aTDF5ExQL5SR1XvD1EMmSKuKlVHKMi0y72jI9mS882Wn4nXyqK+lY3jD4hUlB3NAqk+ZoB9IyDs9sspjihH/TLHqPhPkwMahj5ebDa2UAn+UkHpxryjXx6hsyVukjlxGYM9QTrT5DkIrY7aqJLeYRdad3mTRR4n6xVwrZaI5pWysNGruPPlv3RWx8gBWRzUmuU614eIrCL6h5RV8Cwyt2dd3mtRSBRcwNK1Fe+RpwjQJKUEL+xJS+8BcgVPGghll6RenoypALtS9EB4MD6GMgxczM7cXdR1y2P0jWu2JIksQND8oyAD95QfCD53r98ojZohaHXCN+6QcSv/yKYfsBYGvj+kZ/hF7srkfv6RoGASoFdPnBnwLXkt0Y3zEcuEdFio5R0HIBJ/w/nBkoPhm+dZbesaDGU9hMRleQNzz5lfCWoWv9bRq5ETXgZ+SNorusTsYgcwyFOxWJVRVjusoufIQDxm0XuEQKOLXPkLCCU4a21gViREquk8IvETjIJxDu/vux8aDyEQIoUNYa/SLjiA+1cXkBJ8uPKH4axabYOVNw0hF2koDTVGgdiPEm3hWGzsNsnIhmOO89KclFcp6mpPSkBdk7LadMo5qCcz+Pwg+kaNJQKtOEG9NZEhaJhizLFdRwP0gZsxvbT2cml6KN4UC551pr0gb2j2kUWi3YmgHz8hFzs7iwyrSo0rUV0NSKjpBi/ljPjxtIZsG9HZGFSACacDcE01grhp4YQBlzD7WYy1HdQA01s1QK67oLYKYlKg3OsU8rILZX7QtSSb0qQOd9wj5gMOqIFrUG9RoTQeekWsYFZcr3BPy4c49yhpSgFKU3ADQffCOzoRaeSqMUBOKVpYW0vqCOPDx6wRd4gEtT3iBXcd4HLhESTDUg2/trby847yBn2cYWtq7ER5qT3cKJdyKXanuAHRaHWGCbMpCj232tklSEFi80u3NEygjoSR/TBp4Q0T7ZdkT3mTte4i01rUnfXgBm8zBxJ4pQAkC0IuJx5lTZdK5XAVgKcTQ+fzhqkY45RkUDrf8ASDVylsFTVVo9FCp92i6Cu6KeLm7oi2jMY0ZnY5SDStBbXui2lYgmX3xhuk60aolqdlHBy1XElzZnUL/ST60b0hgxLN7FwDSjAnpYnwrfwgGUBYBtK+XA13dYP4EmhVr1Gp38jzp5xr4X2jBm5V1vIJzqqhGAKtYDgeA5fKKM4k2bUGx16ffKLuKk0JQXTVeVDp4VFDwIivPF4xcqc1hmqGnOUENjYind4QzyptoSMHJYuSpuKQwYZ3pSlfn5GNkV2lNmO5xTRX7Xzf3VOJ+kZdIkd+w3keh/UQ/9oWZ2CcB9+nzgJsvZlHzH4QT9/e6Ep5rRaJxOWWfZ5UqN2nhDPs7ablRSWD1enyUwB2nMKIBa9BbUE1vXzi/2dvLHj8zEq5XL6oZcapdmGv8A1Kb/ANtP62/4x0eKR0J96hvtSInZLEFWwwPwzx5MZan1PpGy1jB9iTSGSmqzFbzK/VR5xu7HlT5RefBnfk8M0V3eJniBxDyIyGYLQOxKxdmroef0MVZsR5Visl+J5QJnrCt2olM4QJqXp5g/pDlPl2gLPk5piD8xP+Uwipp5RXCawz1sXACWgGp3k6k8YvYmdQR0xsopAXauKIWg1Nh+sGqflgUgnFyTPmVDlcthoRzND92g3seSyNRiKi9QKDhfzr4QKkogADoacQCQOdRdesGcAoCswYld1TmpTW5hYWaKPwMiZx8KsOIN/I/rHucnAU6QK2JtKW6Kocq4AzKSTc666iulIJTHNbkHpHKsMSkeTMcaNUc4nTHUFGFOYiJW4xwpFp5GSc5LjYkFajf1Ou+sffaVECpszKe6aV8o9JiqClB4RWblk6l+iae7aLq1r310jM+3E3Ni3TNmWUFljqK5z1zlvSNL2fMDTk5En+kE/MRku3Afbux1LknxmMfrA5a9I7jT9hDaU6okMTegOvDLDlgHqohKY1Cj8OYf5j9KQ0bHm1QdIlyvwysJttBDE6GBmAnVXKdVJXwBt6UgnN0halz8k5x+YeqiM7+S0/AXmLBTAMWWtzS1qVHIg2IgYrVEeRiXl95GI48/Axbi5Or34E5OPsv3C+MwpYFhUMg5Co4EVPnAjLmuIC7c2/OMxURwpAsQAMxNaoa20oRFTZe23R8rjNqeBF72036Q3P1vFInxJzlMbZEhl7wtBaXiDS4DehgRK22jDKN/GL0ieraGIzVT4ZVzNeUVMXLPeYklntodNdfACJsNh0RbjMTwB+touGhj4cODFJ5cPLQtTlYTFTtBPOYLoNSBx3V9fOCnZiZWWRwY/IH6xaxGxkf3l9THzB7L9lUoSQdVJ+RjNbbrsWlJT1ClY6KP7RxBEdAyHqzP8BLAmKdRmX/UP0jd4wDA4gg11AYW3noPD7rG8y8UrAGoFeNj4xq43rZg2z6wiCZEj4hfxL/UIqTsYg1dfCp+UWTQGmVsUTYdYhBqOlogxu1UBtmNtykfOkUMHtdWmZKEBtCaa7h4wnLUteSvFNJ+AjM0gRMtMU8K/KCeIakB8Q14hksfMTOrWFnETC75joLDpx8YNTlLDKPGKwwtKeP36xojh7Tlkq5etYR4w4YXRgw3qbMOh/WJMXiO4RRlFL0p43ibA4daFiNanSsRYvAlla4UEWH2d8ZvBoTySdntoywhlvLzjMSCwBrUDUcYaZ0t/hTyI/WErs8n71EIvmr1AuflGly0jQuKXszVy0ngBksNUPp+sd7XkawfaSI8HDjhDfakT7lCxiJo4x4SZBzFYFG1AgXidmlbqfAwj4qXgZckvyT7OngPXkfUQjdo9nPMc5FrRmGoHxc+kMTMy8REOcCEpvGGUWPRU2Vgcq0dBXwMFFkIBYAdLfKIP2iO9tWEb1hjIsvMtCjj3K4l+By355RDK0wARTbBCYDUXr/4h+OO2RarrhnjBYm0e8ROqIoyJDqcpU1HKC+CwJIJcail+epjp4adYGrkSnIo4zDlmzhS1R3lHvEDQr+ddRFvDFSoeZ30FlnIO8n5ZiC460p0gphZGWYVO4hfKsEJ2w6uXkuZUyl2UAq3J0Nm6xvmJx1fgx1yPOSrh9m51DS2SYvFSPlE4wrrubxB+cU5uDdCWm4Z6/8AewjFWPMpvPMxPh9qUNEx5Qj4MRJFR1cUha+iT3LGn6lryi5KxbrYg+UXpWOB1sYHvtByL7QwoHEICfItAstKLg/tGIxcwfDKUoniBu8Ym/onjz/Qy+qWdobVxEev2iAeAwWKJLTAktT7qCrOP4mrT08oszpcxNRWMt8Fz+/8GieaaCP7QI6AvtW/C3kY+RH7d/DK95+Sp2Z2SrTQ7AdyhHNm92vSjHqBD8mkfY6KMhx/pRDMgfiTHyOhGUQHxUCZ2sdHQjKILSNtqQFeobStLHy0jy00uaDTjH2OjRxynjJC21nBNLkXPKgj5Nl+9/D84+R0eg/0v+DH/wBiRUooQGm80t5nf0gdi8PJDAMMz7q1J8zaOjo8yj0EXOzWFVp2cD3VNK6itqfOHZFjo6NfF+ky8v6iSkfGEdHQxMrzFitPSOjopIjKMyVXdAfG4QC4FI6Ohmk0BN5AGGmOwqSNTFpVblHR0I+GPgouWvkmkYYse9pBjCyKR8jotHHMrRLktt7L6yRaoiR5IAEdHRxwCx2HyzlI+LXqBT9ILYZfvxjo6OAEJK7uEe5mDVrMoPIgH51jo6EbGRANiSK19jKr/An/ABizLkKtlAHICg8hSOjoV02FJH32d6UG71iHHyRbrT0tHR0D2EH+wBjo6Oiwh//Z", school = School(id = 1L, name = "고려대", imageUrl = ""), artists = listOf( @@ -406,8 +406,8 @@ object FakeFestivals { Festival( id = 2, name = "아이브 콘서트", - startDate = LocalDate.MIN, - endDate = LocalDate.MAX, + startDate = LocalDate.now().minusDays(2L), + endDate = LocalDate.now().plusDays(1L), imageUrl = "data:image/jpeg;base64,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", school = School(id = 1L, name = "연세대", imageUrl = ""), artists = listOf( @@ -441,8 +441,8 @@ object FakeFestivals { Festival( id = 3, name = "아이들 콘서트", - startDate = LocalDate.MIN, - endDate = LocalDate.MAX, + startDate = LocalDate.now().plusDays(5L), + endDate = LocalDate.now().plusDays(6L), imageUrl = "data:image/jpeg;base64,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", school = School(id = 1L, name = "연세대", imageUrl = ""), artists = listOf( @@ -486,8 +486,8 @@ object FakeFestivals { Festival( id = 5, name = "아이브 콘서트", - startDate = LocalDate.MIN, - endDate = LocalDate.MAX, + startDate = LocalDate.now().plusDays(10L), + endDate = LocalDate.now().plusDays(11L), imageUrl = "data:image/jpeg;base64,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", school = School(id = 1L, name = "연세대", imageUrl = ""), artists = listOf( diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSearchRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSearchRepository.kt new file mode 100644 index 000000000..bb269124b --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSearchRepository.kt @@ -0,0 +1,94 @@ +package com.festago.festago.data.repository + +import com.festago.festago.domain.model.festival.Festival +import com.festago.festago.domain.model.search.ArtistSearch +import com.festago.festago.domain.model.search.SchoolSearch +import com.festago.festago.domain.repository.SearchRepository +import kotlinx.coroutines.delay +import java.time.LocalDate +import javax.inject.Inject + +class FakeSearchRepository @Inject constructor() : SearchRepository { + private var times = 0 + override suspend fun searchFestivals(searchQuery: String): Result> { + delay(1000) + times++ + if (times % 2 == 0) { + return Result.success(FakeFestivals.popularFestivals) + } + return Result.success(listOf()) + } + + override suspend fun searchArtists(searchQuery: String): Result> { + delay(1000) + if (times % 5 == 0) { + return Result.failure(Exception()) + } + return Result.success( + listOf( + ArtistSearch( + id = 6L, + name = "뉴진스", + profileImageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + todayStage = 2, + upcomingStage = 1, + ), + ArtistSearch( + id = 1L, + name = "BTS", + profileImageUrl = "https://i.namu.wiki/i/gpgJvt_C2vKJS4VA4K_Vm57Y5WoS83ofshxhJlQaT4P9Tu0N96vZ2OcdeAN7ZtRAM26UyyQs3sualkKk6i_SrRMvwVKrU015XJqzJ7wKRbOub_oUAxPSFre_8D5De3oy-fCxL0uZ-HGvsWxIX57yrw.webp", + todayStage = 2, + upcomingStage = 2, + ), + ArtistSearch( + id = 2L, + name = "싸이", + profileImageUrl = "https://i.namu.wiki/i/VH58lI8f-y8QSoxFH9IAjjCobySN0lflZ4rMy6Un7qawUwAyi9UfeseZWCzxH-lQeZk7q_eUyTHGlZBAPqSLWliIKWYDLaAgomVtOyAQg60aCpF3oNTBOgUe_hig3rbHW-YAgoj95Fww3MCToyM6MA.webp", + todayStage = 2, + upcomingStage = 3, + ), + ArtistSearch( + id = 10L, + name = "마마무", + profileImageUrl = "https://i.namu.wiki/i/Mre8tXnE40mB9_UwXIwASMEAUSVhHvyjJxXq-lQo40C3bLWYfxXBeai8t6TugyomPjFgxL3VfDA2zn65HlzqPXgTKlvdRl1gJ6PGZLxYYk8Uhk8L6va7zm_etSK5UzVLE56fUATqUCq-6tRQXigmYQ.webp", + todayStage = 2, + upcomingStage = 4, + ), + ArtistSearch( + id = 11L, + name = "블랙핑크", + profileImageUrl = "https://i.namu.wiki/i/VZxRYO8_CXa2QbOSZgttDq5ue5QEu_Fbk1Lwo3qpasLAfS802YExcnmVmDhCq3ONF0ExzhACz_YkZbxOGmIfjuPDZnFo7i0pWaT05NluHRHGfp9NqsAT6WBNb0k5KecOyDvakXk0VH2fUo4ojSwC6g.webp", + todayStage = 1, + upcomingStage = 5, + ), + ), + ) + } + + override suspend fun searchSchools(searchQuery: String): Result> { + delay(1000) + return Result.success( + listOf( + SchoolSearch( + id = 1L, + name = "부경대학교", + logoUrl = "htts://www.pknu.ac.kr/images/front/sub/univ_logo00.png", + upcomingFestivalStartDate = LocalDate.now().plusDays(10L), + ), + SchoolSearch( + id = 2L, + name = "서울대학교", + logoUrl = "https://blog.kakaocdn.net/dn/CYoCP/btrSeivmaxD/e7JaOZVPI3Je55nAJaHDMK/img.png", + upcomingFestivalStartDate = LocalDate.now().plusDays(3L), + ), + SchoolSearch( + id = 3L, + name = "서울과학기술대학교", + logoUrl = "https://www.seoultech.ac.kr/site/www/images/intro/img_ui01_01.gif", + upcomingFestivalStartDate = null, + ), + + ), + ) + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/service/SearchRetrofitService.kt b/android/festago/data/src/main/java/com/festago/festago/data/service/SearchRetrofitService.kt new file mode 100644 index 000000000..794b43f9d --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/service/SearchRetrofitService.kt @@ -0,0 +1,25 @@ +package com.festago.festago.data.service + +import com.festago.festago.data.dto.artist.ArtistSearchResponse +import com.festago.festago.data.dto.festival.FestivalResponse +import com.festago.festago.data.dto.school.SchoolSearchResponse +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Query + +interface SearchRetrofitService { + @GET("api/v1/search/festivals") + suspend fun searchFestivals( + @Query("keyword") keyword: String, + ): Response> + + @GET("api/v1/search/artists") + suspend fun searchArtists( + @Query("keyword") keyword: String, + ): Response> + + @GET("api/v1/search/schools") + suspend fun searchSchools( + @Query("keyword") keyword: String, + ): Response> +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/recentsearch/RecentSearchQuery.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/recentsearch/RecentSearchQuery.kt new file mode 100644 index 000000000..197b84bf3 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/recentsearch/RecentSearchQuery.kt @@ -0,0 +1,6 @@ +package com.festago.festago.domain.model.recentsearch + +data class RecentSearchQuery( + val query: String, + val queriedDate: Long, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/ArtistSearch.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/ArtistSearch.kt new file mode 100644 index 000000000..fb472d087 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/ArtistSearch.kt @@ -0,0 +1,9 @@ +package com.festago.festago.domain.model.search + +data class ArtistSearch( + val id: Long, + val name: String, + val profileImageUrl: String, + val todayStage: Int, + val upcomingStage: Int, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/SchoolSearch.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/SchoolSearch.kt new file mode 100644 index 000000000..dde84b81e --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/SchoolSearch.kt @@ -0,0 +1,10 @@ +package com.festago.festago.domain.model.search + +import java.time.LocalDate + +data class SchoolSearch( + val id: Long, + val name: String, + val logoUrl: String, + val upcomingFestivalStartDate: LocalDate?, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/repository/RecentSearchRepository.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/RecentSearchRepository.kt new file mode 100644 index 000000000..b24c8c6b8 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/RecentSearchRepository.kt @@ -0,0 +1,10 @@ +package com.festago.festago.domain.repository + +import com.festago.festago.domain.model.recentsearch.RecentSearchQuery + +interface RecentSearchRepository { + suspend fun insertOrReplaceRecentSearch(searchQuery: String) + suspend fun deleteRecentSearch(searchQuery: String) + suspend fun getRecentSearchQueries(limit: Int): List + suspend fun clearRecentSearches() +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/repository/SearchRepository.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/SearchRepository.kt new file mode 100644 index 000000000..b2129f848 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/SearchRepository.kt @@ -0,0 +1,11 @@ +package com.festago.festago.domain.repository + +import com.festago.festago.domain.model.festival.Festival +import com.festago.festago.domain.model.search.ArtistSearch +import com.festago.festago.domain.model.search.SchoolSearch + +interface SearchRepository { + suspend fun searchFestivals(searchQuery: String): Result> + suspend fun searchArtists(searchQuery: String): Result> + suspend fun searchSchools(searchQuery: String): Result> +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/ClearEditText.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/ClearEditText.kt new file mode 100644 index 000000000..2df1be761 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/ClearEditText.kt @@ -0,0 +1,92 @@ +package com.festago.festago.presentation.ui.customview + +import android.content.Context +import android.graphics.drawable.Drawable +import android.text.Editable +import android.text.TextWatcher +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.View.OnFocusChangeListener +import android.view.View.OnTouchListener +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.widget.AppCompatEditText +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import com.festago.festago.presentation.R + +class ClearEditText(context: Context, attrs: AttributeSet) : + AppCompatEditText(context, attrs), + TextWatcher, + OnTouchListener, + OnFocusChangeListener { + + private val clearDrawable: Drawable by lazy { + val drawable = ContextCompat.getDrawable(context, R.drawable.ic_circle_close)!! + DrawableCompat.wrap(drawable) + } + private var onFocusChangeListener: OnFocusChangeListener? = null + private var onTouchListener: OnTouchListener? = null + + init { + clearDrawable.setBounds( + 0, + 0, + clearDrawable.intrinsicWidth, + clearDrawable.intrinsicHeight, + ) + setClearIconVisible(false) + super.setOnTouchListener(this) + super.setOnFocusChangeListener(this) + addTextChangedListener(this) + } + + override fun setOnFocusChangeListener(onFocusChangeListener: OnFocusChangeListener) { + this.onFocusChangeListener = onFocusChangeListener + } + + override fun setOnTouchListener(onTouchListener: OnTouchListener) { + this.onTouchListener = onTouchListener + } + + override fun onFocusChange(view: View, hasFocus: Boolean) { + setClearIconVisible(text!!.isNotEmpty()) + if (onFocusChangeListener != null) { + onFocusChangeListener!!.onFocusChange(view, hasFocus) + } + } + + override fun onTouch(view: View, motionEvent: MotionEvent): Boolean { + val x = motionEvent.x.toInt() + if (clearDrawable.isVisible && x > width - paddingRight - clearDrawable.intrinsicWidth) { + if (motionEvent.action == MotionEvent.ACTION_UP) { + error = null + text = null + isFocusableInTouchMode = true + val inputMethodManager = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + requestFocus() + inputMethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + } + return true + } + return if (onTouchListener != null) { + onTouchListener!!.onTouch(view, motionEvent) + } else { + false + } + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + if (!isFocused) return + setClearIconVisible(s.isNotEmpty()) + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit + + override fun afterTextChanged(s: Editable) = Unit + + private fun setClearIconVisible(visible: Boolean) { + clearDrawable.setVisible(visible, false) + setCompoundDrawables(null, null, if (visible) clearDrawable else null, null) + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/FestagoButton.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/FestagoButton.kt new file mode 100644 index 000000000..af1318081 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/FestagoButton.kt @@ -0,0 +1,27 @@ +package com.festago.festago.presentation.ui.customview + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.constraintlayout.widget.ConstraintLayout +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.BtnFestagoBinding + +class FestagoButton(context: Context, attrs: AttributeSet) : + ConstraintLayout(context, attrs) { + + private val binding by lazy { + BtnFestagoBinding.inflate(LayoutInflater.from(context), this, true) + } + + init { + initAttrs(attrs) + } + + private fun initAttrs(attrs: AttributeSet) { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.FestagoButton) + val title = typedArray.getString(R.styleable.FestagoButton_title) + binding.tvFestagoBtn.text = title + typedArray.recycle() + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/HomeViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/HomeViewModel.kt index bdc2b89d2..06763528a 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/HomeViewModel.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/HomeViewModel.kt @@ -1,5 +1,8 @@ package com.festago.festago.presentation.ui.home import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject -class HomeViewModel : ViewModel() +@HiltViewModel +class HomeViewModel @Inject constructor() : ViewModel() diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt index 1f69952b8..5c97f0b39 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt @@ -13,12 +13,15 @@ import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.domain.model.festival.SchoolRegion import com.festago.festago.presentation.databinding.FragmentFestivalListBinding -import com.festago.festago.presentation.ui.home.festivallist.FestivalListFragmentDirections.actionFestivalListFragmentToSchoolDetailFragment +import com.festago.festago.presentation.ui.home.festivallist.FestivalListFragmentDirections.actionFestivalListFragmentToSearchFragment +import com.festago.festago.presentation.ui.home.festivallist.bottomsheet.RegionBottomSheetDialogFragment import com.festago.festago.presentation.ui.home.festivallist.festival.FestivalListAdapter import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalListUiState import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalMoreItemUiState import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalTabUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.SchoolRegionUiState import com.festago.festago.presentation.ui.notificationlist.NotificationListActivity import com.festago.festago.presentation.util.repeatOnStarted import com.festago.festago.presentation.util.setOnApplyWindowInsetsCompatListener @@ -114,13 +117,17 @@ class FestivalListFragment : Fragment() { val festivalListUiState = vm.uiState.value as? FestivalListUiState.Success ?: return if (festivalListUiState.isLastPage) return + if (festivalListUiState.festivals.isEmpty()) return val lastVisibleItemPosition = (recyclerView.layoutManager as LinearLayoutManager?)!!.findLastCompletelyVisibleItemPosition() val itemTotalCount = recyclerView.adapter!!.itemCount - 1 if (lastVisibleItemPosition == itemTotalCount) { - vm.loadFestivals() + vm.loadFestivals( + schoolRegion = festivalListUiState.schoolRegion, + isLoadMore = true, + ) } } }) @@ -174,21 +181,52 @@ class FestivalListFragment : Fragment() { private fun handleSuccess(uiState: FestivalListUiState.Success) { val items = uiState.getItems() festivalListAdapter.submitList(items) + binding.rvFestivalList.itemAnimator = null } private fun FestivalListUiState.Success.getItems(): List { + val schoolRegions = SchoolRegion.values().map { + SchoolRegionUiState(it, it == this.schoolRegion) + } + val dialog = createRegionDialog(schoolRegions) + return mutableListOf().apply { if (popularFestivalUiState.festivals.isNotEmpty()) { add(popularFestivalUiState) } - add(FestivalTabUiState(festivalFilter) { vm.loadFestivals(it) }) + add( + FestivalTabUiState( + selectedFilter = festivalFilter, + selectedRegion = schoolRegion, + onFilterSelected = { vm.loadFestivals(it, schoolRegion) }, + ) { + dialog.show( + parentFragmentManager, + RegionBottomSheetDialogFragment::class.java.name, + ) + }, + ) addAll(festivals) if (!isLastPage) add(FestivalMoreItemUiState) }.toList() } + private fun FestivalListUiState.Success.createRegionDialog( + schoolRegions: List, + ) = RegionBottomSheetDialogFragment.newInstance( + items = schoolRegions, + listener = object : RegionBottomSheetDialogFragment.OnRegionSelectListener { + override fun onRegionSelect(region: SchoolRegion) { + vm.loadFestivals( + festivalFilterUiState = festivalFilter, + schoolRegion = if (region == schoolRegion) null else region, + ) + } + }, + ) + private fun showSchoolDetail() { - findNavController().navigate(actionFestivalListFragmentToSchoolDetailFragment(0)) + findNavController().navigate(actionFestivalListFragmentToSearchFragment()) } private fun showNotificationList() { diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt index aecc9cf8f..2b96deedd 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt @@ -6,6 +6,7 @@ import com.festago.festago.common.analytics.AnalyticsHelper import com.festago.festago.common.analytics.logNetworkFailure import com.festago.festago.domain.model.festival.Festival import com.festago.festago.domain.model.festival.FestivalFilter +import com.festago.festago.domain.model.festival.SchoolRegion import com.festago.festago.domain.repository.FestivalRepository import com.festago.festago.presentation.ui.home.festivallist.uistate.ArtistUiState import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalFilterUiState @@ -40,9 +41,13 @@ class FestivalListViewModel @Inject constructor( fun initFestivalList() { viewModelScope.launch { + val schoolRegion = (uiState.value as? FestivalListUiState.Success)?.schoolRegion val deferredPopularFestivals = async { festivalRepository.loadPopularFestivals() } val deferredFestivals = async { - festivalRepository.loadFestivals(festivalFilter = festivalFilter) + festivalRepository.loadFestivals( + schoolRegion = schoolRegion, + festivalFilter = festivalFilter, + ) } runCatching { val festivalsPage = deferredFestivals.await().getOrThrow() @@ -56,6 +61,7 @@ class FestivalListViewModel @Inject constructor( festivals = festivalsPage.festivals.map { it.toUiState() }, festivalFilter = festivalFilter.toUiState(), isLastPage = festivalsPage.isLastPage, + schoolRegion = schoolRegion, ) }.onFailure { _uiState.value = FestivalListUiState.Error @@ -67,30 +73,58 @@ class FestivalListViewModel @Inject constructor( } } - fun loadFestivals(festivalFilterUiState: FestivalFilterUiState? = null) { + fun loadFestivals( + festivalFilterUiState: FestivalFilterUiState? = null, + schoolRegion: SchoolRegion? = null, + isLoadMore: Boolean = false, + ) { val successUiState = uiState.value as? FestivalListUiState.Success ?: return viewModelScope.launch { val currentFestivals = getCurrentFestivals(festivalFilterUiState) + updateFestivalsState(festivalFilterUiState, successUiState) festivalRepository.loadFestivals( + schoolRegion = schoolRegion, festivalFilter = festivalFilter, - lastFestivalId = currentFestivals.lastOrNull()?.id, - lastStartDate = currentFestivals.lastOrNull()?.startDate, + lastFestivalId = if (isLoadMore) { + currentFestivals.lastOrNull()?.id + } else { + null + }, + lastStartDate = if (isLoadMore) { + currentFestivals.lastOrNull()?.startDate + } else { + null + }, ).onSuccess { festivalsPage -> _uiState.value = FestivalListUiState.Success( - PopularFestivalUiState( - title = successUiState.popularFestivalUiState.title, - festivals = successUiState.popularFestivalUiState.festivals, - ), - festivals = currentFestivals + festivalsPage.festivals.map { it.toUiState() }, + successUiState.popularFestivalUiState, + festivals = if (isLoadMore) { + currentFestivals + festivalsPage.festivals.map { it.toUiState() } + } else { + festivalsPage.festivals.map { it.toUiState() } + }, festivalFilter = festivalFilter.toUiState(), + schoolRegion = schoolRegion, isLastPage = festivalsPage.isLastPage, ) } } } + private fun updateFestivalsState( + festivalFilterUiState: FestivalFilterUiState?, + successUiState: FestivalListUiState.Success, + ) { + if (festivalFilterUiState == null) return + _uiState.value = successUiState.copy( + festivals = listOf(), + isLastPage = false, + festivalFilter = festivalFilterUiState, + ) + } + private fun getCurrentFestivals(festivalFilterUiState: FestivalFilterUiState?): List { var festivals = (uiState.value as? FestivalListUiState.Success)?.festivals ?: listOf() diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionAdapter.kt new file mode 100644 index 000000000..146c7f479 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionAdapter.kt @@ -0,0 +1,25 @@ +package com.festago.festago.presentation.ui.home.festivallist.bottomsheet + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.domain.model.festival.SchoolRegion +import com.festago.festago.presentation.ui.home.festivallist.uistate.SchoolRegionUiState + +class RegionAdapter( + private val items: List, + private val onRegionSelect: (SchoolRegion) -> Unit, + private val onDismiss: () -> Unit, +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return RegionItemViewHolder.of(parent, onRegionSelect, onDismiss) + } + + override fun getItemCount(): Int { + return items.size + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + (holder as RegionItemViewHolder).bind(items[position]) + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionBottomSheetDialogFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionBottomSheetDialogFragment.kt new file mode 100644 index 000000000..077392d94 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionBottomSheetDialogFragment.kt @@ -0,0 +1,61 @@ +package com.festago.festago.presentation.ui.home.festivallist.bottomsheet + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.festago.festago.domain.model.festival.SchoolRegion +import com.festago.festago.presentation.databinding.FragmentRegionBottomSheetBinding +import com.festago.festago.presentation.ui.home.festivallist.uistate.SchoolRegionUiState +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class RegionBottomSheetDialogFragment() : BottomSheetDialogFragment() { + + private var _binding: FragmentRegionBottomSheetBinding? = null + private val binding: FragmentRegionBottomSheetBinding get() = _binding!! + + private lateinit var listener: OnRegionSelectListener + private lateinit var items: List + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentRegionBottomSheetBinding.inflate(inflater) + binding.lifecycleOwner = viewLifecycleOwner + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + initView() + } + + private fun initView() { + binding.rvRegionList.adapter = RegionAdapter( + items = items, + onRegionSelect = listener::onRegionSelect + ) { dismiss() } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + interface OnRegionSelectListener { + fun onRegionSelect(region: SchoolRegion) + } + + companion object { + fun newInstance( + items: List, + listener: OnRegionSelectListener, + ): RegionBottomSheetDialogFragment = RegionBottomSheetDialogFragment().apply { + this.listener = listener + this.items = items + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionItemViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionItemViewHolder.kt new file mode 100644 index 000000000..031a0f67b --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionItemViewHolder.kt @@ -0,0 +1,40 @@ +package com.festago.festago.presentation.ui.home.festivallist.bottomsheet + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.domain.model.festival.SchoolRegion +import com.festago.festago.presentation.databinding.ItemRegionBinding +import com.festago.festago.presentation.ui.home.festivallist.uistate.SchoolRegionUiState + +class RegionItemViewHolder( + private val binding: ItemRegionBinding, + private val onRegionSelect: (SchoolRegion) -> Unit, + private val onDismiss: () -> Unit +) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: SchoolRegionUiState) { + binding.item = item + with(binding.tvRegion) { + this.isSelected = item.isSelected + setOnClickListener { + onRegionSelect(item.schoolRegion) + onDismiss.invoke() + } + } + } + + companion object { + fun of( + parent: ViewGroup, + onRegionSelect: (SchoolRegion) -> Unit, + onDismiss: () -> Unit + ): RegionItemViewHolder { + val binding = ItemRegionBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return RegionItemViewHolder(binding, onRegionSelect, onDismiss) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListAdapter.kt index abbb0c0b3..f2733c462 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListAdapter.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListAdapter.kt @@ -47,7 +47,7 @@ class FestivalListAdapter( override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean = when { oldItem is PopularFestivalUiState && newItem is PopularFestivalUiState -> true oldItem is FestivalItemUiState && newItem is FestivalItemUiState -> oldItem.id == newItem.id - oldItem is FestivalTabUiState && newItem is FestivalTabUiState -> true + oldItem is FestivalTabUiState && newItem is FestivalTabUiState -> oldItem.selectedRegion == newItem.selectedRegion oldItem is FestivalMoreItemUiState && newItem is FestivalMoreItemUiState -> true else -> false } @@ -59,7 +59,8 @@ class FestivalListAdapter( oldItem is FestivalItemUiState && newItem is FestivalItemUiState -> oldItem as FestivalItemUiState == newItem - oldItem is FestivalTabUiState && newItem is FestivalTabUiState -> true + oldItem is FestivalTabUiState && newItem is FestivalTabUiState + -> oldItem as FestivalTabUiState == newItem oldItem is FestivalMoreItemUiState && newItem is FestivalMoreItemUiState -> true diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListTabViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListTabViewHolder.kt index 9db20206a..2c3b8ca58 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListTabViewHolder.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListTabViewHolder.kt @@ -38,6 +38,21 @@ class FestivalListTabViewHolder(val binding: ItemFestivalListTabBinding) : }, ) } + binding.schoolRegion = festivalTabUiState.selectedRegion + binding.tvRegion.setOnClickListener { + festivalTabUiState.onRegionClick.invoke() + } + if (festivalTabUiState.selectedRegion == null) { + binding.tvRegion.isSelected = false + binding.tvRegion.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.ic_pin_normal, 0, 0, 0 + ) + } else { + binding.tvRegion.isSelected = true + binding.tvRegion.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.ic_pin_select, 0, 0, 0 + ) + } } private fun getFestivalFilterAt(position: Int): FestivalFilterUiState = diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalListUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalListUiState.kt index 0d90779c1..ec4645d27 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalListUiState.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalListUiState.kt @@ -1,5 +1,7 @@ package com.festago.festago.presentation.ui.home.festivallist.uistate +import com.festago.festago.domain.model.festival.SchoolRegion + sealed interface FestivalListUiState { object Loading : FestivalListUiState @@ -8,6 +10,7 @@ sealed interface FestivalListUiState { val festivals: List, val festivalFilter: FestivalFilterUiState, val isLastPage: Boolean, + val schoolRegion: SchoolRegion? = null, ) : FestivalListUiState object Error : FestivalListUiState diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalTabUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalTabUiState.kt index 214a38777..79601dfda 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalTabUiState.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalTabUiState.kt @@ -1,6 +1,10 @@ package com.festago.festago.presentation.ui.home.festivallist.uistate +import com.festago.festago.domain.model.festival.SchoolRegion + data class FestivalTabUiState( val selectedFilter: FestivalFilterUiState, + val selectedRegion: SchoolRegion?, val onFilterSelected: (FestivalFilterUiState) -> Unit, + val onRegionClick: () -> Unit, ) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/SchoolRegionUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/SchoolRegionUiState.kt new file mode 100644 index 000000000..dc674bf84 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/SchoolRegionUiState.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.home.festivallist.uistate + +import com.festago.festago.domain.model.festival.SchoolRegion + +data class SchoolRegionUiState( + val schoolRegion: SchoolRegion, + val isSelected: Boolean, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchEvent.kt new file mode 100644 index 000000000..535934c0f --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchEvent.kt @@ -0,0 +1,9 @@ +package com.festago.festago.presentation.ui.search + +sealed interface SearchEvent { + class ShowFestivalDetail(val festivalId: Long) : SearchEvent + class ShowArtistDetail(val artistId: Long) : SearchEvent + class ShowSchoolDetail(val schoolId: Long) : SearchEvent + class UpdateSearchQuery(val searchQuery: String) : SearchEvent + object SearchBlank : SearchEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchFragment.kt new file mode 100644 index 000000000..069511de6 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchFragment.kt @@ -0,0 +1,225 @@ +package com.festago.festago.presentation.ui.search + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnNextLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.FragmentSearchBinding +import com.festago.festago.presentation.ui.search.recentsearch.RecentSearchAdapter +import com.festago.festago.presentation.ui.search.screen.ItemSearchScreenUiState +import com.festago.festago.presentation.ui.search.screen.ItemSearchScreenUiState.ArtistSearchScreen +import com.festago.festago.presentation.ui.search.screen.ItemSearchScreenUiState.FestivalSearchScreen +import com.festago.festago.presentation.ui.search.screen.ItemSearchScreenUiState.SchoolSearchScreen +import com.festago.festago.presentation.ui.search.screen.SearchScreenAdapter +import com.festago.festago.presentation.ui.search.uistate.SearchUiState +import com.festago.festago.presentation.util.repeatOnStarted +import com.festago.festago.presentation.util.setOnApplyWindowInsetsCompatListener +import com.google.android.material.tabs.TabLayoutMediator +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SearchFragment : Fragment() { + + private var _binding: FragmentSearchBinding? = null + private val binding get() = _binding!! + + private val vm: SearchViewModel by viewModels() + + private lateinit var recentSearchAdapter: RecentSearchAdapter + private lateinit var searchScreenAdapter: SearchScreenAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentSearchBinding.inflate(inflater) + binding.root.setOnApplyWindowInsetsCompatListener { view, windowInsets -> + val statusBarInsets = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) + view.setPadding(0, statusBarInsets.top, 0, 0) + windowInsets + } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initObserve() + initView() + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { + binding.uiState = it + updateUi(it) + } + } + repeatOnStarted(viewLifecycleOwner) { + vm.event.collect { + handleEvent(it) + } + } + } + + private fun updateUi(uiState: SearchUiState) { + when (uiState) { + is SearchUiState.Loading, + is SearchUiState.Error, + -> Unit + + is SearchUiState.RecentSearchSuccess -> handleRecentSearchSuccess(uiState) + is SearchUiState.SearchSuccess -> handleSuccessSearch(uiState) + } + } + + private fun initView() { + initRecyclerView() + initBack() + initSearch() + initDeleteAll() + initViewPager() + } + + private fun initViewPager() { + searchScreenAdapter = SearchScreenAdapter() + binding.vpSearch.adapter = searchScreenAdapter + TabLayoutMediator(binding.tlSearch, binding.vpSearch) { tab, position -> + tab.text = when (position) { + ItemSearchScreenUiState.FESTIVAL_POSITION -> getString(R.string.search_tl_tab_festival) + ItemSearchScreenUiState.ARTIST_POSITION -> getString(R.string.search_tl_tab_Artist) + ItemSearchScreenUiState.SCHOOL_POSITION -> getString(R.string.search_tl_tab_school) + else -> "" + } + tab.view.setOnClickListener { + val currentScreen = when (tab.position) { + ItemSearchScreenUiState.FESTIVAL_POSITION -> FestivalSearchScreen(listOf()) + ItemSearchScreenUiState.ARTIST_POSITION -> ArtistSearchScreen(listOf()) + ItemSearchScreenUiState.SCHOOL_POSITION -> SchoolSearchScreen(listOf()) + else -> FestivalSearchScreen(listOf()) + } + vm.currentScreen = currentScreen + } + }.attach() + } + + private fun initRecyclerView() { + recentSearchAdapter = RecentSearchAdapter() + binding.rvRecentSearch.adapter = recentSearchAdapter + } + + private fun initBack() { + binding.ivBack.setOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + } + + private fun initDeleteAll() { + binding.tvDeleteAll.setOnClickListener { + vm.deleteAllRecentSearch() + } + } + + private fun initSearch() { + binding.etSearch.setOnKeyListener { editText, keyCode, event -> + if ((event.action == KeyEvent.ACTION_DOWN) && (keyCode == KeyEvent.KEYCODE_ENTER)) { + vm.search(binding.etSearch.text.toString()) + val inputMethodManager = + context?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(editText.windowToken, 0) + true + } else { + false + } + } + vm.loadRecentSearch() + } + + private fun handleRecentSearchSuccess(uiState: SearchUiState.RecentSearchSuccess) { + recentSearchAdapter.submitList(uiState.recentSearchQueries) + } + + private fun handleSuccessSearch(uiState: SearchUiState.SearchSuccess) { + searchScreenAdapter.submitList( + listOf( + FestivalSearchScreen(uiState.searchedFestivals, ::requestAddSearchQuery), + ArtistSearchScreen(uiState.searchedArtists, ::requestAddSearchQuery), + SchoolSearchScreen(uiState.searchedSchools, ::requestAddSearchQuery), + ), + ) + initSearchTab() + } + + private fun initSearchTab() { + binding.vpSearch.doOnNextLayout { + binding.vpSearch.setCurrentItem(vm.currentScreen.screenPosition, false) + } + } + + private fun handleEvent(event: SearchEvent) { + when (event) { + is SearchEvent.ShowFestivalDetail -> { + findNavController().navigate( + SearchFragmentDirections.actionSearchFragmentToFestivalDetailFragment( + event.festivalId, + ), + ) + } + + is SearchEvent.ShowArtistDetail -> { + findNavController().navigate( + SearchFragmentDirections.actionSearchFragmentToArtistDetailFragment( + event.artistId, + ), + ) + } + + is SearchEvent.ShowSchoolDetail -> { + findNavController().navigate( + SearchFragmentDirections.actionSearchFragmentToSchoolDetailFragment( + event.schoolId, + ), + ) + } + + is SearchEvent.SearchBlank -> { + Toast.makeText( + requireContext(), + getString(R.string.search_cant_search_blank), + Toast.LENGTH_SHORT, + ).show() + } + + is SearchEvent.UpdateSearchQuery -> { + binding.etSearch.setText(event.searchQuery) + } + } + } + + private fun requestAddSearchQuery() { + startBrowser("https://forms.gle/y17dmCFw1jAYLR9H7") + } + + private fun startBrowser(url: String) { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + startActivity(intent) + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchViewModel.kt new file mode 100644 index 000000000..58e02f39d --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchViewModel.kt @@ -0,0 +1,170 @@ +package com.festago.festago.presentation.ui.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure +import com.festago.festago.domain.model.festival.Festival +import com.festago.festago.domain.model.recentsearch.RecentSearchQuery +import com.festago.festago.domain.model.search.ArtistSearch +import com.festago.festago.domain.model.search.SchoolSearch +import com.festago.festago.domain.repository.RecentSearchRepository +import com.festago.festago.domain.repository.SearchRepository +import com.festago.festago.presentation.ui.search.screen.ItemSearchScreenUiState +import com.festago.festago.presentation.ui.search.screen.ItemSearchScreenUiState.FestivalSearchScreen +import com.festago.festago.presentation.ui.search.uistate.ArtistSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.ArtistUiState +import com.festago.festago.presentation.ui.search.uistate.FestivalSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.RecentSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.SchoolSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.SchoolUiState +import com.festago.festago.presentation.ui.search.uistate.SearchUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val recentSearchRepository: RecentSearchRepository, + private val searchRepository: SearchRepository, + private val analyticsHelper: AnalyticsHelper, +) : ViewModel() { + + private val _uiState: MutableStateFlow = + MutableStateFlow(SearchUiState.RecentSearchSuccess(listOf())) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + var currentScreen: ItemSearchScreenUiState = FestivalSearchScreen(listOf()) + + fun loadRecentSearch() { + if (uiState.value is SearchUiState.SearchSuccess) return + viewModelScope.launch { + _uiState.value = SearchUiState.RecentSearchSuccess( + recentSearchRepository.getRecentSearchQueries(10).map { it.toUiState() }, + ) + } + } + + fun search(searchQuery: String) { + viewModelScope.launch { + if (searchQuery == "") { + _event.emit(SearchEvent.SearchBlank) + return@launch + } + _uiState.value = SearchUiState.Loading + recentSearchRepository.insertOrReplaceRecentSearch(searchQuery) + val deferredFestivals = async { searchRepository.searchFestivals(searchQuery) } + val deferredArtist = async { searchRepository.searchArtists(searchQuery) } + val deferredSchools = async { searchRepository.searchSchools(searchQuery) } + runCatching { + _uiState.value = SearchUiState.SearchSuccess( + deferredFestivals.await().getOrThrow().map { it.toUiState() }, + deferredArtist.await().getOrThrow().map { it.toUiState() }, + deferredSchools.await().getOrThrow().map { it.toUiState() }, + ) + }.onFailure { + _uiState.value = SearchUiState.Error + analyticsHelper.logNetworkFailure( + key = KEY_SEARCH, + value = it.message.toString(), + ) + } + } + } + + private fun deleteRecentSearch(searchQuery: String) { + viewModelScope.launch { + recentSearchRepository.deleteRecentSearch(searchQuery) + loadRecentSearch() + } + } + + fun deleteAllRecentSearch() { + viewModelScope.launch { + recentSearchRepository.clearRecentSearches() + loadRecentSearch() + } + } + + private fun RecentSearchQuery.toUiState() = RecentSearchItemUiState( + recentQuery = query, + onQuerySearched = ::searchRecentQuery, + onRecentSearchDeleted = ::deleteRecentSearch, + ) + + private fun Festival.toUiState() = FestivalSearchItemUiState( + id = id, + name = name, + startDate = startDate, + endDate = endDate, + imageUrl = imageUrl, + schoolUiState = SchoolUiState( + id = school.id, + name = school.name, + ), + artists = artists.map { artist -> + ArtistUiState(artist.id, artist.name, artist.imageUrl, ::showArtistDetail) + }, + ::showFestivalDetail, + ) + + private fun ArtistSearch.toUiState() = ArtistSearchItemUiState( + id = id, + name = name, + profileImageUrl = profileImageUrl, + todayStage = todayStage, + upcomingStage = upcomingStage, + onArtistDetailClick = ::showArtistDetail, + ) + + private fun SchoolSearch.toUiState() = SchoolSearchItemUiState( + id = id, + name = name, + logoUrl = logoUrl, + upcomingFestivalStartDate = upcomingFestivalStartDate, + onSchoolSearchClick = ::showSchoolDetail, + ) + + private fun showFestivalDetail(festivalId: Long) { + viewModelScope.launch { + _event.emit(SearchEvent.ShowFestivalDetail(festivalId)) + } + } + + private fun showArtistDetail(artistId: Long) { + viewModelScope.launch { + _event.emit(SearchEvent.ShowArtistDetail(artistId)) + } + } + + private fun showSchoolDetail(schoolId: Long) { + viewModelScope.launch { + _event.emit(SearchEvent.ShowSchoolDetail(schoolId)) + } + } + + private fun searchRecentQuery(searchQuery: String) { + search(searchQuery) + updateSearchQuery(searchQuery) + } + + private fun updateSearchQuery(searchQuery: String) { + viewModelScope.launch { + _event.emit(SearchEvent.UpdateSearchQuery(searchQuery)) + } + } + + companion object { + private const val KEY_SEARCH = "KEY_SEARCH" + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/artistsearch/ArtistSearchAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/artistsearch/ArtistSearchAdapter.kt new file mode 100644 index 000000000..49e677853 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/artistsearch/ArtistSearchAdapter.kt @@ -0,0 +1,32 @@ +package com.festago.festago.presentation.ui.search.artistsearch + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.search.uistate.ArtistSearchItemUiState + +class ArtistSearchAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistSearchViewHolder { + return ArtistSearchViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: ArtistSearchViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ArtistSearchItemUiState, + newItem: ArtistSearchItemUiState, + ): Boolean = oldItem.id == newItem.id + + override fun areContentsTheSame( + oldItem: ArtistSearchItemUiState, + newItem: ArtistSearchItemUiState, + ): Boolean = oldItem == newItem + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/artistsearch/ArtistSearchViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/artistsearch/ArtistSearchViewHolder.kt new file mode 100644 index 000000000..5f74472e8 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/artistsearch/ArtistSearchViewHolder.kt @@ -0,0 +1,63 @@ +package com.festago.festago.presentation.ui.search.artistsearch + +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemSearchArtistBinding +import com.festago.festago.presentation.ui.search.uistate.ArtistSearchItemUiState + +class ArtistSearchViewHolder( + private val binding: ItemSearchArtistBinding, +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: ArtistSearchItemUiState) { + binding.item = item + binding.tvTodayStageCount.setStageCount( + count = item.todayStage, + stringRes = R.string.search_artist_tv_today_stage_count, + ) + binding.tvUpcomingStageCount.setStageCount( + item.upcomingStage, + stringRes = R.string.search_artist_tv_upcoming_stage_count, + ) + } + + private fun TextView.setStageCount(count: Int, @StringRes stringRes: Int) { + val stageCountText = context.getString(stringRes, count) + text = SpannableString(stageCountText).apply { + getPartialColorText( + start = COLOR_INDEX, + end = COLOR_INDEX + 1, + color = context.getColor(R.color.secondary_pink_01), + ) + } + } + + private fun SpannableString.getPartialColorText( + start: Int, + end: Int, + @ColorInt color: Int, + ) { + setSpan(ForegroundColorSpan(color), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + companion object { + private const val COLOR_INDEX = 6 + + fun of(parent: ViewGroup): ArtistSearchViewHolder { + val binding = ItemSearchArtistBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistSearchViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/FestivalSearchAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/FestivalSearchAdapter.kt new file mode 100644 index 000000000..bcc748f03 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/FestivalSearchAdapter.kt @@ -0,0 +1,33 @@ +package com.festago.festago.presentation.ui.search.festivalsearch + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.search.uistate.FestivalSearchItemUiState + +class FestivalSearchAdapter : + ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FestivalSearchViewHolder { + return FestivalSearchViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: FestivalSearchViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: FestivalSearchItemUiState, + newItem: FestivalSearchItemUiState, + ): Boolean = oldItem.id == newItem.id + + override fun areContentsTheSame( + oldItem: FestivalSearchItemUiState, + newItem: FestivalSearchItemUiState, + ): Boolean = oldItem == newItem + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/FestivalSearchViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/FestivalSearchViewHolder.kt new file mode 100644 index 000000000..bf60e0712 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/FestivalSearchViewHolder.kt @@ -0,0 +1,91 @@ +package com.festago.festago.presentation.ui.search.festivalsearch + +import android.content.res.Resources +import android.graphics.Rect +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemSearchFestivalBinding +import com.festago.festago.presentation.ui.search.festivalsearch.artist.ArtistAdapter +import com.festago.festago.presentation.ui.search.uistate.FestivalSearchItemUiState +import java.time.LocalDate + +class FestivalSearchViewHolder( + private val binding: ItemSearchFestivalBinding, +) : RecyclerView.ViewHolder(binding.root) { + + private val artistAdapter = ArtistAdapter() + + init { + binding.rvFestivalArtists.adapter = artistAdapter + binding.rvFestivalArtists.addItemDecoration(ArtistItemDecoration()) + } + + fun bind(item: FestivalSearchItemUiState) { + binding.item = item + artistAdapter.submitList(item.artists) + binding.tvFestivalDDay.bindFestivalDday(item) + } + + private fun TextView.bindFestivalDday(item: FestivalSearchItemUiState) { + when { + LocalDate.now() > item.endDate -> Unit + + LocalDate.now() >= item.startDate -> { + text = context.getString(R.string.festival_list_tv_dday_in_progress) + setTextColor(context.getColor(R.color.secondary_pink_01)) + background = AppCompatResources.getDrawable( + context, + R.drawable.bg_festival_list_dday_in_progress, + ) + } + + else -> { + val dDay = LocalDate.now().toEpochDay() - item.startDate.toEpochDay() + val backgroundColor = if (dDay >= -7L) { + context.getColor(R.color.secondary_pink_01) + } else { + context.getColor(R.color.contents_gray_07) + } + setBackgroundColor(backgroundColor) + setTextColor(context.getColor(R.color.background_gray_01)) + text = context.getString(R.string.festival_detail_tv_dday_format, dDay.toString()) + } + } + } + + private class ArtistItemDecoration : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + super.getItemOffsets(outRect, view, parent, state) + outRect.right = 8.dpToPx + } + + private val Int.dpToPx: Int + get() = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + Resources.getSystem().displayMetrics, + ).toInt() + } + + companion object { + fun of(parent: ViewGroup): FestivalSearchViewHolder { + val binding = ItemSearchFestivalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return FestivalSearchViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/artist/ArtistAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/artist/ArtistAdapter.kt new file mode 100644 index 000000000..103758f3f --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/artist/ArtistAdapter.kt @@ -0,0 +1,35 @@ +package com.festago.festago.presentation.ui.search.festivalsearch.artist + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.search.uistate.ArtistUiState + +class ArtistAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistViewHolder { + return ArtistViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/artist/ArtistViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/artist/ArtistViewHolder.kt new file mode 100644 index 000000000..cb3f878a4 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/artist/ArtistViewHolder.kt @@ -0,0 +1,27 @@ +package com.festago.festago.presentation.ui.search.festivalsearch.artist + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemSearchFestivalArtistBinding +import com.festago.festago.presentation.ui.search.uistate.ArtistUiState + +class ArtistViewHolder( + private val binding: ItemSearchFestivalArtistBinding, +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: ArtistUiState) { + binding.item = item + } + + companion object { + fun of(parent: ViewGroup): ArtistViewHolder { + val binding = ItemSearchFestivalArtistBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/recentsearch/RecentSearchAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/recentsearch/RecentSearchAdapter.kt new file mode 100644 index 000000000..03721096e --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/recentsearch/RecentSearchAdapter.kt @@ -0,0 +1,36 @@ +package com.festago.festago.presentation.ui.search.recentsearch + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.search.uistate.RecentSearchItemUiState + +class RecentSearchAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecentSearchViewHolder { + return RecentSearchViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: RecentSearchViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: RecentSearchItemUiState, + newItem: RecentSearchItemUiState, + ): Boolean { + return oldItem.recentQuery == newItem.recentQuery + } + + override fun areContentsTheSame( + oldItem: RecentSearchItemUiState, + newItem: RecentSearchItemUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/recentsearch/RecentSearchViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/recentsearch/RecentSearchViewHolder.kt new file mode 100644 index 000000000..1f76330ca --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/recentsearch/RecentSearchViewHolder.kt @@ -0,0 +1,27 @@ +package com.festago.festago.presentation.ui.search.recentsearch + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemRecentSearchBinding +import com.festago.festago.presentation.ui.search.uistate.RecentSearchItemUiState + +class RecentSearchViewHolder( + val binding: ItemRecentSearchBinding, +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: RecentSearchItemUiState) { + binding.item = item + } + + companion object { + fun of(parent: ViewGroup): RecentSearchViewHolder { + val binding = ItemRecentSearchBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return RecentSearchViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/schoolSearchAdatper/SchoolSearchAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/schoolSearchAdatper/SchoolSearchAdapter.kt new file mode 100644 index 000000000..2735e3d71 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/schoolSearchAdatper/SchoolSearchAdapter.kt @@ -0,0 +1,32 @@ +package com.festago.festago.presentation.ui.search.schoolSearchAdatper + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.search.uistate.SchoolSearchItemUiState + +class SchoolSearchAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SchoolSearchViewHolder { + return SchoolSearchViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: SchoolSearchViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: SchoolSearchItemUiState, + newItem: SchoolSearchItemUiState, + ): Boolean = oldItem.id == newItem.id + + override fun areContentsTheSame( + oldItem: SchoolSearchItemUiState, + newItem: SchoolSearchItemUiState, + ): Boolean = oldItem == newItem + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/schoolSearchAdatper/SchoolSearchViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/schoolSearchAdatper/SchoolSearchViewHolder.kt new file mode 100644 index 000000000..26d3b37e4 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/schoolSearchAdatper/SchoolSearchViewHolder.kt @@ -0,0 +1,56 @@ +package com.festago.festago.presentation.ui.search.schoolSearchAdatper + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemSearchSchoolBinding +import com.festago.festago.presentation.ui.search.uistate.SchoolSearchItemUiState +import java.time.LocalDate + +class SchoolSearchViewHolder( + private val binding: ItemSearchSchoolBinding, +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: SchoolSearchItemUiState) { + binding.item = item + binding.tvSchoolFestivalDday.setSchoolFestivalDday(item.upcomingFestivalStartDate) + } + + private fun TextView.setSchoolFestivalDday( + upcomingFestivalStartDate: LocalDate?, + ) { + when { + upcomingFestivalStartDate == null -> { + text = context.getString(R.string.search_school_tv_no_plan) + setTextColor(context.getColor(R.color.contents_gray_05)) + } + + LocalDate.now() >= upcomingFestivalStartDate -> { + text = context.getString(R.string.search_school_tv_dday_in_progress) + setTextColor(context.getColor(R.color.secondary_pink_01)) + } + + LocalDate.now() < upcomingFestivalStartDate -> { + val dDay = + LocalDate.now().toEpochDay() - upcomingFestivalStartDate.toEpochDay() + text = context.getString(R.string.search_school_tv_dday_format, dDay.toString()) + val colorId = + if (dDay >= -7L) R.color.secondary_pink_01 else R.color.contents_gray_07 + setTextColor(context.getColor(colorId)) + } + } + } + + companion object { + fun of(parent: ViewGroup): SchoolSearchViewHolder { + val binding = ItemSearchSchoolBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return SchoolSearchViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/ArtistSearchScreenViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/ArtistSearchScreenViewHolder.kt new file mode 100644 index 000000000..fe9233a74 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/ArtistSearchScreenViewHolder.kt @@ -0,0 +1,44 @@ +package com.festago.festago.presentation.ui.search.screen + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.festago.festago.presentation.databinding.ItemArtistSearchScreenBinding +import com.festago.festago.presentation.ui.search.artistsearch.ArtistSearchAdapter + +class ArtistSearchScreenViewHolder( + private val binding: ItemArtistSearchScreenBinding, +) : SearchScreenViewHolder(binding) { + + private val artistSearchAdapter: ArtistSearchAdapter = ArtistSearchAdapter() + + init { + binding.rvArtists.adapter = artistSearchAdapter + } + + fun bind(item: ItemSearchScreenUiState.ArtistSearchScreen) { + artistSearchAdapter.submitList(item.artistSearches) + setNoResultVisibility(item) + binding.btnFestago.setOnClickListener { + item.onAddSearchQueryClick() + } + } + + private fun setNoResultVisibility(item: ItemSearchScreenUiState.ArtistSearchScreen) { + val visibility = if (item.artistSearches.isEmpty()) View.VISIBLE else View.GONE + binding.tvNoResult.visibility = visibility + binding.tvNoResultGuide.visibility = visibility + binding.btnFestago.visibility = visibility + } + + companion object { + fun of(parent: ViewGroup): ArtistSearchScreenViewHolder { + val binding = ItemArtistSearchScreenBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistSearchScreenViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/FestivalSearchScreenViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/FestivalSearchScreenViewHolder.kt new file mode 100644 index 000000000..eb33d7c81 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/FestivalSearchScreenViewHolder.kt @@ -0,0 +1,44 @@ +package com.festago.festago.presentation.ui.search.screen + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.festago.festago.presentation.databinding.ItemFestivalSearchScreenBinding +import com.festago.festago.presentation.ui.search.festivalsearch.FestivalSearchAdapter + +class FestivalSearchScreenViewHolder( + private val binding: ItemFestivalSearchScreenBinding, +) : SearchScreenViewHolder(binding) { + + private val festivalSearchAdapter: FestivalSearchAdapter = FestivalSearchAdapter() + + init { + binding.rvFestivals.adapter = festivalSearchAdapter + } + + fun bind(item: ItemSearchScreenUiState.FestivalSearchScreen) { + festivalSearchAdapter.submitList(item.festivalSearches) + setNoResultVisibility(item) + binding.btnFestago.setOnClickListener { + item.onAddSearchQueryClick() + } + } + + private fun setNoResultVisibility(item: ItemSearchScreenUiState.FestivalSearchScreen) { + val visibility = if (item.festivalSearches.isEmpty()) View.VISIBLE else View.GONE + binding.tvNoResult.visibility = visibility + binding.tvNoResultGuide.visibility = visibility + binding.btnFestago.visibility = visibility + } + + companion object { + fun of(parent: ViewGroup): FestivalSearchScreenViewHolder { + val binding = ItemFestivalSearchScreenBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return FestivalSearchScreenViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/ItemSearchScreenUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/ItemSearchScreenUiState.kt new file mode 100644 index 000000000..78a886270 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/ItemSearchScreenUiState.kt @@ -0,0 +1,30 @@ +package com.festago.festago.presentation.ui.search.screen + +import com.festago.festago.presentation.ui.search.uistate.ArtistSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.FestivalSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.SchoolSearchItemUiState + +sealed class ItemSearchScreenUiState( + val screenPosition: Int, +) { + data class FestivalSearchScreen( + val festivalSearches: List, + val onAddSearchQueryClick: () -> Unit = {}, + ) : ItemSearchScreenUiState(FESTIVAL_POSITION) + + data class ArtistSearchScreen( + val artistSearches: List, + val onAddSearchQueryClick: () -> Unit = {}, + ) : ItemSearchScreenUiState(ARTIST_POSITION) + + data class SchoolSearchScreen( + val schoolSearches: List, + val onAddSearchQueryClick: () -> Unit = {}, + ) : ItemSearchScreenUiState(SCHOOL_POSITION) + + companion object { + const val FESTIVAL_POSITION = 0 + const val ARTIST_POSITION = 1 + const val SCHOOL_POSITION = 2 + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SchoolSearchScreenViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SchoolSearchScreenViewHolder.kt new file mode 100644 index 000000000..8702fa879 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SchoolSearchScreenViewHolder.kt @@ -0,0 +1,42 @@ +package com.festago.festago.presentation.ui.search.screen + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.festago.festago.presentation.databinding.ItemSchoolSearchScreenBinding +import com.festago.festago.presentation.ui.search.schoolSearchAdatper.SchoolSearchAdapter + +class SchoolSearchScreenViewHolder(private val binding: ItemSchoolSearchScreenBinding) : + SearchScreenViewHolder(binding) { + private val schoolSearchAdapter: SchoolSearchAdapter = SchoolSearchAdapter() + + init { + binding.rvSchools.adapter = schoolSearchAdapter + } + + fun bind(item: ItemSearchScreenUiState.SchoolSearchScreen) { + schoolSearchAdapter.submitList(item.schoolSearches) + setNoResultVisibility(item) + binding.btnFestago.setOnClickListener { + item.onAddSearchQueryClick() + } + } + + private fun setNoResultVisibility(item: ItemSearchScreenUiState.SchoolSearchScreen) { + val visibility = if (item.schoolSearches.isEmpty()) View.VISIBLE else View.GONE + binding.tvNoResult.visibility = visibility + binding.tvNoResultGuide.visibility = visibility + binding.btnFestago.visibility = visibility + } + + companion object { + fun of(parent: ViewGroup): SchoolSearchScreenViewHolder { + val binding = ItemSchoolSearchScreenBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return SchoolSearchScreenViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SearchScreenAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SearchScreenAdapter.kt new file mode 100644 index 000000000..23c7781aa --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SearchScreenAdapter.kt @@ -0,0 +1,47 @@ +package com.festago.festago.presentation.ui.search.screen + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter + +class SearchScreenAdapter : ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchScreenViewHolder { + return when (viewType) { + 1 -> FestivalSearchScreenViewHolder.of(parent) + 2 -> ArtistSearchScreenViewHolder.of(parent) + 3 -> SchoolSearchScreenViewHolder.of(parent) + else -> throw IllegalArgumentException("Invalid viewType") + } + } + + override fun onBindViewHolder(holder: SearchScreenViewHolder, position: Int) { + val item = getItem(position) + return when (holder) { + is FestivalSearchScreenViewHolder -> holder.bind(item as ItemSearchScreenUiState.FestivalSearchScreen) + is ArtistSearchScreenViewHolder -> holder.bind(item as ItemSearchScreenUiState.ArtistSearchScreen) + is SchoolSearchScreenViewHolder -> holder.bind(item as ItemSearchScreenUiState.SchoolSearchScreen) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is ItemSearchScreenUiState.FestivalSearchScreen -> 1 + is ItemSearchScreenUiState.ArtistSearchScreen -> 2 + is ItemSearchScreenUiState.SchoolSearchScreen -> 3 + } + } + + companion object { + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ItemSearchScreenUiState, + newItem: ItemSearchScreenUiState, + ): Boolean = oldItem === newItem + + override fun areContentsTheSame( + oldItem: ItemSearchScreenUiState, + newItem: ItemSearchScreenUiState, + ): Boolean = oldItem == newItem + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SearchScreenViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SearchScreenViewHolder.kt new file mode 100644 index 000000000..40df9c049 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SearchScreenViewHolder.kt @@ -0,0 +1,7 @@ +package com.festago.festago.presentation.ui.search.screen + +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView + +sealed class SearchScreenViewHolder(binding: ViewDataBinding) : + RecyclerView.ViewHolder(binding.root) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/ArtistSearchItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/ArtistSearchItemUiState.kt new file mode 100644 index 000000000..99eaf90e9 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/ArtistSearchItemUiState.kt @@ -0,0 +1,10 @@ +package com.festago.festago.presentation.ui.search.uistate + +data class ArtistSearchItemUiState( + val id: Long, + val name: String, + val profileImageUrl: String, + val todayStage: Int, + val upcomingStage: Int, + val onArtistDetailClick: (Long) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/ArtistUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/ArtistUiState.kt new file mode 100644 index 000000000..9d559f34b --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/ArtistUiState.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.search.uistate + +data class ArtistUiState( + val id: Long, + val name: String, + val imageUrl: String, + val onArtistDetailClick: (Long) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/FestivalSearchItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/FestivalSearchItemUiState.kt new file mode 100644 index 000000000..218141b52 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/FestivalSearchItemUiState.kt @@ -0,0 +1,14 @@ +package com.festago.festago.presentation.ui.search.uistate + +import java.time.LocalDate + +data class FestivalSearchItemUiState( + val id: Long, + val name: String, + val startDate: LocalDate, + val endDate: LocalDate, + val imageUrl: String, + val schoolUiState: SchoolUiState, + val artists: List, + val onFestivalSearchClick: (festivalId: Long) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/RecentSearchItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/RecentSearchItemUiState.kt new file mode 100644 index 000000000..72ad05d82 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/RecentSearchItemUiState.kt @@ -0,0 +1,7 @@ +package com.festago.festago.presentation.ui.search.uistate + +data class RecentSearchItemUiState( + val recentQuery: String, + val onQuerySearched: (recentQuery: String) -> Unit, + val onRecentSearchDeleted: (recentQuery: String) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SchoolSearchItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SchoolSearchItemUiState.kt new file mode 100644 index 000000000..8b4a459fc --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SchoolSearchItemUiState.kt @@ -0,0 +1,11 @@ +package com.festago.festago.presentation.ui.search.uistate + +import java.time.LocalDate + +data class SchoolSearchItemUiState( + val id: Long, + val name: String, + val logoUrl: String, + val upcomingFestivalStartDate: LocalDate?, + val onSchoolSearchClick: (schoolId: Long) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SchoolUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SchoolUiState.kt new file mode 100644 index 000000000..446acb2ba --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SchoolUiState.kt @@ -0,0 +1,6 @@ +package com.festago.festago.presentation.ui.search.uistate + +data class SchoolUiState( + val id: Long, + val name: String, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SearchUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SearchUiState.kt new file mode 100644 index 000000000..d80d392f2 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SearchUiState.kt @@ -0,0 +1,24 @@ +package com.festago.festago.presentation.ui.search.uistate + +sealed interface SearchUiState { + object Loading : SearchUiState + + data class RecentSearchSuccess( + val recentSearchQueries: List, + ) : SearchUiState + + data class SearchSuccess( + val searchedFestivals: List, + val searchedArtists: List, + val searchedSchools: List, + ) : SearchUiState + + object Error : SearchUiState + + val shouldShowNotEmptyRecentSearchSuccess get() = this is RecentSearchSuccess && recentSearchQueries.isNotEmpty() + val shouldShowEmptyRecentSearchSuccess get() = this is RecentSearchSuccess && recentSearchQueries.isEmpty() + val shouldShowRecentSearchSuccess get() = this is RecentSearchSuccess + val shouldShowSearchSuccess get() = this is SearchSuccess + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error +} diff --git a/android/festago/presentation/src/main/res/drawable/bg_btn_region_check.xml b/android/festago/presentation/src/main/res/drawable/bg_btn_region_check.xml new file mode 100644 index 000000000..b513a8b9b --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_btn_region_check.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_btn_region_normal.xml b/android/festago/presentation/src/main/res/drawable/bg_btn_region_normal.xml new file mode 100644 index 000000000..34b6f2806 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_btn_region_normal.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_region_bottom_sheet.xml b/android/festago/presentation/src/main/res/drawable/bg_region_bottom_sheet.xml new file mode 100644 index 000000000..fb5f2c657 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_region_bottom_sheet.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_arrow_back_ios_new_24.xml b/android/festago/presentation/src/main/res/drawable/ic_arrow_back_ios_new_24.xml deleted file mode 100644 index 6bd5650e1..000000000 --- a/android/festago/presentation/src/main/res/drawable/ic_arrow_back_ios_new_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/android/festago/presentation/src/main/res/drawable/ic_back_gray_07.xml b/android/festago/presentation/src/main/res/drawable/ic_back_gray_07.xml new file mode 100644 index 000000000..58dfdf648 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_back_gray_07.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_circle_close.xml b/android/festago/presentation/src/main/res/drawable/ic_circle_close.xml new file mode 100644 index 000000000..b12812144 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_circle_close.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_close.xml b/android/festago/presentation/src/main/res/drawable/ic_close.xml new file mode 100644 index 000000000..03c1b023f --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_next.xml b/android/festago/presentation/src/main/res/drawable/ic_next.xml new file mode 100644 index 000000000..d9d292377 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_next.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_pin_normal.xml b/android/festago/presentation/src/main/res/drawable/ic_pin_normal.xml new file mode 100644 index 000000000..6c804a1cb --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_pin_normal.xml @@ -0,0 +1,20 @@ + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_pin_select.xml b/android/festago/presentation/src/main/res/drawable/ic_pin_select.xml new file mode 100644 index 000000000..ec5d23a6f --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_pin_select.xml @@ -0,0 +1,20 @@ + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_x_close.xml b/android/festago/presentation/src/main/res/drawable/ic_x_close.xml new file mode 100644 index 000000000..0d1129d89 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_x_close.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/selector_btn_region.xml b/android/festago/presentation/src/main/res/drawable/selector_btn_region.xml new file mode 100644 index 000000000..62a91b582 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/selector_btn_region.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/festago/presentation/src/main/res/layout/activity_notification_list.xml b/android/festago/presentation/src/main/res/layout/activity_notification_list.xml index c637ebb1a..d132f9d45 100644 --- a/android/festago/presentation/src/main/res/layout/activity_notification_list.xml +++ b/android/festago/presentation/src/main/res/layout/activity_notification_list.xml @@ -21,10 +21,10 @@ + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_artist_detail.xml b/android/festago/presentation/src/main/res/layout/fragment_artist_detail.xml index d2fe488db..9c9daa8b9 100644 --- a/android/festago/presentation/src/main/res/layout/fragment_artist_detail.xml +++ b/android/festago/presentation/src/main/res/layout/fragment_artist_detail.xml @@ -47,10 +47,10 @@ + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_search.xml b/android/festago/presentation/src/main/res/layout/fragment_search.xml new file mode 100644 index 000000000..77462aee4 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_search.xml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_artist_search_screen.xml b/android/festago/presentation/src/main/res/layout/item_artist_search_screen.xml new file mode 100644 index 000000000..36e3037be --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_artist_search_screen.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_list_tab.xml b/android/festago/presentation/src/main/res/layout/item_festival_list_tab.xml index 912b3eb68..baf6b2398 100644 --- a/android/festago/presentation/src/main/res/layout/item_festival_list_tab.xml +++ b/android/festago/presentation/src/main/res/layout/item_festival_list_tab.xml @@ -1,10 +1,18 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + + + + @@ -29,5 +37,30 @@ android:layout_height="1dp" android:background="@color/background_gray_03" /> + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_search_screen.xml b/android/festago/presentation/src/main/res/layout/item_festival_search_screen.xml new file mode 100644 index 000000000..30893979e --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_festival_search_screen.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_recent_search.xml b/android/festago/presentation/src/main/res/layout/item_recent_search.xml new file mode 100644 index 000000000..dff693669 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_recent_search.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_region.xml b/android/festago/presentation/src/main/res/layout/item_region.xml new file mode 100644 index 000000000..6a4038df5 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_region.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_school_search_screen.xml b/android/festago/presentation/src/main/res/layout/item_school_search_screen.xml new file mode 100644 index 000000000..bf5c0dc52 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_school_search_screen.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_search_artist.xml b/android/festago/presentation/src/main/res/layout/item_search_artist.xml new file mode 100644 index 000000000..30d024ad8 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_search_artist.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_search_festival.xml b/android/festago/presentation/src/main/res/layout/item_search_festival.xml new file mode 100644 index 000000000..80f71338b --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_search_festival.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_search_festival_artist.xml b/android/festago/presentation/src/main/res/layout/item_search_festival_artist.xml new file mode 100644 index 000000000..c1c402ca9 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_search_festival_artist.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_search_school.xml b/android/festago/presentation/src/main/res/layout/item_search_school.xml new file mode 100644 index 000000000..7652d366b --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_search_school.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/navigation/festival_list.xml b/android/festago/presentation/src/main/res/navigation/festival_list.xml index 9df3fa8be..554a8d764 100644 --- a/android/festago/presentation/src/main/res/navigation/festival_list.xml +++ b/android/festago/presentation/src/main/res/navigation/festival_list.xml @@ -25,6 +25,11 @@ app:destination="@id/festivalDetailFragment" app:enterAnim="@android:anim/slide_in_left" app:popExitAnim="@android:anim/slide_out_right" /> + + + + + + + diff --git a/android/festago/presentation/src/main/res/values/attrs.xml b/android/festago/presentation/src/main/res/values/attrs.xml new file mode 100644 index 000000000..23b2df7d8 --- /dev/null +++ b/android/festago/presentation/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/festago/presentation/src/main/res/values/strings.xml b/android/festago/presentation/src/main/res/values/strings.xml index 544729f9a..fd3b7a6e8 100644 --- a/android/festago/presentation/src/main/res/values/strings.xml +++ b/android/festago/presentation/src/main/res/values/strings.xml @@ -33,4 +33,24 @@ D%1$s 종료 + + 지역별 + + + 학교명, 아티스트명, 축제명으로 입력하세요. + 최근 검색어 + 전체 삭제 + 최근 검색 내역이 없어요 + 축제 + 아티스트 + 학교 + 검색어를 입력해주세요 + 축제 중 + D%1$s + 예정 없음 + 오늘 공연 %d개 + 공연 예정 %d개 + 앗! 검색 결과가 없네요 + 찾으시는 정보가 없다면 저희에게 알려주세요 + 추가 요청하러 가기 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..99f3e9e94 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17-jdk +ARG JAR_FILE_PATH=./build/libs/*.jar +COPY ${JAR_FILE_PATH} app.jar +ENTRYPOINT ["java", "-jar", "-Duser.timezone=Asia/Seoul", "app.jar"] diff --git a/backend/src/main/java/com/festago/admin/domain/Admin.java b/backend/src/main/java/com/festago/admin/domain/Admin.java index 80205ee21..48b09dfca 100644 --- a/backend/src/main/java/com/festago/admin/domain/Admin.java +++ b/backend/src/main/java/com/festago/admin/domain/Admin.java @@ -10,6 +10,7 @@ import jakarta.persistence.UniqueConstraint; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import java.util.Objects; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -54,6 +55,10 @@ public Admin(Long id, String username, String password) { this.password = password; } + public static Admin createRootAdmin(String password) { + return new Admin(ROOT_ADMIN_NAME, password); + } + private void validate(String username, String password) { validateUsername(username); validatePassword(password); @@ -73,6 +78,10 @@ private void validatePassword(String password) { Validator.maxLength(password, MAX_PASSWORD_LENGTH, fieldName); } + public boolean isRootAdmin() { + return Objects.equals(username, ROOT_ADMIN_NAME); + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/festago/artist/application/ArtistSearchStageCountV1QueryService.java b/backend/src/main/java/com/festago/artist/application/ArtistSearchStageCountV1QueryService.java new file mode 100644 index 000000000..0e50e82bd --- /dev/null +++ b/backend/src/main/java/com/festago/artist/application/ArtistSearchStageCountV1QueryService.java @@ -0,0 +1,50 @@ +package com.festago.artist.application; + +import static java.util.stream.Collectors.toMap; + +import com.festago.artist.dto.ArtistSearchStageCountV1Response; +import com.festago.artist.repository.ArtistSearchV1QueryDslRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ArtistSearchStageCountV1QueryService { + + private final ArtistSearchV1QueryDslRepository artistSearchV1QueryDslRepository; + + public Map findArtistsStageCountAfterDateTime( + List artistIds, + LocalDateTime dateTime + ) { + Map> artistToStageStartTimes = artistSearchV1QueryDslRepository.findArtistsStageScheduleAfterDateTime( + artistIds, dateTime); + LocalDate today = dateTime.toLocalDate(); + return artistIds.stream() + .collect(toMap( + Function.identity(), + artistId -> getArtistStageCount(artistId, artistToStageStartTimes, today) + )); + } + + private ArtistSearchStageCountV1Response getArtistStageCount( + Long artistId, + Map> artistToStageStartTimes, + LocalDate today + ) { + List stageStartTimes = artistToStageStartTimes.getOrDefault(artistId, Collections.emptyList()); + int countOfTodayStage = (int) stageStartTimes.stream() + .filter(it -> it.toLocalDate().equals(today)) + .count(); + int countOfPlannedStage = stageStartTimes.size() - countOfTodayStage; + return new ArtistSearchStageCountV1Response(countOfTodayStage, countOfPlannedStage); + } +} diff --git a/backend/src/main/java/com/festago/artist/application/ArtistSearchV1QueryService.java b/backend/src/main/java/com/festago/artist/application/ArtistSearchV1QueryService.java new file mode 100644 index 000000000..f21c9ba93 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/application/ArtistSearchV1QueryService.java @@ -0,0 +1,35 @@ +package com.festago.artist.application; + +import com.festago.artist.dto.ArtistSearchV1Response; +import com.festago.artist.repository.ArtistSearchV1QueryDslRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ArtistSearchV1QueryService { + + private static final int MAX_SEARCH_COUNT = 10; + + private final ArtistSearchV1QueryDslRepository artistSearchV1QueryDslRepository; + + public List findAllByKeyword(String keyword) { + List response = getResponse(keyword); + if (response.size() >= MAX_SEARCH_COUNT) { + throw new BadRequestException(ErrorCode.BROAD_SEARCH_KEYWORD); + } + return response; + } + + private List getResponse(String keyword) { + if (keyword.length() == 1) { + return artistSearchV1QueryDslRepository.findAllByEqual(keyword); + } + return artistSearchV1QueryDslRepository.findAllByLike(keyword); + } +} diff --git a/backend/src/main/java/com/festago/artist/application/ArtistTotalSearchV1Service.java b/backend/src/main/java/com/festago/artist/application/ArtistTotalSearchV1Service.java new file mode 100644 index 000000000..46df4e9e2 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/application/ArtistTotalSearchV1Service.java @@ -0,0 +1,34 @@ +package com.festago.artist.application; + +import com.festago.artist.dto.ArtistSearchStageCountV1Response; +import com.festago.artist.dto.ArtistSearchV1Response; +import com.festago.artist.dto.ArtistTotalSearchV1Response; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ArtistTotalSearchV1Service { + + private final ArtistSearchV1QueryService artistSearchV1QueryService; + private final ArtistSearchStageCountV1QueryService artistSearchStageCountV1QueryService; + private final Clock clock; + + public List findAllByKeyword(String keyword) { + List artists = artistSearchV1QueryService.findAllByKeyword(keyword); + List artistIds = artists.stream() + .map(ArtistSearchV1Response::id) + .toList(); + Map artistToStageCount = artistSearchStageCountV1QueryService.findArtistsStageCountAfterDateTime( + artistIds, LocalDate.now(clock).atStartOfDay()); + return artists.stream() + .map(it -> ArtistTotalSearchV1Response.of(it, artistToStageCount.get(it.id()))) + .toList(); + } +} diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistSearchStageCountV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistSearchStageCountV1Response.java new file mode 100644 index 000000000..f8b95fdcc --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/ArtistSearchStageCountV1Response.java @@ -0,0 +1,8 @@ +package com.festago.artist.dto; + +public record ArtistSearchStageCountV1Response( + Integer todayStage, + Integer plannedStage +) { + +} diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistSearchV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistSearchV1Response.java new file mode 100644 index 000000000..1fe05cb99 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/ArtistSearchV1Response.java @@ -0,0 +1,14 @@ +package com.festago.artist.dto; + +import com.querydsl.core.annotations.QueryProjection; + +public record ArtistSearchV1Response( + Long id, + String name, + String profileImageUrl +) { + + @QueryProjection + public ArtistSearchV1Response { + } +} diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistTotalSearchV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistTotalSearchV1Response.java new file mode 100644 index 000000000..882deaa39 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/ArtistTotalSearchV1Response.java @@ -0,0 +1,21 @@ +package com.festago.artist.dto; + +public record ArtistTotalSearchV1Response( + Long id, + String name, + String profileImageUrl, + Integer todayStage, + Integer plannedStage +) { + + public static ArtistTotalSearchV1Response of(ArtistSearchV1Response artistResponse, + ArtistSearchStageCountV1Response stageCount) { + return new ArtistTotalSearchV1Response( + artistResponse.id(), + artistResponse.name(), + artistResponse.profileImageUrl(), + stageCount.todayStage(), + stageCount.plannedStage() + ); + } +} diff --git a/backend/src/main/java/com/festago/artist/infrastructure/JsonArtistsSerializer.java b/backend/src/main/java/com/festago/artist/infrastructure/JsonArtistsSerializer.java index 15d2a6069..0f9b2694f 100644 --- a/backend/src/main/java/com/festago/artist/infrastructure/JsonArtistsSerializer.java +++ b/backend/src/main/java/com/festago/artist/infrastructure/JsonArtistsSerializer.java @@ -20,10 +20,32 @@ public class JsonArtistsSerializer implements ArtistsSerializer { @Override public String serialize(List artists) { try { - return objectMapper.writeValueAsString(artists); + List artistQueryModels = artists.stream() + .map(ArtistQueryModel::from) + .toList(); + return objectMapper.writeValueAsString(artistQueryModels); } catch (JsonProcessingException e) { log.error(e.getMessage(), e); throw new UnexpectedException("Artist 목록을 직렬화 하는 중에 문제가 발생했습니다."); } } + + /** + * 쿼리에서 사용되는 모델이므로, 필드를 추가해도 필드명은 변경되면 절대로 안 됨!!! + */ + private record ArtistQueryModel( + Long id, + String name, + String profileImageUrl, + String backgroundImageUrl + ) { + public static ArtistQueryModel from(Artist artist) { + return new ArtistQueryModel( + artist.getId(), + artist.getName(), + artist.getProfileImage(), + artist.getBackgroundImageUrl() + ); + } + } } diff --git a/backend/src/main/java/com/festago/artist/presentation/v1/ArtistSearchV1Controller.java b/backend/src/main/java/com/festago/artist/presentation/v1/ArtistSearchV1Controller.java new file mode 100644 index 000000000..c2852aad1 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/presentation/v1/ArtistSearchV1Controller.java @@ -0,0 +1,31 @@ +package com.festago.artist.presentation.v1; + +import com.festago.artist.application.ArtistTotalSearchV1Service; +import com.festago.artist.dto.ArtistTotalSearchV1Response; +import com.festago.common.util.Validator; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/search/artists") +@Tag(name = "아티스트 검색 V1") +@RequiredArgsConstructor +public class ArtistSearchV1Controller { + + private final ArtistTotalSearchV1Service artistTotalSearchV1Service; + + @GetMapping + @Operation(description = "키워드로 아티스트 목록을 검색한다", summary = "아티스트 목록 검색 조회") + public ResponseEntity> searchByKeyword(@RequestParam String keyword) { + Validator.notBlank(keyword, "keyword"); + List response = artistTotalSearchV1Service.findAllByKeyword(keyword); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/festago/artist/repository/ArtistRepository.java b/backend/src/main/java/com/festago/artist/repository/ArtistRepository.java index a578ca57e..a0db11fa8 100644 --- a/backend/src/main/java/com/festago/artist/repository/ArtistRepository.java +++ b/backend/src/main/java/com/festago/artist/repository/ArtistRepository.java @@ -26,4 +26,6 @@ default Artist getOrThrow(Long artistId) { long countByIdIn(List artistIds); List findByIdIn(Collection artistIds); + + boolean existsById(Long id); } diff --git a/backend/src/main/java/com/festago/artist/repository/ArtistSearchV1QueryDslRepository.java b/backend/src/main/java/com/festago/artist/repository/ArtistSearchV1QueryDslRepository.java new file mode 100644 index 000000000..323089d3e --- /dev/null +++ b/backend/src/main/java/com/festago/artist/repository/ArtistSearchV1QueryDslRepository.java @@ -0,0 +1,51 @@ +package com.festago.artist.repository; + +import static com.festago.artist.domain.QArtist.artist; +import static com.festago.stage.domain.QStage.stage; +import static com.festago.stage.domain.QStageArtist.stageArtist; +import static com.querydsl.core.group.GroupBy.groupBy; + +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.ArtistSearchV1Response; +import com.festago.artist.dto.QArtistSearchV1Response; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.querydsl.core.group.GroupBy; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Repository; + +@Repository +public class ArtistSearchV1QueryDslRepository extends QueryDslRepositorySupport { + + public ArtistSearchV1QueryDslRepository() { + super(Artist.class); + } + + public List findAllByLike(String keyword) { + return select( + new QArtistSearchV1Response(artist.id, artist.name, artist.profileImage)) + .from(artist) + .where(artist.name.contains(keyword)) + .orderBy(artist.name.asc()) + .fetch(); + } + + public List findAllByEqual(String keyword) { + return select( + new QArtistSearchV1Response(artist.id, artist.name, artist.profileImage)) + .from(artist) + .where(artist.name.eq(keyword)) + .orderBy(artist.name.asc()) + .fetch(); + } + + public Map> findArtistsStageScheduleAfterDateTime(List artistIds, + LocalDateTime localDateTime) { + return selectFrom(stageArtist) + .innerJoin(stage).on(stage.id.eq(stageArtist.stageId)) + .where(stageArtist.artistId.in(artistIds) + .and(stage.startTime.goe(localDateTime))) + .transform(groupBy(stageArtist.artistId).as(GroupBy.list(stage.startTime))); + } +} diff --git a/backend/src/main/java/com/festago/auth/application/AdminAuthService.java b/backend/src/main/java/com/festago/auth/application/AdminAuthService.java index 67e113105..1a075f715 100644 --- a/backend/src/main/java/com/festago/auth/application/AdminAuthService.java +++ b/backend/src/main/java/com/festago/auth/application/AdminAuthService.java @@ -17,6 +17,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Deprecated(forRemoval = true) @Service @Transactional @RequiredArgsConstructor diff --git a/backend/src/main/java/com/festago/auth/application/command/AdminAuthCommandService.java b/backend/src/main/java/com/festago/auth/application/command/AdminAuthCommandService.java new file mode 100644 index 000000000..87f7954b3 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/application/command/AdminAuthCommandService.java @@ -0,0 +1,84 @@ +package com.festago.auth.application.command; + +import com.festago.admin.domain.Admin; +import com.festago.admin.repository.AdminRepository; +import com.festago.auth.application.AuthProvider; +import com.festago.auth.domain.AuthPayload; +import com.festago.auth.domain.AuthType; +import com.festago.auth.domain.Role; +import com.festago.auth.dto.command.AdminLoginCommand; +import com.festago.auth.dto.command.AdminLoginResult; +import com.festago.auth.dto.command.AdminSignupCommand; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.ForbiddenException; +import com.festago.common.exception.UnauthorizedException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class AdminAuthCommandService { + + private final AuthProvider authProvider; + private final AdminRepository adminRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public AdminLoginResult login(AdminLoginCommand command) { + Admin admin = findAdminWithAuthenticate(command); + AuthPayload authPayload = new AuthPayload(admin.getId(), Role.ADMIN); + String accessToken = authProvider.provide(authPayload); + return new AdminLoginResult( + admin.getUsername(), + getAuthType(admin), + accessToken + ); + } + + private Admin findAdminWithAuthenticate(AdminLoginCommand request) { + return adminRepository.findByUsername(request.username()) + .filter(admin -> passwordEncoder.matches(request.password(), admin.getPassword())) + .orElseThrow(() -> new UnauthorizedException(ErrorCode.INCORRECT_PASSWORD_OR_ACCOUNT)); + } + + private AuthType getAuthType(Admin admin) { + if (admin.isRootAdmin()) { + return AuthType.ROOT; + } + return AuthType.ADMIN; + } + + public void signup(Long adminId, AdminSignupCommand command) { + validateRootAdmin(adminId); + String username = command.username(); + String password = passwordEncoder.encode(command.password()); + validateExistsUsername(username); + adminRepository.save(new Admin(username, password)); + } + + private void validateRootAdmin(Long adminId) { + adminRepository.findById(adminId) + .filter(Admin::isRootAdmin) + .ifPresentOrElse(ignore -> { + }, () -> { + throw new ForbiddenException(ErrorCode.NOT_ENOUGH_PERMISSION); + }); + } + + private void validateExistsUsername(String username) { + if (adminRepository.existsByUsername(username)) { + throw new BadRequestException(ErrorCode.DUPLICATE_ACCOUNT_USERNAME); + } + } + + public void initializeRootAdmin(String password) { + adminRepository.findByUsername(Admin.ROOT_ADMIN_NAME) + .ifPresentOrElse(ignore -> { + throw new BadRequestException(ErrorCode.DUPLICATE_ACCOUNT_USERNAME); + }, () -> adminRepository.save(Admin.createRootAdmin(passwordEncoder.encode(password)))); + } +} diff --git a/backend/src/main/java/com/festago/auth/config/LoginConfig.java b/backend/src/main/java/com/festago/auth/config/LoginConfig.java index f0180a0bf..98a1000fb 100644 --- a/backend/src/main/java/com/festago/auth/config/LoginConfig.java +++ b/backend/src/main/java/com/festago/auth/config/LoginConfig.java @@ -38,8 +38,9 @@ public void addInterceptors(InterceptorRegistry registry) { .allowMethod(HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE, HttpMethod.PUT, HttpMethod.PATCH) .interceptor(adminAuthInterceptor()) .build()) - .addPathPatterns("/admin/**", "/js/admin/**") - .excludePathPatterns("/admin/login", "/admin/api/login", "/admin/api/initialize"); + .addPathPatterns("/admin/**") + .excludePathPatterns("/admin/api/login", "/admin/api/initialize", "/admin/api/v1/auth/login", + "/admin/api/v1/auth/initialize"); // TODO #797 이슈 해결되면 레거시 API 경로 삭제할 것 registry.addInterceptor(HttpMethodDelegateInterceptor.builder() .allowMethod(HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE, HttpMethod.PUT, HttpMethod.PATCH) .interceptor(memberAuthInterceptor()) diff --git a/backend/src/main/java/com/festago/auth/domain/AuthType.java b/backend/src/main/java/com/festago/auth/domain/AuthType.java new file mode 100644 index 000000000..98881c150 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/domain/AuthType.java @@ -0,0 +1,7 @@ +package com.festago.auth.domain; + +public enum AuthType { + ROOT, + ADMIN, + ; +} diff --git a/backend/src/main/java/com/festago/auth/dto/AdminLoginRequest.java b/backend/src/main/java/com/festago/auth/dto/AdminLoginRequest.java index 313977f5c..f5a63b974 100644 --- a/backend/src/main/java/com/festago/auth/dto/AdminLoginRequest.java +++ b/backend/src/main/java/com/festago/auth/dto/AdminLoginRequest.java @@ -2,6 +2,7 @@ import jakarta.validation.constraints.NotBlank; +@Deprecated(forRemoval = true) public record AdminLoginRequest( @NotBlank(message = "username은 공백일 수 없습니다.") String username, diff --git a/backend/src/main/java/com/festago/auth/dto/AdminLoginV1Request.java b/backend/src/main/java/com/festago/auth/dto/AdminLoginV1Request.java new file mode 100644 index 000000000..47fa8d59d --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/AdminLoginV1Request.java @@ -0,0 +1,16 @@ +package com.festago.auth.dto; + +import com.festago.auth.dto.command.AdminLoginCommand; +import jakarta.validation.constraints.NotBlank; + +public record AdminLoginV1Request( + @NotBlank + String username, + @NotBlank + String password +) { + + public AdminLoginCommand toCommand() { + return new AdminLoginCommand(username, password); + } +} diff --git a/backend/src/main/java/com/festago/auth/dto/AdminLoginV1Response.java b/backend/src/main/java/com/festago/auth/dto/AdminLoginV1Response.java new file mode 100644 index 000000000..42903f90b --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/AdminLoginV1Response.java @@ -0,0 +1,10 @@ +package com.festago.auth.dto; + +import com.festago.auth.domain.AuthType; + +public record AdminLoginV1Response( + String username, + AuthType authType +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/AdminSignupRequest.java b/backend/src/main/java/com/festago/auth/dto/AdminSignupRequest.java index 02c689d9f..3e9210746 100644 --- a/backend/src/main/java/com/festago/auth/dto/AdminSignupRequest.java +++ b/backend/src/main/java/com/festago/auth/dto/AdminSignupRequest.java @@ -2,6 +2,7 @@ import jakarta.validation.constraints.NotBlank; +@Deprecated(forRemoval = true) public record AdminSignupRequest( @NotBlank(message = "username은 공백일 수 없습니다.") String username, diff --git a/backend/src/main/java/com/festago/auth/dto/AdminSignupResponse.java b/backend/src/main/java/com/festago/auth/dto/AdminSignupResponse.java index 99da63f2d..26502c861 100644 --- a/backend/src/main/java/com/festago/auth/dto/AdminSignupResponse.java +++ b/backend/src/main/java/com/festago/auth/dto/AdminSignupResponse.java @@ -1,5 +1,6 @@ package com.festago.auth.dto; +@Deprecated(forRemoval = true) public record AdminSignupResponse( String username ) { diff --git a/backend/src/main/java/com/festago/auth/dto/AdminSignupV1Request.java b/backend/src/main/java/com/festago/auth/dto/AdminSignupV1Request.java new file mode 100644 index 000000000..9e58dde72 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/AdminSignupV1Request.java @@ -0,0 +1,16 @@ +package com.festago.auth.dto; + +import com.festago.auth.dto.command.AdminSignupCommand; +import jakarta.validation.constraints.NotBlank; + +public record AdminSignupV1Request( + @NotBlank + String username, + @NotBlank + String password +) { + + public AdminSignupCommand toCommand() { + return new AdminSignupCommand(username, password); + } +} diff --git a/backend/src/main/java/com/festago/auth/dto/command/AdminLoginCommand.java b/backend/src/main/java/com/festago/auth/dto/command/AdminLoginCommand.java new file mode 100644 index 000000000..083d7047e --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/command/AdminLoginCommand.java @@ -0,0 +1,8 @@ +package com.festago.auth.dto.command; + +public record AdminLoginCommand( + String username, + String password +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/command/AdminLoginResult.java b/backend/src/main/java/com/festago/auth/dto/command/AdminLoginResult.java new file mode 100644 index 000000000..1a14cad3f --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/command/AdminLoginResult.java @@ -0,0 +1,18 @@ +package com.festago.auth.dto.command; + +import com.festago.auth.domain.AuthType; + +// TODO Command에서 반환하는 객체의 이름을 어떻게 하면 좋을까 +// 버저닝을 사용하지 않고 AdminLoginResponse라고 한 뒤 버저닝 된 컨트롤러에서 AdminLoginV1Response 객체로 변환하여 사용? +// 혹은 지금과 같이 AdminLoginResult와 같이 Response라는 이름을 빼고 반환할지..? +// Controller에서 필요한 응답은 username과 authType임. +// 그리고 accessToken은 쿠키로 반환하기 때문에 다음과 같이 accessToken이 필드로 있게 되면 필요하지 않은 응답이 나감 +// 클라이언트에서 필요하지 않은 필드는 무시하지만, accessToken과 같은 보안에 관련된 값일때 필요하지 않은 값은 +// 필요가 없다면 보내지 않는게 좋지 않을까? +public record AdminLoginResult( + String username, + AuthType authType, + String accessToken +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/command/AdminSignupCommand.java b/backend/src/main/java/com/festago/auth/dto/command/AdminSignupCommand.java new file mode 100644 index 000000000..215d2b948 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/command/AdminSignupCommand.java @@ -0,0 +1,8 @@ +package com.festago.auth.dto.command; + +public record AdminSignupCommand( + String username, + String password +) { + +} diff --git a/backend/src/main/java/com/festago/auth/presentation/AdminAuthController.java b/backend/src/main/java/com/festago/auth/presentation/AdminAuthController.java index 73cfe36b2..1646b36d6 100644 --- a/backend/src/main/java/com/festago/auth/presentation/AdminAuthController.java +++ b/backend/src/main/java/com/festago/auth/presentation/AdminAuthController.java @@ -19,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Deprecated(forRemoval = true) @RestController @RequestMapping("/admin/api") @Hidden diff --git a/backend/src/main/java/com/festago/auth/presentation/v1/AdminAuthV1Controller.java b/backend/src/main/java/com/festago/auth/presentation/v1/AdminAuthV1Controller.java new file mode 100644 index 000000000..7323444e4 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/presentation/v1/AdminAuthV1Controller.java @@ -0,0 +1,82 @@ +package com.festago.auth.presentation.v1; + +import com.festago.auth.annotation.Admin; +import com.festago.auth.application.command.AdminAuthCommandService; +import com.festago.auth.dto.AdminLoginV1Request; +import com.festago.auth.dto.AdminLoginV1Response; +import com.festago.auth.dto.AdminSignupV1Request; +import com.festago.auth.dto.RootAdminInitializeRequest; +import com.festago.auth.dto.command.AdminLoginResult; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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.RestController; + +@RestController +@RequestMapping("/admin/api/v1/auth") +@Hidden +@RequiredArgsConstructor +public class AdminAuthV1Controller { + + private final AdminAuthCommandService adminAuthCommandService; + + @PostMapping("/login") + public ResponseEntity login( + @RequestBody @Valid AdminLoginV1Request request + ) { + AdminLoginResult result = adminAuthCommandService.login(request.toCommand()); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, createLoginCookie(result.accessToken())) + .body(new AdminLoginV1Response(result.username(), result.authType())); + } + + private String createLoginCookie(String token) { + return ResponseCookie.from("token", token) + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .build().toString(); + } + + @GetMapping("/logout") + public ResponseEntity logout() { + return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, createLogoutCookie()) + .build(); + } + + private String createLogoutCookie() { + return ResponseCookie.from("token", "") + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(Duration.ZERO) + .build().toString(); + } + + @PostMapping("/signup") + public ResponseEntity signupAdminAccount( + @RequestBody @Valid AdminSignupV1Request request, + @Admin Long adminId + ) { + adminAuthCommandService.signup(adminId, request.toCommand()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/initialize") + public ResponseEntity initializeRootAdmin( + @RequestBody @Valid RootAdminInitializeRequest request + ) { + adminAuthCommandService.initializeRootAdmin(request.password()); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/application/ArtistBookmarkV1QueryService.java b/backend/src/main/java/com/festago/bookmark/application/ArtistBookmarkV1QueryService.java new file mode 100644 index 000000000..106b62fbb --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/ArtistBookmarkV1QueryService.java @@ -0,0 +1,20 @@ +package com.festago.bookmark.application; + +import com.festago.bookmark.dto.v1.ArtistBookmarkV1Response; +import com.festago.bookmark.repository.v1.ArtistBookmarkV1QueryDslRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ArtistBookmarkV1QueryService { + + private final ArtistBookmarkV1QueryDslRepository artistBookmarkV1QueryDslRepository; + + public List findArtistBookmarksByMemberId(Long memberid){ + return artistBookmarkV1QueryDslRepository.findByMemberId(memberid); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/application/FestivalBookmarkV1QueryService.java b/backend/src/main/java/com/festago/bookmark/application/FestivalBookmarkV1QueryService.java new file mode 100644 index 000000000..0a738724c --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/FestivalBookmarkV1QueryService.java @@ -0,0 +1,33 @@ +package com.festago.bookmark.application; + +import com.festago.bookmark.dto.v1.FestivalBookmarkV1Response; +import com.festago.bookmark.repository.FestivalBookmarkOrder; +import com.festago.bookmark.repository.v1.FestivalBookmarkV1QueryDslRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FestivalBookmarkV1QueryService { + + private final FestivalBookmarkV1QueryDslRepository festivalBookmarkV1QueryDslRepository; + + public List findBookmarkedFestivalIds(Long memberId) { + return festivalBookmarkV1QueryDslRepository.findBookmarkedFestivalIds(memberId); + } + + public List findBookmarkedFestivals( + Long memberId, + List festivalIds, + FestivalBookmarkOrder festivalBookmarkOrder + ) { + return festivalBookmarkV1QueryDslRepository.findBookmarkedFestivals( + memberId, + festivalIds, + festivalBookmarkOrder + ); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/application/SchoolBookmarkV1QueryService.java b/backend/src/main/java/com/festago/bookmark/application/SchoolBookmarkV1QueryService.java new file mode 100644 index 000000000..cc0a58c40 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/SchoolBookmarkV1QueryService.java @@ -0,0 +1,21 @@ +package com.festago.bookmark.application; + +import com.festago.bookmark.dto.v1.SchoolBookmarkV1Response; +import com.festago.bookmark.repository.v1.SchoolBookmarkV1QuerydslRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SchoolBookmarkV1QueryService { + + private final SchoolBookmarkV1QuerydslRepository schoolBookmarkV1QuerydslRepository; + + public List findAllByMemberId(Long memberId) { + return schoolBookmarkV1QuerydslRepository.findAllByMemberId(memberId); + } +} + diff --git a/backend/src/main/java/com/festago/bookmark/application/command/ArtistBookmarkCommandService.java b/backend/src/main/java/com/festago/bookmark/application/command/ArtistBookmarkCommandService.java new file mode 100644 index 000000000..e28d10995 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/command/ArtistBookmarkCommandService.java @@ -0,0 +1,61 @@ +package com.festago.bookmark.application.command; + +import com.festago.artist.repository.ArtistRepository; +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ArtistBookmarkCommandService { + + private static final long MAX_ARTIST_BOOKMARK_COUNT = 12L; + + private final BookmarkRepository bookmarkRepository; + private final ArtistRepository artistRepository; + + public void save(Long artistId, Long memberId) { + validate(artistId, memberId); + if (isExistsBookmark(artistId, memberId)) { + return; + } + bookmarkRepository.save(new Bookmark(BookmarkType.ARTIST, artistId, memberId)); + } + + private void validate(Long artistId, Long memberId) { + validateExistArtist(artistId); + validateMaxBookmark(memberId); + } + + private void validateExistArtist(Long artistId) { + if (!artistRepository.existsById(artistId)) { + throw new NotFoundException(ErrorCode.ARTIST_NOT_FOUND); + } + } + + private void validateMaxBookmark(Long memberId) { + long bookmarkCount = bookmarkRepository.countByMemberIdAndBookmarkType(memberId, BookmarkType.ARTIST); + if (bookmarkCount >= MAX_ARTIST_BOOKMARK_COUNT) { + throw new BadRequestException(ErrorCode.BOOKMARK_LIMIT_EXCEEDED); + } + } + + private boolean isExistsBookmark(Long artistId, Long memberId) { + return bookmarkRepository.existsByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType.ARTIST, + memberId, + artistId + ); + } + + public void delete(Long artistId, Long memberId) { + bookmarkRepository.deleteByBookmarkTypeAndMemberIdAndResourceId(BookmarkType.ARTIST, memberId, artistId); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/application/command/BookmarkFacadeService.java b/backend/src/main/java/com/festago/bookmark/application/command/BookmarkFacadeService.java new file mode 100644 index 000000000..056331b9d --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/command/BookmarkFacadeService.java @@ -0,0 +1,38 @@ +package com.festago.bookmark.application.command; + +import com.festago.bookmark.domain.BookmarkType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BookmarkFacadeService { + + private final SchoolBookmarkCommandService schoolBookmarkCommandService; + private final ArtistBookmarkCommandService artistBookmarkCommandService; + private final FestivalBookmarkCommandService festivalBookmarkCommandService; + + public void save( + BookmarkType bookmarkType, + Long resourceId, + Long memberId + ) { + switch (bookmarkType) { + case SCHOOL -> schoolBookmarkCommandService.save(resourceId, memberId); + case ARTIST -> artistBookmarkCommandService.save(resourceId, memberId); + case FESTIVAL -> festivalBookmarkCommandService.save(resourceId, memberId); + } + } + + public void delete( + BookmarkType bookmarkType, + Long resourceId, + Long memberId + ) { + switch (bookmarkType) { + case SCHOOL -> schoolBookmarkCommandService.delete(resourceId, memberId); + case ARTIST -> artistBookmarkCommandService.delete(resourceId, memberId); + case FESTIVAL -> festivalBookmarkCommandService.delete(resourceId, memberId); + } + } +} diff --git a/backend/src/main/java/com/festago/bookmark/application/command/FestivalBookmarkCommandService.java b/backend/src/main/java/com/festago/bookmark/application/command/FestivalBookmarkCommandService.java new file mode 100644 index 000000000..0ad04ff5c --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/command/FestivalBookmarkCommandService.java @@ -0,0 +1,57 @@ +package com.festago.bookmark.application.command; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.repository.FestivalRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class FestivalBookmarkCommandService { + + private static final long MAX_FESTIVAL_BOOKMARK_COUNT = 12L; + + private final BookmarkRepository bookmarkRepository; + private final FestivalRepository festivalRepository; + + public void save(Long festivalId, Long memberId) { + validate(festivalId, memberId); + if (isExistsBookmark(festivalId, memberId)) { + return; + } + bookmarkRepository.save(new Bookmark(BookmarkType.FESTIVAL, festivalId, memberId)); + } + + private void validate(Long festivalId, Long memberId) { + if (!festivalRepository.existsById(festivalId)) { + throw new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND); + } + long festivalBookmarkCount = bookmarkRepository.countByMemberIdAndBookmarkType(memberId, BookmarkType.FESTIVAL); + if (festivalBookmarkCount >= MAX_FESTIVAL_BOOKMARK_COUNT) { + throw new BadRequestException(ErrorCode.BOOKMARK_LIMIT_EXCEEDED); + } + } + + private boolean isExistsBookmark(Long festivalId, Long memberId) { + return bookmarkRepository.existsByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType.FESTIVAL, + memberId, + festivalId + ); + } + + public void delete(Long festivalId, Long memberId) { + bookmarkRepository.deleteByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType.FESTIVAL, + memberId, + festivalId + ); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/application/command/SchoolBookmarkCommandService.java b/backend/src/main/java/com/festago/bookmark/application/command/SchoolBookmarkCommandService.java new file mode 100644 index 000000000..6bdae2079 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/command/SchoolBookmarkCommandService.java @@ -0,0 +1,54 @@ +package com.festago.bookmark.application.command; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.school.repository.SchoolRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class SchoolBookmarkCommandService { + + private static final long MAX_SCHOOL_BOOKMARK_COUNT = 12L; + + private final BookmarkRepository bookmarkRepository; + private final SchoolRepository schoolRepository; + + public void save(Long schoolId, Long memberId) { + validate(schoolId, memberId); + if (isExistsBookmark(schoolId, memberId)) { + return; + } + bookmarkRepository.save(new Bookmark(BookmarkType.SCHOOL, schoolId, memberId)); + } + + private void validate(Long schoolId, Long memberId) { + if (!schoolRepository.existsById(schoolId)) { + throw new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND); + } + + long bookmarkCount = bookmarkRepository.countByMemberIdAndBookmarkType(memberId, BookmarkType.SCHOOL); + if (bookmarkCount >= MAX_SCHOOL_BOOKMARK_COUNT) { + throw new BadRequestException(ErrorCode.BOOKMARK_LIMIT_EXCEEDED); + } + } + + private boolean isExistsBookmark(Long schoolId, Long memberId) { + return bookmarkRepository.existsByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType.SCHOOL, + memberId, + schoolId + ); + } + + public void delete(Long schoolId, Long memberId) { + bookmarkRepository.deleteByBookmarkTypeAndMemberIdAndResourceId(BookmarkType.SCHOOL, memberId, schoolId); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/domain/Bookmark.java b/backend/src/main/java/com/festago/bookmark/domain/Bookmark.java new file mode 100644 index 000000000..e1852bdf0 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/domain/Bookmark.java @@ -0,0 +1,69 @@ +package com.festago.bookmark.domain; + +import com.festago.common.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "bookmark", + uniqueConstraints = { + @UniqueConstraint( + name = "UNIQUE_BOOKMARK_TYPE_RESOURCE_ID_MEMBER_ID", + columnNames = {"bookmark_type", "resource_id", "member_id"} + ) + } +) +public class Bookmark extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "bookmark_type") + private BookmarkType bookmarkType; + + @Column(name = "resource_id") + private Long resourceId; + + @Column(name = "member_id") + private Long memberId; + + public Bookmark(Long id, BookmarkType bookmarkType, Long resourceId, Long memberId) { + this.id = id; + this.bookmarkType = bookmarkType; + this.resourceId = resourceId; + this.memberId = memberId; + } + + public Bookmark(BookmarkType bookmarkType, Long resourceId, Long memberId) { + this(null, bookmarkType, resourceId, memberId); + } + + public Long getId() { + return id; + } + + public BookmarkType getBookmarkType() { + return bookmarkType; + } + + public Long getResourceId() { + return resourceId; + } + + public Long getMemberId() { + return memberId; + } +} diff --git a/backend/src/main/java/com/festago/bookmark/domain/BookmarkType.java b/backend/src/main/java/com/festago/bookmark/domain/BookmarkType.java new file mode 100644 index 000000000..84cca15aa --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/domain/BookmarkType.java @@ -0,0 +1,8 @@ +package com.festago.bookmark.domain; + +public enum BookmarkType { + SCHOOL, + ARTIST, + FESTIVAL + ; +} diff --git a/backend/src/main/java/com/festago/bookmark/dto/v1/ArtistBookmarkInfoV1Response.java b/backend/src/main/java/com/festago/bookmark/dto/v1/ArtistBookmarkInfoV1Response.java new file mode 100644 index 000000000..4f10b8827 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/dto/v1/ArtistBookmarkInfoV1Response.java @@ -0,0 +1,13 @@ +package com.festago.bookmark.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; + +public record ArtistBookmarkInfoV1Response( + String name, + String profileImageUrl +) { + + @QueryProjection + public ArtistBookmarkInfoV1Response { + } +} diff --git a/backend/src/main/java/com/festago/bookmark/dto/v1/ArtistBookmarkV1Response.java b/backend/src/main/java/com/festago/bookmark/dto/v1/ArtistBookmarkV1Response.java new file mode 100644 index 000000000..54835c52c --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/dto/v1/ArtistBookmarkV1Response.java @@ -0,0 +1,14 @@ +package com.festago.bookmark.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; + +public record ArtistBookmarkV1Response( + ArtistBookmarkInfoV1Response artist, + LocalDateTime createdAt +) { + + @QueryProjection + public ArtistBookmarkV1Response { + } +} diff --git a/backend/src/main/java/com/festago/bookmark/dto/v1/FestivalBookmarkV1Response.java b/backend/src/main/java/com/festago/bookmark/dto/v1/FestivalBookmarkV1Response.java new file mode 100644 index 000000000..0db51e03b --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/dto/v1/FestivalBookmarkV1Response.java @@ -0,0 +1,15 @@ +package com.festago.bookmark.dto.v1; + +import com.festago.festival.dto.FestivalV1Response; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; + +public record FestivalBookmarkV1Response( + FestivalV1Response festival, + LocalDateTime createdAt +) { + + @QueryProjection + public FestivalBookmarkV1Response { + } +} diff --git a/backend/src/main/java/com/festago/bookmark/dto/v1/SchoolBookmarkInfoV1Response.java b/backend/src/main/java/com/festago/bookmark/dto/v1/SchoolBookmarkInfoV1Response.java new file mode 100644 index 000000000..d53f08690 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/dto/v1/SchoolBookmarkInfoV1Response.java @@ -0,0 +1,14 @@ +package com.festago.bookmark.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; + +public record SchoolBookmarkInfoV1Response( + Long id, + String name, + String logoUrl +) { + + @QueryProjection + public SchoolBookmarkInfoV1Response { + } +} diff --git a/backend/src/main/java/com/festago/bookmark/dto/v1/SchoolBookmarkV1Response.java b/backend/src/main/java/com/festago/bookmark/dto/v1/SchoolBookmarkV1Response.java new file mode 100644 index 000000000..2c13ddeb1 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/dto/v1/SchoolBookmarkV1Response.java @@ -0,0 +1,14 @@ +package com.festago.bookmark.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; + +public record SchoolBookmarkV1Response( + SchoolBookmarkInfoV1Response school, + LocalDateTime createdAt +) { + + @QueryProjection + public SchoolBookmarkV1Response { + } +} diff --git a/backend/src/main/java/com/festago/bookmark/presentation/v1/ArtistBookmarkV1Controller.java b/backend/src/main/java/com/festago/bookmark/presentation/v1/ArtistBookmarkV1Controller.java new file mode 100644 index 000000000..96290355f --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/presentation/v1/ArtistBookmarkV1Controller.java @@ -0,0 +1,32 @@ +package com.festago.bookmark.presentation.v1; + +import com.festago.auth.annotation.Member; +import com.festago.auth.annotation.MemberAuth; +import com.festago.bookmark.application.ArtistBookmarkV1QueryService; +import com.festago.bookmark.dto.v1.ArtistBookmarkV1Response; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/bookmarks/artists") +@Tag(name = "아티스트 북마크 V1") +public class ArtistBookmarkV1Controller { + + private final ArtistBookmarkV1QueryService artistBookmarkV1QueryService; + + @MemberAuth + @GetMapping + @Operation(description = "유저의 아티스트 북마크 목록을 조회한다.", summary = "아티스트 북마크 조회") + public ResponseEntity> findArtistBookmarksByMemberId( + @Member Long memberId + ) { + return ResponseEntity.ok(artistBookmarkV1QueryService.findArtistBookmarksByMemberId(memberId)); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/presentation/v1/BookmarkManagementV1Controller.java b/backend/src/main/java/com/festago/bookmark/presentation/v1/BookmarkManagementV1Controller.java new file mode 100644 index 000000000..6eed9991f --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/presentation/v1/BookmarkManagementV1Controller.java @@ -0,0 +1,50 @@ +package com.festago.bookmark.presentation.v1; + +import com.festago.auth.annotation.Member; +import com.festago.auth.annotation.MemberAuth; +import com.festago.bookmark.application.command.BookmarkFacadeService; +import com.festago.bookmark.domain.BookmarkType; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/bookmarks") +@Tag(name = "북마크 등록/삭제 V1") +public class BookmarkManagementV1Controller { + + private final BookmarkFacadeService bookmarkFacadeService; + + @MemberAuth + @PutMapping + @Operation(description = "자원의 식별자와 타입으로 북마크를 등록한다.", summary = "북마크 등록") + public ResponseEntity putBookmark( + @Member Long memberId, + @RequestParam Long resourceId, + @RequestParam BookmarkType bookmarkType + ) { + bookmarkFacadeService.save(bookmarkType, resourceId, memberId); + return ResponseEntity.ok() + .build(); + } + + @MemberAuth + @DeleteMapping + @Operation(description = "자원의 식별자와 타입으로 북마크를 삭제한다.", summary = "북마크 삭제") + public ResponseEntity deleteBookmark( + @Member Long memberId, + @RequestParam Long resourceId, + @RequestParam BookmarkType bookmarkType + ) { + bookmarkFacadeService.delete(bookmarkType, resourceId, memberId); + return ResponseEntity.noContent() + .build(); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/presentation/v1/FestivalBookmarkV1Controller.java b/backend/src/main/java/com/festago/bookmark/presentation/v1/FestivalBookmarkV1Controller.java new file mode 100644 index 000000000..57c9d2a3a --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/presentation/v1/FestivalBookmarkV1Controller.java @@ -0,0 +1,51 @@ +package com.festago.bookmark.presentation.v1; + +import com.festago.auth.annotation.Member; +import com.festago.auth.annotation.MemberAuth; +import com.festago.bookmark.application.FestivalBookmarkV1QueryService; +import com.festago.bookmark.dto.v1.FestivalBookmarkV1Response; +import com.festago.bookmark.repository.FestivalBookmarkOrder; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/bookmarks/festivals") +@Tag(name = "축제 북마크 V1") +public class FestivalBookmarkV1Controller { + + private final FestivalBookmarkV1QueryService festivalBookmarkV1QueryService; + + @MemberAuth + @GetMapping("/ids") + @Operation(description = "북마크 된 축제의 식별자 목록을 조회한다.", summary = "북마크 된 축제 식별자 목록 조회") + public ResponseEntity> findBookmarkedFestivalIds( + @Member Long memberId + ) { + return ResponseEntity.ok() + .body(festivalBookmarkV1QueryService.findBookmarkedFestivalIds(memberId)); + } + + @MemberAuth + @GetMapping + @Operation(description = "축제의 식별자 목록으로 북마크 된 축제의 목록을 조회한다.", summary = "축제의 식별자 목록으로 북마크 된 축제의 목록 조회") + public ResponseEntity> findBookmarkedFestivals( + @Member Long memberId, + @RequestParam List festivalIds, + @RequestParam FestivalBookmarkOrder festivalBookmarkOrder + ) { + return ResponseEntity.ok() + .body(festivalBookmarkV1QueryService.findBookmarkedFestivals( + memberId, + festivalIds, + festivalBookmarkOrder + )); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/presentation/v1/SchoolBookmarkV1Controller.java b/backend/src/main/java/com/festago/bookmark/presentation/v1/SchoolBookmarkV1Controller.java new file mode 100644 index 000000000..6f4773cc2 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/presentation/v1/SchoolBookmarkV1Controller.java @@ -0,0 +1,30 @@ +package com.festago.bookmark.presentation.v1; + +import com.festago.auth.annotation.Member; +import com.festago.auth.annotation.MemberAuth; +import com.festago.bookmark.application.SchoolBookmarkV1QueryService; +import com.festago.bookmark.dto.v1.SchoolBookmarkV1Response; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/bookmarks/schools") +@Tag(name = "학교 북마크 V1") +public class SchoolBookmarkV1Controller { + + private final SchoolBookmarkV1QueryService schoolBookmarkV1QueryService; + + @MemberAuth + @GetMapping + @Operation(description = "특정한 회원의 학교 북마크 목록을 반환한다", summary = "회원 학교 북마크 목록 조회") + public ResponseEntity> findAllByMemberId(@Member Long memberId) { + return ResponseEntity.ok(schoolBookmarkV1QueryService.findAllByMemberId(memberId)); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/repository/BookmarkRepository.java b/backend/src/main/java/com/festago/bookmark/repository/BookmarkRepository.java new file mode 100644 index 000000000..83c99df43 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/repository/BookmarkRepository.java @@ -0,0 +1,22 @@ +package com.festago.bookmark.repository; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import org.springframework.data.repository.Repository; + +public interface BookmarkRepository extends Repository { + + Bookmark save(Bookmark bookmark); + + void deleteById(Long id); + + boolean existsByBookmarkTypeAndMemberIdAndResourceId(BookmarkType bookmarkType, Long memberId, Long resourceId); + + long countByMemberIdAndBookmarkType(Long memberId, BookmarkType bookmarkType); + + void deleteByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType bookmarkType, + Long memberId, + Long resourceId + ); +} diff --git a/backend/src/main/java/com/festago/bookmark/repository/FestivalBookmarkOrder.java b/backend/src/main/java/com/festago/bookmark/repository/FestivalBookmarkOrder.java new file mode 100644 index 000000000..640ebaaff --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/repository/FestivalBookmarkOrder.java @@ -0,0 +1,7 @@ +package com.festago.bookmark.repository; + +public enum FestivalBookmarkOrder { + BOOKMARK, + FESTIVAL, + ; +} diff --git a/backend/src/main/java/com/festago/bookmark/repository/v1/ArtistBookmarkV1QueryDslRepository.java b/backend/src/main/java/com/festago/bookmark/repository/v1/ArtistBookmarkV1QueryDslRepository.java new file mode 100644 index 000000000..83d043112 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/repository/v1/ArtistBookmarkV1QueryDslRepository.java @@ -0,0 +1,37 @@ +package com.festago.bookmark.repository.v1; + +import static com.festago.artist.domain.QArtist.artist; +import static com.festago.bookmark.domain.QBookmark.bookmark; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.dto.v1.ArtistBookmarkV1Response; +import com.festago.bookmark.dto.v1.QArtistBookmarkInfoV1Response; +import com.festago.bookmark.dto.v1.QArtistBookmarkV1Response; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class ArtistBookmarkV1QueryDslRepository extends QueryDslRepositorySupport { + + protected ArtistBookmarkV1QueryDslRepository() { + super(Bookmark.class); + } + + public List findByMemberId(Long memberId) { + return select( + new QArtistBookmarkV1Response( + new QArtistBookmarkInfoV1Response( + artist.name, + artist.profileImage + ), + bookmark.createdAt)) + .from(bookmark) + .innerJoin(artist).on( + bookmark.bookmarkType.eq(BookmarkType.ARTIST) + .and(bookmark.memberId.eq(memberId)) + .and(bookmark.resourceId.eq(artist.id))) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/repository/v1/FestivalBookmarkV1QueryDslRepository.java b/backend/src/main/java/com/festago/bookmark/repository/v1/FestivalBookmarkV1QueryDslRepository.java new file mode 100644 index 000000000..4e1e31aee --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/repository/v1/FestivalBookmarkV1QueryDslRepository.java @@ -0,0 +1,71 @@ +package com.festago.bookmark.repository.v1; + +import static com.festago.bookmark.domain.QBookmark.bookmark; +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; +import static com.festago.school.domain.QSchool.school; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.dto.v1.FestivalBookmarkV1Response; +import com.festago.bookmark.dto.v1.QFestivalBookmarkV1Response; +import com.festago.bookmark.repository.FestivalBookmarkOrder; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.dto.QFestivalV1Response; +import com.festago.festival.dto.QSchoolV1Response; +import com.querydsl.core.types.OrderSpecifier; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class FestivalBookmarkV1QueryDslRepository extends QueryDslRepositorySupport { + + public FestivalBookmarkV1QueryDslRepository() { + super(Bookmark.class); + } + + public List findBookmarkedFestivalIds(Long memberId) { + return select(bookmark.resourceId) + .from(bookmark) + .where(bookmark.memberId.eq(memberId).and(bookmark.bookmarkType.eq(BookmarkType.FESTIVAL))) + .fetch(); + } + + public List findBookmarkedFestivals( + Long memberId, + List festivalIds, + FestivalBookmarkOrder festivalBookmarkOrder + ) { + return select( + new QFestivalBookmarkV1Response( + new QFestivalV1Response( + festival.id, + festival.name, + festival.startDate, + festival.endDate, + festival.thumbnail, + new QSchoolV1Response( + school.id, + school.name + ), + festivalQueryInfo.artistInfo + ), + bookmark.createdAt + )) + .from(festival) + .innerJoin(school).on(school.id.eq(festival.school.id)) + .innerJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id)) + .innerJoin(bookmark).on(bookmark.bookmarkType.eq(BookmarkType.FESTIVAL) + .and(bookmark.resourceId.eq(festival.id)).and(bookmark.memberId.eq(memberId))) + .where(festival.id.in(festivalIds)) + .orderBy(dynamicOrder(festivalBookmarkOrder)) + .fetch(); + } + + private OrderSpecifier dynamicOrder(FestivalBookmarkOrder festivalBookmarkOrder) { + return switch (festivalBookmarkOrder) { + case BOOKMARK -> bookmark.id.desc(); + case FESTIVAL -> festival.startDate.asc(); + }; + } +} diff --git a/backend/src/main/java/com/festago/bookmark/repository/v1/SchoolBookmarkV1QuerydslRepository.java b/backend/src/main/java/com/festago/bookmark/repository/v1/SchoolBookmarkV1QuerydslRepository.java new file mode 100644 index 000000000..b277fc066 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/repository/v1/SchoolBookmarkV1QuerydslRepository.java @@ -0,0 +1,37 @@ +package com.festago.bookmark.repository.v1; + +import static com.festago.bookmark.domain.QBookmark.bookmark; +import static com.festago.school.domain.QSchool.school; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.dto.v1.QSchoolBookmarkInfoV1Response; +import com.festago.bookmark.dto.v1.QSchoolBookmarkV1Response; +import com.festago.bookmark.dto.v1.SchoolBookmarkV1Response; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class SchoolBookmarkV1QuerydslRepository extends QueryDslRepositorySupport { + + protected SchoolBookmarkV1QuerydslRepository() { + super(Bookmark.class); + } + + public List findAllByMemberId(Long memberId) { + return select(new QSchoolBookmarkV1Response( + new QSchoolBookmarkInfoV1Response( + school.id, + school.name, + school.logoUrl + ), + bookmark.createdAt + )) + .from(bookmark) + .innerJoin(school).on(school.id.eq(bookmark.resourceId) + .and(bookmark.memberId.eq(memberId)) + .and(bookmark.bookmarkType.eq(BookmarkType.SCHOOL))) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/common/exception/ErrorCode.java b/backend/src/main/java/com/festago/common/exception/ErrorCode.java index f62da8d44..b27c52603 100644 --- a/backend/src/main/java/com/festago/common/exception/ErrorCode.java +++ b/backend/src/main/java/com/festago/common/exception/ErrorCode.java @@ -38,6 +38,9 @@ public enum ErrorCode { INVALID_NUMBER_FORMAT_PAGING_SIZE("size는 1 이상의 정수 형식이어야 합니다."), FESTIVAL_DELETE_CONSTRAINT_EXISTS_STAGE("공연이 등록된 축제는 삭제할 수 없습니다."), FESTIVAL_UPDATE_OUT_OF_DATE_STAGE_START_TIME("축제에 등록된 공연 중 변경하려는 날짜에 포함되지 않는 공연이 있습니다."), + BOOKMARK_LIMIT_EXCEEDED("최대 북마크 갯수를 초과했습니다"), + BROAD_SEARCH_KEYWORD("더 자세한 검색어로 입력해야합니다."), + INVALID_KEYWORD("유효하지 않은 키워드 입니다."), // 401 EXPIRED_AUTH_TOKEN("만료된 로그인 토큰입니다."), diff --git a/backend/src/main/java/com/festago/config/SchedulerConfig.java b/backend/src/main/java/com/festago/config/SchedulerConfig.java new file mode 100644 index 000000000..3a496b0d8 --- /dev/null +++ b/backend/src/main/java/com/festago/config/SchedulerConfig.java @@ -0,0 +1,10 @@ +package com.festago.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@Configuration +public class SchedulerConfig { + +} diff --git a/backend/src/main/java/com/festago/festival/application/FestivalSearchV1QueryService.java b/backend/src/main/java/com/festago/festival/application/FestivalSearchV1QueryService.java new file mode 100644 index 000000000..d7988e748 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/FestivalSearchV1QueryService.java @@ -0,0 +1,30 @@ +package com.festago.festival.application; + +import com.festago.festival.dto.FestivalSearchV1Response; +import com.festago.festival.repository.ArtistFestivalSearchV1QueryDslRepository; +import com.festago.festival.repository.SchoolFestivalSearchV1QueryDslRepository; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FestivalSearchV1QueryService { + + private static final Pattern SCHOOL_PATTERN = Pattern.compile(".*대(학교)?$"); + + private final ArtistFestivalSearchV1QueryDslRepository artistFestivalSearchV1QueryDslRepository; + private final SchoolFestivalSearchV1QueryDslRepository schoolFestivalSearchV1QueryDslRepository; + + public List search(String keyword) { + Matcher schoolMatcher = SCHOOL_PATTERN.matcher(keyword); + if (schoolMatcher.matches()) { + return schoolFestivalSearchV1QueryDslRepository.executeSearch(keyword); + } + return artistFestivalSearchV1QueryDslRepository.executeSearch(keyword); + } +} diff --git a/backend/src/main/java/com/festago/festival/application/QueryDslSchoolSearchUpcomingFestivalV1QueryService.java b/backend/src/main/java/com/festago/festival/application/QueryDslSchoolSearchUpcomingFestivalV1QueryService.java new file mode 100644 index 000000000..822a3304d --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/QueryDslSchoolSearchUpcomingFestivalV1QueryService.java @@ -0,0 +1,31 @@ +package com.festago.festival.application; + +import static java.util.stream.Collectors.toUnmodifiableMap; + +import com.festago.festival.repository.RecentSchoolFestivalV1QueryDslRepository; +import com.festago.school.application.v1.SchoolSearchUpcomingFestivalV1QueryService; +import com.festago.school.dto.v1.SchoolSearchUpcomingFestivalV1Response; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class QueryDslSchoolSearchUpcomingFestivalV1QueryService implements SchoolSearchUpcomingFestivalV1QueryService { + + private final RecentSchoolFestivalV1QueryDslRepository recentSchoolFestivalV1QueryDslRepository; + private final Clock clock; + + @Override + public Map searchUpcomingFestivals(List schoolIds) { + return recentSchoolFestivalV1QueryDslRepository.findRecentSchoolFestivals(schoolIds, LocalDate.now(clock)) + .stream() + .collect(toUnmodifiableMap(SchoolSearchUpcomingFestivalV1Response::schoolId, Function.identity())); + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalSearchV1Response.java b/backend/src/main/java/com/festago/festival/dto/FestivalSearchV1Response.java new file mode 100644 index 000000000..25710388a --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/FestivalSearchV1Response.java @@ -0,0 +1,20 @@ +package com.festago.festival.dto; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDate; + +public record FestivalSearchV1Response( + Long id, + String name, + LocalDate startDate, + LocalDate endDate, + String posterImageUrl, + @JsonRawValue String artists +) { + + @QueryProjection + public FestivalSearchV1Response { + + } +} diff --git a/backend/src/main/java/com/festago/festival/presentation/v1/FestivalSearchV1Controller.java b/backend/src/main/java/com/festago/festival/presentation/v1/FestivalSearchV1Controller.java new file mode 100644 index 000000000..42bdad8e6 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/presentation/v1/FestivalSearchV1Controller.java @@ -0,0 +1,34 @@ +package com.festago.festival.presentation.v1; + +import com.festago.common.util.Validator; +import com.festago.festival.application.FestivalSearchV1QueryService; +import com.festago.festival.dto.FestivalSearchV1Response; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/search/festivals") +@Tag(name = "축제 검색 요청 V1") +@RequiredArgsConstructor +public class FestivalSearchV1Controller { + + private final FestivalSearchV1QueryService festivalSearchV1QueryService; + + @GetMapping + @Operation(description = "축제를 검색한다. ~대 혹은 ~대학교로 끝날 시 대학교 축제 검색이며 그 외의 경우는 아티스트 기반 축제 검색입니다.", summary = "축제 검색") + public ResponseEntity> getArtistInfo(@RequestParam String keyword) { + validate(keyword); + return ResponseEntity.ok(festivalSearchV1QueryService.search(keyword)); + } + + private void validate(String keyword) { + Validator.notBlank(keyword, "keyword"); + } +} diff --git a/backend/src/main/java/com/festago/festival/repository/ArtistFestivalSearchV1QueryDslRepository.java b/backend/src/main/java/com/festago/festival/repository/ArtistFestivalSearchV1QueryDslRepository.java new file mode 100644 index 000000000..685acde9c --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/ArtistFestivalSearchV1QueryDslRepository.java @@ -0,0 +1,62 @@ +package com.festago.festival.repository; + +import static com.festago.artist.domain.QArtist.artist; +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; +import static com.festago.stage.domain.QStage.stage; +import static com.festago.stage.domain.QStageArtist.stageArtist; + +import com.festago.artist.domain.Artist; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.dto.FestivalSearchV1Response; +import com.festago.festival.dto.QFestivalSearchV1Response; +import com.querydsl.core.types.dsl.BooleanExpression; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class ArtistFestivalSearchV1QueryDslRepository extends QueryDslRepositorySupport { + + public ArtistFestivalSearchV1QueryDslRepository() { + super(Artist.class); + } + + public List executeSearch(String keyword) { + int keywordLength = keyword.length(); + if (keywordLength == 0) { + throw new BadRequestException(ErrorCode.INVALID_KEYWORD); + } + if (keywordLength == 1) { + return searchByEqual(keyword); + } + return searchByLike(keyword); + } + + private List searchByEqual(String keyword) { + return searchByExpression(artist.name.eq(keyword)); + } + + private List searchByExpression(BooleanExpression expression) { + return select( + new QFestivalSearchV1Response( + festival.id, + festival.name, + festival.startDate, + festival.endDate, + festival.thumbnail, + festivalQueryInfo.artistInfo)) + .from(artist) + .innerJoin(stageArtist).on(expression.and(stageArtist.artistId.eq(artist.id))) + .innerJoin(stage).on(stage.id.eq(stageArtist.stageId)) + .innerJoin(festival).on(festival.id.eq(stage.festival.id)) + .innerJoin(festivalQueryInfo).on(festival.id.eq(festivalQueryInfo.festivalId)) + .where(expression) + .fetch(); + } + + private List searchByLike(String keyword) { + return searchByExpression(artist.name.contains(keyword)); + } +} diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java b/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java index e807237b6..8b3ef5d34 100644 --- a/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java +++ b/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java @@ -20,4 +20,6 @@ default Festival getOrThrow(Long festivalId) { Optional findById(Long festivalId); void deleteById(Long festivalId); + + boolean existsById(Long festivalId); } diff --git a/backend/src/main/java/com/festago/festival/repository/RecentSchoolFestivalV1QueryDslRepository.java b/backend/src/main/java/com/festago/festival/repository/RecentSchoolFestivalV1QueryDslRepository.java new file mode 100644 index 000000000..a061fe555 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/RecentSchoolFestivalV1QueryDslRepository.java @@ -0,0 +1,34 @@ +package com.festago.festival.repository; + +import static com.festago.festival.domain.QFestival.festival; + +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.domain.Festival; +import com.festago.school.dto.v1.QSchoolSearchUpcomingFestivalV1Response; +import com.festago.school.dto.v1.SchoolSearchUpcomingFestivalV1Response; +import java.time.LocalDate; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class RecentSchoolFestivalV1QueryDslRepository extends QueryDslRepositorySupport { + + public RecentSchoolFestivalV1QueryDslRepository() { + super(Festival.class); + } + + public List findRecentSchoolFestivals( + List schoolIds, + LocalDate now + ) { + return select( + new QSchoolSearchUpcomingFestivalV1Response( + festival.school.id, + festival.startDate.min() + )) + .from(festival) + .where(festival.school.id.in(schoolIds).and(festival.endDate.goe(now))) + .groupBy(festival.school.id) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/festival/repository/SchoolFestivalSearchV1QueryDslRepository.java b/backend/src/main/java/com/festago/festival/repository/SchoolFestivalSearchV1QueryDslRepository.java new file mode 100644 index 000000000..d09053f30 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/SchoolFestivalSearchV1QueryDslRepository.java @@ -0,0 +1,41 @@ +package com.festago.festival.repository; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.dto.FestivalSearchV1Response; +import com.festago.festival.dto.QFestivalSearchV1Response; +import com.festago.school.domain.School; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class SchoolFestivalSearchV1QueryDslRepository extends QueryDslRepositorySupport { + + protected SchoolFestivalSearchV1QueryDslRepository() { + super(School.class); + } + + public List executeSearch(String keyword) { + int keywordLength = keyword.length(); + if (keywordLength == 0) { + throw new BadRequestException(ErrorCode.INVALID_KEYWORD); + } + + return select( + new QFestivalSearchV1Response( + festival.id, + festival.name, + festival.startDate, + festival.endDate, + festival.thumbnail, + festivalQueryInfo.artistInfo)) + .from(festival) + .innerJoin(festivalQueryInfo).on(festival.id.eq(festivalQueryInfo.festivalId)) + .where(festival.name.contains(keyword)) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/member/repository/MemberRepository.java b/backend/src/main/java/com/festago/member/repository/MemberRepository.java index e9536e0af..b2fe61046 100644 --- a/backend/src/main/java/com/festago/member/repository/MemberRepository.java +++ b/backend/src/main/java/com/festago/member/repository/MemberRepository.java @@ -3,9 +3,19 @@ import com.festago.auth.domain.SocialType; import com.festago.member.domain.Member; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.Repository; -public interface MemberRepository extends JpaRepository { +public interface MemberRepository extends Repository { + + Member save(Member member); + + Optional findById(Long id); + + void delete(Member member); + + boolean existsById(Long id); + + long count(); Optional findBySocialIdAndSocialType(String socialId, SocialType socialType); } diff --git a/backend/src/main/java/com/festago/mock/CommandLineAppStartupRunner.java b/backend/src/main/java/com/festago/mock/CommandLineAppStartupRunner.java new file mode 100644 index 000000000..cde40d7a4 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/CommandLineAppStartupRunner.java @@ -0,0 +1,22 @@ +package com.festago.mock; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Profile({"dev"}) +@Component +@RequiredArgsConstructor +public class CommandLineAppStartupRunner implements CommandLineRunner { + + private final MockDataService mockDataService; + private final MockScheduler mockScheduler; + + @Override + public void run(String... args) { + if (mockDataService.initialize()) { + mockScheduler.run(); + } + } +} diff --git a/backend/src/main/java/com/festago/mock/MockArtist.java b/backend/src/main/java/com/festago/mock/MockArtist.java new file mode 100644 index 000000000..ede1c5f65 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/MockArtist.java @@ -0,0 +1,40 @@ +package com.festago.mock; + +public enum MockArtist { + 윤하("https://i.namu.wiki/i/GXgnzRzEe4tq0YmevyZdmLNTPmVK2fzRyZtiCMA-0QSO8vbau9tah4rgP3qNokmT33-El0fx2PauysBruWpTk8B6MLu73gDgVnC3tvFeBnm3Lwo9oRreHM6slID3ilDTyFjpV2eKMO80Jf5u5E920g.webp", + "https://i.namu.wiki/i/IBHythQ09eithIwC-Ix3hr0Sh-AohBVBavl0u-2WW28L6f16JM8F8Klm-0A6gDacgy4t7ATbto7wBc9xt5gwsj0IVG1y9EphSkJun-op4O9OGwvOvX8ES8aTUejOsKPFX5Iat_ubwgGWAYudo5q-Yw.webp"), + 뉴진스("https://i.namu.wiki/i/l1co7IV1KFVJ9HBgzxCXkMbgMfuZp_MJhjhqgB7e76DLuokabw6CNlltqr7HGzAMFqt42JfXF94Cflw5XdDuXTS2QkvomS7WYpiiJbuAn5MAjBxOA_zT93dsgyLO-gJXtV0JN-jEQ4tQ-MWtqbHJyA.webp", + "https://i.namu.wiki/i/j5Fs-OjRcQsjfrrFFUVumAWauv-47tj5WPrfyIcCMuBrV5UeStJwaFK17HKcaKxvME2NVpo5PuxVgRpED1huULNxCYBydqsOs-HCLRD-kMztnZdaMJJvi1VefVB1RN0MnwMdxS7xKzxJa11qem0LMg.svg"), + 아이유("https://i.namu.wiki/i/k-to3_lfqjjcdnXtWMu3aLtZAArBM1nDpDP6cCWz5iJYm3HjJZ3b7i2H-4-KFSkQ6HOeftXIilMOQXvkdp83hu1FdBv5GE_PyYuacNUSygQ2cnT8vfNHqQVUReYdEYY3ob1BWoyGBE6BQRaHmnGPLw.webp", + "https://i.namu.wiki/i/bJDL9DWmKsrHvvMcLOSKMlVv_E62CX6brjhiddhFuLrGPVYN6-bYcJxUHnE_KP04Ok-8PqezYob8OCepRWFBw6CTDE5Jvde2iJOZEqGgYVt6Gdbub0s9pBIOqzI1DQYZvJbAezbh-8xns_ZugPW68g.webp"), + 방탄소년단( + "https://i.namu.wiki/i/EvnNG2DchyHHYmFtyWWzVHhPKkURdc6kdoiRVYisSKHE6BDE8itzfhfYvIMdoX0-6wvum0UgELIowRGR6cuwfNsR1OHrLamq-Rpg0F4XzFMSJHJ_xchPwFBBurNR45kOUYk2ueOKasd-xZ0g9Z14dg.webp", + "https://i.namu.wiki/i/PmFR4AjifhcmdLRXQUPPce9Z7BXVWc6mVX4N22fPUKOzK5ZfjNTfo9e1M7HPa2jiEmG_tuhm43aJMGWLylyQqw.svg"), + 푸우("https://i.namu.wiki/i/aj4JXZR4P-ZiY6Gf0EsoPMK3XFHHsSmx0b8ysKnUDpEd0ET1BVQHZIEAGvZGHCNJrn7Nkz7N5zeYzKh3WPSTGdCdOPpj1LnlAceeLWTmMSsiXvl2fyGaEZfRjm7i6DiBTW6_7pDqIRCWfYRQFKUsdg.webp", + "https://i.namu.wiki/i/_EIBF52MTqZAj5wtmY86jsU4fs7aYsdns4guDgLKYWQGoauVdCyZZiFcxc5qI92HxTUiWRRRrK0qk4Ot0qCRGpIc1GUTjUaYz1Y20IKkDuIo9472InXbpNMxcsE8PmP-taYj-7-Ql3_P557yYOk3EA.webp"), + 장범준("https://i.namu.wiki/i/6VAPyai_C0lBvsGytiMDu3moDOzS9UH9TDHqxzkjPWFymhQV-vcyH4q884nf1KKH2lVzLqMndBnCOTlUh4ZJE6QeB1oUQEH23d_FwMa3CFsyj8mkn3nG2DQMmJ31TN0cvCrxk6II8-IWq6C-d883zA.webp", + "https://i.namu.wiki/i/WPzIZvkNW_-q9UZmEHLKMtrvAQ2g2Oo7MwbrbzWnWEkRYYAdc2cyougS-n8-BwWsLuo3knt9aaHYEGiyhd7jtg.webp"), + 세븐틴("https://i.namu.wiki/i/55JrvWZKaTo_Vik4Wim6-PfXiEmWqwYnAwL5_KmNg9FWiM31U5VV-lmMl76TgtON5hpGP9dIEBhub70rAQvbvOVWt7dQ6GLqvrnpbQSV3Vr1vEKRXjk_RSqCpz5a_7nupUqhB8sBJExEDHf7WGKQDw.webp", + "https://i.namu.wiki/i/l4c_545au41xOK_9XRJyDh1PoNU1k9v-T5NF1UhLHxgCBQJzahV-ra8kP87FLmVFhey_OaWJcrBDJs0RmqbBB3lziiAgbM9lkDUAkENQBv4GweS2MSglXGGno9XQfnzisf5e-Z7185_U4jTqIqTiQA.svg"), + 아이들("https://i.namu.wiki/i/LTu_7r5vrTyZgjGb0pV79BoSI_CZr3hwLMnj2s1-ShPb-A07Nc0Gh_rGn8dic1_JwcJlB-pnSunyqmmIP-UhKRw33PlPO5GECFE2u4I5EtKIXN3c5u8_Wln6U22-Ofyjf90PxLLG1BLQziOoQ0d-pg.webp", + "https://i.namu.wiki/i/KwThwv_MdMM3O7mCr-WyXHXCZhfwKtLgAof5i-wIkkgp10izoSGyTKwCgMgBcoAaIP7VocBS-D36nHI6pkiPy3E8ncWJqsqrghC8bwoaM5dOEs32E9QSxk12CZUKCzGg9AM1bJivIuBBzFBpuc5JBg.webp"), + 에스파("https://i.namu.wiki/i/KJ5Gpz42djU6ZUsFKnkAnpMS1zRFxUOuqzt9plzbjV_mkFlruZcDULsfEjpVw-2vxjsSKbcGflPlOThHE1DgzST-hnm9jmxPqdPMExPkqH_71ZMF6jhQVfQX6QuNZw3Bz0EZ4C1sO5vpZ-OJNfvTyg.webp", + "https://i.namu.wiki/i/yAfAHme6H-HfWWQCvNAje-KInl_XM-xzRHOUmUxvRxh-HLbzk8KbG6zmD9qQXfUAeCenhHM5whZJ2nhQk0lanzT1LVja3BEQCVk1yPWABxy4NygdaLGyNpiRZTwVFkhD_PnCcESdUQ7-oEtK0YptsQ.webp"), + ; + + private final String profileImage; + private final String backgroundImageUrl; + + MockArtist(String profileImage, String backgroundImageUrl) { + this.profileImage = profileImage; + this.backgroundImageUrl = backgroundImageUrl; + } + + public String getProfileImage() { + return profileImage; + } + + public String getBackgroundImageUrl() { + return backgroundImageUrl; + } +} diff --git a/backend/src/main/java/com/festago/mock/MockDataService.java b/backend/src/main/java/com/festago/mock/MockDataService.java new file mode 100644 index 000000000..81081a7cc --- /dev/null +++ b/backend/src/main/java/com/festago/mock/MockDataService.java @@ -0,0 +1,216 @@ +package com.festago.mock; + +import com.festago.artist.application.ArtistCommandService; +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.command.ArtistCreateCommand; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.application.command.FestivalCommandFacadeService; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.command.FestivalCreateCommand; +import com.festago.mock.repository.ForMockArtistRepository; +import com.festago.mock.repository.ForMockFestivalRepository; +import com.festago.mock.repository.ForMockSchoolRepository; +import com.festago.school.application.SchoolCommandService; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.dto.SchoolCreateCommand; +import com.festago.stage.application.command.StageCommandFacadeService; +import com.festago.stage.dto.command.StageCreateCommand; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.atomic.AtomicLong; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Profile({"dev"}) +@Service +@Transactional +@RequiredArgsConstructor +public class MockDataService { + + public static final int STAGE_ARTIST_COUNT = 3; + private static final AtomicLong festivalSequence = new AtomicLong(); + private static final int STAGE_START_HOUR = 19; + private static final int SCHOOL_PER_REGION = 3; + private static final int DATE_OFFSET = 1; + + private final MockFestivalDateGenerator mockFestivalDateGenerator; + private final ForMockSchoolRepository schoolRepository; + private final ForMockArtistRepository artistRepository; + private final ForMockFestivalRepository festivalRepository; + private final FestivalCommandFacadeService festivalCommandFacadeService; + private final StageCommandFacadeService stageCommandFacadeService; + private final ArtistCommandService artistCommandService; + private final SchoolCommandService schoolCommandService; + + public boolean initialize() { + if (alreadyInitialized()) { + return false; + } + initializeData(); + return true; + } + + private boolean alreadyInitialized() { + return !schoolRepository.findAll().isEmpty(); + } + + private void initializeData() { + initializeSchool(); + initializeArtist(); + } + + private void initializeSchool() { + for (SchoolRegion schoolRegion : SchoolRegion.values()) { + if (SchoolRegion.ANY.equals(schoolRegion)) { + continue; + } + makeRegionSchools(schoolRegion); + } + } + + /** + * 각 지역 별로 3개의 학교를 만듭니다. ex) 서울1대학교 서울2대학교 서울3대학교 + */ + private void makeRegionSchools(SchoolRegion schoolRegion) { + for (int i = 0; i < SCHOOL_PER_REGION; i++) { + String schoolName = String.format("%s%d대학교", schoolRegion.name(), i + 1); + String schoolEmail = String.format("%s%d.com", schoolRegion.name(), i + 1); + crateSchool(schoolRegion, schoolName, schoolEmail); + } + } + + private void crateSchool(SchoolRegion schoolRegion, String schoolName, String schoolEmail) { + schoolCommandService.createSchool(new SchoolCreateCommand( + schoolName, + schoolEmail, + schoolRegion, + null, + null + ) + ); + } + + private void initializeArtist() { + for (MockArtist artist : MockArtist.values()) { + artistCommandService.save(new ArtistCreateCommand( + artist.name(), + artist.getProfileImage(), + artist.getBackgroundImageUrl() + ) + ); + } + } + + public void makeMockFestivals(int availableFestivalDuration) { + List allSchool = schoolRepository.findAll(); + List allArtist = artistRepository.findAll(); + int artistSize = allArtist.size(); + if (STAGE_ARTIST_COUNT > artistSize) { + throw new IllegalArgumentException( + String.format("공연을 구성하기 위한 아티스트의 최소 수를 만족하지 못합니다 최소 수 : %d 현재 수 : %d", STAGE_ARTIST_COUNT, artistSize)); + } + for (School school : allSchool) { + makeFestival(availableFestivalDuration, school, allArtist); + } + } + + /** + * 현재 날짜 + 입력받은 축제 기간 안의 기간을 갖는 축제를 생성합니다. 이때 하나의 축제에 중복된 아티스트가 포함되지 않기 위해서 makeRandomArtists 라는 메서드를 통해 섞인 Artist + * 들의 큐가 생성됩니다. + */ + private void makeFestival(int availableFestivalDuration, School school, List artists) { + LocalDate now = LocalDate.now(); + LocalDate startDate = mockFestivalDateGenerator.makeRandomStartDate(availableFestivalDuration, now); + LocalDate endDate = mockFestivalDateGenerator.makeRandomEndDate(availableFestivalDuration, now, startDate); + + Long newFestivalId = festivalCommandFacadeService.createFestival(new FestivalCreateCommand( + school.getName() + "축제" + festivalSequence.incrementAndGet(), + startDate, + endDate, + "https://picsum.photos/536/354", + school.getId() + )); + + makeStages(newFestivalId, makeRandomArtists(artists)); + } + + private Queue makeRandomArtists(List artists) { + List randomArtists = new ArrayList<>(artists); + Collections.shuffle(randomArtists); + return new ArrayDeque<>(randomArtists); + } + + /** + * 축제 기간 동안 축제를 채웁니다. 에를 들어 Festival 이 23~25일 이라면 23, 24, 25 날짜의 stage 를 생성합니다. + */ + private void makeStages(Long festivalId, Queue artists) { + Festival festival = festivalRepository.findById(festivalId) + .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); + LocalDate endDate = festival.getEndDate(); + LocalDate startDate = festival.getStartDate(); + startDate.datesUntil(endDate.plusDays(DATE_OFFSET)) + .forEach(localDate -> makeStage(festival, artists, localDate)); + } + + /** + * 실질적으로 무대를 만드는 부분으로 이때 하나의 stage 는 랜덤한 아티스트 3명을 갖도록 만듭니다. + * 축제 별로 생성되는 queue 에서 poll 을 통해 stageArtist 를 결정하기 때문에 같은 축제에서 아티스트는 중복되지 않습니다. + */ + private void makeStage(Festival festival, Queue artists, LocalDate localDate) { + LocalDateTime startTime = localDate.atTime(STAGE_START_HOUR, 0); + stageCommandFacadeService.createStage(new StageCreateCommand( + festival.getId(), + startTime, + startTime.minusDays(1L), + makeStageArtists(artists) + )); + } + + private List makeStageArtists(Queue artists) { + return makeStageArtistsByArtistCount(artists).stream() + .map(Artist::getId) + .toList(); + } + + /** + * Stage 는 생성 제약 조건에 의해서 무조건 다른 아티스트로 구성해야합니다. + * 만약 STAGE_ARTIST_COUNT * 2 값보다 큐에 artist 가 작게 들어있으면 poll 연산 이후 artist 는 STAGE_ARTIST_COUNT 보다 적게 들어있습니다. + * 예를 들어 STAGE_ARTIST_COUNT = 3 일떄 6개의 아티스트에 대해서 poll 를 한다면 3개가 남아 나머지 3개로 중복 없는Stage 를 구성할 수 있지만 + * 5개의 아티스트에 대해서 poll 한 후에 2개의 artist 로는 Stage 에 중복이 생길 수 밖에 없습니다. + * 따라서 STAGE_ARTIST_COUNT * 2 artists 가 크다면 poll, 아닐 경우 poll 이후 다시 insert 해주는 로직을 진행합니다. + */ + private List makeStageArtistsByArtistCount(Queue artists) { + if (artists.size() < STAGE_ARTIST_COUNT * 2) { + return makeDuplicateStageArtists(artists); + } + return makeUniqueStageArtists(artists); + } + + private List makeDuplicateStageArtists(Queue artists) { + List result = new ArrayList<>(); + for (int i = 0; i < STAGE_ARTIST_COUNT; i++) { + Artist artist = artists.poll(); + result.add(artist); + artists.add(artist); + } + return result; + } + + private List makeUniqueStageArtists(Queue artists) { + List result = new ArrayList<>(); + for (int i = 0; i < STAGE_ARTIST_COUNT; i++) { + Artist artist = artists.poll(); + result.add(artist); + } + return result; + } +} diff --git a/backend/src/main/java/com/festago/mock/MockFestivalDateGenerator.java b/backend/src/main/java/com/festago/mock/MockFestivalDateGenerator.java new file mode 100644 index 000000000..51b72e243 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/MockFestivalDateGenerator.java @@ -0,0 +1,10 @@ +package com.festago.mock; + +import java.time.LocalDate; + +public interface MockFestivalDateGenerator { + + LocalDate makeRandomStartDate(int festivalDuration, LocalDate now); + + LocalDate makeRandomEndDate(int festivalDuration, LocalDate now, LocalDate startDate); +} diff --git a/backend/src/main/java/com/festago/mock/MockScheduler.java b/backend/src/main/java/com/festago/mock/MockScheduler.java new file mode 100644 index 000000000..98d171068 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/MockScheduler.java @@ -0,0 +1,21 @@ +package com.festago.mock; + +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Profile("dev") +@Component +@RequiredArgsConstructor +public class MockScheduler { + + private static final long SCHEDULER_CYCLE = 7; + private final MockDataService mockDataService; + + @Scheduled(fixedDelay = SCHEDULER_CYCLE, timeUnit = TimeUnit.DAYS) + public void run() { + mockDataService.makeMockFestivals((int) SCHEDULER_CYCLE); + } +} diff --git a/backend/src/main/java/com/festago/mock/RandomMockFestivalDateGenerator.java b/backend/src/main/java/com/festago/mock/RandomMockFestivalDateGenerator.java new file mode 100644 index 000000000..7ec66a5b5 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/RandomMockFestivalDateGenerator.java @@ -0,0 +1,31 @@ +package com.festago.mock; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Profile("dev") +@Component +public class RandomMockFestivalDateGenerator implements MockFestivalDateGenerator { + + private static final int COUNT_FIRST_DAY_AS_DURATION_ONE = 1; + private static final int RANDOM_OFFSET = 1; + private static final int MAX_END_DATE_FROM_START_DATE = 2; + private final Random random = ThreadLocalRandom.current(); + + @Override + public LocalDate makeRandomStartDate(int festivalDuration, LocalDate now) { + return now.plusDays(random.nextInt(festivalDuration)); + } + + @Override + public LocalDate makeRandomEndDate(int festivalDuration, LocalDate now, LocalDate startDate) { + long timeUntilFestivalStart = startDate.until(now, ChronoUnit.DAYS); + long maxAvailableEndDateFromStartDate = festivalDuration - (timeUntilFestivalStart + COUNT_FIRST_DAY_AS_DURATION_ONE); + int randomEndDate = random.nextInt((int) (maxAvailableEndDateFromStartDate + RANDOM_OFFSET)); + return startDate.plusDays(Math.min(randomEndDate, MAX_END_DATE_FROM_START_DATE)); + } +} diff --git a/backend/src/main/java/com/festago/mock/repository/ForMockArtistRepository.java b/backend/src/main/java/com/festago/mock/repository/ForMockArtistRepository.java new file mode 100644 index 000000000..15970e2c6 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/repository/ForMockArtistRepository.java @@ -0,0 +1,8 @@ +package com.festago.mock.repository; + +import com.festago.artist.domain.Artist; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForMockArtistRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/festago/mock/repository/ForMockFestivalInfoRepository.java b/backend/src/main/java/com/festago/mock/repository/ForMockFestivalInfoRepository.java new file mode 100644 index 000000000..37f78f296 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/repository/ForMockFestivalInfoRepository.java @@ -0,0 +1,8 @@ +package com.festago.mock.repository; + +import com.festago.festival.domain.FestivalQueryInfo; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForMockFestivalInfoRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/festago/mock/repository/ForMockFestivalRepository.java b/backend/src/main/java/com/festago/mock/repository/ForMockFestivalRepository.java new file mode 100644 index 000000000..a18224397 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/repository/ForMockFestivalRepository.java @@ -0,0 +1,8 @@ +package com.festago.mock.repository; + +import com.festago.festival.domain.Festival; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForMockFestivalRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/festago/mock/repository/ForMockSchoolRepository.java b/backend/src/main/java/com/festago/mock/repository/ForMockSchoolRepository.java new file mode 100644 index 000000000..312f55dbc --- /dev/null +++ b/backend/src/main/java/com/festago/mock/repository/ForMockSchoolRepository.java @@ -0,0 +1,8 @@ +package com.festago.mock.repository; + +import com.festago.school.domain.School; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForMockSchoolRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/festago/mock/repository/ForMockStageArtistRepository.java b/backend/src/main/java/com/festago/mock/repository/ForMockStageArtistRepository.java new file mode 100644 index 000000000..cc666e420 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/repository/ForMockStageArtistRepository.java @@ -0,0 +1,8 @@ +package com.festago.mock.repository; + +import com.festago.stage.domain.StageArtist; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForMockStageArtistRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/festago/mock/repository/ForMockStageQueryInfoRepository.java b/backend/src/main/java/com/festago/mock/repository/ForMockStageQueryInfoRepository.java new file mode 100644 index 000000000..6a6480c63 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/repository/ForMockStageQueryInfoRepository.java @@ -0,0 +1,8 @@ +package com.festago.mock.repository; + +import com.festago.stage.domain.StageQueryInfo; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForMockStageQueryInfoRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/festago/mock/repository/ForMockStageRepository.java b/backend/src/main/java/com/festago/mock/repository/ForMockStageRepository.java new file mode 100644 index 000000000..e606c76d5 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/repository/ForMockStageRepository.java @@ -0,0 +1,8 @@ +package com.festago.mock.repository; + +import com.festago.stage.domain.Stage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForMockStageRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/festago/school/application/v1/SchoolSearchUpcomingFestivalV1QueryService.java b/backend/src/main/java/com/festago/school/application/v1/SchoolSearchUpcomingFestivalV1QueryService.java new file mode 100644 index 000000000..187bcc9ce --- /dev/null +++ b/backend/src/main/java/com/festago/school/application/v1/SchoolSearchUpcomingFestivalV1QueryService.java @@ -0,0 +1,10 @@ +package com.festago.school.application.v1; + +import com.festago.school.dto.v1.SchoolSearchUpcomingFestivalV1Response; +import java.util.List; +import java.util.Map; + +public interface SchoolSearchUpcomingFestivalV1QueryService { + + Map searchUpcomingFestivals(List schoolIds); +} diff --git a/backend/src/main/java/com/festago/school/application/v1/SchoolSearchV1QueryService.java b/backend/src/main/java/com/festago/school/application/v1/SchoolSearchV1QueryService.java new file mode 100644 index 000000000..a9dd4a8c2 --- /dev/null +++ b/backend/src/main/java/com/festago/school/application/v1/SchoolSearchV1QueryService.java @@ -0,0 +1,20 @@ +package com.festago.school.application.v1; + +import com.festago.school.dto.v1.SchoolSearchV1Response; +import com.festago.school.repository.v1.SchoolSearchV1QueryDslRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class SchoolSearchV1QueryService { + + private final SchoolSearchV1QueryDslRepository schoolSearchV1QueryDslRepository; + + public List searchSchools(String keyword) { + return schoolSearchV1QueryDslRepository.searchSchools(keyword); + } +} diff --git a/backend/src/main/java/com/festago/school/application/v1/SchoolTotalSearchV1QueryService.java b/backend/src/main/java/com/festago/school/application/v1/SchoolTotalSearchV1QueryService.java new file mode 100644 index 000000000..ca5b3effc --- /dev/null +++ b/backend/src/main/java/com/festago/school/application/v1/SchoolTotalSearchV1QueryService.java @@ -0,0 +1,29 @@ +package com.festago.school.application.v1; + +import com.festago.school.dto.v1.SchoolSearchV1Response; +import com.festago.school.dto.v1.SchoolTotalSearchV1Response; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class SchoolTotalSearchV1QueryService { + + private final SchoolSearchV1QueryService schoolSearchV1QueryService; + private final SchoolSearchUpcomingFestivalV1QueryService schoolSearchUpcomingFestivalV1QueryService; + + public List searchSchools(String keyword) { + var schoolSearchResponses = schoolSearchV1QueryService.searchSchools(keyword); + List schoolIds = schoolSearchResponses.stream() + .map(SchoolSearchV1Response::id) + .toList(); + var schoolIdToUpcomingFestivalResponse = schoolSearchUpcomingFestivalV1QueryService.searchUpcomingFestivals( + schoolIds); + return schoolSearchResponses.stream() + .map(it -> SchoolTotalSearchV1Response.of(it, schoolIdToUpcomingFestivalResponse.get(it.id()))) + .toList(); + } +} diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolSearchUpcomingFestivalV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolSearchUpcomingFestivalV1Response.java new file mode 100644 index 000000000..ccd1f2ecf --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolSearchUpcomingFestivalV1Response.java @@ -0,0 +1,14 @@ +package com.festago.school.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDate; + +public record SchoolSearchUpcomingFestivalV1Response( + Long schoolId, + LocalDate startDate +) { + + @QueryProjection + public SchoolSearchUpcomingFestivalV1Response { + } +} diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolSearchV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolSearchV1Response.java new file mode 100644 index 000000000..be8d86636 --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolSearchV1Response.java @@ -0,0 +1,14 @@ +package com.festago.school.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; + +public record SchoolSearchV1Response( + Long id, + String name, + String logoUrl +) { + + @QueryProjection + public SchoolSearchV1Response { + } +} diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolTotalSearchV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolTotalSearchV1Response.java new file mode 100644 index 000000000..f95631b41 --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolTotalSearchV1Response.java @@ -0,0 +1,24 @@ +package com.festago.school.dto.v1; + +import jakarta.annotation.Nullable; +import java.time.LocalDate; + +public record SchoolTotalSearchV1Response( + Long id, + String name, + String logoUrl, + @Nullable LocalDate upcomingFestivalStartDate +) { + + public static SchoolTotalSearchV1Response of( + SchoolSearchV1Response schoolSearchV1Response, + SchoolSearchUpcomingFestivalV1Response schoolSearchUpcomingFestivalV1Response + ) { + return new SchoolTotalSearchV1Response( + schoolSearchV1Response.id(), + schoolSearchV1Response.name(), + schoolSearchV1Response.logoUrl(), + schoolSearchUpcomingFestivalV1Response.startDate() + ); + } +} diff --git a/backend/src/main/java/com/festago/school/presentation/v1/SchoolSearchV1Controller.java b/backend/src/main/java/com/festago/school/presentation/v1/SchoolSearchV1Controller.java new file mode 100644 index 000000000..f3d5bfe50 --- /dev/null +++ b/backend/src/main/java/com/festago/school/presentation/v1/SchoolSearchV1Controller.java @@ -0,0 +1,38 @@ +package com.festago.school.presentation.v1; + +import com.festago.common.util.Validator; +import com.festago.school.application.v1.SchoolTotalSearchV1QueryService; +import com.festago.school.dto.v1.SchoolTotalSearchV1Response; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/search/schools") +@Tag(name = "학교 검색 요청 V1") +@RequiredArgsConstructor +public class SchoolSearchV1Controller { + + private final SchoolTotalSearchV1QueryService schoolTotalSearchV1QueryService; + + @GetMapping + @Operation(description = "곧 시작하는 축제일을 포함된 학교를 검색한다.", summary = "학교 검색") + public ResponseEntity> searchSchools( + @RequestParam String keyword + ) { + validate(keyword); + return ResponseEntity.ok() + .body(schoolTotalSearchV1QueryService.searchSchools(keyword)); + } + + private void validate(String keyword) { + Validator.notBlank(keyword, "keyword"); + Validator.minLength(keyword, 2, "keyword"); + } +} diff --git a/backend/src/main/java/com/festago/school/repository/SchoolRepository.java b/backend/src/main/java/com/festago/school/repository/SchoolRepository.java index 157a5fd60..dc3705a1b 100644 --- a/backend/src/main/java/com/festago/school/repository/SchoolRepository.java +++ b/backend/src/main/java/com/festago/school/repository/SchoolRepository.java @@ -3,26 +3,24 @@ import com.festago.common.exception.ErrorCode; import com.festago.common.exception.NotFoundException; import com.festago.school.domain.School; -import com.festago.school.domain.SchoolRegion; -import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.Repository; -public interface SchoolRepository extends JpaRepository { +public interface SchoolRepository extends Repository { + + School save(School school); + + Optional findById(Long schoolId); + + void deleteById(Long id); + + boolean existsById(Long id); default School getOrThrow(Long schoolId) { return findById(schoolId) .orElseThrow(() -> new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND)); } - /** - * @deprecated API 버저닝이 적용되면 해당 메서드 삭제 - */ - @Deprecated(forRemoval = true) - boolean existsByDomainOrName(String domain, String name); - - List findAllByRegion(SchoolRegion schoolRegion); - boolean existsByDomain(String domain); boolean existsByName(String name); diff --git a/backend/src/main/java/com/festago/school/repository/v1/SchoolSearchV1QueryDslRepository.java b/backend/src/main/java/com/festago/school/repository/v1/SchoolSearchV1QueryDslRepository.java new file mode 100644 index 000000000..923a14846 --- /dev/null +++ b/backend/src/main/java/com/festago/school/repository/v1/SchoolSearchV1QueryDslRepository.java @@ -0,0 +1,34 @@ +package com.festago.school.repository.v1; + +import static com.festago.school.domain.QSchool.school; + +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.school.domain.School; +import com.festago.school.dto.v1.QSchoolSearchV1Response; +import com.festago.school.dto.v1.SchoolSearchV1Response; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class SchoolSearchV1QueryDslRepository extends QueryDslRepositorySupport { + + private static final long MAX_FETCH_SIZE = 50L; + + public SchoolSearchV1QueryDslRepository() { + super(School.class); + } + + public List searchSchools(String keyword) { + return select( + new QSchoolSearchV1Response( + school.id, + school.name, + school.logoUrl + )) + .from(school) + .where(school.name.contains(keyword)) + .orderBy(school.name.asc()) + .limit(MAX_FETCH_SIZE) + .fetch(); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 38c23dc60..d74c444c1 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,8 +1,3 @@ spring: profiles: active: local - config: - import: - - classpath:/festago-config/backend/application-dev.yml - - classpath:/festago-config/backend/application-prod.yml - - classpath:/festago-config/backend/application-infra.yml diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config new file mode 160000 index 000000000..6c59707f2 --- /dev/null +++ b/backend/src/main/resources/config @@ -0,0 +1 @@ +Subproject commit 6c59707f211ada3ab89ab8fa61e44f0f1d40d9c2 diff --git a/backend/src/main/resources/db/migration/V17__add_bookmark.sql b/backend/src/main/resources/db/migration/V17__add_bookmark.sql new file mode 100644 index 000000000..a87b06d95 --- /dev/null +++ b/backend/src/main/resources/db/migration/V17__add_bookmark.sql @@ -0,0 +1,16 @@ +create table if not exists bookmark +( + id bigint not null auto_increment, + bookmark_type varchar(10), + resource_id bigint, + member_id bigint, + created_at datetime(6), + updated_at datetime(6), + primary key (id) +); + +alter table bookmark + add constraint UNIQUE_BOOKMARK_TYPE_RESOURCE_ID_MEMBER_ID unique (bookmark_type, resource_id, member_id); + +create index bookmark_member_id_bookmark_type + on bookmark (member_id, bookmark_type); diff --git a/backend/src/main/resources/festago-config b/backend/src/main/resources/festago-config deleted file mode 160000 index 691d66a3c..000000000 --- a/backend/src/main/resources/festago-config +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 691d66a3ca69cd811862c06138c949043b546f59 diff --git a/backend/src/main/resources/static/css/404.css b/backend/src/main/resources/static/css/404.css deleted file mode 100644 index cddded461..000000000 --- a/backend/src/main/resources/static/css/404.css +++ /dev/null @@ -1,33 +0,0 @@ -body { - font-family: Arial, sans-serif; - background-color: #f4f4f4; - margin: 0; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - height: 100vh; -} - -.container { - width: 100%; - max-width: 600px; - background-color: #ffffff; - box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.1); -} - -.content { - text-align: center; - padding: 50px; -} - -h3 { - font-size: 40px; - color: #37569a; - margin-bottom: 10px; -} - -p { - color: #666; - font-size: 20px; -} diff --git a/backend/src/main/resources/static/error/404.html b/backend/src/main/resources/static/error/404.html deleted file mode 100644 index 3b75ba2eb..000000000 --- a/backend/src/main/resources/static/error/404.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - 404 - Not Found - - - -
-
-

해당 페이지가 없습니다. 😭

-

URL이 올바른지 다시 확인해주세요!

-
-
- - diff --git a/backend/src/test/java/com/festago/artist/application/ArtistTotalSearchV1ServiceTest.java b/backend/src/test/java/com/festago/artist/application/ArtistTotalSearchV1ServiceTest.java new file mode 100644 index 000000000..fc99354b6 --- /dev/null +++ b/backend/src/test/java/com/festago/artist/application/ArtistTotalSearchV1ServiceTest.java @@ -0,0 +1,86 @@ +package com.festago.artist.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.festago.artist.dto.ArtistSearchStageCountV1Response; +import com.festago.artist.dto.ArtistSearchV1Response; +import com.festago.artist.dto.ArtistTotalSearchV1Response; +import java.time.Clock; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class ArtistTotalSearchV1ServiceTest { + + @Mock + ArtistSearchV1QueryService artistSearchV1QueryService; + + @Mock + ArtistSearchStageCountV1QueryService artistSearchStageCountV1QueryService; + + @Spy + Clock clock = Clock.systemDefaultZone(); + + @InjectMocks + ArtistTotalSearchV1Service artistTotalSearchV1Service; + + @Test + void 아티스트_정보와_공연_일정을_종합하여_반환한다() { + List artists = List.of( + new ArtistSearchV1Response(1L, "아이브", "www.IVE-image.png"), + new ArtistSearchV1Response(2L, "아이유", "www.IU-image.png"), + new ArtistSearchV1Response(3L, "(여자)아이들", "www.IDLE-image.png")); + given(artistSearchV1QueryService.findAllByKeyword("아이")) + .willReturn(artists); + + LocalDate today = LocalDate.now(); + Map artistToStageSchedule = Map.of( + 1L, new ArtistSearchStageCountV1Response(1, 0), + 2L, new ArtistSearchStageCountV1Response(0, 0), + 3L, new ArtistSearchStageCountV1Response(0, 2)); + given(artistSearchStageCountV1QueryService.findArtistsStageCountAfterDateTime( + List.of(1L, 2L, 3L), today.atStartOfDay())) + .willReturn(artistToStageSchedule); + + // when + List actual = artistTotalSearchV1Service.findAllByKeyword("아이"); + + // then + var expected = List.of( + new ArtistTotalSearchV1Response(1L, "아이브", "www.IVE-image.png", 1, 0), + new ArtistTotalSearchV1Response(2L, "아이유", "www.IU-image.png", 0, 0), + new ArtistTotalSearchV1Response(3L, "(여자)아이들", "www.IDLE-image.png", 0, 2) + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + void 검색_결과가_해당하는_아티스트가_없으면_빈리스트를_반환한다() { + // given + LocalDate today = LocalDate.now(); + given(artistSearchV1QueryService.findAllByKeyword("없어")) + .willReturn(Collections.emptyList()); + given(artistSearchStageCountV1QueryService.findArtistsStageCountAfterDateTime( + Collections.emptyList(), today.atStartOfDay())) + .willReturn(Collections.emptyMap()); + + // when + List actual = artistTotalSearchV1Service.findAllByKeyword("없어"); + + // then + assertThat(actual).isEmpty(); + } +} diff --git a/backend/src/test/java/com/festago/artist/application/ArtistDetailV1QueryServiceTest.java b/backend/src/test/java/com/festago/artist/application/integration/ArtistDetailV1QueryServiceIntegrationTest.java similarity index 98% rename from backend/src/test/java/com/festago/artist/application/ArtistDetailV1QueryServiceTest.java rename to backend/src/test/java/com/festago/artist/application/integration/ArtistDetailV1QueryServiceIntegrationTest.java index f9e836671..45160db18 100644 --- a/backend/src/test/java/com/festago/artist/application/ArtistDetailV1QueryServiceTest.java +++ b/backend/src/test/java/com/festago/artist/application/integration/ArtistDetailV1QueryServiceIntegrationTest.java @@ -1,4 +1,4 @@ -package com.festago.artist.application; +package com.festago.artist.application.integration; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -43,7 +43,7 @@ @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") -class ArtistDetailV1QueryServiceTest extends ApplicationIntegrationTest { +class ArtistDetailV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { @Autowired Clock clock; diff --git a/backend/src/test/java/com/festago/artist/application/integration/ArtistSearchStageCountV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/artist/application/integration/ArtistSearchStageCountV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..d7a2fd474 --- /dev/null +++ b/backend/src/test/java/com/festago/artist/application/integration/ArtistSearchStageCountV1QueryServiceIntegrationTest.java @@ -0,0 +1,128 @@ +package com.festago.artist.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.artist.application.ArtistSearchStageCountV1QueryService; +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.ArtistSearchStageCountV1Response; +import com.festago.artist.repository.ArtistRepository; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.domain.StageArtist; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistSearchStageCountV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + private ArtistSearchStageCountV1QueryService artistSearchStageCountV1QueryService; + + @Autowired + private FestivalRepository festivalRepository; + + @Autowired + private ArtistRepository artistRepository; + + @Autowired + private StageRepository stageRepository; + + @Autowired + private StageArtistRepository stageArtistRepository; + + @Autowired + private SchoolRepository schoolRepository; + + @Nested + class 검색 { + + Artist 아이유; + Artist 아이브; + Artist 아이들; + Stage 어제_공연; + Stage 오늘_공연; + Stage 내일_공연; + LocalDate today = LocalDate.now(); + School school; + + @BeforeEach + void setUp() { + 아이유 = artistRepository.save(new Artist("아이유", "www.IU-profileImage.png")); + 아이브 = artistRepository.save(new Artist("아이브", "www.IVE-profileImage.png")); + 아이들 = artistRepository.save(new Artist("아이들", "www.Idle-profileImage.png")); + school = schoolRepository.save(new School("knu.ac.kr", "경북대", SchoolRegion.대구)); + var festivalA = festivalRepository.save( + new Festival("축제", today.minusDays(1), today.plusDays(10L), school)); + + var yesterdayDateTime = LocalDateTime.of(festivalA.getStartDate(), LocalTime.MIN); + 어제_공연 = stageRepository.save(new Stage(yesterdayDateTime, yesterdayDateTime.minusHours(1), festivalA)); + 오늘_공연 = stageRepository.save( + new Stage(yesterdayDateTime.plusDays(1), yesterdayDateTime.minusHours(1), festivalA)); + 내일_공연 = stageRepository.save( + new Stage(yesterdayDateTime.plusDays(2), yesterdayDateTime.minusHours(1), festivalA)); + } + + @Test + void 아티스트의_당일_및_예정_공연_갯수를_조회한다() { + // given + saveStageArtist(아이유, 오늘_공연); + var 아이유_공연_갯수 = new ArtistSearchStageCountV1Response(1, 0); + + saveStageArtist(아이브, 오늘_공연); + saveStageArtist(아이브, 내일_공연); + var 아이브_공연_갯수 = new ArtistSearchStageCountV1Response(1, 1); + + saveStageArtist(아이들, 어제_공연); + saveStageArtist(아이들, 내일_공연); + var 아이들_공연_갯수 = new ArtistSearchStageCountV1Response(0, 1); + + List artistIds = List.of(아이브.getId(), 아이유.getId(), 아이들.getId()); + + // when + Map actual = artistSearchStageCountV1QueryService.findArtistsStageCountAfterDateTime( + artistIds, today.atStartOfDay()); + + assertSoftly(softly -> { + softly.assertThat(actual.get(아이유.getId())).isEqualTo(아이유_공연_갯수); + softly.assertThat(actual.get(아이브.getId())).isEqualTo(아이브_공연_갯수); + softly.assertThat(actual.get(아이들.getId())).isEqualTo(아이들_공연_갯수); + }); + } + + @Test + void 아티스트가_오늘_이후_공연이_없으면_0개() { + saveStageArtist(아이브, 어제_공연); + List artistIds = List.of(아이브.getId()); + var 아이브_공연_갯수 = new ArtistSearchStageCountV1Response(0, 0); + + // when + Map actual = artistSearchStageCountV1QueryService.findArtistsStageCountAfterDateTime( + artistIds, today.atStartOfDay()); + + // then + assertThat(actual.get(아이브.getId())).isEqualTo(아이브_공연_갯수); + } + } + + private void saveStageArtist(Artist artist, Stage stage) { + stageArtistRepository.save(new StageArtist(stage.getId(), artist.getId())); + } +} diff --git a/backend/src/test/java/com/festago/artist/application/integration/ArtistSearchV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/artist/application/integration/ArtistSearchV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..79f92582c --- /dev/null +++ b/backend/src/test/java/com/festago/artist/application/integration/ArtistSearchV1QueryServiceIntegrationTest.java @@ -0,0 +1,101 @@ +package com.festago.artist.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.artist.application.ArtistSearchV1QueryService; +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.ArtistSearchV1Response; +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.support.ApplicationIntegrationTest; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistSearchV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + ArtistSearchV1QueryService artistSearchV1QueryService; + + @Autowired + ArtistRepository artistRepository; + + @Test + void 검색어가_한글자면_동등검색을_한다() { + // given + artistRepository.save(new Artist("난못", "www.profileImage.png")); + artistRepository.save(new Artist("못난", "www.profileImage.png")); + artistRepository.save(new Artist("못", "www.profileImage.png")); + + // when + List actual = artistSearchV1QueryService.findAllByKeyword("못"); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(1); + softly.assertThat(actual.get(0).name()).isEqualTo("못"); + }); + } + + @Test + void 검색어가_두글자_이상이면_like검색을_한다() { + // given + artistRepository.save(new Artist("에이핑크", "www.profileImage.png")); + artistRepository.save(new Artist("블랙핑크", "www.profileImage.png")); + artistRepository.save(new Artist("핑크", "www.profileImage.png")); + artistRepository.save(new Artist("핑크 플로이드", "www.profileImage.png")); + artistRepository.save(new Artist("핑", "www.profileImage.png")); + artistRepository.save(new Artist("크", "www.profileImage.png")); + + // when + List actual = artistSearchV1QueryService.findAllByKeyword("핑크"); + + // then + assertThat(actual).hasSize(4); + } + + @Test + void 아티스트명은_영어_한국어_순으로_오름차순_정렬된다() { + // given + Artist 세번째 = artistRepository.save(new Artist("가_아티스트", "www.profileImage.png")); + Artist 첫번째 = artistRepository.save(new Artist("A_아티스트", "www.profileImage.png")); + Artist 네번째 = artistRepository.save(new Artist("나_아티스트", "www.profileImage.png")); + Artist 두번째 = artistRepository.save(new Artist("C_아티스트", "www.profileImage.png")); + + // when + List actual = artistSearchV1QueryService.findAllByKeyword("아티스트"); + + // then + List result = actual.stream() + .map(ArtistSearchV1Response::id) + .toList(); + assertThat(result).isEqualTo(List.of(첫번째.getId(), 두번째.getId(), 세번째.getId(), 네번째.getId())); + } + + @Test + void 검색결과가_10개_이상이면_예외() { + // given + for (int i = 0; i < 10; i++) { + artistRepository.save(new Artist("핑크", "www.profileImage.png")); + } + + // when && then + assertThatThrownBy(() -> artistSearchV1QueryService.findAllByKeyword("핑크")) + .isInstanceOf(BadRequestException.class); + } + + @Test + void 검색_결과가_없다면_빈리스트_반환() { + // when + List actual = artistSearchV1QueryService.findAllByKeyword("없음"); + + // then + assertThat(actual).isEmpty(); + } +} diff --git a/backend/src/test/java/com/festago/artist/presentation/v1/ArtistSearchV1ControllerTest.java b/backend/src/test/java/com/festago/artist/presentation/v1/ArtistSearchV1ControllerTest.java new file mode 100644 index 000000000..fd5087132 --- /dev/null +++ b/backend/src/test/java/com/festago/artist/presentation/v1/ArtistSearchV1ControllerTest.java @@ -0,0 +1,80 @@ +package com.festago.artist.presentation.v1; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.artist.application.ArtistTotalSearchV1Service; +import com.festago.artist.dto.ArtistTotalSearchV1Response; +import com.festago.support.CustomWebMvcTest; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistSearchV1ControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ArtistTotalSearchV1Service artistTotalSearchV1Service; + + @Autowired + ObjectMapper objectMapper; + + @Nested + class 아티스트_검색_조회 { + + final String uri = "/api/v1/search/artists"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_body가_반환된다() throws Exception { + // given + var expected = List.of( + new ArtistTotalSearchV1Response(1L, "블랙핑크", "www.profileImage.png", 1, 1), + new ArtistTotalSearchV1Response(2L, "에이핑크", "www.profileImage.png", 0, 0) + ); + + given(artistTotalSearchV1Service.findAllByKeyword("핑크")) + .willReturn(expected); + + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .param("keyword", "핑크")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(expected))); + } + + @ParameterizedTest + @NullAndEmptySource + void 키워드가_빈값이거나_null이면_400을_반환한다(String keyword) throws Exception { + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .param("keyword", keyword)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/artist/repository/MemoryArtistRepository.java b/backend/src/test/java/com/festago/artist/repository/MemoryArtistRepository.java index b76633268..0382002b9 100644 --- a/backend/src/test/java/com/festago/artist/repository/MemoryArtistRepository.java +++ b/backend/src/test/java/com/festago/artist/repository/MemoryArtistRepository.java @@ -57,4 +57,9 @@ public List findByIdIn(Collection artistIds) { .filter(artist -> artistIds.contains(artist.getId())) .toList(); } + + @Override + public boolean existsById(Long id) { + return memory.containsKey(id); + } } diff --git a/backend/src/test/java/com/festago/auth/application/AdminAuthServiceTest.java b/backend/src/test/java/com/festago/auth/application/AdminAuthServiceTest.java index 9ff006b24..2897abf38 100644 --- a/backend/src/test/java/com/festago/auth/application/AdminAuthServiceTest.java +++ b/backend/src/test/java/com/festago/auth/application/AdminAuthServiceTest.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test; import org.springframework.security.crypto.factory.PasswordEncoderFactories; +@Deprecated(forRemoval = true) @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") class AdminAuthServiceTest { diff --git a/backend/src/test/java/com/festago/auth/application/command/AdminAuthCommandServiceTest.java b/backend/src/test/java/com/festago/auth/application/command/AdminAuthCommandServiceTest.java new file mode 100644 index 000000000..78e251143 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/application/command/AdminAuthCommandServiceTest.java @@ -0,0 +1,170 @@ +package com.festago.auth.application.command; + +import static com.festago.common.exception.ErrorCode.DUPLICATE_ACCOUNT_USERNAME; +import static com.festago.common.exception.ErrorCode.INCORRECT_PASSWORD_OR_ACCOUNT; +import static com.festago.common.exception.ErrorCode.NOT_ENOUGH_PERMISSION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import com.festago.admin.domain.Admin; +import com.festago.admin.repository.AdminRepository; +import com.festago.admin.repository.MemoryAdminRepository; +import com.festago.auth.application.AuthProvider; +import com.festago.auth.dto.command.AdminLoginCommand; +import com.festago.auth.dto.command.AdminSignupCommand; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.ForbiddenException; +import com.festago.common.exception.UnauthorizedException; +import com.festago.support.fixture.AdminFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminAuthCommandServiceTest { + + AdminRepository adminRepository; + + AuthProvider authProvider; + + AdminAuthCommandService adminAuthCommandService; + + @BeforeEach + void setUp() { + adminRepository = new MemoryAdminRepository(); + authProvider = mock(AuthProvider.class); + adminAuthCommandService = new AdminAuthCommandService( + authProvider, + adminRepository, + PasswordEncoderFactories.createDelegatingPasswordEncoder() + ); + } + + @Nested + class 로그인 { + + @Test + void 계정이_없으면_예외() { + // given + var command = new AdminLoginCommand("admin", "password"); + + // when & then + assertThatThrownBy(() -> adminAuthCommandService.login(command)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(INCORRECT_PASSWORD_OR_ACCOUNT.getMessage()); + } + + @Test + void 비밀번호가_틀리면_예외() { + // given + adminRepository.save(AdminFixture.builder() + .username("admin") + .password("{noop}password") + .build()); + var command = new AdminLoginCommand("admin", "admin"); + + // when & then + assertThatThrownBy(() -> adminAuthCommandService.login(command)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(INCORRECT_PASSWORD_OR_ACCOUNT.getMessage()); + } + + @Test + void 성공() { + // given + adminRepository.save(AdminFixture.builder() + .username("admin") + .password("{noop}password") + .build()); + var command = new AdminLoginCommand("admin", "password"); + given(authProvider.provide(any())) + .willReturn("token"); + + // when + var result = adminAuthCommandService.login(command); + + // then + assertThat(result.accessToken()).isEqualTo("token"); + } + } + + @Nested + class 가입 { + + @Test + void 닉네임이_중복이면_예외() { + // given + Admin rootAdmin = adminRepository.save(Admin.createRootAdmin("{noop}password")); + var command = new AdminSignupCommand("admin", "password"); + + // when & then + Long rootAdminId = rootAdmin.getId(); + assertThatThrownBy(() -> adminAuthCommandService.signup(rootAdminId, command)) + .isInstanceOf(BadRequestException.class) + .hasMessage(DUPLICATE_ACCOUNT_USERNAME.getMessage()); + } + + @Test + void Root_어드민이_아니면_예외() { + // given + Admin admin = adminRepository.save(AdminFixture.builder() + .username("glen") + .password("{noop}password") + .build()); + var command = new AdminSignupCommand("newAdmin", "password"); + + // when & then + Long adminId = admin.getId(); + assertThatThrownBy(() -> adminAuthCommandService.signup(adminId, command)) + .isInstanceOf(ForbiddenException.class) + .hasMessage(NOT_ENOUGH_PERMISSION.getMessage()); + } + + @Test + void 성공() { + // given + Admin rootAdmin = adminRepository.save(Admin.createRootAdmin("{noop}password")); + var command = new AdminSignupCommand("newAdmin", "password"); + + // when + adminAuthCommandService.signup(rootAdmin.getId(), command); + + // then + assertThat(adminRepository.existsByUsername(command.username())).isTrue(); + } + } + + @Nested + class 루트_어드민_초기화 { + + @Test + void 루트_어드민을_활성화하면_저장된다() { + // when + adminAuthCommandService.initializeRootAdmin("1234"); + + // then + Admin rootAdmin = Admin.createRootAdmin("1234"); + assertThat(adminRepository.existsByUsername(rootAdmin.getUsername())) + .isTrue(); + } + + @Test + void 루트_어드민이_존재하는데_초기화하면_예외() { + // given + adminAuthCommandService.initializeRootAdmin("1234"); + + // when & then + assertThatThrownBy(() -> adminAuthCommandService.initializeRootAdmin("1234")) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DUPLICATE_ACCOUNT_USERNAME.getMessage()); + } + } +} diff --git a/backend/src/test/java/com/festago/auth/presentation/AdminAuthControllerTest.java b/backend/src/test/java/com/festago/auth/presentation/AdminAuthControllerTest.java index 9159c0f15..99b8416a8 100644 --- a/backend/src/test/java/com/festago/auth/presentation/AdminAuthControllerTest.java +++ b/backend/src/test/java/com/festago/auth/presentation/AdminAuthControllerTest.java @@ -28,6 +28,7 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +@Deprecated(forRemoval = true) @CustomWebMvcTest @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") diff --git a/backend/src/test/java/com/festago/auth/presentation/v1/AdminAuthV1ControllerTest.java b/backend/src/test/java/com/festago/auth/presentation/v1/AdminAuthV1ControllerTest.java new file mode 100644 index 000000000..2b3bec4f5 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/presentation/v1/AdminAuthV1ControllerTest.java @@ -0,0 +1,194 @@ +package com.festago.auth.presentation.v1; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.auth.application.command.AdminAuthCommandService; +import com.festago.auth.domain.AuthType; +import com.festago.auth.domain.Role; +import com.festago.auth.dto.AdminLoginV1Request; +import com.festago.auth.dto.AdminSignupV1Request; +import com.festago.auth.dto.RootAdminInitializeRequest; +import com.festago.auth.dto.command.AdminLoginCommand; +import com.festago.auth.dto.command.AdminLoginResult; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminAuthV1ControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + AdminAuthCommandService adminAuthCommandService; + + @Nested + class 어드민_로그인 { + + final String uri = "/admin/api/v1/auth/login"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_로그인_토큰이_담긴_쿠키가_반환된다() throws Exception { + // given + var request = new AdminLoginV1Request("admin", "1234"); + given(adminAuthCommandService.login(any(AdminLoginCommand.class))) + .willReturn(new AdminLoginResult("admin", AuthType.ROOT, "token")); + + // when & then + mockMvc.perform(post(uri) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(cookie().exists(TOKEN_COOKIE.getName())) + .andExpect(cookie().path(TOKEN_COOKIE.getName(), "/")) + .andExpect(cookie().secure(TOKEN_COOKIE.getName(), true)) + .andExpect(cookie().httpOnly(TOKEN_COOKIE.getName(), true)) + .andExpect(cookie().sameSite(TOKEN_COOKIE.getName(), "None")); + } + } + } + + @Nested + class 어드민_로그아웃 { + + final String uri = "/admin/api/v1/auth/logout"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답과_비어있는_값의_로그인_토큰이_담긴_쿠키가_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isOk()) + .andExpect(cookie().exists(TOKEN_COOKIE.getName())) + .andExpect(cookie().value(TOKEN_COOKIE.getName(), "")) + .andExpect(cookie().path(TOKEN_COOKIE.getName(), "/")) + .andExpect(cookie().secure(TOKEN_COOKIE.getName(), true)) + .andExpect(cookie().httpOnly(TOKEN_COOKIE.getName(), true)) + .andExpect(cookie().sameSite(TOKEN_COOKIE.getName(), "None")); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 어드민_회원가입 { + + final String uri = "/admin/api/v1/auth/signup"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답과_생성한_계정이_반환된다() throws Exception { + var request = new AdminSignupV1Request("newAdmin", "1234"); + + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 루트_어드민_활성화 { + + final String uri = "/admin/api/v1/auth/initialize"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + var request = new RootAdminInitializeRequest("1234"); + + // when & then + mockMvc.perform(post(uri) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + @WithMockAuth(role = Role.ANONYMOUS) + void 권한이_없어도_200_응답이_반환된다() throws Exception { + // given + var request = new RootAdminInitializeRequest("1234"); + + // when & then + mockMvc.perform(post(uri) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/application/command/ArtistBookmarkCommandServiceTest.java b/backend/src/test/java/com/festago/bookmark/application/command/ArtistBookmarkCommandServiceTest.java new file mode 100644 index 000000000..731d7ec8c --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/application/command/ArtistBookmarkCommandServiceTest.java @@ -0,0 +1,113 @@ +package com.festago.bookmark.application.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.artist.repository.MemoryArtistRepository; +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.bookmark.repository.MemoryBookmarkRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.support.fixture.ArtistFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistBookmarkCommandServiceTest { + + ArtistRepository artistRepository; + BookmarkRepository bookmarkRepository; + ArtistBookmarkCommandService artistBookmarkCommandService; + + Long 회원_식별자 = 1234L; + Artist 브라운; + + @BeforeEach + void setting() { + initializeRepository(); + 브라운 = artistRepository.save(ArtistFixture.builder() + .name("브라운") + .build()); + } + + private void initializeRepository() { + artistRepository = new MemoryArtistRepository(); + bookmarkRepository = new MemoryBookmarkRepository(); + artistBookmarkCommandService = new ArtistBookmarkCommandService(bookmarkRepository, artistRepository); + } + + @Nested + class 북마크_저장 { + + @Test + void 존재_하지_않는_아티스트로_저장하면_예외가_발생한다() { + // given & when & then + assertThatThrownBy(() -> artistBookmarkCommandService.save(1000L, 회원_식별자)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.ARTIST_NOT_FOUND.getMessage()); + } + + @Test + void 최대_북마크_개수를_넘기면_에외가_발생한다() { + // given + for (int i = 0; i < 12; i++) { + Artist artist = artistRepository.save(ArtistFixture.builder().build()); + bookmarkRepository.save(new Bookmark(BookmarkType.ARTIST, artist.getId(), 회원_식별자)); + } + + // when & then + assertThatThrownBy(() -> artistBookmarkCommandService.save(브라운.getId(), 회원_식별자)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.BOOKMARK_LIMIT_EXCEEDED.getMessage()); + } + + @Test + void 북마크를_저장한다() { + // when + artistBookmarkCommandService.save(브라운.getId(), 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.ARTIST)) + .isNotZero(); + } + + @Test + void 기존에_저장된_북마크가_있으면_저장되지_않는다() { + // given + artistBookmarkCommandService.save(브라운.getId(), 회원_식별자); + + // when + artistBookmarkCommandService.save(브라운.getId(), 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.ARTIST)) + .isOne(); + } + } + + @Nested + class 북마크_삭제 { + + @Test + void 북마크를_삭제한다() { + // given + bookmarkRepository.save(new Bookmark(BookmarkType.ARTIST, 브라운.getId(), 회원_식별자)); + + // when + artistBookmarkCommandService.delete(브라운.getId(), 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.ARTIST)) + .isZero(); + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/application/command/FestivalBookmarkCommandServiceTest.java b/backend/src/test/java/com/festago/bookmark/application/command/FestivalBookmarkCommandServiceTest.java new file mode 100644 index 000000000..b76029be8 --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/application/command/FestivalBookmarkCommandServiceTest.java @@ -0,0 +1,117 @@ +package com.festago.bookmark.application.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.bookmark.repository.MemoryBookmarkRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.festival.repository.MemoryFestivalRepository; +import com.festago.support.fixture.FestivalFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalBookmarkCommandServiceTest { + + BookmarkRepository bookmarkRepository; + + FestivalRepository festivalRepository; + + FestivalBookmarkCommandService festivalBookmarkCommandService; + + Long 회원_식별자 = 1234L; + Festival 축제; + + @BeforeEach + void setUp() { + bookmarkRepository = new MemoryBookmarkRepository(); + festivalRepository = new MemoryFestivalRepository(); + festivalBookmarkCommandService = new FestivalBookmarkCommandService(bookmarkRepository, festivalRepository); + 축제 = festivalRepository.save(FestivalFixture.builder().build()); + } + + @Nested + class 북마크_저장 { + + @Test + void 북마크로_저장하려는_축제가_존재하지_않으면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> festivalBookmarkCommandService.save(4885L, 회원_식별자)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.FESTIVAL_NOT_FOUND.getMessage()); + } + + @Test + void 기존에_저장된_북마크의_개수가_12개_이상이면_예외가_발생한다() { + // given + for (long i = 1; i <= 12; i++) { + Festival festival = festivalRepository.save(FestivalFixture.builder().build()); + bookmarkRepository.save(new Bookmark(BookmarkType.FESTIVAL, festival.getId(), 회원_식별자)); + } + + // when & then + assertThatThrownBy(() -> festivalBookmarkCommandService.save(축제.getId(), 회원_식별자)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.BOOKMARK_LIMIT_EXCEEDED.getMessage()); + } + + @Test + void 북마크를_저장한다() { + // when + festivalBookmarkCommandService.save(축제.getId(), 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.FESTIVAL)) + .isNotZero(); + } + + @Test + void 기존에_저장된_북마크가_있으면_저장되지_않는다() { + // given + festivalBookmarkCommandService.save(축제.getId(), 회원_식별자); + + // when + festivalBookmarkCommandService.save(축제.getId(), 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.FESTIVAL)) + .isOne(); + } + } + + @Nested + class 북마크_삭제 { + + @Test + void 존재하지_않는_북마크를_삭제하더라도_예외가_발생하지_않는다() { + // when & then + assertThatNoException() + .isThrownBy(() -> festivalBookmarkCommandService.delete(4885L, 회원_식별자)); + } + + @Test + void 북마크를_삭제할_수_있다() { + // given + festivalBookmarkCommandService.save(축제.getId(), 회원_식별자); + + // when + festivalBookmarkCommandService.delete(축제.getId(), 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.FESTIVAL)) + .isZero(); + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/application/command/SchoolBookmarkCommandServiceTest.java b/backend/src/test/java/com/festago/bookmark/application/command/SchoolBookmarkCommandServiceTest.java new file mode 100644 index 000000000..46ce00ca2 --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/application/command/SchoolBookmarkCommandServiceTest.java @@ -0,0 +1,108 @@ +package com.festago.bookmark.application.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.bookmark.repository.MemoryBookmarkRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.school.domain.School; +import com.festago.school.repository.MemorySchoolRepository; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.fixture.SchoolFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolBookmarkCommandServiceTest { + + BookmarkRepository bookmarkRepository; + SchoolRepository schoolRepository; + SchoolBookmarkCommandService schoolBookmarkCommandService; + + Long 회원_식별자 = 1234L; + + @BeforeEach + void setUp() { + bookmarkRepository = new MemoryBookmarkRepository(); + schoolRepository = new MemorySchoolRepository(); + schoolBookmarkCommandService = new SchoolBookmarkCommandService(bookmarkRepository, schoolRepository); + } + + @Nested + class 학교_북마크_추가시 { + + @Test + void 해당하는_학교가_없으면_예외() { + // when && then + assertThatThrownBy(() -> schoolBookmarkCommandService.save(-1L, 회원_식별자)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.SCHOOL_NOT_FOUND.getMessage()); + } + + @Test + void 학교_북마크_갯수가_이미_12개_이상이면_예외() { + // given + for (long i = 0; i < 12; i++) { + School school = schoolRepository.save(SchoolFixture.builder().build()); + bookmarkRepository.save(new Bookmark(BookmarkType.SCHOOL, school.getId(), 회원_식별자)); + } + + Long schoolId = schoolRepository.save(SchoolFixture.builder().build()).getId(); + + // when && then + assertThatThrownBy(() -> schoolBookmarkCommandService.save(schoolId, 회원_식별자)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.BOOKMARK_LIMIT_EXCEEDED.getMessage()); + } + + @Test + void 이미_해당하는_북마크가_저장됐다면_예외() { + // given + Long schoolId = schoolRepository.save(SchoolFixture.builder().build()).getId(); + bookmarkRepository.save(new Bookmark(BookmarkType.SCHOOL, schoolId, 회원_식별자)); + + // when + schoolBookmarkCommandService.save(schoolId, 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.SCHOOL)) + .isOne(); + } + + @Test + void 북마크_저장_성공() { + // given + Long schoolId = schoolRepository.save(SchoolFixture.builder().build()).getId(); + + // when + schoolBookmarkCommandService.save(schoolId, 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.SCHOOL)) + .isNotZero(); + } + } + + @Test + void 북마크를_삭제한다() { + // given + Long schoolId = schoolRepository.save(SchoolFixture.builder().build()).getId(); + bookmarkRepository.save(new Bookmark(BookmarkType.SCHOOL, schoolId, 회원_식별자)); + + // when + schoolBookmarkCommandService.delete(schoolId, 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.SCHOOL)) + .isZero(); + } +} diff --git a/backend/src/test/java/com/festago/bookmark/application/integration/ArtistBookmarkV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/bookmark/application/integration/ArtistBookmarkV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..891cba863 --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/application/integration/ArtistBookmarkV1QueryServiceIntegrationTest.java @@ -0,0 +1,128 @@ +package com.festago.bookmark.application.integration; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.bookmark.application.ArtistBookmarkV1QueryService; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.dto.v1.ArtistBookmarkV1Response; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.member.domain.Member; +import com.festago.member.repository.MemberRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.BookmarkFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.SchoolFixture; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistBookmarkV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + ArtistRepository artistRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + MemberRepository memberRepository; + + @Autowired + BookmarkRepository bookmarkRepository; + + @Autowired + ArtistBookmarkV1QueryService artistBookmarkV1QueryService; + + Artist 네오; + Artist 브라운; + Artist 브리; + + Member 푸우; + Member 오리; + Member 글렌; + + @BeforeEach + void setting() { + 네오 = artistRepository.save(ArtistFixture.builder() + .name("네오") + .build()); + 브라운 = artistRepository.save(ArtistFixture.builder() + .name("브라운") + .build()); + 브리 = artistRepository.save(ArtistFixture.builder() + .name("브리") + .build()); + + 푸우 = memberRepository.save(MemberFixture.builder() + .nickname("푸우") + .build()); + 오리 = memberRepository.save(MemberFixture.builder() + .nickname("오리") + .build()); + 글렌 = memberRepository.save(MemberFixture.builder() + .nickname("글렌") + .build()); + } + + @Test + void 유저의_아티스트_북마크_목록을_반환한다() { + // given + createBookmark(브리.getId(), 푸우.getId()); + createBookmark(네오.getId(), 푸우.getId()); + createBookmark(브라운.getId(), 오리.getId()); + + // when + List actual = artistBookmarkV1QueryService.findArtistBookmarksByMemberId( + 푸우.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(2); + softly.assertThat(actual).extracting("artist").extracting("name").contains("브리", "네오"); + }); + } + + @Test + void 북마크들_중_아티스트에_대한_북마크만_가져온다() { + // given + createBookmark(브리.getId(), 푸우.getId()); + createBookmark(네오.getId(), 푸우.getId()); + School school = schoolRepository.save(SchoolFixture.builder().build()); + bookmarkRepository.save( + BookmarkFixture.builder() + .bookmarkType(BookmarkType.SCHOOL) + .resourceId(school.getId()) + .memberId(푸우.getId()) + .build() + ); + + // when + List actual = artistBookmarkV1QueryService.findArtistBookmarksByMemberId( + 푸우.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(2); + softly.assertThat(actual).extracting("artist").extracting("name").contains("브리", "네오"); + }); + } + + public void createBookmark(Long resourceId, Long memberId) { + bookmarkRepository.save(BookmarkFixture.builder() + .bookmarkType(BookmarkType.ARTIST) + .resourceId(resourceId) + .memberId(memberId) + .build() + ); + } +} diff --git a/backend/src/test/java/com/festago/bookmark/application/integration/FestivalBookmarkV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/bookmark/application/integration/FestivalBookmarkV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..f22788d0d --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/application/integration/FestivalBookmarkV1QueryServiceIntegrationTest.java @@ -0,0 +1,190 @@ +package com.festago.bookmark.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.bookmark.application.FestivalBookmarkV1QueryService; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.dto.v1.FestivalBookmarkV1Response; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.bookmark.repository.FestivalBookmarkOrder; +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.member.repository.MemberRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.BookmarkFixture; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.FestivalQueryInfoFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.SchoolFixture; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalBookmarkV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + BookmarkRepository bookmarkRepository; + + @Autowired + FestivalBookmarkV1QueryService festivalBookmarkV1QueryService; + + @Autowired + FestivalInfoRepository festivalQueryInfoRepository; + + @Autowired + MemberRepository memberRepository; + + Long 회원A_식별자; + Long 회원B_식별자; + Long 회원C_식별자; + + School 테코대학교; + School 우테대학교; + + Long 테코대학교_봄_축제_식별자; + Long 우테대학교_여름_축제_식별자; + Long 우테대학교_가을_축제_식별자; + + @BeforeEach + void setUp() { + 회원A_식별자 = memberRepository.save(MemberFixture.builder().socialId("1").nickname("회원A").build()).getId(); + 회원B_식별자 = memberRepository.save(MemberFixture.builder().socialId("2").nickname("회원B").build()).getId(); + 회원C_식별자 = memberRepository.save(MemberFixture.builder().socialId("3").nickname("회원C").build()).getId(); + + 테코대학교 = createSchool("테코대학교"); + 우테대학교 = createSchool("우테대학교"); + + 테코대학교_봄_축제_식별자 = createFestival("테코대학교 봄 축제", 테코대학교, LocalDate.parse("2077-03-01")); + 우테대학교_여름_축제_식별자 = createFestival("우테대학교 여름 축제", 우테대학교, LocalDate.parse("2077-07-01")); + 우테대학교_가을_축제_식별자 = createFestival("우테대학교 가을 축제", 우테대학교, LocalDate.parse("2077-10-01")); + } + + private School createSchool(String schoolName) { + return schoolRepository.save(SchoolFixture.builder().name(schoolName).build()); + } + + private Long createFestival(String festivalName, School school, LocalDate startDate) { + Long festivalId = festivalRepository.save(FestivalFixture.builder() + .name(festivalName) + .startDate(startDate) + .endDate(startDate.plusDays(2)) + .school(school) + .build()).getId(); + festivalQueryInfoRepository.save( + FestivalQueryInfoFixture.builder() + .festivalId(festivalId) + .artistInfo("").build() + ); + return festivalId; + } + + @Nested + class findBookmarkedFestivalIds { + + @Test + void 회원의_식별자로_북마크한_축제의_식별자를_조회한다() { + // given + createBookmark(테코대학교_봄_축제_식별자, 회원A_식별자); + createBookmark(우테대학교_여름_축제_식별자, 회원B_식별자); + createBookmark(우테대학교_가을_축제_식별자, 회원B_식별자); + + // when + var 회원A_축제_북마크_식별자_목록 = festivalBookmarkV1QueryService.findBookmarkedFestivalIds(회원A_식별자); + var 회원B_축제_북마크_식별자_목록 = festivalBookmarkV1QueryService.findBookmarkedFestivalIds(회원B_식별자); + var 회원C_축제_북마크_식별자_목록 = festivalBookmarkV1QueryService.findBookmarkedFestivalIds(회원C_식별자); + + // then + assertSoftly(softly -> { + softly.assertThat(회원A_축제_북마크_식별자_목록).containsExactlyInAnyOrder(테코대학교_봄_축제_식별자); + softly.assertThat(회원B_축제_북마크_식별자_목록).containsExactlyInAnyOrder(우테대학교_여름_축제_식별자, 우테대학교_가을_축제_식별자); + softly.assertThat(회원C_축제_북마크_식별자_목록).isEmpty(); + }); + } + } + + @Nested + class findBookmarkedFestivals { + + @Test + void 북마크를_등록한_시간의_내림차순으로_조회할_수_있다() { + // given + createBookmark(우테대학교_여름_축제_식별자, 회원A_식별자); + createBookmark(테코대학교_봄_축제_식별자, 회원A_식별자); + createBookmark(우테대학교_가을_축제_식별자, 회원A_식별자); + + // when + var 회원A_북마크_축제_정보_목록 = festivalBookmarkV1QueryService.findBookmarkedFestivals( + 회원A_식별자, + List.of(테코대학교_봄_축제_식별자, 우테대학교_여름_축제_식별자, 우테대학교_가을_축제_식별자), + FestivalBookmarkOrder.BOOKMARK + ); + + // then + assertThat(회원A_북마크_축제_정보_목록) + .map(FestivalBookmarkV1Response::festival) + .map(FestivalV1Response::id) + .containsExactly(우테대학교_가을_축제_식별자, 테코대학교_봄_축제_식별자, 우테대학교_여름_축제_식별자); + } + + @Test + void 축제의_시작_시간의_오름차순으로_조회할_수_있다() { + // given + createBookmark(우테대학교_여름_축제_식별자, 회원A_식별자); + createBookmark(테코대학교_봄_축제_식별자, 회원A_식별자); + createBookmark(우테대학교_가을_축제_식별자, 회원A_식별자); + + // when + var 회원A_북마크_축제_정보_목록 = festivalBookmarkV1QueryService.findBookmarkedFestivals( + 회원A_식별자, + List.of(우테대학교_가을_축제_식별자, 우테대학교_여름_축제_식별자, 테코대학교_봄_축제_식별자), + FestivalBookmarkOrder.FESTIVAL + ); + + // then + assertThat(회원A_북마크_축제_정보_목록) + .map(FestivalBookmarkV1Response::festival) + .map(FestivalV1Response::id) + .containsExactly(테코대학교_봄_축제_식별자, 우테대학교_여름_축제_식별자, 우테대학교_가을_축제_식별자); + } + + @Test + void 북마크에_등록되지_않은_축제_식별자를_보내면_해당_축제는_조회되지_않는다() { + // given + createBookmark(테코대학교_봄_축제_식별자, 회원A_식별자); + + // when + var 회원A_북마크_축제_정보_목록 = festivalBookmarkV1QueryService.findBookmarkedFestivals( + 회원A_식별자, + List.of(우테대학교_여름_축제_식별자, 우테대학교_가을_축제_식별자), + FestivalBookmarkOrder.BOOKMARK + ); + + assertThat(회원A_북마크_축제_정보_목록).isEmpty(); + } + } + + public void createBookmark(Long resourceId, Long memberId) { + bookmarkRepository.save(BookmarkFixture.builder() + .bookmarkType(BookmarkType.FESTIVAL) + .resourceId(resourceId) + .memberId(memberId) + .build()); + } +} diff --git a/backend/src/test/java/com/festago/bookmark/application/integration/SchoolBookmarkV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/bookmark/application/integration/SchoolBookmarkV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..1981e95c6 --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/application/integration/SchoolBookmarkV1QueryServiceIntegrationTest.java @@ -0,0 +1,91 @@ +package com.festago.bookmark.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.bookmark.application.SchoolBookmarkV1QueryService; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.dto.v1.SchoolBookmarkInfoV1Response; +import com.festago.bookmark.dto.v1.SchoolBookmarkV1Response; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.member.repository.MemberRepository; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.BookmarkFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.SchoolFixture; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolBookmarkV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + SchoolBookmarkV1QueryService schoolBookmarkV1QueryService; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + MemberRepository memberRepository; + + @Autowired + BookmarkRepository bookmarkRepository; + + @Test + void 특정_회원의_북마크_목록들을_검색한다() { + // given + var 회원A_ID = saveMember("socialId_A"); + var 회원B_ID = saveMember("socialId_B"); + + var 학교A_ID = saveSchool("A대학교", "a.ac.kr", "https://www.festago.com/A.png"); + var 학교B_ID = saveSchool("B대학교", "b.ac.kr", "https://www.festago.com/B.png"); + + saveBookmark(학교A_ID, 회원A_ID); + saveBookmark(학교B_ID, 회원A_ID); + + saveBookmark(학교A_ID, 회원B_ID); + saveBookmark(학교B_ID, 회원B_ID); + + // when + var actual = schoolBookmarkV1QueryService.findAllByMemberId(회원A_ID); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(2); + softly.assertThat(actual).allSatisfy(it -> assertThat(it).hasNoNullFieldsOrProperties()); + softly.assertThat(actual).map(SchoolBookmarkV1Response::school) + .containsExactly( + new SchoolBookmarkInfoV1Response(학교A_ID, "A대학교", "https://www.festago.com/A.png"), + new SchoolBookmarkInfoV1Response(학교B_ID, "B대학교", "https://www.festago.com/B.png") + ); + }); + } + + private Long saveMember(String socialId) { + return memberRepository.save(MemberFixture.builder() + .socialId(socialId) + .build()).getId(); + } + + private Long saveSchool(String name, String domain, String logoUrl) { + return schoolRepository.save(SchoolFixture.builder() + .name(name) + .domain(domain) + .logoUrl(logoUrl) + .build()).getId(); + } + + private void saveBookmark(Long schoolId, Long memberId) { + bookmarkRepository.save( + BookmarkFixture.builder() + .bookmarkType(BookmarkType.SCHOOL) + .resourceId(schoolId) + .memberId(memberId) + .build() + ); + } +} diff --git a/backend/src/test/java/com/festago/bookmark/presentation/v1/ArtistBookmarkV1ControllerTest.java b/backend/src/test/java/com/festago/bookmark/presentation/v1/ArtistBookmarkV1ControllerTest.java new file mode 100644 index 000000000..f9272a32a --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/presentation/v1/ArtistBookmarkV1ControllerTest.java @@ -0,0 +1,59 @@ +package com.festago.bookmark.presentation.v1; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistBookmarkV1ControllerTest { + + private static final String TOKEN = "Bearer token"; + + @Autowired + MockMvc mockMvc; + + @Nested + class 아티스트_북마크_목록_조회_에서 { + + final String uri = "/api/v1/bookmarks/artists"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 요청을_보내면_200_응답을_반환한다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .header(HttpHeaders.AUTHORIZATION, TOKEN) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + void 인증을_안했으면_4xx_응답을_반환한다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/presentation/v1/BookmarkManagementV1ControllerTest.java b/backend/src/test/java/com/festago/bookmark/presentation/v1/BookmarkManagementV1ControllerTest.java new file mode 100644 index 000000000..39deb75cf --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/presentation/v1/BookmarkManagementV1ControllerTest.java @@ -0,0 +1,89 @@ +package com.festago.bookmark.presentation.v1; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class BookmarkManagementV1ControllerTest { + + private static final String TOKEN = "Bearer token"; + + @Autowired + MockMvc mockMvc; + + @Nested + class 북마크_등록 { + + final String uri = "/api/v1/bookmarks"; + + String resourceId = "1"; + + @Nested + @DisplayName("PUT " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(put(uri) + .param("resourceId", resourceId) + .param("bookmarkType", "FESTIVAL") + .header(HttpHeaders.AUTHORIZATION, TOKEN)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(put(uri)) + .andExpect(status().isUnauthorized()); + } + } + } + + @Nested + class 북마크_삭제 { + + final String uri = "/api/v1/bookmarks"; + + String resourceId = "1"; + + @Nested + @DisplayName("DELETE " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 요청을_보내면_204_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri) + .param("resourceId", resourceId) + .param("bookmarkType", "FESTIVAL") + .header(HttpHeaders.AUTHORIZATION, TOKEN)) + .andExpect(status().isNoContent()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri)) + .andExpect(status().isUnauthorized()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/presentation/v1/FestivalBookmarkV1ControllerTest.java b/backend/src/test/java/com/festago/bookmark/presentation/v1/FestivalBookmarkV1ControllerTest.java new file mode 100644 index 000000000..8dee24020 --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/presentation/v1/FestivalBookmarkV1ControllerTest.java @@ -0,0 +1,135 @@ +package com.festago.bookmark.presentation.v1; + +import static org.hamcrest.Matchers.contains; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.anyList; +import static org.mockito.BDDMockito.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.bookmark.application.FestivalBookmarkV1QueryService; +import com.festago.bookmark.dto.v1.FestivalBookmarkV1Response; +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.dto.SchoolV1Response; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalBookmarkV1ControllerTest { + + private static final String TOKEN = "Bearer token"; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + MockMvc mockMvc; + + @Autowired + FestivalBookmarkV1QueryService festivalBookmarkV1QueryService; + + @Nested + class 축제_북마크_축제_식별자_목록_조회 { + + final String uri = "/api/v1/bookmarks/festivals/ids"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 요청을_보내면_200_응답과_북마크한_축제_식별자_목록이_반환된다() throws Exception { + // given + given(festivalBookmarkV1QueryService.findBookmarkedFestivalIds(anyLong())) + .willReturn(List.of(1L, 2L, 3L)); + + // when & then + mockMvc.perform(get(uri) + .header(HttpHeaders.AUTHORIZATION, TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").value(contains(1, 2, 3))); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri)) + .andExpect(status().isUnauthorized()); + } + } + } + + @Nested + class 축제_북마크_축제_목록_조회 { + + final String uri = "/api/v1/bookmarks/festivals"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 식별자_목록과_정렬_기준_요청을_보내면_200_응답과_축제_목록이_반환된다() throws Exception { + // given + given(festivalBookmarkV1QueryService.findBookmarkedFestivals(anyLong(), anyList(), any())) + .willReturn(List.of( + createFestivalV1Response(1L, "테코대학교 봄 축제"), + createFestivalV1Response(2L, "테코대학교 여름 축제"), + createFestivalV1Response(3L, "테코대학교 가을 축제") + )); + + // when & then + mockMvc.perform(get(uri) + .header(HttpHeaders.AUTHORIZATION, TOKEN) + .queryParam("festivalIds", "1,2,3") + .queryParam("festivalBookmarkOrder", "FESTIVAL")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[*].festival.id").value(contains(1, 2, 3))); + } + + private FestivalBookmarkV1Response createFestivalV1Response(Long festivalId, String festivalName) { + LocalDate startDate = LocalDate.now(); + LocalDate endDate = LocalDate.now(); + return new FestivalBookmarkV1Response( + new FestivalV1Response( + festivalId, + festivalName, + startDate, + endDate, + "https://image.com/posterImage.png", + new SchoolV1Response(1L, "테코대학교"), + "[]" + ), + LocalDateTime.now() + ); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri)) + .andExpect(status().isUnauthorized()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/presentation/v1/SchoolBookmarkV1ControllerTest.java b/backend/src/test/java/com/festago/bookmark/presentation/v1/SchoolBookmarkV1ControllerTest.java new file mode 100644 index 000000000..59963cf4f --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/presentation/v1/SchoolBookmarkV1ControllerTest.java @@ -0,0 +1,59 @@ +package com.festago.bookmark.presentation.v1; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolBookmarkV1ControllerTest { + + private static final String TOKEN = "Bearer token"; + + @Autowired + MockMvc mockMvc; + + @Nested + class 학교_북마크_목록_조회 { + + final String uri = "/api/v1/bookmarks/schools"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 요청을_보내면_200_응답을_반환한다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .header(HttpHeaders.AUTHORIZATION, TOKEN) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + void 인증을_안했으면_4xx_응답을_반환한다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/repository/MemoryBookmarkRepository.java b/backend/src/test/java/com/festago/bookmark/repository/MemoryBookmarkRepository.java new file mode 100644 index 000000000..883d99e73 --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/repository/MemoryBookmarkRepository.java @@ -0,0 +1,76 @@ +package com.festago.bookmark.repository; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import java.lang.reflect.Field; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import lombok.SneakyThrows; + +public class MemoryBookmarkRepository implements BookmarkRepository { + + private final ConcurrentHashMap memory = new ConcurrentHashMap<>(); + private final AtomicLong autoIncrement = new AtomicLong(); + + public void clear() { + memory.clear(); + } + + @Override + @SneakyThrows + public Bookmark save(Bookmark bookmark) { + Field idField = bookmark.getClass() + .getDeclaredField("id"); + idField.setAccessible(true); + idField.set(bookmark, autoIncrement.incrementAndGet()); + memory.put(bookmark.getId(), bookmark); + return bookmark; + } + + @Override + public void deleteById(Long id) { + memory.remove(id); + } + + @Override + public boolean existsByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType bookmarkType, + Long memberId, + Long resourceId + ) { + return getByBookmarkTypeAndMemberIdAndResourceId(bookmarkType, memberId, resourceId) + .isPresent(); + } + + private Optional getByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType bookmarkType, + Long memberId, + Long resourceId + ) { + return memory.values().stream() + .filter(bookmark -> bookmark.getBookmarkType() == bookmarkType) + .filter(bookmark -> Objects.equals(bookmark.getMemberId(), memberId)) + .filter(bookmark -> Objects.equals(bookmark.getResourceId(), resourceId)) + .findAny(); + } + + @Override + public long countByMemberIdAndBookmarkType(Long memberId, BookmarkType bookmarkType) { + return memory.values().stream() + .filter(bookmark -> Objects.equals(bookmark.getMemberId(), memberId)) + .filter(bookmark -> bookmark.getBookmarkType() == bookmarkType) + .count(); + } + + @Override + public void deleteByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType bookmarkType, + Long memberId, + Long resourceId + ) { + getByBookmarkTypeAndMemberIdAndResourceId(bookmarkType, memberId, resourceId) + .ifPresent(it -> memory.remove(it.getId())); + } +} diff --git a/backend/src/test/java/com/festago/festival/application/QueryDslSchoolSearchRecentFestivalV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/festival/application/QueryDslSchoolSearchRecentFestivalV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..cfea90f7e --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/QueryDslSchoolSearchRecentFestivalV1QueryServiceIntegrationTest.java @@ -0,0 +1,153 @@ +package com.festago.festival.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.festago.festival.application.command.FestivalCreateService; +import com.festago.festival.dto.command.FestivalCreateCommand; +import com.festago.school.application.SchoolCommandService; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.dto.SchoolCreateCommand; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.TimeInstantProvider; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class QueryDslSchoolSearchRecentFestivalV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + private static final String LOGO_URL = "https://image.com/logo.png"; + private static final String BACKGROUND_IMAGE_URL = "https://image.com/backgroundimage.png"; + private static final String POSTER_IMAGE_URL = "https://image.com/posterimage.png"; + + @Autowired + QueryDslSchoolSearchUpcomingFestivalV1QueryService queryDslSchoolSearchRecentFestivalV1QueryService; + + @Autowired + SchoolCommandService schoolCommandService; + + @Autowired + FestivalCreateService festivalCreateService; + + @Autowired + Clock clock; + + Long 테코대학교_식별자; + + Long 우테대학교_식별자; + + LocalDate _6월_14일 = LocalDate.parse("2077-06-14"); + LocalDate _6월_15일 = LocalDate.parse("2077-06-15"); + LocalDate _6월_16일 = LocalDate.parse("2077-06-16"); + LocalDate _6월_17일 = LocalDate.parse("2077-06-17"); + LocalDate _6월_18일 = LocalDate.parse("2077-06-18"); + + /** + * 테코대학교에 6월 15일 ~ 6월 15일 축제, 6월 16일 ~ 6월 16일 축제 우테대학교에 6월 16일 ~ 6월 17일 축제 + */ + @BeforeEach + void setUp() { + 테코대학교_식별자 = schoolCommandService.createSchool( + new SchoolCreateCommand("테코대학교", "teco.ac.kr", SchoolRegion.서울, LOGO_URL, BACKGROUND_IMAGE_URL) + ); + 우테대학교_식별자 = schoolCommandService.createSchool( + new SchoolCreateCommand("우테대학교", "wote.ac.kr", SchoolRegion.서울, LOGO_URL, BACKGROUND_IMAGE_URL) + ); + festivalCreateService.createFestival( + new FestivalCreateCommand("테코대학교 6월 15일 당일 축제", _6월_15일, _6월_15일, POSTER_IMAGE_URL, 테코대학교_식별자) + ); + festivalCreateService.createFestival( + new FestivalCreateCommand("테코대학교 6월 16일 당일 축제", _6월_16일, _6월_16일, POSTER_IMAGE_URL, 테코대학교_식별자) + ); + festivalCreateService.createFestival( + new FestivalCreateCommand("우테대학교 6월 16~17일 축제", _6월_16일, _6월_17일, POSTER_IMAGE_URL, 우테대학교_식별자) + ); + } + + @Test + void 오늘이_6월_14일_일때_테코대학교는_6월_15일_우테대학교는_6월_16일이_조회된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_14일)); + + // when + var actual = queryDslSchoolSearchRecentFestivalV1QueryService.searchUpcomingFestivals( + List.of(테코대학교_식별자, 우테대학교_식별자) + ); + + // then + assertThat(actual.get(테코대학교_식별자).startDate()).isEqualTo(_6월_15일); + assertThat(actual.get(우테대학교_식별자).startDate()).isEqualTo(_6월_16일); + } + + @Test + void 오늘이_6월_15일_일때_테코대학교는_6월_15일_우테대학교는_6월_16일_조회된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_15일)); + + // when + var actual = queryDslSchoolSearchRecentFestivalV1QueryService.searchUpcomingFestivals( + List.of(테코대학교_식별자, 우테대학교_식별자) + ); + + // then + assertThat(actual.get(테코대학교_식별자).startDate()).isEqualTo(_6월_15일); + assertThat(actual.get(우테대학교_식별자).startDate()).isEqualTo(_6월_16일); + } + + @Test + void 오늘이_6월_16일_일때_테코대학교는_6월_16일_우테대학교는_6월_16일_조회된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_16일)); + + // when + var actual = queryDslSchoolSearchRecentFestivalV1QueryService.searchUpcomingFestivals( + List.of(테코대학교_식별자, 우테대학교_식별자) + ); + + // then + assertThat(actual.get(테코대학교_식별자).startDate()).isEqualTo(_6월_16일); + assertThat(actual.get(우테대학교_식별자).startDate()).isEqualTo(_6월_16일); + } + + @Test + void 오늘이_6월_17일_일때_테코대학교는_null_우테대학교는_6월_16일_조회된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_17일)); + + // when + var actual = queryDslSchoolSearchRecentFestivalV1QueryService.searchUpcomingFestivals( + List.of(테코대학교_식별자, 우테대학교_식별자) + ); + + // then + assertThat(actual.get(테코대학교_식별자)).isNull(); + assertThat(actual.get(우테대학교_식별자).startDate()).isEqualTo(_6월_16일); + } + + @Test + void 오늘이_6월_18일_일때_테코대학교는_null_우테대학교는_null_조회된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_18일)); + + // when + var actual = queryDslSchoolSearchRecentFestivalV1QueryService.searchUpcomingFestivals( + List.of(테코대학교_식별자, 우테대학교_식별자) + ); + + // then + assertThat(actual.get(테코대학교_식별자)).isNull(); + assertThat(actual.get(우테대학교_식별자)).isNull(); + } +} diff --git a/backend/src/test/java/com/festago/festival/application/integration/FestivalSearchV1QueryServiceTest.java b/backend/src/test/java/com/festago/festival/application/integration/FestivalSearchV1QueryServiceTest.java new file mode 100644 index 000000000..4a86a8cb0 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/integration/FestivalSearchV1QueryServiceTest.java @@ -0,0 +1,245 @@ +package com.festago.festival.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.festival.application.FestivalSearchV1QueryService; +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.FestivalQueryInfo; +import com.festago.festival.dto.FestivalSearchV1Response; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.domain.StageArtist; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalSearchV1QueryServiceTest extends ApplicationIntegrationTest { + + @Autowired + StageArtistRepository stageArtistRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalInfoRepository festivalInfoRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + ArtistRepository artistRepository; + + @Autowired + FestivalSearchV1QueryService festivalSearchV1QueryService; + + Stage 부산_공연; + Stage 서울_공연; + Stage 대구_공연; + + @BeforeEach + void setting() { + LocalDate nowDate = LocalDate.now(); + LocalDateTime nowDateTime = LocalDateTime.now(); + + School 부산_학교 = schoolRepository.save(new School("domain1", "부산 학교", SchoolRegion.부산)); + School 서울_학교 = schoolRepository.save(new School("domain2", "서울 학교", SchoolRegion.서울)); + School 대구_학교 = schoolRepository.save(new School("domain3", "대구 학교", SchoolRegion.대구)); + + Festival 부산_축제 = festivalRepository.save( + new Festival("부산대학교 축제", nowDate.minusDays(5), nowDate.minusDays(1), 부산_학교)); + Festival 서울_축제 = festivalRepository.save( + new Festival("서울대학교 축제", nowDate.minusDays(1), nowDate.plusDays(3), 서울_학교)); + Festival 대구_축제 = festivalRepository.save( + new Festival("대구대학교 축제", nowDate.plusDays(1), nowDate.plusDays(5), 대구_학교)); + + festivalInfoRepository.save(FestivalQueryInfo.create(부산_축제.getId())); + festivalInfoRepository.save(FestivalQueryInfo.create(서울_축제.getId())); + festivalInfoRepository.save(FestivalQueryInfo.create(대구_축제.getId())); + + 부산_공연 = stageRepository.save(new Stage(nowDateTime.minusDays(5L), nowDateTime.minusDays(6L), 부산_축제)); + 서울_공연 = stageRepository.save(new Stage(nowDateTime.minusDays(1L), nowDateTime.minusDays(2L), 서울_축제)); + 대구_공연 = stageRepository.save(new Stage(nowDateTime.plusDays(1L), nowDateTime, 대구_축제)); + } + + @Nested + class 학교_기반_축제_검색에서 { + + @Test + void 대_로끝나는_검색은_학교_검색으로_들어간다() { + // given + String keyword = "부산대"; + + // when + List actual = festivalSearchV1QueryService.search(keyword); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(1); + softly.assertThat(actual.get(0).name()).contains(keyword); + }); + } + + @Test + void 대학교_로_끝나는_검색은_학교_검색으로_들어간다() { + // given + String keyword = "부산대학교"; + + // when + List actual = festivalSearchV1QueryService.search(keyword); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(1); + softly.assertThat(actual.get(0).name()).contains(keyword); + }); + } + } + + @Nested + class 아티스트_기반_축제_검색에서 { + + @Nested + class 두_글자_이상_라이크_검색은 { + + @Test + void 키워드가_두_글자_이상일_때_해당_키워드를_가진_아티스트의_정보를_반환한다() { + // given + Artist 오리 = artistRepository.save(new Artist("오리", "image.jpg")); + Artist 우푸우 = artistRepository.save(new Artist("우푸우", "image.jpg")); + Artist 글렌 = artistRepository.save(new Artist("글렌", "image.jpg")); + + stageArtistRepository.save(new StageArtist(부산_공연.getId(), 오리.getId())); + stageArtistRepository.save(new StageArtist(부산_공연.getId(), 우푸우.getId())); + + stageArtistRepository.save(new StageArtist(서울_공연.getId(), 오리.getId())); + stageArtistRepository.save(new StageArtist(서울_공연.getId(), 글렌.getId())); + + stageArtistRepository.save(new StageArtist(대구_공연.getId(), 우푸우.getId())); + + // when + List actual = festivalSearchV1QueryService.search("푸우"); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(2); + softly.assertThat(actual.get(0).name()).isEqualTo("부산대학교 축제"); + softly.assertThat(actual.get(1).name()).isEqualTo("대구대학교 축제"); + }); + } + + @Test + void 해당하는_키워드의_아티스트가_없으면_빈_리스트을_반환한다() { + // given + Artist 오리 = artistRepository.save(new Artist("오리", "image.jpg")); + Artist 우푸우 = artistRepository.save(new Artist("우푸우", "image.jpg")); + Artist 글렌 = artistRepository.save(new Artist("글렌", "image.jpg")); + + stageArtistRepository.save(new StageArtist(부산_공연.getId(), 오리.getId())); + stageArtistRepository.save(new StageArtist(부산_공연.getId(), 우푸우.getId())); + + stageArtistRepository.save(new StageArtist(서울_공연.getId(), 오리.getId())); + stageArtistRepository.save(new StageArtist(서울_공연.getId(), 글렌.getId())); + + stageArtistRepository.save(new StageArtist(대구_공연.getId(), 우푸우.getId())); + + // when + List actual = festivalSearchV1QueryService.search("렌글"); + + // then + assertThat(actual).isEmpty(); + } + + @Test + void 아티스트가_공연에_참여하고_있지_않으면_빈_리스트가_반환된다() { + // given + Artist 우푸우 = artistRepository.save(new Artist("우푸우", "image.jpg")); + + // when + List actual = festivalSearchV1QueryService.search("우푸"); + + // then + assertThat(actual).isEmpty(); + } + + } + + @Nested + class 한_글자_동일_검색은 { + + @Test + void 두_글자_이상_아티스트와_함께_검색되지_않는다() { + // given + Artist 푸우 = artistRepository.save(new Artist("푸우", "image.jpg")); + Artist 푸 = artistRepository.save(new Artist("푸", "image.jpg")); + + stageArtistRepository.save(new StageArtist(부산_공연.getId(), 푸우.getId())); + stageArtistRepository.save(new StageArtist(부산_공연.getId(), 푸.getId())); + + stageArtistRepository.save(new StageArtist(서울_공연.getId(), 푸.getId())); + + // when + List actual = festivalSearchV1QueryService.search("푸"); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(2); + softly.assertThat(actual.get(0).name()).isEqualTo("부산대학교 축제"); + softly.assertThat(actual.get(1).name()).isEqualTo("서울대학교 축제"); + }); + } + + @Test + void 해당하는_키워드의_아티스트가_없으면_빈_리스트를_반환한다() { + // given + Artist 푸우 = artistRepository.save(new Artist("푸우", "image.jpg")); + Artist 푸푸푸푸 = artistRepository.save(new Artist("푸푸푸푸", "image.jpg")); + Artist 글렌 = artistRepository.save(new Artist("글렌", "image.jpg")); + + stageArtistRepository.save(new StageArtist(부산_공연.getId(), 푸우.getId())); + stageArtistRepository.save(new StageArtist(부산_공연.getId(), 푸푸푸푸.getId())); + stageArtistRepository.save(new StageArtist(부산_공연.getId(), 글렌.getId())); + + // when + List actual = festivalSearchV1QueryService.search("푸"); + + // then + assertThat(actual).isEmpty(); + } + + @Test + void 아티스트가_공연에_참여하고_있지_않으면_빈_리스트를_반환한다() { + // given + Artist 우푸우 = artistRepository.save(new Artist("우푸우", "image.jpg")); + + // when + List actual = festivalSearchV1QueryService.search("우푸"); + + // then + assertThat(actual).isEmpty(); + } + } + } +} diff --git a/backend/src/test/java/com/festago/festival/repository/MemoryFestivalRepository.java b/backend/src/test/java/com/festago/festival/repository/MemoryFestivalRepository.java index f8257c591..bdcbf92fe 100644 --- a/backend/src/test/java/com/festago/festival/repository/MemoryFestivalRepository.java +++ b/backend/src/test/java/com/festago/festival/repository/MemoryFestivalRepository.java @@ -43,4 +43,9 @@ public Optional findById(Long festivalId) { public void deleteById(Long festivalId) { memory.remove(festivalId); } + + @Override + public boolean existsById(Long festivalId) { + return memory.containsKey(festivalId); + } } diff --git a/backend/src/test/java/com/festago/member/repository/MemoryMemberRepository.java b/backend/src/test/java/com/festago/member/repository/MemoryMemberRepository.java new file mode 100644 index 000000000..7d56f1fb4 --- /dev/null +++ b/backend/src/test/java/com/festago/member/repository/MemoryMemberRepository.java @@ -0,0 +1,55 @@ +package com.festago.member.repository; + +import com.festago.auth.domain.SocialType; +import com.festago.member.domain.Member; +import java.lang.reflect.Field; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import lombok.SneakyThrows; + +public class MemoryMemberRepository implements MemberRepository { + + private final ConcurrentHashMap memory = new ConcurrentHashMap<>(); + private final AtomicLong autoIncrement = new AtomicLong(); + + @Override + @SneakyThrows + public Member save(Member member) { + Field idField = member.getClass() + .getDeclaredField("id"); + idField.setAccessible(true); + idField.set(member, autoIncrement.incrementAndGet()); + memory.put(member.getId(), member); + return member; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(memory.get(id)); + } + + @Override + public void delete(Member member) { + memory.remove(member.getId()); + } + + @Override + public boolean existsById(Long id) { + return memory.containsKey(id); + } + + @Override + public long count() { + return memory.size(); + } + + @Override + public Optional findBySocialIdAndSocialType(String socialId, SocialType socialType) { + return memory.values().stream() + .filter(member -> Objects.equals(member.getSocialId(), socialId)) + .filter(member -> Objects.equals(member.getSocialType(), socialType)) + .findAny(); + } +} diff --git a/backend/src/test/java/com/festago/mock/application/MockDataServiceTest.java b/backend/src/test/java/com/festago/mock/application/MockDataServiceTest.java new file mode 100644 index 000000000..7acf0249c --- /dev/null +++ b/backend/src/test/java/com/festago/mock/application/MockDataServiceTest.java @@ -0,0 +1,280 @@ +package com.festago.mock.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doReturn; + +import com.festago.artist.domain.Artist; +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.FestivalQueryInfo; +import com.festago.mock.MockArtist; +import com.festago.mock.MockDataService; +import com.festago.mock.MockFestivalDateGenerator; +import com.festago.mock.config.MockDataConfig; +import com.festago.mock.repository.ForMockArtistRepository; +import com.festago.mock.repository.ForMockFestivalInfoRepository; +import com.festago.mock.repository.ForMockFestivalRepository; +import com.festago.mock.repository.ForMockSchoolRepository; +import com.festago.mock.repository.ForMockStageArtistRepository; +import com.festago.mock.repository.ForMockStageQueryInfoRepository; +import com.festago.mock.repository.ForMockStageRepository; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.stage.domain.Stage; +import com.festago.stage.domain.StageArtist; +import com.festago.stage.domain.StageQueryInfo; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.SchoolFixture; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@Import(MockDataConfig.class) +class MockDataServiceTest extends ApplicationIntegrationTest { + + private static final int INCLUDE_FIRST_DATE = 1; + @Autowired + ForMockArtistRepository artistRepository; + + @Autowired + ForMockSchoolRepository schoolRepository; + + @Autowired + ForMockFestivalRepository festivalRepository; + + @Autowired + ForMockStageRepository stageRepository; + + @Autowired + ForMockStageArtistRepository stageArtistRepository; + + @Autowired + ForMockStageQueryInfoRepository stageQueryInfoRepository; + + @Autowired + ForMockFestivalInfoRepository festivalInfoRepository; + + @SpyBean + MockFestivalDateGenerator mockFestivalDateGenerator; + + @Autowired + MockDataService mockDataService; + + @Nested + class 목_데이터_초기화는 { + + @Test + void 만약_하나의_학교라도_존재하면_초기화_된_상태로_판단한다() { + // given + schoolRepository.save(SchoolFixture.builder().build()); + + // when + mockDataService.initialize(); + List allSchool = schoolRepository.findAll(); + + // then + assertThat(allSchool).hasSize(1); + } + + @Test + void 학교가_없다면_ANY_를_제외한_지역_곱하기_3개만큼의_학교와_MOCK_ARTIST_만큼의_아티스트를_생성한다() { + // given + mockDataService.initialize(); + int expectGeneratedSchoolSize = (SchoolRegion.values().length - 1) * 3; + int expectArtistSize = MockArtist.values().length; + + // when + List allSchool = schoolRepository.findAll(); + List allArtist = artistRepository.findAll(); + + // then + assertSoftly(softly -> { + softly.assertThat(allSchool).hasSize(expectGeneratedSchoolSize); + softly.assertThat(allArtist).hasSize(expectArtistSize); + }); + } + } + + @Nested + class 목_축제_생성_요청은 { + + @Test + @Transactional + void 존재하는_학교수_만큼의_축제를_만들어_낸다() { + // given + mockDataService.initialize(); + List allSchool = schoolRepository.findAll(); + List beforeFestivals = festivalRepository.findAll(); + + // when + mockDataService.makeMockFestivals(7); + List afterFestivals = festivalRepository.findAll(); + List festivalSchools = afterFestivals.stream() + .map(festival -> festival.getSchool()) + .toList(); + + // then + assertSoftly(softly -> { + softly.assertThat(beforeFestivals).hasSize(0); + softly.assertThat(afterFestivals).hasSize(allSchool.size()); + softly.assertThat(festivalSchools).containsAll(allSchool); + }); + } + + @Test + void 쿼리_최적화_정보들을_생성한다() { + // given + mockDataService.initialize(); + mockDataService.makeMockFestivals(7); + + // when + List stageQueryInfos = stageQueryInfoRepository.findAll(); + List festivalQueryInfos = festivalInfoRepository.findAll(); + + // then + assertSoftly(softly -> { + assertThat(stageQueryInfos).isNotEmpty(); + assertThat(festivalQueryInfos).isNotEmpty(); + }); + } + + @Test + void 생성된_모든_축제는_기간은_전달_받은_기간_이내_이다() { + // given + mockDataService.initialize(); + mockDataService.makeMockFestivals(1); + + // when + List allFestival = festivalRepository.findAll(); + + // then + assertThat(allFestival).allMatch( + festival -> festival.getStartDate().until(festival.getStartDate(), ChronoUnit.DAYS) == 0); + } + + @Test + @Transactional + void 무대는_생성된_축제_기간동안_전부_존재한다() { + // given + LocalDate now = LocalDate.now(); + + mockDataService.initialize(); + mockDataService.makeMockFestivals(7); + List allFestival = festivalRepository.findAll(); + + // when + List allStage = stageRepository.findAll(); + Map> stageByFestival = allStage.stream() + .collect(Collectors.groupingBy(Stage::getFestival)); + + // then + assertThat(stageByFestival.entrySet()).allMatch(festivalListEntry -> { + Festival festival = festivalListEntry.getKey(); + long festivalDuration = + festival.getStartDate().until(festival.getEndDate(), ChronoUnit.DAYS) + INCLUDE_FIRST_DATE; + return festivalListEntry.getValue().size() == festivalDuration; + }); + } + + @Test + void 같은_축제_속_무대_아티스트_들은_겹치지_않는다() { + // given + mockDataService.initialize(); + mockDataService.makeMockFestivals(7); + + List stageArtists = stageArtistRepository.findAll(); + List allStage = stageRepository.findAll(); + + Map> stageArtistByStageId = stageArtists.stream() + .collect(Collectors.groupingBy(StageArtist::getStageId)); + Map> stageByFestival = allStage.stream() + .collect(Collectors.groupingBy(Stage::getFestival)); + + // when + Map> stageArtistsByFestival = new HashMap<>(); + + stageByFestival.forEach((festival, stages) -> { + List artistsForFestival = stages.stream() + .map(stage -> stageArtistByStageId.getOrDefault(stage.getId(), Collections.emptyList())) + .flatMap(List::stream) + .collect(Collectors.toList()); + + stageArtistsByFestival.put(festival, artistsForFestival); + }); + + // then + assertThat(stageArtistsByFestival.keySet()) + .allMatch(festival -> { + List stageArtistsValue = stageArtistsByFestival.get(festival); + long uniqueStageArtists = stageArtistsValue.stream() + .map(stageArtist -> stageArtist.getArtistId()) + .distinct() + .count(); + return stageArtistsValue.size() == uniqueStageArtists; + }); + } + + @Test + void 만약_아티스트가_중복없이_무대를_구성하기_부족하다면_중복을_허용한다() { + + // given + LocalDate now = LocalDate.now(); + int availableUniqueStageCount = MockArtist.values().length / MockDataService.STAGE_ARTIST_COUNT; + doReturn(now) + .when(mockFestivalDateGenerator) + .makeRandomStartDate(anyInt(), any(LocalDate.class)); + doReturn(now.plusDays(availableUniqueStageCount + 1)) + .when(mockFestivalDateGenerator) + .makeRandomEndDate(anyInt(), any(LocalDate.class), any(LocalDate.class)); + + mockDataService.initialize(); + mockDataService.makeMockFestivals(10); + + List stageArtists = stageArtistRepository.findAll(); + List allStage = stageRepository.findAll(); + + Map> stageArtistByStageId = stageArtists.stream() + .collect(Collectors.groupingBy(StageArtist::getStageId)); + Map> stageByFestival = allStage.stream() + .collect(Collectors.groupingBy(Stage::getFestival)); + + // when + Map> stageArtistsByFestival = new HashMap<>(); + + stageByFestival.forEach((festival, stages) -> { + List artistsForFestival = stages.stream() + .map(stage -> stageArtistByStageId.getOrDefault(stage.getId(), Collections.emptyList())) + .flatMap(List::stream) + .collect(Collectors.toList()); + + stageArtistsByFestival.put(festival, artistsForFestival); + }); + + List artistIds = stageArtistsByFestival.values().stream() + .flatMap(List::stream) + .map(stageArtist -> stageArtist.getArtistId()) + .toList(); + + // then + assertThat(artistIds.size()).isNotEqualTo(new HashSet<>(artistIds).size()); + } + } +} diff --git a/backend/src/test/java/com/festago/mock/config/MockDataConfig.java b/backend/src/test/java/com/festago/mock/config/MockDataConfig.java new file mode 100644 index 000000000..ba4632540 --- /dev/null +++ b/backend/src/test/java/com/festago/mock/config/MockDataConfig.java @@ -0,0 +1,68 @@ +package com.festago.mock.config; + +import com.festago.artist.application.ArtistCommandService; +import com.festago.artist.repository.ArtistRepository; +import com.festago.festival.application.command.FestivalCommandFacadeService; +import com.festago.festival.repository.FestivalRepository; +import com.festago.mock.CommandLineAppStartupRunner; +import com.festago.mock.MockScheduler; +import com.festago.mock.MockFestivalDateGenerator; +import com.festago.mock.MockDataService; +import com.festago.mock.RandomMockFestivalDateGenerator; +import com.festago.mock.repository.ForMockArtistRepository; +import com.festago.mock.repository.ForMockFestivalRepository; +import com.festago.mock.repository.ForMockSchoolRepository; +import com.festago.school.application.SchoolCommandService; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.application.command.StageCommandFacadeService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class MockDataConfig { + + @Autowired + private ForMockSchoolRepository schoolRepository; + + @Autowired + private ForMockArtistRepository artistRepository; + + @Autowired + private ForMockFestivalRepository festivalRepository; + + @Autowired + private FestivalCommandFacadeService festivalCommandFacadeService; + + @Autowired + private StageCommandFacadeService stageCommandFacadeService; + + @Autowired + private ArtistCommandService artistCommandService; + + @Autowired + private SchoolCommandService schoolCommandService; + + + @Bean + public MockFestivalDateGenerator festivalDateGenerator() { + return new RandomMockFestivalDateGenerator(); + } + + @Bean + public MockDataService mockDataService() { + return new MockDataService(festivalDateGenerator(), schoolRepository, artistRepository, festivalRepository, + festivalCommandFacadeService, stageCommandFacadeService, artistCommandService, schoolCommandService); + } + + @Bean + public MockScheduler mockScheduler() { + return new MockScheduler(mockDataService()); + + } + + @Bean + public CommandLineAppStartupRunner commandLineAppStartupRunner() { + return new CommandLineAppStartupRunner(mockDataService(), mockScheduler()); + } +} diff --git a/backend/src/test/java/com/festago/school/application/integration/SchoolSearchV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/school/application/integration/SchoolSearchV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..752de4595 --- /dev/null +++ b/backend/src/test/java/com/festago/school/application/integration/SchoolSearchV1QueryServiceIntegrationTest.java @@ -0,0 +1,79 @@ +package com.festago.school.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.school.application.v1.SchoolSearchV1QueryService; +import com.festago.school.domain.School; +import com.festago.school.dto.v1.SchoolSearchV1Response; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.SchoolFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class SchoolSearchV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + SchoolSearchV1QueryService schoolSearchV1QueryService; + + @Autowired + SchoolRepository schoolRepository; + + School 테코대학교; + + School 테코여자대학교; + + School 우테대학교; + + @BeforeEach + void setUp() { + 테코대학교 = schoolRepository.save(SchoolFixture.builder().name("테코대학교").domain("teco.ac.kr").build()); + 테코여자대학교 = schoolRepository.save(SchoolFixture.builder().name("테코여자대학교").domain("tecowoman.ac.kr").build()); + 우테대학교 = schoolRepository.save(SchoolFixture.builder().name("우테대학교").domain("woote.ac.kr").build()); + } + + @Test + void 우테대학교를_검색하면_우테대학교가_검색되어야_한다() { + // given + String keyword = "우테대학교"; + + // when + var response = schoolSearchV1QueryService.searchSchools(keyword); + + // then + assertThat(response) + .map(SchoolSearchV1Response::id) + .containsExactly(우테대학교.getId()); + } + + @Test + void 테코를_검색하면_테코대학교와_테코여자대학교가_검색되어야_한다() { + // given + String keyword = "테코"; + + // when + var response = schoolSearchV1QueryService.searchSchools(keyword); + + // then + assertThat(response) + .map(SchoolSearchV1Response::id) + .containsExactly(테코대학교.getId(), 테코여자대학교.getId()); + } + + @Test + void 학교의_이름에_포함되지_않으면_빈_리스트가_반환된다() { + // given + String keyword = "글렌"; + + // when + var response = schoolSearchV1QueryService.searchSchools(keyword); + + // then + assertThat(response).isEmpty(); + } +} diff --git a/backend/src/test/java/com/festago/school/presentation/v1/SchoolSearchV1ControllerTest.java b/backend/src/test/java/com/festago/school/presentation/v1/SchoolSearchV1ControllerTest.java new file mode 100644 index 000000000..488c2d7dc --- /dev/null +++ b/backend/src/test/java/com/festago/school/presentation/v1/SchoolSearchV1ControllerTest.java @@ -0,0 +1,82 @@ +package com.festago.school.presentation.v1; + +import static org.mockito.BDDMockito.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.school.application.v1.SchoolTotalSearchV1QueryService; +import com.festago.school.dto.v1.SchoolTotalSearchV1Response; +import com.festago.support.CustomWebMvcTest; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolSearchV1ControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + SchoolTotalSearchV1QueryService schoolTotalSearchV1QueryService; + + @Nested + class 학교_상세_조회 { + + final String uri = "/api/v1/search/schools"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_학교_목록이_반환된다() throws Exception { + // given + LocalDate festivalStartDate = LocalDate.now(); + + var response = List.of( + new SchoolTotalSearchV1Response(1L, "테코대학교", "https://image.com/logo1.png", festivalStartDate), + new SchoolTotalSearchV1Response(2L, "우테대학교", "https://image.com/logo2.png", null) + ); + given(schoolTotalSearchV1QueryService.searchSchools(anyString())) + .willReturn(response); + + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .queryParam("keyword", "테코대학교")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.size()").value(2)); + } + + @Test + void 쿼리_파라미터_keyword가_2글자_미만이면_400_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .queryParam("keyword", "1")) + .andExpect(status().isBadRequest()); + } + + @Test + void 쿼리_파라미터_keyword가_blank_이면_400_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .queryParam("keyword", " ")) + .andExpect(status().isBadRequest()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/school/repository/MemorySchoolRepository.java b/backend/src/test/java/com/festago/school/repository/MemorySchoolRepository.java new file mode 100644 index 000000000..6bd01b50f --- /dev/null +++ b/backend/src/test/java/com/festago/school/repository/MemorySchoolRepository.java @@ -0,0 +1,60 @@ +package com.festago.school.repository; + +import com.festago.school.domain.School; +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import lombok.SneakyThrows; + +public class MemorySchoolRepository implements SchoolRepository { + + private final Map db = new HashMap<>(); + private final AtomicLong id = new AtomicLong(1L); + + @SneakyThrows + @Override + public School save(School school) { + Field idField = school.getClass() + .getDeclaredField("id"); + idField.setAccessible(true); + idField.set(school, id.getAndIncrement()); + db.put(school.getId(), school); + return school; + } + + @Override + public Optional findById(Long schoolId) { + return Optional.ofNullable(db.get(schoolId)); + } + + @Override + public void deleteById(Long id) { + db.remove(id); + } + + @Override + public boolean existsById(Long id) { + return db.get(id) != null; + } + + @Override + public boolean existsByDomain(String domain) { + return db.values().stream() + .anyMatch(it -> it.getDomain().equals(domain)); + } + + @Override + public boolean existsByName(String name) { + return db.values().stream() + .anyMatch(it -> it.getName().equals(name)); + } + + @Override + public Optional findByName(String name) { + return db.values().stream() + .filter(it -> it.getName().equals(name)) + .findFirst(); + } +} diff --git a/backend/src/test/java/com/festago/school/repository/MemorySchoolRepositoryTest.java b/backend/src/test/java/com/festago/school/repository/MemorySchoolRepositoryTest.java new file mode 100644 index 000000000..7ea98b7fe --- /dev/null +++ b/backend/src/test/java/com/festago/school/repository/MemorySchoolRepositoryTest.java @@ -0,0 +1,54 @@ +package com.festago.school.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.support.fixture.SchoolFixture; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MemorySchoolRepositoryTest { + + SchoolRepository schoolRepository; + + @BeforeEach + void setUp() { + schoolRepository = new MemorySchoolRepository(); + } + + @Test + void 학교를_저장한다() { + // given + School school = schoolRepository.save(SchoolFixture.builder().build()); + + // when && then + assertThat(school.getId()).isPositive(); + } + + @Test + void 특정_필드로_조회한다() { + // given + schoolRepository.save(SchoolFixture.builder() + .region(SchoolRegion.서울) + .name("학교이름") + .domain("knu.ac.kr") + .build() + ); + + // when && then + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(schoolRepository.findByName("학교이름")).isNotEmpty(); + softly.assertThat(schoolRepository.findByName("없는학교")).isEmpty(); + softly.assertThat(schoolRepository.existsByName("학교이름")).isTrue(); + softly.assertThat(schoolRepository.existsByName("없는학교")).isFalse(); + softly.assertThat(schoolRepository.existsByDomain("knu.ac.kr")).isTrue(); + softly.assertThat(schoolRepository.existsByDomain("no.ac.kr")).isFalse(); + }); + } +} diff --git a/backend/src/test/java/com/festago/school/repository/SchoolRepositoryTest.java b/backend/src/test/java/com/festago/school/repository/SchoolRepositoryTest.java deleted file mode 100644 index 8cfa80478..000000000 --- a/backend/src/test/java/com/festago/school/repository/SchoolRepositoryTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.festago.school.repository; - -import com.festago.school.domain.School; -import com.festago.school.domain.SchoolRegion; -import com.festago.support.RepositoryTest; -import java.util.List; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -@RepositoryTest -class SchoolRepositoryTest { - - @Autowired - SchoolRepository schoolRepository; - - @Test - void 지역으로_학교를_검색한다() { - // given - School expectSchool = schoolRepository.save(new School("domain", "name", SchoolRegion.서울)); - schoolRepository.save(new School("domain2", "name2", SchoolRegion.부산)); - schoolRepository.save(new School("domain3", "name3", SchoolRegion.대구)); - - // when - List actual = schoolRepository.findAllByRegion(SchoolRegion.서울); - - // then - Assertions.assertThat(actual).containsExactly(expectSchool); - } -} diff --git a/backend/src/test/java/com/festago/support/fixture/BookmarkFixture.java b/backend/src/test/java/com/festago/support/fixture/BookmarkFixture.java new file mode 100644 index 000000000..b2d7c1c85 --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/BookmarkFixture.java @@ -0,0 +1,45 @@ +package com.festago.support.fixture; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; + +public class BookmarkFixture extends BaseFixture { + + private Long id; + private BookmarkType bookmarkType; + private Long resourceId; + private Long memberId; + + public static BookmarkFixture builder() { + return new BookmarkFixture(); + } + + public BookmarkFixture id(Long id) { + this.id = id; + return this; + } + + public BookmarkFixture bookmarkType(BookmarkType bookmarkType) { + this.bookmarkType = bookmarkType; + return this; + } + + public BookmarkFixture resourceId(Long resourceId) { + this.resourceId = resourceId; + return this; + } + + public BookmarkFixture memberId(Long memberId) { + this.memberId = memberId; + return this; + } + + public Bookmark build() { + return new Bookmark( + id, + bookmarkType, + resourceId, + memberId + ); + } +}