mirror of
https://github.com/gotson/komga.git
synced 2025-12-22 00:13:30 +01:00
parent
1650aec75b
commit
c2f940336a
19 changed files with 1268 additions and 3 deletions
|
|
@ -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);
|
||||
|
|
@ -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<Long> = 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()
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package org.gotson.komga.domain.persistence
|
||||
|
||||
import org.gotson.komga.domain.model.SeriesCollection
|
||||
|
||||
interface SeriesCollectionRepository {
|
||||
fun findByIdOrNull(collectionId: Long): SeriesCollection?
|
||||
fun findAll(): Collection<SeriesCollection>
|
||||
|
||||
/**
|
||||
* Find one SeriesCollection by collectionId,
|
||||
* optionally with only seriesId filtered by the provided filterOnLibraryIds.
|
||||
*/
|
||||
fun findByIdOrNull(collectionId: Long, filterOnLibraryIds: Collection<Long>?): 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<Long>, filterOnLibraryIds: Collection<Long>?): Collection<SeriesCollection>
|
||||
|
||||
/**
|
||||
* Find all SeriesCollection that contains the provided containsSeriesId,
|
||||
* optionally with only seriesId filtered by the provided filterOnLibraryIds.
|
||||
*/
|
||||
fun findAllBySeries(containsSeriesId: Long, filterOnLibraryIds: Collection<Long>?): Collection<SeriesCollection>
|
||||
|
||||
fun insert(collection: SeriesCollection): SeriesCollection
|
||||
fun update(collection: SeriesCollection)
|
||||
|
||||
fun removeSeriesFromAll(seriesId: Long)
|
||||
|
||||
fun delete(collectionId: Long)
|
||||
fun deleteAll()
|
||||
|
||||
fun existsByName(name: String): Boolean
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
package org.gotson.komga.infrastructure.image
|
||||
|
||||
import net.coobird.thumbnailator.Thumbnails
|
||||
import org.springframework.stereotype.Service
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayOutputStream
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
|
||||
@Service
|
||||
class MosaicGenerator {
|
||||
|
||||
fun createMosaic(images: List<ByteArray>): ByteArray {
|
||||
val thumbs = images.map { resize(it, 150) }
|
||||
|
||||
return ByteArrayOutputStream().use { baos ->
|
||||
val mosaic = BufferedImage(212, 300, BufferedImage.TYPE_INT_RGB)
|
||||
mosaic.createGraphics().apply {
|
||||
listOf(
|
||||
0 to 0,
|
||||
106 to 0,
|
||||
0 to 150,
|
||||
106 to 150
|
||||
).forEachIndexed { index, (x, y) ->
|
||||
thumbs.getOrNull(index)?.let { drawImage(it, x, y, null) }
|
||||
}
|
||||
}
|
||||
|
||||
ImageIO.write(mosaic, "jpeg", baos)
|
||||
|
||||
baos.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resize(imageBytes: ByteArray, size: Int) =
|
||||
Thumbnails.of(imageBytes.inputStream())
|
||||
.size(size, size)
|
||||
.outputFormat("jpeg")
|
||||
.asBufferedImage()
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
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<Long>?): 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<SeriesCollection> =
|
||||
selectBase()
|
||||
.groupBy(*groupFields)
|
||||
.orderBy(cs.NUMBER.asc())
|
||||
.fetchAndMap()
|
||||
|
||||
override fun findAllByLibraries(belongsToLibraryIds: Collection<Long>, filterOnLibraryIds: Collection<Long>?): Collection<SeriesCollection> {
|
||||
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()
|
||||
}
|
||||
|
||||
override fun findAllBySeries(containsSeriesId: Long, filterOnLibraryIds: Collection<Long>?): Collection<SeriesCollection> {
|
||||
val ids = dsl.select(c.ID)
|
||||
.from(c)
|
||||
.leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID))
|
||||
.where(cs.SERIES_ID.eq(containsSeriesId))
|
||||
.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<Record>.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<Long>) =
|
||||
SeriesCollection(
|
||||
name = name,
|
||||
ordered = ordered,
|
||||
seriesIds = seriesIds,
|
||||
id = id,
|
||||
createdDate = createdDate,
|
||||
lastModifiedDate = lastModifiedDate,
|
||||
filtered = seriesCount != seriesIds.size
|
||||
)
|
||||
}
|
||||
|
|
@ -82,6 +82,12 @@ class SeriesDtoDao(
|
|||
.fetchAndMap()
|
||||
.firstOrNull()
|
||||
|
||||
override fun findByIds(seriesIds: Collection<Long>, userId: Long): List<SeriesDto> =
|
||||
selectBase(userId)
|
||||
.where(s.ID.`in`(seriesIds))
|
||||
.groupBy(*groupFields)
|
||||
.fetchAndMap()
|
||||
|
||||
|
||||
private fun findAll(conditions: Condition, having: Condition, userId: Long, pageable: Pageable): Page<SeriesDto> {
|
||||
val count = dsl.select(s.ID)
|
||||
|
|
|
|||
|
|
@ -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<KClass<out Any>> = [],
|
||||
val payload: Array<KClass<out Any>> = []
|
||||
)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package org.gotson.komga.infrastructure.validation
|
||||
|
||||
import javax.validation.Constraint
|
||||
import javax.validation.ConstraintValidator
|
||||
import javax.validation.ConstraintValidatorContext
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
|
||||
@Constraint(validatedBy = [UniqueValidator::class])
|
||||
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Unique(
|
||||
val message: String = "Must contain unique values",
|
||||
val groups: Array<KClass<out Any>> = [],
|
||||
val payload: Array<KClass<out Any>> = []
|
||||
)
|
||||
|
||||
class UniqueValidator : ConstraintValidator<Unique, List<*>> {
|
||||
override fun isValid(value: List<*>?, context: ConstraintValidatorContext?): Boolean {
|
||||
if (value == null) return true
|
||||
return value.distinct().size == value.size
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
package org.gotson.komga.interfaces.rest
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter
|
||||
import io.swagger.v3.oas.annotations.media.Content
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
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.image.MosaicGenerator
|
||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
|
||||
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.SeriesDto
|
||||
import org.gotson.komga.interfaces.rest.dto.restrictUrl
|
||||
import org.gotson.komga.interfaces.rest.dto.toDto
|
||||
import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.http.CacheControl
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
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 java.util.concurrent.TimeUnit
|
||||
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,
|
||||
private val seriesDtoRepository: SeriesDtoRepository,
|
||||
private val seriesController: SeriesController,
|
||||
private val mosaicGenerator: MosaicGenerator
|
||||
) {
|
||||
|
||||
@GetMapping
|
||||
fun getAll(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@RequestParam(name = "library_id", required = false) libraryIds: List<Long>?
|
||||
): List<CollectionDto> =
|
||||
when {
|
||||
principal.user.sharedAllLibraries && libraryIds == null -> collectionRepository.findAll()
|
||||
principal.user.sharedAllLibraries && libraryIds != null -> collectionRepository.findAllByLibraries(libraryIds, null)
|
||||
!principal.user.sharedAllLibraries && libraryIds != null -> collectionRepository.findAllByLibraries(libraryIds, principal.user.sharedLibrariesIds)
|
||||
else -> collectionRepository.findAllByLibraries(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds)
|
||||
}.sortedBy { it.name.toLowerCase() }.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)
|
||||
|
||||
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
|
||||
@GetMapping(value = ["{id}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE])
|
||||
fun getCollectionThumbnail(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@PathVariable id: Long
|
||||
): ResponseEntity<ByteArray> {
|
||||
collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let {
|
||||
val ids = with(mutableListOf<Long>()) {
|
||||
while (size < 4) {
|
||||
this += it.seriesIds.take(4)
|
||||
}
|
||||
this.take(4)
|
||||
}
|
||||
|
||||
val images = ids.mapNotNull { seriesController.getSeriesThumbnail(principal, it).body }
|
||||
val thumbnail = mosaicGenerator.createMosaic(images)
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate())
|
||||
.body(thumbnail)
|
||||
} ?: 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)
|
||||
}
|
||||
|
||||
@PageableAsQueryParam
|
||||
@GetMapping("{id}/series")
|
||||
fun getSeriesForCollection(
|
||||
@PathVariable id: Long,
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@Parameter(hidden = true) page: Pageable
|
||||
): List<SeriesDto> =
|
||||
collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection ->
|
||||
if (collection.ordered) {
|
||||
// use map to ensure the order is conserved
|
||||
collection.seriesIds.mapNotNull { seriesDtoRepository.findByIdOrNull(it, principal.user.id) }
|
||||
} else {
|
||||
seriesDtoRepository.findByIds(collection.seriesIds, principal.user.id)
|
||||
.sortedBy { it.metadata.titleSort }
|
||||
}.map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ import org.gotson.komga.domain.model.ReadStatus
|
|||
import org.gotson.komga.domain.model.SeriesMetadata
|
||||
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||
import org.gotson.komga.domain.service.BookLifecycle
|
||||
|
|
@ -21,9 +22,11 @@ import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
|||
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
|
||||
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
|
||||
import org.gotson.komga.interfaces.rest.dto.BookDto
|
||||
import org.gotson.komga.interfaces.rest.dto.CollectionDto
|
||||
import org.gotson.komga.interfaces.rest.dto.SeriesDto
|
||||
import org.gotson.komga.interfaces.rest.dto.SeriesMetadataUpdateDto
|
||||
import org.gotson.komga.interfaces.rest.dto.restrictUrl
|
||||
import org.gotson.komga.interfaces.rest.dto.toDto
|
||||
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
|
||||
import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository
|
||||
import org.springframework.data.domain.Page
|
||||
|
|
@ -60,7 +63,8 @@ class SeriesController(
|
|||
private val bookLifecycle: BookLifecycle,
|
||||
private val bookRepository: BookRepository,
|
||||
private val bookDtoRepository: BookDtoRepository,
|
||||
private val bookController: BookController
|
||||
private val bookController: BookController,
|
||||
private val collectionRepository: SeriesCollectionRepository
|
||||
) {
|
||||
|
||||
@PageableAsQueryParam
|
||||
|
|
@ -207,6 +211,19 @@ class SeriesController(
|
|||
).map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||
}
|
||||
|
||||
@GetMapping("{seriesId}/collections")
|
||||
fun getAllCollectionsBySeries(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@PathVariable(name = "seriesId") seriesId: Long
|
||||
): List<CollectionDto> {
|
||||
seriesRepository.getLibraryId(seriesId)?.let {
|
||||
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
return collectionRepository.findAllBySeries(seriesId, principal.user.getAuthorizedLibraryIds(null))
|
||||
.map { it.toDto() }
|
||||
}
|
||||
|
||||
@PostMapping("{seriesId}/analyze")
|
||||
@PreAuthorize("hasRole('$ROLE_ADMIN')")
|
||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package org.gotson.komga.interfaces.rest.dto
|
||||
|
||||
import org.gotson.komga.infrastructure.validation.Unique
|
||||
import javax.validation.constraints.NotBlank
|
||||
import javax.validation.constraints.NotEmpty
|
||||
|
||||
data class CollectionCreationDto(
|
||||
@get:NotBlank val name: String,
|
||||
val ordered: Boolean,
|
||||
@get:NotEmpty @get:Unique val seriesIds: List<Long>
|
||||
)
|
||||
|
|
@ -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<Long>,
|
||||
|
||||
@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
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.gotson.komga.interfaces.rest.dto
|
||||
|
||||
import org.gotson.komga.infrastructure.validation.NullOrNotBlank
|
||||
import org.gotson.komga.infrastructure.validation.NullOrNotEmpty
|
||||
import org.gotson.komga.infrastructure.validation.Unique
|
||||
|
||||
data class CollectionUpdateDto(
|
||||
@get:NullOrNotBlank val name: String?,
|
||||
val ordered: Boolean?,
|
||||
@get:NullOrNotEmpty @get:Unique val seriesIds: List<Long>?
|
||||
)
|
||||
|
|
@ -9,4 +9,5 @@ interface SeriesDtoRepository {
|
|||
fun findAll(search: SeriesSearchWithReadProgress, userId: Long, pageable: Pageable): Page<SeriesDto>
|
||||
fun findRecentlyUpdated(search: SeriesSearchWithReadProgress, userId: Long, pageable: Pageable): Page<SeriesDto>
|
||||
fun findByIdOrNull(seriesId: Long, userId: Long): SeriesDto?
|
||||
fun findByIds(seriesIds: Collection<Long>, userId: Long): List<SeriesDto>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,394 @@
|
|||
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<Series>
|
||||
private lateinit var seriesLibrary2: List<Series>
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(roles = [ROLE_ADMIN])
|
||||
fun `given collection with duplicate seriesIds when creating collection then return bad request`() {
|
||||
makeCollections()
|
||||
|
||||
val jsonString = """
|
||||
{"name":"Lib1","ordered":false,"seriesIds":[${seriesLibrary1.first().id},${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 existing collection when updating collection with duplicate seriesIds then return bad request`() {
|
||||
makeCollections()
|
||||
|
||||
val jsonString = """{"seriesIds":[${seriesLibrary1.first().id},${seriesLibrary1.first().id}]}"""
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue