diff --git a/build.gradle.kts b/build.gradle.kts index 60e55e4..944e19e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -73,8 +73,11 @@ dependencies { // Tests testImplementation(libs.ktor.server.tests) - testImplementation(libs.kotlin.test.junit) + testImplementation(libs.kotlin.test) testImplementation(libs.ktor.client.mock) + testImplementation(libs.mockk) + testImplementation(libs.kotlin.coroutines.test) + testImplementation(libs.junit.jupiter) } val shadowJarTask = tasks.named("shadowJar") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 01dc733..ba617d0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,9 @@ bcrypt = "0.4" slf4j = "2.0.17" sonarqube = "6.0.1.5171" jacoco = "0.8.12" +mockk = "1.14.2" +kotlin-coroutines-test = "1.10.1" +junit-jupiter = "5.11.4" [libraries] # Ktor Server dependencies @@ -44,7 +47,10 @@ micrometer-registry-prometheus = { module = "io.micrometer:micrometer-registry-p # Test dependencies ktor-server-tests = { module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktor-server-test" } -kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines-test" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } # Logs logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } diff --git a/src/main/kotlin/es/wokis/data/bo/sound/SoundBO.kt b/src/main/kotlin/es/wokis/data/bo/sound/SoundBO.kt index 57f77a2..4dd924b 100644 --- a/src/main/kotlin/es/wokis/data/bo/sound/SoundBO.kt +++ b/src/main/kotlin/es/wokis/data/bo/sound/SoundBO.kt @@ -1,19 +1,25 @@ package es.wokis.data.bo.sound -import es.wokis.data.bo.user.UserBO data class SoundBO( val id: Long? = null, val displayId: String, val title: String, + val description: String = "", val soundUrl: String, val createdBy: String, - val thumbsUp: List, - val thumbsDown: List, + val thumbsUp: List = emptyList(), + val thumbsDown: List = emptyList(), val createdOn: Long, + val status: String = "pending", val reactions: List = emptyList() ) +data class SoundUserBO( + val id: String, + val displayName: String +) + data class ReactionBO( val unicode: String, val addedBy: String diff --git a/src/main/kotlin/es/wokis/data/database/AppDataBase.kt b/src/main/kotlin/es/wokis/data/database/AppDataBase.kt index 8c41be3..277c47f 100644 --- a/src/main/kotlin/es/wokis/data/database/AppDataBase.kt +++ b/src/main/kotlin/es/wokis/data/database/AppDataBase.kt @@ -5,6 +5,7 @@ import com.mongodb.MongoClientSettings import com.mongodb.MongoCredential import com.mongodb.kotlin.client.coroutine.MongoClient import com.mongodb.kotlin.client.coroutine.MongoDatabase +import es.wokis.data.dbo.SoundDBO import es.wokis.data.dbo.radio.RadioCollectionDBO import es.wokis.data.dbo.radio.RadioDBO import es.wokis.data.dbo.recover.RecoverDBO @@ -35,6 +36,7 @@ class AppDataBase { val recoverCollection by lazy { database.getCollection("recover") } val statsCollection by lazy { database.getCollection("stats") } val radioCollection by lazy { database.getCollection("radios") } + val soundsCollection by lazy { database.getCollection("sounds") } companion object { private const val MONGODB_PREFIX = "mongodb://" diff --git a/src/main/kotlin/es/wokis/data/datasource/local/sound/SoundsLocalDataSource.kt b/src/main/kotlin/es/wokis/data/datasource/local/sound/SoundsLocalDataSource.kt new file mode 100644 index 0000000..40b8935 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/datasource/local/sound/SoundsLocalDataSource.kt @@ -0,0 +1,85 @@ +package es.wokis.data.datasource.local.sound + +import com.mongodb.client.model.Filters +import com.mongodb.client.model.Sorts +import com.mongodb.kotlin.client.coroutine.MongoCollection +import es.wokis.data.bo.sound.SoundBO +import es.wokis.data.dbo.SoundDBO +import es.wokis.data.mapper.sound.toBO +import es.wokis.data.mapper.sound.toDBO +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.toList +import org.slf4j.LoggerFactory + +interface SoundsLocalDataSource { + suspend fun getAllSounds(page: Int, limit: Int, status: String? = null): List + suspend fun getSoundsCount(status: String? = null): Long + suspend fun getSoundByDisplayId(displayId: String): SoundBO? + suspend fun getSoundsByCreatedBy(userId: String, page: Int, limit: Int): List + suspend fun getSoundsByCreatedByCount(userId: String): Long + suspend fun createSound(sound: SoundBO): Boolean + suspend fun updateSound(sound: SoundBO): Boolean + suspend fun deleteSound(displayId: String): Boolean +} + +class SoundsLocalDataSourceImpl( + private val soundsCollection: MongoCollection +) : SoundsLocalDataSource { + private val logger = LoggerFactory.getLogger(SoundsLocalDataSourceImpl::class.java) + + override suspend fun getAllSounds(page: Int, limit: Int, status: String?): List { + val skip = (page - 1) * limit + val filter = status?.let { Filters.eq(SoundDBO::status.name, it) } ?: Filters.empty() + return soundsCollection.find(filter) + .sort(Sorts.descending(SoundDBO::createdOn.name)) + .skip(skip) + .limit(limit) + .toList() + .map { it.toBO() } + } + + override suspend fun getSoundsCount(status: String?): Long { + val filter = status?.let { Filters.eq(SoundDBO::status.name, it) } ?: Filters.empty() + return soundsCollection.countDocuments(filter) + } + + override suspend fun getSoundByDisplayId(displayId: String): SoundBO? { + val filter = Filters.eq(SoundDBO::displayId.name, displayId) + return soundsCollection.find(filter).firstOrNull()?.toBO() + } + + override suspend fun getSoundsByCreatedBy(userId: String, page: Int, limit: Int): List { + val skip = (page - 1) * limit + val filter = Filters.eq(SoundDBO::createdBy.name, userId) + return soundsCollection.find(filter) + .sort(Sorts.descending(SoundDBO::createdOn.name)) + .skip(skip) + .limit(limit) + .toList() + .map { it.toBO() } + } + + override suspend fun getSoundsByCreatedByCount(userId: String): Long { + val filter = Filters.eq(SoundDBO::createdBy.name, userId) + return soundsCollection.countDocuments(filter) + } + + override suspend fun createSound(sound: SoundBO): Boolean = try { + soundsCollection.insertOne(sound.toDBO()).wasAcknowledged() + } catch (e: Throwable) { + logger.error("Failed to create sound", e) + false + } + + override suspend fun updateSound(sound: SoundBO): Boolean { + val filter = Filters.eq(SoundDBO::displayId.name, sound.displayId) + return soundsCollection.replaceOne(filter, sound.toDBO()).wasAcknowledged() + } + + override suspend fun deleteSound(displayId: String): Boolean = try { + soundsCollection.deleteOne(Filters.eq(SoundDBO::displayId.name, displayId)).wasAcknowledged() + } catch (e: Throwable) { + logger.error("Failed to delete sound", e) + false + } +} diff --git a/src/main/kotlin/es/wokis/data/dbo/SoundDBO.kt b/src/main/kotlin/es/wokis/data/dbo/SoundDBO.kt index 8cc1f8b..05e1dc6 100644 --- a/src/main/kotlin/es/wokis/data/dbo/SoundDBO.kt +++ b/src/main/kotlin/es/wokis/data/dbo/SoundDBO.kt @@ -1,6 +1,5 @@ package es.wokis.data.dbo -import es.wokis.data.dbo.user.UserDBO import org.bson.codecs.pojo.annotations.BsonId data class SoundDBO( @@ -8,14 +7,21 @@ data class SoundDBO( val id: Long? = null, val displayId: String, val title: String, + val description: String = "", val soundUrl: String, val createdBy: String, - val thumbsUp: List, - val thumbsDown: List, + val thumbsUp: List = emptyList(), + val thumbsDown: List = emptyList(), val createdOn: Long, + val status: String = "pending", val reactions: List = emptyList() ) +data class SoundUserDBO( + val id: String, + val displayName: String +) + data class ReactionDBO( val unicode: String, val addedBy: String diff --git a/src/main/kotlin/es/wokis/data/dto/sound/SoundDTO.kt b/src/main/kotlin/es/wokis/data/dto/sound/SoundDTO.kt index c779111..7c5f728 100644 --- a/src/main/kotlin/es/wokis/data/dto/sound/SoundDTO.kt +++ b/src/main/kotlin/es/wokis/data/dto/sound/SoundDTO.kt @@ -1,6 +1,5 @@ package es.wokis.data.dto.sound -import es.wokis.data.dto.user.UserDTO import es.wokis.utils.HashGenerator.generateHashWithSeed import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -13,18 +12,30 @@ data class SoundDTO( val displayId: String = generateHashWithSeed(), @SerialName("title") val title: String, + @SerialName("description") + val description: String = "", @SerialName("sound") val soundUrl: String, @SerialName("createdBy") val createdBy: String, @SerialName("thumbsUp") - val thumbsUp: List, + val thumbsUp: List = emptyList(), @SerialName("thumbsDown") - val thumbsDown: List, + val thumbsDown: List = emptyList(), @SerialName("createdOn") val createdOn: Long, + @SerialName("status") + val status: String = "pending", @SerialName("reactions") - val reactions: List + val reactions: List = emptyList() +) + +@Serializable +data class SoundUserDTO( + @SerialName("id") + val id: String, + @SerialName("displayName") + val displayName: String ) @Serializable @@ -39,6 +50,22 @@ data class ReactionDTO( data class SoundRequestDTO( @SerialName("title") val title: String, + @SerialName("description") + val description: String = "", @SerialName("sound") val sound: String ) + +@Serializable +data class VoteRequestDTO( + @SerialName("vote") + val vote: String +) + +@Serializable +data class UpdateSoundRequestDTO( + @SerialName("title") + val title: String? = null, + @SerialName("description") + val description: String? = null +) diff --git a/src/main/kotlin/es/wokis/data/exception/CustomExceptions.kt b/src/main/kotlin/es/wokis/data/exception/CustomExceptions.kt index 4cac7a1..b07e143 100644 --- a/src/main/kotlin/es/wokis/data/exception/CustomExceptions.kt +++ b/src/main/kotlin/es/wokis/data/exception/CustomExceptions.kt @@ -12,4 +12,6 @@ object TotpNotFoundException : IllegalStateException() object RecoverCodeNotFoundException : IllegalStateException() -object UserNotFoundException : IllegalStateException() \ No newline at end of file +object UserNotFoundException : IllegalStateException() + +object SoundNotFoundException : IllegalStateException() diff --git a/src/main/kotlin/es/wokis/data/mapper/sound/SoundMapper.kt b/src/main/kotlin/es/wokis/data/mapper/sound/SoundMapper.kt index 899938b..a3f636f 100644 --- a/src/main/kotlin/es/wokis/data/mapper/sound/SoundMapper.kt +++ b/src/main/kotlin/es/wokis/data/mapper/sound/SoundMapper.kt @@ -2,13 +2,13 @@ package es.wokis.data.mapper.sound import es.wokis.data.bo.sound.ReactionBO import es.wokis.data.bo.sound.SoundBO +import es.wokis.data.bo.sound.SoundUserBO import es.wokis.data.dbo.ReactionDBO import es.wokis.data.dbo.SoundDBO +import es.wokis.data.dbo.SoundUserDBO import es.wokis.data.dto.sound.ReactionDTO import es.wokis.data.dto.sound.SoundDTO -import es.wokis.data.mapper.user.toBO -import es.wokis.data.mapper.user.toDBO -import es.wokis.data.mapper.user.toDTO +import es.wokis.data.dto.sound.SoundUserDTO // region dto to bo @JvmName("soundDTOToBO") @@ -18,14 +18,24 @@ fun SoundDTO.toBO() = SoundBO( id = id, displayId = displayId, title = title, + description = description, soundUrl = soundUrl, createdBy = createdBy, thumbsUp = thumbsUp.toBO(), thumbsDown = thumbsDown.toBO(), createdOn = createdOn, + status = status, reactions = reactions.toBO(), ) +@JvmName("soundUserDTOToBO") +fun List.toBO() = this.map { it.toBO() } + +fun SoundUserDTO.toBO() = SoundUserBO( + id = id, + displayName = displayName +) + @JvmName("reactionDTOToBO") fun List.toBO() = this.map { it.toBO() } @@ -45,14 +55,24 @@ fun SoundBO.toDTO() = SoundDTO( id = id, displayId = displayId, title = title, + description = description, soundUrl = soundUrl, createdBy = createdBy, thumbsUp = thumbsUp.toDTO(), thumbsDown = thumbsDown.toDTO(), createdOn = createdOn, + status = status, reactions = reactions.toDTO() ) +@JvmName("soundUserBOToDTO") +fun List.toDTO() = this.map { it.toDTO() } + +fun SoundUserBO.toDTO() = SoundUserDTO( + id = id, + displayName = displayName +) + @JvmName("reactionBOToDTO") fun List.toDTO() = this.map { it.toDTO() } @@ -72,14 +92,24 @@ fun SoundDBO.toBO() = SoundBO( id = id, displayId = displayId, title = title, + description = description, soundUrl = soundUrl, createdBy = createdBy, thumbsUp = thumbsUp.toBO(), thumbsDown = thumbsDown.toBO(), createdOn = createdOn, + status = status, reactions = reactions.toBO(), ) +@JvmName("soundUserDBOToBO") +fun List.toBO() = this.map { it.toBO() } + +fun SoundUserDBO.toBO() = SoundUserBO( + id = id, + displayName = displayName +) + @JvmName("reactionDBOToBO") fun List.toBO() = this.map { it.toBO() } @@ -90,7 +120,7 @@ fun ReactionDBO.toBO() = ReactionBO( // endregion -// region dbo to bo +// region bo to dbo @JvmName("soundBOToDBO") fun List.toDBO() = this.map { it.toDBO() } @@ -99,14 +129,24 @@ fun SoundBO.toDBO() = SoundDBO( id = id, displayId = displayId, title = title, + description = description, soundUrl = soundUrl, createdBy = createdBy, thumbsUp = thumbsUp.toDBO(), thumbsDown = thumbsDown.toDBO(), createdOn = createdOn, + status = status, reactions = reactions.toDBO(), ) +@JvmName("soundUserBOToDBO") +fun List.toDBO() = this.map { it.toDBO() } + +fun SoundUserBO.toDBO() = SoundUserDBO( + id = id, + displayName = displayName +) + @JvmName("reactionBOToDBO") fun List.toDBO() = this.map { it.toDBO() } @@ -115,4 +155,4 @@ fun ReactionBO.toDBO() = ReactionDBO( addedBy = addedBy ) -// endregion \ No newline at end of file +// endregion diff --git a/src/main/kotlin/es/wokis/data/repository/sound/SoundRepository.kt b/src/main/kotlin/es/wokis/data/repository/sound/SoundRepository.kt index 12df718..34cb770 100644 --- a/src/main/kotlin/es/wokis/data/repository/sound/SoundRepository.kt +++ b/src/main/kotlin/es/wokis/data/repository/sound/SoundRepository.kt @@ -1,48 +1,127 @@ package es.wokis.data.repository.sound import es.wokis.data.bo.sound.SoundBO +import es.wokis.data.bo.sound.SoundUserBO import es.wokis.data.bo.user.UserBO +import es.wokis.data.datasource.local.sound.SoundsLocalDataSource +import es.wokis.data.exception.SoundNotFoundException +import es.wokis.data.mapper.sound.toBO +import es.wokis.data.mapper.sound.toDBO +import es.wokis.services.SoundFileService +import es.wokis.utils.HashGenerator import io.ktor.http.content.* interface SoundRepository { - fun addRawSounds(sounds: List, user: UserBO) - fun updateRawSound(sound: PartData.FileItem, user: UserBO) - fun addSound(sound: SoundBO, user: UserBO) - fun updateSound(sound: SoundBO, user: UserBO) - fun removeSound(sound: SoundBO, user: UserBO) - fun upVoteSound(user: UserBO, id: String) - fun downVoteSound(user: UserBO, id: String) + suspend fun getSounds(page: Int, limit: Int, status: String? = null): List + suspend fun getSoundsCount(status: String? = null): Long + suspend fun getSoundById(displayId: String): SoundBO? + suspend fun getUserSounds(userId: String, page: Int, limit: Int): List + suspend fun getUserSoundsCount(userId: String): Long + suspend fun createSounds( + title: String, + description: String, + audioFiles: List, + user: UserBO + ): List + suspend fun updateRawSound(displayId: String, audioFile: PartData.FileItem, user: UserBO): Boolean + suspend fun updateSound( + displayId: String, + title: String?, + description: String?, + user: UserBO + ): Boolean + suspend fun removeSound(displayId: String, user: UserBO): Boolean + suspend fun voteSound(user: UserBO, displayId: String, vote: String): Boolean } class SoundRepositoryImpl( - // private val soundsLocalDataSource: SoundsLocalDataSource + private val soundsLocalDataSource: SoundsLocalDataSource, ) : SoundRepository { - override fun addRawSounds(sounds: List, user: UserBO) { - TODO("Not yet implemented") - } + override suspend fun getSounds(page: Int, limit: Int, status: String?): List = + soundsLocalDataSource.getAllSounds(page, limit, status) - override fun updateRawSound(sound: PartData.FileItem, user: UserBO) { - TODO("Not yet implemented") - } + override suspend fun getSoundsCount(status: String?): Long = + soundsLocalDataSource.getSoundsCount(status) + + override suspend fun getSoundById(displayId: String): SoundBO? = + soundsLocalDataSource.getSoundByDisplayId(displayId) + + override suspend fun getUserSounds(userId: String, page: Int, limit: Int): List = + soundsLocalDataSource.getSoundsByCreatedBy(userId, page, limit) + + override suspend fun getUserSoundsCount(userId: String): Long = + soundsLocalDataSource.getSoundsByCreatedByCount(userId) - override fun addSound(sound: SoundBO, user: UserBO) { - TODO("Not yet implemented") + override suspend fun createSounds( + title: String, + description: String, + audioFiles: List, + user: UserBO + ): List = audioFiles.mapNotNull { audioFile -> + val displayId = HashGenerator.generateHashWithSeed() + val soundUrl = SoundFileService.saveSound(displayId, audioFile) + val now = System.currentTimeMillis() + val sound = SoundBO( + displayId = displayId, + title = title, + description = description, + soundUrl = soundUrl, + createdBy = user.id ?: "", + createdOn = now, + status = "pending" + ) + val created = soundsLocalDataSource.createSound(sound) + if (created) sound else null } - override fun updateSound(sound: SoundBO, user: UserBO) { - TODO("Not yet implemented") + override suspend fun updateRawSound( + displayId: String, + audioFile: PartData.FileItem, + user: UserBO + ): Boolean { + val existing = soundsLocalDataSource.getSoundByDisplayId(displayId) + ?: throw SoundNotFoundException + SoundFileService.deleteSoundFiles(displayId) + val soundUrl = SoundFileService.saveSound(displayId, audioFile) + return soundsLocalDataSource.updateSound(existing.copy(soundUrl = soundUrl)) } - override fun removeSound(sound: SoundBO, user: UserBO) { - TODO("Not yet implemented") + override suspend fun updateSound( + displayId: String, + title: String?, + description: String?, + user: UserBO + ): Boolean { + val existing = soundsLocalDataSource.getSoundByDisplayId(displayId) + ?: throw SoundNotFoundException + val updated = existing.copy( + title = title ?: existing.title, + description = description ?: existing.description + ) + return soundsLocalDataSource.updateSound(updated) } - override fun upVoteSound(user: UserBO, id: String) { - TODO("Not yet implemented") + override suspend fun removeSound(displayId: String, user: UserBO): Boolean { + val existing = soundsLocalDataSource.getSoundByDisplayId(displayId) + ?: throw SoundNotFoundException + SoundFileService.deleteSoundFiles(displayId) + return soundsLocalDataSource.deleteSound(displayId) } - override fun downVoteSound(user: UserBO, id: String) { - TODO("Not yet implemented") + override suspend fun voteSound(user: UserBO, displayId: String, vote: String): Boolean { + val existing = soundsLocalDataSource.getSoundByDisplayId(displayId) + ?: throw SoundNotFoundException + val userId = user.id ?: return false + val voter = SoundUserBO(id = userId, displayName = user.username) + val wasUp = existing.thumbsUp.any { it.id == userId } + val wasDown = existing.thumbsDown.any { it.id == userId } + val thumbsUp = existing.thumbsUp.filterNot { it.id == userId } + val thumbsDown = existing.thumbsDown.filterNot { it.id == userId } + val updatedThumbsUp = if (vote == "up" && !wasUp) thumbsUp + voter else thumbsUp + val updatedThumbsDown = if (vote == "down" && !wasDown) thumbsDown + voter else thumbsDown + return soundsLocalDataSource.updateSound( + existing.copy(thumbsUp = updatedThumbsUp, thumbsDown = updatedThumbsDown) + ) } } diff --git a/src/main/kotlin/es/wokis/di/DataSourceModule.kt b/src/main/kotlin/es/wokis/di/DataSourceModule.kt index 9a1de12..2b2eae1 100644 --- a/src/main/kotlin/es/wokis/di/DataSourceModule.kt +++ b/src/main/kotlin/es/wokis/di/DataSourceModule.kt @@ -6,6 +6,8 @@ import es.wokis.data.datasource.local.radio.RadioLocalDataSource import es.wokis.data.datasource.local.radio.RadioLocalDataSourceImpl import es.wokis.data.datasource.local.recover.RecoverLocalDataSource import es.wokis.data.datasource.local.recover.RecoverLocalDataSourceImpl +import es.wokis.data.datasource.local.sound.SoundsLocalDataSource +import es.wokis.data.datasource.local.sound.SoundsLocalDataSourceImpl import es.wokis.data.datasource.local.stats.StatsLocalDataSource import es.wokis.data.datasource.local.stats.StatsLocalDataSourceImpl import es.wokis.data.datasource.local.user.UserLocalDataSource @@ -14,6 +16,7 @@ import es.wokis.data.datasource.local.verify.VerifyLocalDataSource import es.wokis.data.datasource.local.verify.VerifyLocalDataSourceImpl import es.wokis.data.datasource.remote.radio.RadioRemoteDataSource import es.wokis.data.datasource.remote.radio.RadioRemoteDataSourceImpl +import es.wokis.data.dbo.SoundDBO import es.wokis.data.dbo.radio.RadioDBO import es.wokis.data.dbo.recover.RecoverDBO import es.wokis.data.dbo.stat.FullStatDBO @@ -47,6 +50,8 @@ val localDataSourceModule = module { single { RecoverLocalDataSourceImpl(get(named("recoverCollection"))) } single { StatsLocalDataSourceImpl(get(named("statsCollection"))) } single { RadioLocalDataSourceImpl(get(named("radioCollection"))) } + single(named("soundsCollection")) { getSoundsCollection(get()) as MongoCollection } + single { SoundsLocalDataSourceImpl(get(named("soundsCollection"))) } } val remoteDataSourceModule = module { @@ -90,3 +95,5 @@ private fun getRecoverCollection(database: AppDataBase) = database.recoverCollec private fun getStatsCollection(database: AppDataBase) = database.statsCollection private fun getRadioCollection(database: AppDataBase) = database.radioCollection + +private fun getSoundsCollection(database: AppDataBase) = database.soundsCollection diff --git a/src/main/kotlin/es/wokis/di/RepositoryModule.kt b/src/main/kotlin/es/wokis/di/RepositoryModule.kt index ce66f05..5d13c48 100644 --- a/src/main/kotlin/es/wokis/di/RepositoryModule.kt +++ b/src/main/kotlin/es/wokis/di/RepositoryModule.kt @@ -6,6 +6,7 @@ import es.wokis.data.repository.radio.RadioRepository import es.wokis.data.repository.radio.RadioRepositoryImpl import es.wokis.data.repository.recover.RecoverRepository import es.wokis.data.repository.recover.RecoverRepositoryImpl +import es.wokis.data.datasource.local.sound.SoundsLocalDataSource import es.wokis.data.repository.sound.SoundRepository import es.wokis.data.repository.sound.SoundRepositoryImpl import es.wokis.data.repository.user.UserRepository @@ -20,7 +21,7 @@ val repositoryModule = module { single { RecoverRepositoryImpl(get(), get(), get()) } single { StatsRepositoryImpl(get()) } single { - SoundRepositoryImpl() + SoundRepositoryImpl(soundsLocalDataSource = get()) } single { RadioRepositoryImpl( diff --git a/src/main/kotlin/es/wokis/routing/SoundRouting.kt b/src/main/kotlin/es/wokis/routing/SoundRouting.kt index 1db4dbe..d248118 100644 --- a/src/main/kotlin/es/wokis/routing/SoundRouting.kt +++ b/src/main/kotlin/es/wokis/routing/SoundRouting.kt @@ -1,6 +1,11 @@ package es.wokis.routing +import es.wokis.data.dto.sound.UpdateSoundRequestDTO +import es.wokis.data.dto.sound.VoteRequestDTO +import es.wokis.data.exception.SoundNotFoundException +import es.wokis.data.mapper.sound.toDTO import es.wokis.data.repository.sound.SoundRepository +import es.wokis.services.SoundFileService import es.wokis.utils.getAllParts import es.wokis.utils.user import io.ktor.http.* @@ -15,77 +20,201 @@ fun Routing.setUpSoundRouting() { val repository by inject() authenticate { get("/sounds") { + val page = call.request.queryParameters["page"] + ?.toIntOrNull() + ?.coerceAtLeast(1) ?: 1 + val limit = call.request.queryParameters["limit"] + ?.toIntOrNull() + ?.coerceIn(1, 100) ?: 20 + val status = call.request.queryParameters["status"] + ?.takeIf { it.isNotBlank() } + val sounds = repository.getSounds(page, limit, status) + val total = repository.getSoundsCount(status) + call.respond( + HttpStatusCode.OK, + mapOf( + "data" to sounds.toDTO(), + "page" to page, + "limit" to limit, + "total" to total + ) + ) + } + get("/user/sounds") { + val user = call.user ?: run { + call.respond(HttpStatusCode.Unauthorized) + return@get + } + val page = call.request.queryParameters["page"] + ?.toIntOrNull() + ?.coerceAtLeast(1) ?: 1 + val limit = call.request.queryParameters["limit"] + ?.toIntOrNull() + ?.coerceIn(1, 100) ?: 20 + val userId = user.id ?: run { + call.respond(HttpStatusCode.Unauthorized) + return@get + } + val sounds = repository.getUserSounds(userId, page, limit) + val total = repository.getUserSoundsCount(userId) + call.respond( + HttpStatusCode.OK, + mapOf( + "data" to sounds.toDTO(), + "page" to page, + "limit" to limit, + "total" to total + ) + ) } route("/sound") { post { + val user = call.user ?: run { + call.respond(HttpStatusCode.Unauthorized) + return@post + } val multipartData = call.receiveMultipart() - val callUser = call.user - callUser?.let { user -> - val sounds = multipartData.getAllParts() - .filterIsInstance() - .filter { - it.contentType.toString().startsWith("audio") - } - repository.addRawSounds(sounds, user) + val parts = multipartData.getAllParts() + val title = parts + .filterIsInstance() + .firstOrNull { it.name == "title" } + ?.value ?: run { + call.respond(HttpStatusCode.BadRequest, "Title is required") + return@post } - } - - route("/{id}") { - get { + val description = parts + .filterIsInstance() + .firstOrNull { it.name == "description" } + ?.value ?: "" + val audioFiles = parts + .filterIsInstance() + .filter { it.contentType.toString().startsWith("audio") } + if (audioFiles.isEmpty()) { + call.respond(HttpStatusCode.BadRequest, "No audio files provided") + return@post } - post { + val created = repository.createSounds(title, description, audioFiles, user) + call.respond(HttpStatusCode.Created, created.toDTO()) + } + route("/{id}") { + get { + val displayId = call.parameters["id"] ?: run { + call.respond(HttpStatusCode.BadRequest, "Id is required") + return@get + } + val sound = repository.getSoundById(displayId) + ?: run { + call.respond(HttpStatusCode.NotFound, "Sound not found") + return@get + } + call.respond(HttpStatusCode.OK, sound.toDTO()) } put { - + val user = call.user ?: run { + call.respond(HttpStatusCode.Unauthorized) + return@put + } + val displayId = call.parameters["id"] ?: run { + call.respond(HttpStatusCode.BadRequest, "Id is required") + return@put + } + val body = call.receive() + if (body.title == null && body.description == null) { + call.respond(HttpStatusCode.BadRequest, "Nothing to update") + return@put + } + try { + repository.updateSound(displayId, body.title, body.description, user) + call.respond(HttpStatusCode.OK, "Sound updated") + } catch (e: SoundNotFoundException) { + call.respond(HttpStatusCode.NotFound, "Sound not found") + } } delete { - + val user = call.user ?: run { + call.respond(HttpStatusCode.Unauthorized) + return@delete + } + val displayId = call.parameters["id"] ?: run { + call.respond(HttpStatusCode.BadRequest, "Id is required") + return@delete + } + try { + repository.removeSound(displayId, user) + call.respond(HttpStatusCode.OK, "Sound deleted") + } catch (e: SoundNotFoundException) { + call.respond(HttpStatusCode.NotFound, "Sound not found") + } } - post("/upvote") { - val callUser = call.user - val soundId = call.parameters["id"] - callUser?.let { user -> - soundId?.let { - repository.upVoteSound(user, soundId) - } + post("/vote") { + val user = call.user ?: run { + call.respond(HttpStatusCode.Unauthorized) + return@post + } + val displayId = call.parameters["id"] ?: run { + call.respond(HttpStatusCode.BadRequest, "Id is required") + return@post + } + val body = call.receive() + if (body.vote !in listOf("up", "down")) { + call.respond(HttpStatusCode.BadRequest, "Vote must be 'up' or 'down'") + return@post + } + try { + repository.voteSound(user, displayId, body.vote) + call.respond(HttpStatusCode.OK, "Vote recorded") + } catch (e: SoundNotFoundException) { + call.respond(HttpStatusCode.NotFound, "Sound not found") } } - post("/downvote") { - val callUser = call.user - val soundId = call.parameters["id"] - callUser?.let { user -> - soundId?.let { - repository.downVoteSound(user, soundId) - } + get("/file") { + val displayId = call.parameters["id"] ?: run { + call.respond(HttpStatusCode.BadRequest, "Id is required") + return@get } + val file = SoundFileService.getSoundFile(displayId) + ?: run { + call.respond(HttpStatusCode.NotFound, "Sound file not found") + return@get + } + call.respondFile(file) } - post("/rawSound") { + post("/file") { + val user = call.user ?: run { + call.respond(HttpStatusCode.Unauthorized) + return@post + } + val displayId = call.parameters["id"] ?: run { + call.respond(HttpStatusCode.BadRequest, "Id is required") + return@post + } val multipartData = call.receiveMultipart() - val callUser = call.user - callUser?.let { user -> - val sound = multipartData.getAllParts() - .filterIsInstance() - .find { - it.contentType.toString().startsWith("audio") - } - ?: run { - call.respond(HttpStatusCode.NotFound, "No sound found") - return@let - } - repository.updateRawSound(sound, user) + val audioFile = multipartData + .getAllParts() + .filterIsInstance() + .firstOrNull { it.contentType.toString().startsWith("audio") } + ?: run { + call.respond(HttpStatusCode.BadRequest, "No audio file provided") + return@post + } + try { + repository.updateRawSound(displayId, audioFile, user) + call.respond(HttpStatusCode.OK, "Sound file updated") + } catch (e: SoundNotFoundException) { + call.respond(HttpStatusCode.NotFound, "Sound not found") } } } } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/es/wokis/services/SoundFileService.kt b/src/main/kotlin/es/wokis/services/SoundFileService.kt new file mode 100644 index 0000000..2271104 --- /dev/null +++ b/src/main/kotlin/es/wokis/services/SoundFileService.kt @@ -0,0 +1,59 @@ +package es.wokis.services + +import es.wokis.plugins.config +import es.wokis.utils.normalizeUrl +import io.ktor.http.content.* +import io.ktor.utils.io.* +import java.io.File + +object SoundFileService { + private val soundFolder by lazy { config.getString("soundFolder") } + private val baseUri by lazy { config.getString("baseUri") } + + suspend fun saveSound(displayId: String, file: PartData.FileItem): String { + val fileName = "$displayId.${getExtension(file)}" + val soundPath = File("$soundFolder/$displayId", fileName).normalize() + soundPath.parentFile.mkdirs() + val channel = file.provider.invoke() + val buffer = mutableListOf() + val temp = ByteArray(4096) + while (!channel.isClosedForRead) { + val read = channel.readAvailable(temp) + if (read > 0) { + buffer.addAll(temp.take(read)) + } else { + break + } + } + soundPath.writeBytes(buffer.toByteArray()) + return "$baseUri/sound/$displayId/file".normalizeUrl() + } + + fun getSoundFile(displayId: String): File? { + val dir = File("$soundFolder/$displayId") + if (!dir.exists()) return null + return dir.listFiles()?.firstOrNull() + } + + fun deleteSoundFiles(displayId: String): Boolean { + val dir = File("$soundFolder/$displayId") + return if (dir.exists()) { + dir.deleteRecursively() + } else { + true + } + } + + private fun getExtension(file: PartData.FileItem): String { + val contentType = file.contentType?.toString() ?: "mp3" + return when { + contentType.contains("mpeg") -> "mp3" + contentType.contains("ogg") -> "ogg" + contentType.contains("wav") -> "wav" + contentType.contains("flac") -> "flac" + contentType.contains("aac") -> "aac" + contentType.contains("webm") -> "webm" + else -> "mp3" + } + } +} diff --git a/src/main/resources/app.conf b/src/main/resources/app.conf index 2fb3bd0..982b5e9 100644 --- a/src/main/resources/app.conf +++ b/src/main/resources/app.conf @@ -16,6 +16,7 @@ db { # used to store avatars baseUri = "https://api.wokis.es/ecibot" imageFolder = "images/" +soundFolder = "sounds/" mail { user = "test@test.es" diff --git a/src/test/kotlin/es/wokis/repository/SoundRepositoryTest.kt b/src/test/kotlin/es/wokis/repository/SoundRepositoryTest.kt new file mode 100644 index 0000000..005b63d --- /dev/null +++ b/src/test/kotlin/es/wokis/repository/SoundRepositoryTest.kt @@ -0,0 +1,164 @@ +package es.wokis.repository + +import es.wokis.data.bo.sound.SoundBO +import es.wokis.data.bo.sound.SoundUserBO +import es.wokis.data.bo.user.UserBO +import es.wokis.data.datasource.local.sound.SoundsLocalDataSource +import es.wokis.data.exception.SoundNotFoundException +import es.wokis.data.repository.sound.SoundRepositoryImpl +import es.wokis.services.SoundFileService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SoundRepositoryTest { + private val dataSource = mockk() + private val repository = SoundRepositoryImpl(dataSource) + + private val baseSound = SoundBO( + displayId = "sound123", + title = "Test Sound", + soundUrl = "https://api.example.com/sound/sound123/file", + createdBy = "owner123", + createdOn = 1000L, + status = "pending" + ) + + private val alice = UserBO( + id = "user1", + username = "Alice", + email = "alice@test.com", + password = "hash" + ) + + @BeforeEach + fun setup() { + mockkObject(SoundFileService) + every { SoundFileService.deleteSoundFiles(any()) } returns true + } + + @AfterEach + fun teardown() { + unmockkAll() + } + + @Test + fun `Given no existing votes When voteSound up Then adds user to thumbsUp`() = runTest { + coEvery { dataSource.getSoundByDisplayId("sound123") } returns baseSound + coEvery { dataSource.updateSound(any()) } returns true + + val result = repository.voteSound(alice, "sound123", "up") + + assertTrue(result) + coVerify { + dataSource.updateSound(match { it.thumbsUp.any { u -> u.id == "user1" } }) + } + } + + @Test + fun `Given user already voted up When voteSound down Then moves from thumbsUp to thumbsDown`() = runTest { + val soundWithUpvote = baseSound.copy( + thumbsUp = listOf(SoundUserBO(id = "user1", displayName = "Alice")) + ) + coEvery { dataSource.getSoundByDisplayId("sound123") } returns soundWithUpvote + coEvery { dataSource.updateSound(any()) } returns true + + val result = repository.voteSound(alice, "sound123", "down") + + assertTrue(result) + coVerify { + dataSource.updateSound( + match { sound -> + sound.thumbsUp.none { it.id == "user1" } && + sound.thumbsDown.any { it.id == "user1" } + } + ) + } + } + + @Test + fun `Given same vote twice When voteSound Then toggles off`() = runTest { + val soundWithUpvote = baseSound.copy( + thumbsUp = listOf(SoundUserBO(id = "user1", displayName = "Alice")) + ) + coEvery { dataSource.getSoundByDisplayId("sound123") } returns soundWithUpvote + coEvery { dataSource.updateSound(any()) } returns true + + repository.voteSound(alice, "sound123", "up") + + coVerify { + dataSource.updateSound( + match { sound -> + sound.thumbsUp.none { it.id == "user1" } && + sound.thumbsDown.none { it.id == "user1" } + } + ) + } + } + + @Test + fun `Given non-existent sound When voteSound Then throws SoundNotFoundException`() = runTest { + coEvery { dataSource.getSoundByDisplayId("nonexistent") } returns null + + assertThrows { + repository.voteSound(alice, "nonexistent", "up") + } + } + + @Test + fun `Given user id is null When voteSound Then returns false`() = runTest { + val userWithNullId = alice.copy(id = null) + coEvery { dataSource.getSoundByDisplayId("sound123") } returns baseSound + + val result = repository.voteSound(userWithNullId, "sound123", "up") + + assertFalse(result) + } + + @Test + fun `Given existing sound When updateSound Then updates title only`() = runTest { + coEvery { dataSource.getSoundByDisplayId("sound123") } returns baseSound + coEvery { dataSource.updateSound(any()) } returns true + + val result = repository.updateSound("sound123", "New Title", null, alice) + + assertTrue(result) + coVerify { + dataSource.updateSound( + match { sound -> + sound.title == "New Title" && sound.description == baseSound.description + } + ) + } + } + + @Test + fun `Given existing sound When removeSound Then deletes sound`() = runTest { + coEvery { dataSource.getSoundByDisplayId("sound123") } returns baseSound + coEvery { dataSource.deleteSound("sound123") } returns true + + val result = repository.removeSound("sound123", alice) + + assertTrue(result) + coVerify { dataSource.deleteSound("sound123") } + } + + @Test + fun `Given non-existent sound When removeSound Then throws SoundNotFoundException`() = runTest { + coEvery { dataSource.getSoundByDisplayId("nonexistent") } returns null + + assertThrows { + repository.removeSound("nonexistent", alice) + } + } +}