From f0852cc6b411254787cce2579df17b4715208a00 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Fri, 12 Jun 2020 17:52:44 +0800 Subject: [PATCH] feat(api): series collections related to #30 --- .../V20200610172313__collections.sql | 23 ++ .../komga/domain/model/SeriesCollection.kt | 20 + .../persistence/SeriesCollectionRepository.kt | 30 ++ .../service/SeriesCollectionLifecycle.kt | 41 ++ .../komga/domain/service/SeriesLifecycle.kt | 6 +- .../jooq/SeriesCollectionDao.kt | 171 +++++++++ .../validation/NullOrNotEmpty.kt | 21 + .../rest/SeriesCollectionController.kt | 107 ++++++ .../rest/dto/CollectionCreationDto.kt | 10 + .../interfaces/rest/dto/CollectionDto.kt | 31 ++ .../rest/dto/CollectionUpdateDto.kt | 10 + .../jooq/SeriesCollectionDaoTest.kt | 219 +++++++++++ .../interfaces/rest/LibraryControllerTest.kt | 17 +- .../rest/SeriesCollectionControllerTest.kt | 362 ++++++++++++++++++ 14 files changed, 1066 insertions(+), 2 deletions(-) create mode 100644 komga/src/flyway/resources/db/migration/V20200610172313__collections.sql create mode 100644 komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesCollection.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesCollectionRepository.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDao.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrNotEmpty.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionController.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/CollectionCreationDto.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/CollectionDto.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/CollectionUpdateDto.kt create mode 100644 komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDaoTest.kt create mode 100644 komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionControllerTest.kt diff --git a/komga/src/flyway/resources/db/migration/V20200610172313__collections.sql b/komga/src/flyway/resources/db/migration/V20200610172313__collections.sql new file mode 100644 index 00000000000..b8f912d08a7 --- /dev/null +++ b/komga/src/flyway/resources/db/migration/V20200610172313__collections.sql @@ -0,0 +1,23 @@ +create table collection +( + id bigint not null, + name varchar not null, + ordered boolean not null default false, + series_count int not null, + created_date timestamp not null default now(), + last_modified_date timestamp not null default now(), + primary key (id) +); + +create table collection_series +( + collection_id bigint not null, + series_id bigint not null, + number integer not null +); + +alter table collection_series + add constraint fk_collection_series_collection_collection_id foreign key (collection_id) references collection (id); + +alter table collection_series + add constraint fk_collection_series_series_series_id foreign key (series_id) references series (id); diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesCollection.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesCollection.kt new file mode 100644 index 00000000000..dbd7632ec16 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesCollection.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.domain.model + +import java.time.LocalDateTime + +data class SeriesCollection( + val name: String, + val ordered: Boolean = false, + + val seriesIds: List = emptyList(), + + val id: Long = 0, + + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = LocalDateTime.now(), + + /** + * Indicates that the seriesIds have been filtered and is not exhaustive. + */ + val filtered: Boolean = false +) : Auditable() diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesCollectionRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesCollectionRepository.kt new file mode 100644 index 00000000000..2df24f11666 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesCollectionRepository.kt @@ -0,0 +1,30 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.SeriesCollection + +interface SeriesCollectionRepository { + fun findByIdOrNull(collectionId: Long): SeriesCollection? + fun findAll(): Collection + + /** + * Find one SeriesCollection by collectionId, + * optionally with only seriesId filtered by the provided filterOnLibraryIds. + */ + fun findByIdOrNull(collectionId: Long, filterOnLibraryIds: Collection?): SeriesCollection? + + /** + * Find all SeriesCollection with at least one Series belonging to the provided belongsToLibraryIds, + * optionally with only seriesId filtered by the provided filterOnLibraryIds. + */ + fun findAllByLibraries(belongsToLibraryIds: Collection, filterOnLibraryIds: Collection?): Collection + + fun insert(collection: SeriesCollection): SeriesCollection + fun update(collection: SeriesCollection) + + fun removeSeriesFromAll(seriesId: Long) + + fun delete(collectionId: Long) + fun deleteAll() + + fun existsByName(name: String): Boolean +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt new file mode 100644 index 00000000000..301d720706c --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt @@ -0,0 +1,41 @@ +package org.gotson.komga.domain.service + +import mu.KotlinLogging +import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class SeriesCollectionLifecycle( + private val collectionRepository: SeriesCollectionRepository +) { + + @Throws( + DuplicateNameException::class + ) + fun addCollection(collection: SeriesCollection): SeriesCollection { + logger.info { "Adding new collection: $collection" } + + if (collectionRepository.existsByName(collection.name)) + throw DuplicateNameException("Collection name already exists") + + return collectionRepository.insert(collection) + } + + fun updateCollection(toUpdate: SeriesCollection) { + val existing = collectionRepository.findByIdOrNull(toUpdate.id) + ?: throw IllegalArgumentException("Cannot update collection that does not exist") + + if (existing.name != toUpdate.name && collectionRepository.existsByName(toUpdate.name)) + throw DuplicateNameException("Collection name already exists") + + collectionRepository.update(toUpdate) + } + + fun deleteCollection(collectionId: Long) { + collectionRepository.delete(collectionId) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt index 3d77dd102d2..b4556674acc 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt @@ -11,6 +11,7 @@ import org.gotson.komga.domain.model.SeriesMetadata import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.SeriesRepository import org.springframework.stereotype.Service @@ -26,7 +27,8 @@ class SeriesLifecycle( private val mediaRepository: MediaRepository, private val bookMetadataRepository: BookMetadataRepository, private val seriesRepository: SeriesRepository, - private val seriesMetadataRepository: SeriesMetadataRepository + private val seriesMetadataRepository: SeriesMetadataRepository, + private val collectionRepository: SeriesCollectionRepository ) { fun sortBooks(series: Series) { @@ -90,6 +92,8 @@ class SeriesLifecycle( bookLifecycle.delete(it.id) } + collectionRepository.removeSeriesFromAll(seriesId) + seriesRepository.delete(seriesId) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDao.kt new file mode 100644 index 00000000000..cb6bff64b8f --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDao.kt @@ -0,0 +1,171 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.jooq.Sequences +import org.gotson.komga.jooq.Tables +import org.gotson.komga.jooq.tables.records.CollectionRecord +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.ResultQuery +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class SeriesCollectionDao( + private val dsl: DSLContext +) : SeriesCollectionRepository { + + private val c = Tables.COLLECTION + private val cs = Tables.COLLECTION_SERIES + private val s = Tables.SERIES + + private val groupFields = arrayOf(*c.fields(), *cs.fields()) + + + override fun findByIdOrNull(collectionId: Long): SeriesCollection? = + selectBase() + .where(c.ID.eq(collectionId)) + .groupBy(*groupFields) + .orderBy(cs.NUMBER.asc()) + .fetchAndMap() + .firstOrNull() + + override fun findByIdOrNull(collectionId: Long, filterOnLibraryIds: Collection?): SeriesCollection? = + selectBase() + .where(c.ID.eq(collectionId)) + .also { step -> + filterOnLibraryIds?.let { step.and(s.LIBRARY_ID.`in`(it)) } + } + .groupBy(*groupFields) + .orderBy(cs.NUMBER.asc()) + .fetchAndMap() + .firstOrNull() + + override fun findAll(): Collection = + selectBase() + .groupBy(*groupFields) + .orderBy(cs.NUMBER.asc()) + .fetchAndMap() + + override fun findAllByLibraries(belongsToLibraryIds: Collection, filterOnLibraryIds: Collection?): Collection { + val ids = dsl.select(c.ID) + .from(c) + .leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID)) + .leftJoin(s).on(cs.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(belongsToLibraryIds)) + .fetch(0, Long::class.java) + + return selectBase() + .where(c.ID.`in`(ids)) + .also { step -> + filterOnLibraryIds?.let { step.and(s.LIBRARY_ID.`in`(it)) } + } + .groupBy(*groupFields) + .orderBy(cs.NUMBER.asc()) + .fetchAndMap() + } + + private fun selectBase() = + dsl.select(*groupFields) + .from(c) + .leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID)) + .leftJoin(s).on(cs.SERIES_ID.eq(s.ID)) + + private fun ResultQuery.fetchAndMap() = + fetchGroups({ it.into(c) }, { it.into(cs) }) + .map { (cr, csr) -> + val seriesIds = csr.map { it.seriesId } + cr.toDomain(seriesIds) + } + + override fun insert(collection: SeriesCollection): SeriesCollection { + val id = dsl.nextval(Sequences.HIBERNATE_SEQUENCE) + val insert = collection.copy(id = id) + + dsl.insertInto(c) + .set(c.ID, insert.id) + .set(c.NAME, insert.name) + .set(c.ORDERED, insert.ordered) + .set(c.SERIES_COUNT, collection.seriesIds.size) + .execute() + + insertSeries(insert) + + return findByIdOrNull(id)!! + } + + + private fun insertSeries(collection: SeriesCollection) { + collection.seriesIds.forEachIndexed { index, id -> + dsl.insertInto(cs) + .set(cs.COLLECTION_ID, collection.id) + .set(cs.SERIES_ID, id) + .set(cs.NUMBER, index) + .execute() + } + } + + override fun update(collection: SeriesCollection) { + dsl.transaction { config -> + with(config.dsl()) + { + update(c) + .set(c.NAME, collection.name) + .set(c.ORDERED, collection.ordered) + .set(c.SERIES_COUNT, collection.seriesIds.size) + .set(c.LAST_MODIFIED_DATE, LocalDateTime.now()) + .where(c.ID.eq(collection.id)) + .execute() + + deleteFrom(cs).where(cs.COLLECTION_ID.eq(collection.id)).execute() + + insertSeries(collection) + } + } + } + + override fun removeSeriesFromAll(seriesId: Long) { + dsl.deleteFrom(cs) + .where(cs.SERIES_ID.eq(seriesId)) + .execute() + } + + override fun delete(collectionId: Long) { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(cs).where(cs.COLLECTION_ID.eq(collectionId)).execute() + deleteFrom(c).where(c.ID.eq(collectionId)).execute() + } + } + } + + override fun deleteAll() { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(cs).execute() + deleteFrom(c).execute() + } + } + } + + override fun existsByName(name: String): Boolean = + dsl.fetchExists( + dsl.selectFrom(c) + .where(c.NAME.equalIgnoreCase(name)) + ) + + + private fun CollectionRecord.toDomain(seriesIds: List) = + SeriesCollection( + name = name, + ordered = ordered, + seriesIds = seriesIds, + id = id, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate, + filtered = seriesCount != seriesIds.size + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrNotEmpty.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrNotEmpty.kt new file mode 100644 index 00000000000..8adee7a86ba --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrNotEmpty.kt @@ -0,0 +1,21 @@ +package org.gotson.komga.infrastructure.validation + +import org.hibernate.validator.constraints.CompositionType +import org.hibernate.validator.constraints.ConstraintComposition +import javax.validation.Constraint +import javax.validation.constraints.NotEmpty +import javax.validation.constraints.Null +import kotlin.reflect.KClass + + +@ConstraintComposition(CompositionType.OR) +@Constraint(validatedBy = []) +@Null +@NotEmpty +@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER) +@Retention(AnnotationRetention.RUNTIME) +annotation class NullOrNotEmpty( + val message: String = "Must be null or not empty", + val groups: Array> = [], + val payload: Array> = [] +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionController.kt new file mode 100644 index 00000000000..e9870afe033 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionController.kt @@ -0,0 +1,107 @@ +package org.gotson.komga.interfaces.rest + +import mu.KotlinLogging +import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.ROLE_ADMIN +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.service.SeriesCollectionLifecycle +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.rest.dto.CollectionCreationDto +import org.gotson.komga.interfaces.rest.dto.CollectionDto +import org.gotson.komga.interfaces.rest.dto.CollectionUpdateDto +import org.gotson.komga.interfaces.rest.dto.toDto +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import javax.validation.Valid + +private val logger = KotlinLogging.logger {} + +@RestController +@RequestMapping("api/v1/collections", produces = [MediaType.APPLICATION_JSON_VALUE]) +class SeriesCollectionController( + private val collectionRepository: SeriesCollectionRepository, + private val collectionLifecycle: SeriesCollectionLifecycle +) { + + @GetMapping + fun getAll( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: List? + ): List = + if (principal.user.sharedAllLibraries) { + collectionRepository.findAll() + } else { + collectionRepository.findAllByLibraries(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds) + }.sortedBy { it.name }.map { it.toDto() } + + @GetMapping("{id}") + fun getOne( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: Long + ): CollectionDto = + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null)) + ?.toDto() + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @PostMapping + @PreAuthorize("hasRole('$ROLE_ADMIN')") + fun addOne( + @Valid @RequestBody collection: CollectionCreationDto + ): CollectionDto = + try { + collectionLifecycle.addCollection(SeriesCollection( + name = collection.name, + ordered = collection.ordered, + seriesIds = collection.seriesIds + )).toDto() + } catch (e: DuplicateNameException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + + @PatchMapping("{id}") + @PreAuthorize("hasRole('$ROLE_ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun updateOne( + @PathVariable id: Long, + @Valid @RequestBody collection: CollectionUpdateDto + ) { + collectionRepository.findByIdOrNull(id)?.let { existing -> + val updated = existing.copy( + name = collection.name ?: existing.name, + ordered = collection.ordered ?: existing.ordered, + seriesIds = collection.seriesIds ?: existing.seriesIds + ) + try { + collectionLifecycle.updateCollection(updated) + } catch (e: DuplicateNameException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @DeleteMapping("{id}") + @PreAuthorize("hasRole('$ROLE_ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deleteOne( + @PathVariable id: Long + ) { + collectionRepository.findByIdOrNull(id)?.let { + collectionLifecycle.deleteCollection(it.id) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } +} + diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/CollectionCreationDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/CollectionCreationDto.kt new file mode 100644 index 00000000000..fd012ebb5aa --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/CollectionCreationDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.rest.dto + +import javax.validation.constraints.NotBlank +import javax.validation.constraints.NotEmpty + +data class CollectionCreationDto( + @get:NotBlank val name: String, + val ordered: Boolean, + @get:NotEmpty val seriesIds: List +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/CollectionDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/CollectionDto.kt new file mode 100644 index 00000000000..e25838b1212 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/CollectionDto.kt @@ -0,0 +1,31 @@ +package org.gotson.komga.interfaces.rest.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import org.gotson.komga.domain.model.SeriesCollection +import java.time.LocalDateTime + +data class CollectionDto( + val id: Long, + val name: String, + val ordered: Boolean, + + val seriesIds: List, + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + val createdDate: LocalDateTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + val lastModifiedDate: LocalDateTime, + + val filtered: Boolean +) + +fun SeriesCollection.toDto() = + CollectionDto( + id = id, + name = name, + ordered = ordered, + seriesIds = seriesIds, + createdDate = createdDate.toUTC(), + lastModifiedDate = lastModifiedDate.toUTC(), + filtered = filtered + ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/CollectionUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/CollectionUpdateDto.kt new file mode 100644 index 00000000000..eadc0878a56 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/CollectionUpdateDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.rest.dto + +import org.gotson.komga.infrastructure.validation.NullOrNotBlank +import org.gotson.komga.infrastructure.validation.NullOrNotEmpty + +data class CollectionUpdateDto( + @get:NullOrNotBlank val name: String?, + val ordered: Boolean?, + @get:NullOrNotEmpty val seriesIds: List? +) diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDaoTest.kt new file mode 100644 index 00000000000..62be37cbd12 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDaoTest.kt @@ -0,0 +1,219 @@ +package org.gotson.komga.infrastructure.jooq + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.time.LocalDateTime + +@ExtendWith(SpringExtension::class) +@SpringBootTest +@AutoConfigureTestDatabase +class SeriesCollectionDaoTest( + @Autowired private val collectionDao: SeriesCollectionDao, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository +) { + + private var library = makeLibrary() + private var library2 = makeLibrary("library2") + + @BeforeAll + fun setup() { + library = libraryRepository.insert(library) + library2 = libraryRepository.insert(library2) + } + + @AfterEach + fun deleteSeries() { + collectionDao.deleteAll() + seriesRepository.deleteAll() + } + + @AfterAll + fun tearDown() { + libraryRepository.deleteAll() + } + + @Test + fun `given collection with series when inserting then it is persisted`() { + // given + val series = (1..10) + .map { makeSeries("Series $it", library.id) } + .map { seriesRepository.insert(it) } + + val collection = SeriesCollection( + name = "MyCollection", + seriesIds = series.map { it.id } + ) + + // when + val now = LocalDateTime.now() + val created = collectionDao.insert(collection) + + // then + assertThat(created.name).isEqualTo(collection.name) + assertThat(created.ordered).isEqualTo(collection.ordered) + assertThat(created.createdDate) + .isEqualTo(created.lastModifiedDate) + .isAfterOrEqualTo(now) + assertThat(created.seriesIds).containsExactlyElementsOf(series.map { it.id }) + } + + @Test + fun `given collection with updated series when updating then it is persisted`() { + // given + val series = (1..10) + .map { makeSeries("Series $it", library.id) } + .map { seriesRepository.insert(it) } + + val collection = SeriesCollection( + name = "MyCollection", + seriesIds = series.map { it.id } + ) + + val created = collectionDao.insert(collection) + + // when + val updatedCollection = created.copy( + name = "UpdatedCollection", + ordered = true, + seriesIds = created.seriesIds.take(5) + ) + + val now = LocalDateTime.now() + collectionDao.update(updatedCollection) + val updated = collectionDao.findByIdOrNull(updatedCollection.id)!! + + // then + assertThat(updated.name).isEqualTo(updatedCollection.name) + assertThat(updated.ordered).isEqualTo(updatedCollection.ordered) + assertThat(updated.createdDate).isNotEqualTo(updated.lastModifiedDate) + assertThat(updated.lastModifiedDate).isAfterOrEqualTo(now) + assertThat(updated.seriesIds) + .hasSize(5) + .containsExactlyElementsOf(series.map { it.id }.take(5)) + } + + @Test + fun `given collections with series when removing one series from all then it is removed from all`() { + // given + val series = (1..10) + .map { makeSeries("Series $it", library.id) } + .map { seriesRepository.insert(it) } + + val collection1 = collectionDao.insert( + SeriesCollection( + name = "MyCollection", + seriesIds = series.map { it.id } + ) + ) + + val collection2 = collectionDao.insert( + SeriesCollection( + name = "MyCollection2", + seriesIds = series.map { it.id }.take(5) + ) + ) + + // when + collectionDao.removeSeriesFromAll(series.first().id) + + // then + val col1 = collectionDao.findByIdOrNull(collection1.id)!! + assertThat(col1.seriesIds) + .hasSize(9) + .doesNotContain(series.first().id) + + val col2 = collectionDao.findByIdOrNull(collection2.id)!! + assertThat(col2.seriesIds) + .hasSize(4) + .doesNotContain(series.first().id) + } + + @Test + fun `given collections spanning different libraries when finding by library then only matching collections are returned`() { + // given + val seriesLibrary1 = seriesRepository.insert(makeSeries("Series1", library.id)) + val seriesLibrary2 = seriesRepository.insert(makeSeries("Series2", library2.id)) + + collectionDao.insert(SeriesCollection( + name = "collectionLibrary1", + seriesIds = listOf(seriesLibrary1.id) + )) + + collectionDao.insert(SeriesCollection( + name = "collectionLibrary2", + seriesIds = listOf(seriesLibrary2.id) + )) + + collectionDao.insert(SeriesCollection( + name = "collectionLibraryBoth", + seriesIds = listOf(seriesLibrary1.id, seriesLibrary2.id) + )) + + // when + val foundLibrary1Filtered = collectionDao.findAllByLibraries(listOf(library.id), listOf(library.id)) + val foundLibrary1Unfiltered = collectionDao.findAllByLibraries(listOf(library.id), null) + val foundLibrary2Filtered = collectionDao.findAllByLibraries(listOf(library2.id), listOf(library2.id)) + val foundLibrary2Unfiltered = collectionDao.findAllByLibraries(listOf(library2.id), null) + val foundBothUnfiltered = collectionDao.findAllByLibraries(listOf(library.id, library2.id), null) + + // then + assertThat(foundLibrary1Filtered).hasSize(2) + assertThat(foundLibrary1Filtered.map { it.name }).containsExactly("collectionLibrary1", "collectionLibraryBoth") + with(foundLibrary1Filtered.find { it.name == "collectionLibraryBoth" }!!) { + assertThat(seriesIds) + .hasSize(1) + .containsExactly(seriesLibrary1.id) + assertThat(filtered).isTrue() + } + + assertThat(foundLibrary1Unfiltered).hasSize(2) + assertThat(foundLibrary1Unfiltered.map { it.name }).containsExactly("collectionLibrary1", "collectionLibraryBoth") + with(foundLibrary1Unfiltered.find { it.name == "collectionLibraryBoth" }!!) { + assertThat(seriesIds) + .hasSize(2) + .containsExactly(seriesLibrary1.id, seriesLibrary2.id) + assertThat(filtered).isFalse() + } + + assertThat(foundLibrary2Filtered).hasSize(2) + assertThat(foundLibrary2Filtered.map { it.name }).containsExactly("collectionLibrary2", "collectionLibraryBoth") + with(foundLibrary2Filtered.find { it.name == "collectionLibraryBoth" }!!) { + assertThat(seriesIds) + .hasSize(1) + .containsExactly(seriesLibrary2.id) + assertThat(filtered).isTrue() + } + + assertThat(foundLibrary2Unfiltered).hasSize(2) + assertThat(foundLibrary2Unfiltered.map { it.name }).containsExactly("collectionLibrary2", "collectionLibraryBoth") + with(foundLibrary2Unfiltered.find { it.name == "collectionLibraryBoth" }!!) { + assertThat(seriesIds) + .hasSize(2) + .containsExactly(seriesLibrary1.id, seriesLibrary2.id) + assertThat(filtered).isFalse() + } + + assertThat(foundBothUnfiltered).hasSize(3) + assertThat(foundBothUnfiltered.map { it.name }).containsExactly("collectionLibrary1", "collectionLibrary2", "collectionLibraryBoth") + with(foundBothUnfiltered.find { it.name == "collectionLibraryBoth" }!!) { + assertThat(seriesIds) + .hasSize(2) + .containsExactly(seriesLibrary1.id, seriesLibrary2.id) + assertThat(filtered).isFalse() + } + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/LibraryControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/LibraryControllerTest.kt index 42e88250179..6be276361f8 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/LibraryControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/LibraryControllerTest.kt @@ -10,30 +10,45 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate import org.springframework.security.test.context.support.WithAnonymousUser import org.springframework.security.test.context.support.WithMockUser import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.post +import javax.sql.DataSource @ExtendWith(SpringExtension::class) @SpringBootTest @AutoConfigureMockMvc(printOnlyOnFailure = false) +@AutoConfigureTestDatabase class LibraryControllerTest( @Autowired private val mockMvc: MockMvc, @Autowired private val libraryRepository: LibraryRepository ) { + + lateinit var jdbcTemplate: JdbcTemplate + + @Autowired + fun setDataSource(dataSource: DataSource) { + jdbcTemplate = JdbcTemplate(dataSource) + } + + private val route = "/api/v1/libraries" private var library = makeLibrary(url = "file:/library1") @BeforeAll fun `setup library`() { - library = libraryRepository.insert(library) + jdbcTemplate.execute("ALTER SEQUENCE hibernate_sequence RESTART WITH 1") + + library = libraryRepository.insert(library) // id = 1 } @AfterAll diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionControllerTest.kt new file mode 100644 index 00000000000..81ff6fee0f4 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionControllerTest.kt @@ -0,0 +1,362 @@ +package org.gotson.komga.interfaces.rest + +import org.gotson.komga.domain.model.ROLE_ADMIN +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.SeriesCollectionLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.patch +import org.springframework.test.web.servlet.post +import javax.sql.DataSource + +@ExtendWith(SpringExtension::class) +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +@AutoConfigureTestDatabase +class SeriesCollectionControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val collectionLifecycle: SeriesCollectionLifecycle, + @Autowired private val collectionRepository: SeriesCollectionRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle + +) { + + lateinit var jdbcTemplate: JdbcTemplate + + @Autowired + fun setDataSource(dataSource: DataSource) { + jdbcTemplate = JdbcTemplate(dataSource) + } + + private var library1 = makeLibrary("Library1") + private var library2 = makeLibrary("Library2") + private lateinit var seriesLibrary1: List + private lateinit var seriesLibrary2: List + private lateinit var colLib1: SeriesCollection + private lateinit var colLib2: SeriesCollection + private lateinit var colLibBoth: SeriesCollection + + @BeforeAll + fun setup() { + jdbcTemplate.execute("ALTER SEQUENCE hibernate_sequence RESTART WITH 1") + + library1 = libraryRepository.insert(library1) // id = 1 + library2 = libraryRepository.insert(library2) // id = 2 + + seriesLibrary1 = (1..5) + .map { makeSeries("Series_$it", library1.id) } + .map { seriesLifecycle.createSeries(it) } + + seriesLibrary2 = (6..10) + .map { makeSeries("Series_$it", library2.id) } + .map { seriesLifecycle.createSeries(it) } + } + + @AfterAll + fun teardown() { + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @AfterEach + fun clear() { + collectionRepository.deleteAll() + } + + private fun makeCollections() { + colLib1 = collectionLifecycle.addCollection(SeriesCollection( + name = "Lib1", + seriesIds = seriesLibrary1.map { it.id } + )) + + colLib2 = collectionLifecycle.addCollection(SeriesCollection( + name = "Lib2", + seriesIds = seriesLibrary2.map { it.id } + )) + + colLibBoth = collectionLifecycle.addCollection(SeriesCollection( + name = "Lib1+2", + seriesIds = (seriesLibrary1 + seriesLibrary2).map { it.id } + )) + } + + @Nested + inner class GetAndFilter { + @Test + @WithMockCustomUser + fun `given user with access to all libraries when getting collections then get all collections`() { + makeCollections() + + mockMvc.get("/api/v1/collections") + .andExpect { + status { isOk } + jsonPath("$.length()") { value(3) } + jsonPath("$[?(@.name == 'Lib1')].filtered") { value(false) } + jsonPath("$[?(@.name == 'Lib2')].filtered") { value(false) } + jsonPath("$[?(@.name == 'Lib1+2')].filtered") { value(false) } + } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [1]) + fun `given user with access to a single library when getting collections then only get collections from this library`() { + makeCollections() + + mockMvc.get("/api/v1/collections") + .andExpect { + status { isOk } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == 'Lib1')].filtered") { value(false) } + jsonPath("$[?(@.name == 'Lib1+2')].filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser + fun `given user with access to all libraries when getting single collection then it is not filtered`() { + makeCollections() + + mockMvc.get("/api/v1/collections/${colLibBoth.id}") + .andExpect { + status { isOk } + jsonPath("$.seriesIds.length()") { value(10) } + jsonPath("$.filtered") { value(false) } + } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [1]) + fun `given user with access to a single library when getting single collection with items from 2 libraries then it is filtered`() { + makeCollections() + + mockMvc.get("/api/v1/collections/${colLibBoth.id}") + .andExpect { + status { isOk } + jsonPath("$.seriesIds.length()") { value(5) } + jsonPath("$.filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [1]) + fun `given user with access to a single library when getting single collection from another library then return not found`() { + makeCollections() + + mockMvc.get("/api/v1/collections/${colLib2.id}") + .andExpect { + status { isNotFound } + } + } + } + + @Nested + inner class Creation { + @Test + @WithMockCustomUser + fun `given non-admin user when creating collection then return forbidden`() { + val jsonString = """ + {"name":"collection","ordered":false,"seriesIds":[3]} + """.trimIndent() + + mockMvc.post("/api/v1/collections") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isForbidden } + } + } + + @Test + @WithMockCustomUser(roles = [ROLE_ADMIN]) + fun `given admin user when creating collection then return ok`() { + val jsonString = """ + {"name":"collection","ordered":false,"seriesIds":[${seriesLibrary1.first().id}]} + """.trimIndent() + + mockMvc.post("/api/v1/collections") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isOk } + jsonPath("$.seriesIds.length()") { value(1) } + jsonPath("$.name") { value("collection") } + jsonPath("$.ordered") { value(false) } + } + } + + @Test + @WithMockCustomUser(roles = [ROLE_ADMIN]) + fun `given existing collections when creating collection with existing name then return bad request`() { + makeCollections() + + val jsonString = """ + {"name":"Lib1","ordered":false,"seriesIds":[${seriesLibrary1.first().id}]} + """.trimIndent() + + mockMvc.post("/api/v1/collections") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest } + } + } + } + + @Nested + inner class Update { + @Test + @WithMockCustomUser + fun `given non-admin user when updating collection then return forbidden`() { + val jsonString = """ + {"name":"collection","ordered":false,"seriesIds":[3]} + """.trimIndent() + + mockMvc.patch("/api/v1/collections/5") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isForbidden } + } + } + + @Test + @WithMockCustomUser(roles = [ROLE_ADMIN]) + fun `given admin user when updating collection then return no content`() { + makeCollections() + + val jsonString = """ + {"name":"updated","ordered":true,"seriesIds":[${seriesLibrary1.first().id}]} + """.trimIndent() + + mockMvc.patch("/api/v1/collections/${colLib1.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent } + } + + mockMvc.get("/api/v1/collections/${colLib1.id}") + .andExpect { + status { isOk } + jsonPath("$.name") { value("updated") } + jsonPath("$.ordered") { value(true) } + jsonPath("$.seriesIds.length()") { value(1) } + jsonPath("$.filtered") { value(false) } + } + } + + @Test + @WithMockCustomUser(roles = [ROLE_ADMIN]) + fun `given existing collections when updating collection with existing name then return bad request`() { + makeCollections() + + val jsonString = """{"name":"Lib2"}""" + + mockMvc.patch("/api/v1/collections/${colLib1.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest } + } + } + + @Test + @WithMockCustomUser(roles = [ROLE_ADMIN]) + fun `given admin user when updating collection then only updated fields are modified`() { + makeCollections() + + mockMvc.patch("/api/v1/collections/${colLib1.id}") { + contentType = MediaType.APPLICATION_JSON + content = """{"ordered":true}""" + } + + mockMvc.get("/api/v1/collections/${colLib1.id}") + .andExpect { + status { isOk } + jsonPath("$.name") { value("Lib1") } + jsonPath("$.ordered") { value(true) } + jsonPath("$.seriesIds.length()") { value(5) } + } + + + mockMvc.patch("/api/v1/collections/${colLib2.id}") { + contentType = MediaType.APPLICATION_JSON + content = """{"name":"newName"}""" + } + + mockMvc.get("/api/v1/collections/${colLib2.id}") + .andExpect { + status { isOk } + jsonPath("$.name") { value("newName") } + jsonPath("$.ordered") { value(false) } + jsonPath("$.seriesIds.length()") { value(5) } + } + + + mockMvc.patch("/api/v1/collections/${colLibBoth.id}") { + contentType = MediaType.APPLICATION_JSON + content = """{"seriesIds":[${seriesLibrary1.first().id}]}""" + } + + mockMvc.get("/api/v1/collections/${colLibBoth.id}") + .andExpect { + status { isOk } + jsonPath("$.name") { value("Lib1+2") } + jsonPath("$.ordered") { value(false) } + jsonPath("$.seriesIds.length()") { value(1) } + } + } + } + + @Nested + inner class Delete { + @Test + @WithMockCustomUser + fun `given non-admin user when deleting collection then return forbidden`() { + mockMvc.delete("/api/v1/collections/5") + .andExpect { + status { isForbidden } + } + } + + @Test + @WithMockCustomUser(roles = [ROLE_ADMIN]) + fun `given admin user when deleting collection then return no content`() { + makeCollections() + + mockMvc.delete("/api/v1/collections/${colLib1.id}") + .andExpect { + status { isNoContent } + } + + mockMvc.get("/api/v1/collections/${colLib1.id}") + .andExpect { + status { isNotFound } + } + } + } +}