diff --git a/backend/README.md b/backend/README.md index a2915631..b48ec157 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,14 +12,40 @@ You have to provide config information to the backend: * Dashboards configuration, e.g. the LAPIS instances of the organisms. We have profiles available that only need to be activated via `spring.profiles.active`. * Database connection configuration: values need to be passed via [external properties](https://docs.spring.io/spring-boot/reference/features/external-config.html). - For local development, we have a `local-db` profile available. + For local development, we have a `local-db` profile available. You can also check that for required properties. +### Start local database + +Start the local PostgreSQL database using Docker Compose (from the repo root): + +```bash +docker compose up -d database +``` + +Stop the database: + +```bash +docker compose down database +``` + +Stop and remove data volumes: + +```bash +docker compose down -v database +``` + +### Run the backend + To run the backend locally, you can use the following command: ```bash ./gradlew bootRun --args='--spring.profiles.active=local-db,dashboards-prod' ``` +The backend will be available at: +- Base URL: `http://localhost:8080` +- Swagger UI: `http://localhost:8080/swagger-ui/index.html` + Run tests: ```bash diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index b3739d87..803bf75d 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { implementation("org.postgresql:postgresql:42.7.10") implementation("org.jetbrains.exposed:exposed-spring-boot-starter:0.56.0") implementation("org.jetbrains.exposed:exposed-json:0.56.0") + implementation("org.jetbrains.exposed:exposed-jdbc:0.56.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.6.x-compat") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt new file mode 100644 index 00000000..ac026160 --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt @@ -0,0 +1,81 @@ +package org.genspectrum.dashboardsbackend.api + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema( + description = "A collection of variants", + example = """ +{ + "id": 1, + "name": "My Collection", + "ownedBy": "user123", + "organism": "covid", + "description": "A collection of interesting variants", + "variants": [] +} +""", +) +data class Collection( + val id: Long, + val name: String, + val ownedBy: String, + val organism: String, + val description: String?, + val variants: List, +) + +@Schema( + description = "Request to create a collection", + example = """ +{ + "name": "My Collection", + "organism": "covid", + "description": "A collection of interesting variants", + "variants": [ + { + "type": "query", + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" + } + ] +} +""", +) +data class CollectionRequest( + val name: String, + val organism: String, + val description: String? = null, + val variants: List, +) + +@Schema( + description = "Request to update a collection", + example = """ +{ + "name": "Updated Collection Name", + "description": "Updated description", + "variants": [ + { + "type": "query", + "id": 1, + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" + }, + { + "type": "query", + "name": "New Variant Without ID", + "countQuery": "country='Germany'" + } + ] +} +""", +) +data class CollectionUpdate( + val name: String? = null, + val description: String? = null, + val variants: List? = null, +) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinition.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinition.kt new file mode 100644 index 00000000..6ccc6dfb --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinition.kt @@ -0,0 +1,16 @@ +package org.genspectrum.dashboardsbackend.api + +import com.fasterxml.jackson.annotation.JsonInclude + +/** + * A JSON object with mutation lists (keys: aaMutations, nucMutations, ...) + * as well as lineage filtering under the "filters" key + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +data class MutationListDefinition( + val aaMutations: List? = null, + val nucMutations: List? = null, + val aaInsertions: List? = null, + val nucInsertions: List? = null, + val filters: Map? = null, +) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Variant.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Variant.kt new file mode 100644 index 00000000..93768271 --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Variant.kt @@ -0,0 +1,196 @@ +package org.genspectrum.dashboardsbackend.api + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import io.swagger.v3.oas.annotations.media.Schema +import org.genspectrum.dashboardsbackend.api.Variant.MutationListVariant +import org.genspectrum.dashboardsbackend.api.Variant.QueryVariant + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", +) +@JsonSubTypes( + JsonSubTypes.Type(value = QueryVariant::class, name = "query"), + JsonSubTypes.Type(value = MutationListVariant::class, name = "mutationList"), +) +@Schema( + description = "Base interface for different variant types", +) +sealed interface Variant { + val id: Long + val collectionId: Long + + enum class QueryVariantType { + @JsonProperty("query") + QUERY, + } + + enum class MutationListVariantType { + @JsonProperty("mutationList") + MUTATION_LIST, + } + + @Schema( + description = "A variant defined by LAPIS queries", + example = """ +{ + "type": "query", + "id": 1, + "collectionId": 2, + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" +} +""", + ) + data class QueryVariant @JsonCreator constructor( + override val id: Long, + override val collectionId: Long, + val name: String, + val description: String?, + val countQuery: String, + val coverageQuery: String? = null, + ) : Variant { + val type: QueryVariantType = QueryVariantType.QUERY + } + + @Schema( + description = "A variant defined by a list of mutations", + example = """ +{ + "type": "mutationList", + "id": 1, + "collectionId": 2, + "name": "Omicron mutations", + "description": "Key mutations for Omicron", + "mutationList": { + "aaMutations": ["S:N501Y", "S:E484K", "S:K417N"] + } +} +""", + ) + data class MutationListVariant @JsonCreator constructor( + override val id: Long, + override val collectionId: Long, + val name: String, + val description: String?, + val mutationList: MutationListDefinition, + ) : Variant { + val type: MutationListVariantType = MutationListVariantType.MUTATION_LIST + } +} + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", +) +@JsonSubTypes( + JsonSubTypes.Type(value = VariantRequest.QueryVariantRequest::class, name = "query"), + JsonSubTypes.Type(value = VariantRequest.MutationListVariantRequest::class, name = "mutationList"), +) +@Schema( + description = "Request to create a variant", +) +sealed interface VariantRequest { + @Schema( + description = "Request to create a query variant", + example = """ +{ + "type": "query", + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" +} +""", + ) + data class QueryVariantRequest( + val name: String, + val description: String? = null, + val countQuery: String, + val coverageQuery: String? = null, + ) : VariantRequest + + @Schema( + description = "Request to create a mutation list variant", + example = """ +{ + "type": "mutationList", + "name": "Omicron mutations", + "description": "Key mutations for Omicron", + "mutationList": { + "aaMutations": ["S:N501Y", "S:E484K", "S:K417N"] + } +} +""", + ) + data class MutationListVariantRequest( + val name: String, + val description: String? = null, + val mutationList: MutationListDefinition, + ) : VariantRequest +} + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", +) +@JsonSubTypes( + JsonSubTypes.Type(value = VariantUpdate.QueryVariantUpdate::class, name = "query"), + JsonSubTypes.Type(value = VariantUpdate.MutationListVariantUpdate::class, name = "mutationList"), +) +@Schema( + description = "Request to update or create a variant", +) +sealed interface VariantUpdate { + val id: Long? + + @Schema( + description = "Request to update or create a query variant", + example = """ +{ + "type": "query", + "id": 1, + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" +} +""", + ) + data class QueryVariantUpdate( + override val id: Long? = null, + val name: String, + val description: String? = null, + val countQuery: String, + val coverageQuery: String? = null, + ) : VariantUpdate + + @Schema( + description = "Request to update or create a mutation list variant", + example = """ +{ + "type": "mutationList", + "id": 1, + "name": "Omicron mutations", + "description": "Key mutations for Omicron", + "mutationList": { + "aaMutations": ["S:N501Y", "S:E484K", "S:K417N"] + } +} +""", + ) + data class MutationListVariantUpdate( + override val id: Long? = null, + val name: String, + val description: String? = null, + val mutationList: MutationListDefinition, + ) : VariantUpdate +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/BackendSpringConfig.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/BackendSpringConfig.kt index 49a8a23a..c0cb9ea0 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/BackendSpringConfig.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/BackendSpringConfig.kt @@ -7,6 +7,7 @@ import org.flywaydb.core.Flyway import org.genspectrum.dashboardsbackend.logging.REQUEST_ID_HEADER import org.genspectrum.dashboardsbackend.logging.REQUEST_ID_HEADER_DESCRIPTION import org.jetbrains.exposed.spring.autoconfigure.ExposedAutoConfiguration +import org.jetbrains.exposed.sql.Database import org.springdoc.core.customizers.OperationCustomizer import org.springframework.boot.autoconfigure.ImportAutoConfiguration import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration @@ -31,6 +32,10 @@ class BackendSpringConfig { .validateMigrationNaming(true) val flyway = Flyway(configuration) flyway.migrate() + + // Set up exposed database connection after migration is done + Database.connect(dataSource) + return flyway } diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt index 6db05404..1ea0a5bb 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt @@ -1,15 +1,27 @@ package org.genspectrum.dashboardsbackend.config +import org.genspectrum.dashboardsbackend.controller.BadRequestException import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "dashboards") data class DashboardsConfig(val organisms: Map) { fun getOrganismConfig(organism: String) = organisms[organism] ?: throw IllegalArgumentException("No configuration found for organism $organism") + + fun validateIsValidOrganism(organism: String) { + if (!organisms.containsKey(organism)) { + throw BadRequestException("Organism '$organism' is not supported") + } + } } data class OrganismConfig(val lapis: LapisConfig, val externalNavigationLinks: List?) -data class LapisConfig(val url: String, val mainDateField: String, val additionalFilters: Map?) +data class LapisConfig( + val url: String, + val mainDateField: String, + val lineageFields: List?, + val additionalFilters: Map?, +) data class ExternalNavigationLink(val url: String, val label: String, val menuIcon: String, val description: String) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt new file mode 100644 index 00000000..99798118 --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt @@ -0,0 +1,81 @@ +package org.genspectrum.dashboardsbackend.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import org.genspectrum.dashboardsbackend.api.Collection +import org.genspectrum.dashboardsbackend.api.CollectionRequest +import org.genspectrum.dashboardsbackend.api.CollectionUpdate +import org.genspectrum.dashboardsbackend.model.collection.CollectionModel +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +class CollectionsController(private val collectionModel: CollectionModel) { + @GetMapping("/collections", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Get collections", + description = "Returns collections filtered by optional userId and/or organism parameters.", + ) + fun getCollections( + @RequestParam(required = false) userId: String?, + @RequestParam(required = false) organism: String?, + ): List = collectionModel.getCollections( + userId = userId, + organism = organism, + ) + + @GetMapping("/collections/{id}", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Get a collection by ID", + description = "Returns a single collection with all its variants.", + ) + fun getCollection( + @Parameter(description = "The ID of the collection", example = "1") @PathVariable id: Long, + ): Collection = collectionModel.getCollection(id) + + @PostMapping("/collections") + @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "Create a new collection", + description = "Creates a new collection with variants for a user.", + ) + fun postCollection( + @RequestBody collection: CollectionRequest, + @UserIdParameter @RequestParam userId: String, + ): Collection = collectionModel.createCollection( + request = collection, + userId = userId, + ) + + @PutMapping("/collections/{id}") + @Operation( + summary = "Update a collection", + description = "Updates a collection. Only the owner can update their collection. " + + "Provide only the fields you want to update.", + ) + fun putCollection( + @RequestBody collection: CollectionUpdate, + @Parameter(description = "The ID of the collection", example = "1") @PathVariable id: Long, + @UserIdParameter @RequestParam userId: String, + ): Collection = collectionModel.putCollection(id, collection, userId) + + @DeleteMapping("/collections/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Delete a collection", + description = "Deletes a collection. Only the owner can delete their collection.", + ) + fun deleteCollection( + @Parameter(description = "The ID of the collection", example = "1") @PathVariable id: Long, + @UserIdParameter @RequestParam userId: String, + ) = collectionModel.deleteCollection(id, userId) +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/ExceptionHandler.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/ExceptionHandler.kt index cc89c9e4..18c21c3f 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/ExceptionHandler.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/ExceptionHandler.kt @@ -1,11 +1,13 @@ package org.genspectrum.dashboardsbackend.controller import org.genspectrum.dashboardsbackend.log +import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.HttpStatusCode import org.springframework.http.MediaType import org.springframework.http.ProblemDetail import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.web.bind.annotation.ControllerAdvice import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus @@ -47,6 +49,17 @@ class ExceptionHandler : ResponseEntityExceptionHandler() { ) } + @ExceptionHandler(ForbiddenException::class) + @ResponseStatus(HttpStatus.FORBIDDEN) + fun handleForbiddenException(e: Exception): ResponseEntity { + log.info { "Caught ${e.javaClass}: ${e.message}" } + + return responseEntity( + HttpStatus.FORBIDDEN, + e.message, + ) + } + private fun responseEntity(httpStatus: HttpStatus, detail: String?): ResponseEntity = responseEntity(httpStatus, httpStatus.reasonPhrase, detail) @@ -64,6 +77,20 @@ class ExceptionHandler : ResponseEntityExceptionHandler() { }, ) + override fun handleHttpMessageNotReadable( + ex: HttpMessageNotReadableException, + headers: HttpHeaders, + status: HttpStatusCode, + request: WebRequest, + ): ResponseEntity? { + val cause = ex.cause?.cause + if (cause is BadRequestException) { + log.info { "Caught ${cause.javaClass}: ${cause.message}" } + return responseEntity(HttpStatus.BAD_REQUEST, cause.message) as ResponseEntity + } + return super.handleHttpMessageNotReadable(ex, headers, status, request) + } + override fun createProblemDetail( ex: java.lang.Exception, status: HttpStatusCode, @@ -80,3 +107,4 @@ class ExceptionHandler : ResponseEntityExceptionHandler() { class BadRequestException(message: String) : RuntimeException(message) class NotFoundException(message: String) : RuntimeException(message) +class ForbiddenException(message: String) : RuntimeException(message) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt new file mode 100644 index 00000000..6d78891e --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -0,0 +1,270 @@ +package org.genspectrum.dashboardsbackend.model.collection + +import org.genspectrum.dashboardsbackend.api.Collection +import org.genspectrum.dashboardsbackend.api.CollectionRequest +import org.genspectrum.dashboardsbackend.api.CollectionUpdate +import org.genspectrum.dashboardsbackend.api.MutationListDefinition +import org.genspectrum.dashboardsbackend.api.VariantRequest +import org.genspectrum.dashboardsbackend.api.VariantUpdate +import org.genspectrum.dashboardsbackend.config.DashboardsConfig +import org.genspectrum.dashboardsbackend.controller.BadRequestException +import org.genspectrum.dashboardsbackend.controller.ForbiddenException +import org.genspectrum.dashboardsbackend.controller.NotFoundException +import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.SqlExpressionBuilder.notInList +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.deleteWhere +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional +class CollectionModel(private val dashboardsConfig: DashboardsConfig) { + fun getCollections(userId: String?, organism: String?): List { + val query = if (userId == null && organism == null) { + CollectionEntity.all() + } else { + CollectionEntity.find { + var conditions: Op = Op.TRUE + if (userId != null) { + conditions = conditions and (CollectionTable.ownedBy eq userId) + } + if (organism != null) { + conditions = conditions and (CollectionTable.organism eq organism) + } + conditions + } + } + + // Materialize the collections first to avoid re-running the query + val collectionEntities = query.toList() + if (collectionEntities.isEmpty()) { + return emptyList() + } + // Batch-load all variants + val allCollectionIds = collectionEntities.map { it.id } + val variantsByCollectionId = VariantEntity + .find { VariantTable.collectionId inList allCollectionIds } + .groupBy { it.collectionId } + return collectionEntities.map { collectionEntity -> + val variants = variantsByCollectionId[collectionEntity.id].orEmpty().map { it.toVariant() } + Collection( + id = collectionEntity.id.value, + name = collectionEntity.name, + ownedBy = collectionEntity.ownedBy, + organism = collectionEntity.organism, + description = collectionEntity.description, + variants = variants, + ) + } + } + + fun getCollection(id: Long): Collection = CollectionEntity.findById(id) + ?.toCollection() + ?: throw NotFoundException("Collection $id not found") + + fun createCollection(request: CollectionRequest, userId: String): Collection { + dashboardsConfig.validateIsValidOrganism(request.organism) + + val collectionEntity = CollectionEntity.new { + name = request.name + organism = request.organism + description = request.description + ownedBy = userId + } + + val variantEntities = request.variants.map { variantRequest -> + val variantEntity = when (variantRequest) { + is VariantRequest.QueryVariantRequest -> { + VariantEntity.new { + this.collectionId = collectionEntity.id + this.variantType = VariantType.QUERY + this.name = variantRequest.name + this.description = variantRequest.description + this.countQuery = variantRequest.countQuery + this.coverageQuery = variantRequest.coverageQuery + this.mutationList = null + } + } + is VariantRequest.MutationListVariantRequest -> { + validateLineageFilters(request.organism, variantRequest.mutationList) + + VariantEntity.new { + this.collectionId = collectionEntity.id + this.variantType = VariantType.MUTATION_LIST + this.name = variantRequest.name + this.description = variantRequest.description + this.mutationList = variantRequest.mutationList + this.countQuery = null + this.coverageQuery = null + } + } + } + variantEntity.validate() + variantEntity + } + + return Collection( + id = collectionEntity.id.value, + name = collectionEntity.name, + ownedBy = collectionEntity.ownedBy, + organism = collectionEntity.organism, + description = collectionEntity.description, + variants = variantEntities.map { it.toVariant() }, + ) + } + + fun deleteCollection(id: Long, userId: String) { + // Find with ownership check + val entity = CollectionEntity.findForUser(id, userId) + ?: throw ForbiddenException("Collection $id not found or you don't have permission to delete it") + + // Delete (variants cascade automatically via DB constraint) + entity.delete() + } + + fun putCollection(id: Long, update: CollectionUpdate, userId: String): Collection { + val collectionEntity = CollectionEntity.findForUser(id, userId) + ?: throw ForbiddenException("Collection $id not found or you don't have permission to update it") + + if (update.name != null) { + collectionEntity.name = update.name + } + + if (update.description != null) { + collectionEntity.description = update.description + } + + if (update.variants != null) { + // Track which variant IDs should be kept + val variantIdsToKeep = mutableSetOf() + + update.variants.forEach { variantUpdate -> + when { + variantUpdate.id == null -> { + val variantEntity = createVariantEntity( + collectionEntity, + variantUpdate, + ) + variantEntity.validate() + variantIdsToKeep.add(variantEntity.id.value) + } + // Case 2: Has ID = Update existing variant + else -> { + val variantId = variantUpdate.id!! + val variantEntity = VariantEntity.findById(variantId) + ?: throw BadRequestException( + "Variant $variantId not found", + ) + + // Verify the variant belongs to this collection + if (variantEntity.collectionId.value != id) { + throw BadRequestException( + "Variant ${variantUpdate.id} does not belong to collection $id", + ) + } + + // Update the variant fields + updateVariantEntity(variantEntity, variantUpdate, collectionEntity) + variantEntity.validate() + variantIdsToKeep.add(variantId) + } + } + } + + // Case 3: Delete variants not in the update list + if (variantIdsToKeep.isEmpty()) { + VariantTable.deleteWhere { collectionId eq id } + } else { + // Delete all variants for this collection whose IDs are not in the keep-set + VariantTable.deleteWhere { + (collectionId eq id) and (VariantTable.id notInList variantIdsToKeep.toList()) + } + } + VariantEntity.find { VariantTable.collectionId eq id } + .filter { it.id.value !in variantIdsToKeep } + .forEach { it.delete() } + } + + return collectionEntity.toCollection() + } + + private fun createVariantEntity(collectionEntity: CollectionEntity, variantUpdate: VariantUpdate): VariantEntity = + when (variantUpdate) { + is VariantUpdate.QueryVariantUpdate -> { + VariantEntity.new { + this.collectionId = collectionEntity.id + this.variantType = VariantType.QUERY + this.name = variantUpdate.name + this.description = variantUpdate.description + this.countQuery = variantUpdate.countQuery + this.coverageQuery = variantUpdate.coverageQuery + this.mutationList = null + } + } + is VariantUpdate.MutationListVariantUpdate -> { + validateLineageFilters(collectionEntity.organism, variantUpdate.mutationList) + + VariantEntity.new { + this.collectionId = collectionEntity.id + this.variantType = VariantType.MUTATION_LIST + this.name = variantUpdate.name + this.description = variantUpdate.description + this.mutationList = variantUpdate.mutationList + this.countQuery = null + this.coverageQuery = null + } + } + } + + private fun validateLineageFilters(organism: String, mutationList: MutationListDefinition) { + val validLineageFields = dashboardsConfig.getOrganismConfig(organism).lapis.lineageFields ?: emptyList() + val invalidFields = mutationList.filters.orEmpty().keys - validLineageFields.toSet() + if (invalidFields.isNotEmpty()) { + val validFieldsStr = if (validLineageFields.isEmpty()) { + "no lineage fields are configured" + } else { + "valid fields are: ${validLineageFields.joinToString(", ")}" + } + throw BadRequestException( + "Invalid lineage fields for organism '$organism': ${invalidFields.joinToString(", ")}. $validFieldsStr", + ) + } + } + + private fun updateVariantEntity( + variantEntity: VariantEntity, + variantUpdate: VariantUpdate, + collectionEntity: CollectionEntity, + ) { + when (variantUpdate) { + is VariantUpdate.QueryVariantUpdate -> { + // Verify type matches + if (variantEntity.variantType != VariantType.QUERY) { + throw BadRequestException( + "Cannot change variant type from ${variantEntity.variantType} to QUERY", + ) + } + variantEntity.name = variantUpdate.name + variantEntity.description = variantUpdate.description + variantEntity.countQuery = variantUpdate.countQuery + variantEntity.coverageQuery = variantUpdate.coverageQuery + } + is VariantUpdate.MutationListVariantUpdate -> { + // Verify type matches + if (variantEntity.variantType != VariantType.MUTATION_LIST) { + throw BadRequestException( + "Cannot change variant type from ${variantEntity.variantType} to MUTATION_LIST", + ) + } + + validateLineageFilters(collectionEntity.organism, variantUpdate.mutationList) + + variantEntity.name = variantUpdate.name + variantEntity.description = variantUpdate.description + variantEntity.mutationList = variantUpdate.mutationList + } + } + } +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt new file mode 100644 index 00000000..8b9d1eb1 --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt @@ -0,0 +1,40 @@ +package org.genspectrum.dashboardsbackend.model.collection + +import org.genspectrum.dashboardsbackend.api.Collection +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.LongIdTable + +const val COLLECTION_TABLE = "collections_table" + +object CollectionTable : LongIdTable(COLLECTION_TABLE) { + val name = text("name") + val ownedBy = varchar("owned_by", 255) + val organism = varchar("organism", 255) + val description = text("description").nullable() +} + +class CollectionEntity(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(CollectionTable) { + fun findForUser(id: Long, userId: String) = findById(id) + ?.takeIf { it.ownedBy == userId } + } + + var name by CollectionTable.name + var ownedBy by CollectionTable.ownedBy + var organism by CollectionTable.organism + var description by CollectionTable.description + + // Navigation property to access variants + val variants by VariantEntity referrersOn VariantTable.collectionId + + fun toCollection() = Collection( + id = id.value, + name = name, + ownedBy = ownedBy, + organism = organism, + description = description, + variants = variants.map { it.toVariant() }, + ) +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt new file mode 100644 index 00000000..40d5609a --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt @@ -0,0 +1,106 @@ +package org.genspectrum.dashboardsbackend.model.collection + +import org.genspectrum.dashboardsbackend.api.Variant +import org.genspectrum.dashboardsbackend.model.subscription.jacksonSerializableJsonb +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.ReferenceOption + +const val VARIANT_TABLE = "variants_table" + +enum class VariantType { + QUERY, + MUTATION_LIST, + ; + + fun toDatabaseValue(): String = when (this) { + QUERY -> "query" + MUTATION_LIST -> "mutationList" + } + + companion object { + fun fromDatabaseValue(value: String): VariantType = when (value) { + "query" -> QUERY + "mutationList" -> MUTATION_LIST + else -> throw IllegalArgumentException("Unknown variant type: $value") + } + } +} + +object VariantTable : LongIdTable(VARIANT_TABLE) { + val collectionId = reference( + "collection_id", + CollectionTable, + onDelete = ReferenceOption.CASCADE, + ) + val variantType = varchar("variant_type", 50) + val name = text("name") + val description = text("description").nullable() + + val countQuery = text("count_query").nullable() + val coverageQuery = text("coverage_query").nullable() + + val mutationList = jacksonSerializableJsonb( + "mutation_list", + ).nullable() +} + +class VariantEntity(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(VariantTable) { + fun findForCollection(collectionId: Long): List = + find { VariantTable.collectionId eq collectionId }.toList() + } + + var collectionId by VariantTable.collectionId + private var variantTypeString by VariantTable.variantType + var name by VariantTable.name + var description by VariantTable.description + + // Polymorphic property access + var countQuery by VariantTable.countQuery + var coverageQuery by VariantTable.coverageQuery + var mutationList by VariantTable.mutationList + + // Type-safe variant type accessor + var variantType: VariantType + get() = VariantType.fromDatabaseValue(variantTypeString) + set(value) { + variantTypeString = value.toDatabaseValue() + } + + // Validation helper + fun validate() { + when (variantType) { + VariantType.QUERY -> { + require(countQuery != null) { "Query variant must have count_query" } + require(mutationList == null) { "Query variant must not have mutation_list" } + } + VariantType.MUTATION_LIST -> { + require(mutationList != null) { "MutationList variant must have mutation_list" } + require(countQuery == null && coverageQuery == null) { + "MutationList variant must not have query columns" + } + } + } + } + + fun toVariant(): Variant = when (variantType) { + VariantType.QUERY -> Variant.QueryVariant( + id = id.value, + collectionId = collectionId.value, + name = name, + description = description, + countQuery = countQuery!!, + coverageQuery = coverageQuery, + ) + VariantType.MUTATION_LIST -> Variant.MutationListVariant( + id = id.value, + collectionId = collectionId.value, + name = name, + description = description, + mutationList = mutationList!!, + ) + } +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt index 55c6fa4d..31656316 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt @@ -4,21 +4,14 @@ import org.genspectrum.dashboardsbackend.api.Subscription import org.genspectrum.dashboardsbackend.api.SubscriptionRequest import org.genspectrum.dashboardsbackend.api.SubscriptionUpdate import org.genspectrum.dashboardsbackend.config.DashboardsConfig -import org.genspectrum.dashboardsbackend.controller.BadRequestException import org.genspectrum.dashboardsbackend.controller.NotFoundException -import org.jetbrains.exposed.sql.Database +import org.genspectrum.dashboardsbackend.util.convertToUuid import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.util.UUID -import javax.sql.DataSource @Service @Transactional -class SubscriptionModel(pool: DataSource, private val dashboardsConfig: DashboardsConfig) { - init { - Database.connect(pool) - } - +class SubscriptionModel(private val dashboardsConfig: DashboardsConfig) { fun getSubscription(subscriptionId: String, userId: String): Subscription = SubscriptionEntity.findForUser(convertToUuid(subscriptionId), userId) ?.toSubscription() @@ -31,7 +24,7 @@ class SubscriptionModel(pool: DataSource, private val dashboardsConfig: Dashboar } fun postSubscriptions(request: SubscriptionRequest, userId: String): Subscription { - validateIsValidOrganism(request.organism) + dashboardsConfig.validateIsValidOrganism(request.organism) return SubscriptionEntity .new { @@ -54,7 +47,7 @@ class SubscriptionModel(pool: DataSource, private val dashboardsConfig: Dashboar } fun putSubscription(subscriptionId: String, subscriptionUpdate: SubscriptionUpdate, userId: String): Subscription { - subscriptionUpdate.organism?.also { validateIsValidOrganism(it) } + subscriptionUpdate.organism?.also { dashboardsConfig.validateIsValidOrganism(it) } val subscription = SubscriptionEntity.findForUser(convertToUuid(subscriptionId), userId) ?: throw NotFoundException("Subscription $subscriptionId not found") @@ -80,16 +73,4 @@ class SubscriptionModel(pool: DataSource, private val dashboardsConfig: Dashboar return subscription.toSubscription() } - - private fun validateIsValidOrganism(organism: String) { - if (!dashboardsConfig.organisms.containsKey(organism)) { - throw BadRequestException("Organism '$organism' is not supported") - } - } - - private fun convertToUuid(id: String) = try { - UUID.fromString(id) - } catch (_: IllegalArgumentException) { - throw BadRequestException("Invalid UUID $id") - } } diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/util/ConvertToUuid.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/util/ConvertToUuid.kt new file mode 100644 index 00000000..91e79929 --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/util/ConvertToUuid.kt @@ -0,0 +1,10 @@ +package org.genspectrum.dashboardsbackend.util + +import org.genspectrum.dashboardsbackend.controller.BadRequestException +import java.util.UUID + +fun convertToUuid(id: String): UUID = try { + UUID.fromString(id) +} catch (_: IllegalArgumentException) { + throw BadRequestException("Invalid UUID $id") +} diff --git a/backend/src/main/resources/application-dashboards-prod.yaml b/backend/src/main/resources/application-dashboards-prod.yaml index c5510dcf..f170d623 100644 --- a/backend/src/main/resources/application-dashboards-prod.yaml +++ b/backend/src/main/resources/application-dashboards-prod.yaml @@ -4,6 +4,9 @@ dashboards: lapis: url: "https://lapis.cov-spectrum.org/open/v2" mainDateField: "date" + lineageFields: + - "pangoLineage" + - "nextcladePangoLineage" externalNavigationLinks: - url: "https://cov-spectrum.org" label: "CoV-Spectrum" diff --git a/backend/src/main/resources/application-local-db.yaml b/backend/src/main/resources/application-local-db.yaml index 902be2b2..4d2f9d0b 100644 --- a/backend/src/main/resources/application-local-db.yaml +++ b/backend/src/main/resources/application-local-db.yaml @@ -1,5 +1,5 @@ spring: datasource: - url: "jdbc:postgresql://localhost:9022/subscriptions" + url: "jdbc:postgresql://localhost:9022/dashboards-backend-db" username: "postgres" password: "unsecure" diff --git a/backend/src/main/resources/db/migration/V1.1__collections_and_variants.sql b/backend/src/main/resources/db/migration/V1.1__collections_and_variants.sql new file mode 100644 index 00000000..7af98772 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1.1__collections_and_variants.sql @@ -0,0 +1,34 @@ +-- Collections table +create table collections_table ( + id bigserial primary key, + name text not null, + owned_by varchar(255) not null, + organism varchar(255) not null, + description text +); + +-- Variants table with polymorphic data storage +create table variants_table ( + id bigserial primary key, + collection_id bigint not null, + variant_type varchar(50) not null, + name text not null, + description text, + -- Query variant columns (nullable, only used when variant_type='query') + count_query text, + coverage_query text, + -- MutationList variant column (nullable, only used when variant_type='mutationList') + mutation_list jsonb, + -- Constraints + constraint fk_collection foreign key (collection_id) references collections_table(id) on delete cascade, + constraint chk_variant_type check (variant_type in ('query', 'mutationList')), + -- Ensure correct columns are populated based on type + constraint chk_query_columns check ( + (variant_type = 'query' and count_query is not null and mutation_list is null) or + (variant_type = 'mutationList' and mutation_list is not null and count_query is null and coverage_query is null) + ) +); + +create index idx_collections_owned_by on collections_table(owned_by); +create index idx_collections_organism on collections_table(organism); +create index idx_variants_collection_id on variants_table(collection_id); diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/TestHelpers.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/TestHelpers.kt index 33902030..3aabc373 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/TestHelpers.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/TestHelpers.kt @@ -1,9 +1,11 @@ package org.genspectrum.dashboardsbackend +import org.genspectrum.dashboardsbackend.api.CollectionRequest import org.genspectrum.dashboardsbackend.api.DateWindow import org.genspectrum.dashboardsbackend.api.EvaluationInterval import org.genspectrum.dashboardsbackend.api.SubscriptionRequest import org.genspectrum.dashboardsbackend.api.Trigger +import org.genspectrum.dashboardsbackend.api.VariantRequest enum class KnownTestOrganisms { Covid, @@ -26,3 +28,25 @@ val dummySubscriptionRequest = SubscriptionRequest( organism = KnownTestOrganisms.Covid.name, active = true, ) + +val dummyQueryVariantRequest = VariantRequest.QueryVariantRequest( + name = "BA.2 in USA", + description = "BA.2 lineage cases in USA", + countQuery = "country='USA' & lineage='BA.2'", + coverageQuery = "country='USA'", +) + +val dummyMutationListVariantRequest = VariantRequest.MutationListVariantRequest( + name = "Omicron mutations", + description = "Key mutations", + mutationList = org.genspectrum.dashboardsbackend.api.MutationListDefinition( + aaMutations = listOf("S:N501Y", "S:E484K", "S:K417N"), + ), +) + +val dummyCollectionRequest = CollectionRequest( + name = "Test Collection", + organism = KnownTestOrganisms.Covid.name, + description = "Test collection description", + variants = listOf(dummyQueryVariantRequest, dummyMutationListVariantRequest), +) diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinitionTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinitionTest.kt new file mode 100644 index 00000000..f76c7cc8 --- /dev/null +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinitionTest.kt @@ -0,0 +1,54 @@ +package org.genspectrum.dashboardsbackend.api + +import com.fasterxml.jackson.databind.ObjectMapper +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class MutationListDefinitionTest { + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `serializes mutation lists`() { + val underTest = MutationListDefinition( + aaMutations = listOf("S:N501Y", "S:E484K"), + nucMutations = listOf("A23403G"), + ) + + val json = objectMapper.writeValueAsString(underTest) + + assertThat( + json, + equalTo("""{"aaMutations":["S:N501Y","S:E484K"],"nucMutations":["A23403G"]}"""), + ) + } + + @Test + fun `serializes lineage filters under filters key`() { + val underTest = MutationListDefinition( + aaMutations = listOf("S:N501Y"), + filters = mapOf("lineage" to "B.1.1.7"), + ) + + val json = objectMapper.writeValueAsString(underTest) + + assertThat( + json, + equalTo("""{"aaMutations":["S:N501Y"],"filters":{"lineage":"B.1.1.7"}}"""), + ) + } + + @Test + fun `deserializes lineage filters from filters key`() { + val json = """{"aaMutations":["S:N501Y"],"filters":{"lineage":"B.1.1.7"}}""" + + val result = objectMapper.readValue(json, MutationListDefinition::class.java) + + assertThat(result.aaMutations, equalTo(listOf("S:N501Y"))) + assertThat(result.filters, equalTo(mapOf("lineage" to "B.1.1.7"))) + } +} diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt new file mode 100644 index 00000000..2dd8f35b --- /dev/null +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt @@ -0,0 +1,82 @@ +package org.genspectrum.dashboardsbackend.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.genspectrum.dashboardsbackend.api.Collection +import org.genspectrum.dashboardsbackend.api.CollectionRequest +import org.genspectrum.dashboardsbackend.api.CollectionUpdate +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +class CollectionsClient(private val mockMvc: MockMvc, private val objectMapper: ObjectMapper) { + fun postCollectionRaw(collection: CollectionRequest, userId: String): ResultActions = mockMvc.perform( + post("/collections?userId=$userId") + .content(objectMapper.writeValueAsString(collection)) + .contentType(MediaType.APPLICATION_JSON), + ) + + fun postCollection(collection: CollectionRequest, userId: String): Collection = deserializeJsonResponse( + postCollectionRaw(collection, userId) + .andExpect(status().isCreated), + ) + + fun getCollectionsRaw(userId: String? = null, organism: String? = null): ResultActions { + val params = buildString { + val queryParams = mutableListOf() + if (userId != null) queryParams.add("userId=$userId") + if (organism != null) queryParams.add("organism=$organism") + if (queryParams.isNotEmpty()) { + append("?") + append(queryParams.joinToString("&")) + } + } + return mockMvc.perform(get("/collections$params")) + } + + fun getCollections(userId: String? = null, organism: String? = null): List = deserializeJsonResponse( + getCollectionsRaw(userId, organism) + .andExpect(status().isOk), + ) + + fun getCollectionRaw(id: Long): ResultActions = mockMvc.perform(get("/collections/$id")) + + fun getCollection(id: Long): Collection = deserializeJsonResponse( + getCollectionRaw(id) + .andExpect(status().isOk), + ) + + fun putCollectionRaw(collection: CollectionUpdate, id: Long, userId: String): ResultActions = mockMvc.perform( + put("/collections/$id?userId=$userId") + .content(objectMapper.writeValueAsString(collection)) + .contentType(MediaType.APPLICATION_JSON), + ) + + fun putCollection(collection: CollectionUpdate, id: Long, userId: String): Collection = deserializeJsonResponse( + putCollectionRaw(collection, id, userId) + .andExpect(status().isOk), + ) + + fun deleteCollectionRaw(id: Long, userId: String): ResultActions = + mockMvc.perform(delete("/collections/$id?userId=$userId")) + + fun deleteCollection(id: Long, userId: String) { + deleteCollectionRaw(id, userId).andExpect(status().isNoContent) + } + + private inline fun deserializeJsonResponse(resultActions: ResultActions): T { + val content = + resultActions + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andReturn() + .response + .contentAsString + return objectMapper.readValue(content) + } +} diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt new file mode 100644 index 00000000..a3c93b28 --- /dev/null +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt @@ -0,0 +1,764 @@ +package org.genspectrum.dashboardsbackend.controller + +import org.genspectrum.dashboardsbackend.KnownTestOrganisms +import org.genspectrum.dashboardsbackend.api.CollectionUpdate +import org.genspectrum.dashboardsbackend.api.MutationListDefinition +import org.genspectrum.dashboardsbackend.api.Variant +import org.genspectrum.dashboardsbackend.api.VariantRequest +import org.genspectrum.dashboardsbackend.api.VariantUpdate +import org.genspectrum.dashboardsbackend.dummyCollectionRequest +import org.genspectrum.dashboardsbackend.dummyMutationListVariantRequest +import org.genspectrum.dashboardsbackend.dummyQueryVariantRequest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.hasItem +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.instanceOf +import org.hamcrest.Matchers.not +import org.hamcrest.Matchers.notNullValue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@SpringBootTest +@AutoConfigureMockMvc +@Import(CollectionsClient::class) +class CollectionsControllerTest( + @param:Autowired private val collectionsClient: CollectionsClient, + @param:Autowired private val mockMvc: MockMvc, +) { + + @Test + fun `GIVEN collection with variants WHEN creating THEN returns with generated IDs`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + assertThat(createdCollection.id, notNullValue()) + assertThat(createdCollection.name, equalTo(dummyCollectionRequest.name)) + assertThat(createdCollection.ownedBy, equalTo(userId)) + assertThat(createdCollection.organism, equalTo(dummyCollectionRequest.organism)) + assertThat(createdCollection.description, equalTo(dummyCollectionRequest.description)) + assertThat(createdCollection.variants, hasSize(2)) + assertThat(createdCollection.variants[0].id, notNullValue()) + assertThat(createdCollection.variants[1].id, notNullValue()) + } + + @Test + fun `WHEN creating collection with only query variants THEN succeeds`() { + val userId = getNewUserId() + val request = dummyCollectionRequest.copy( + variants = listOf(dummyQueryVariantRequest), + ) + + val createdCollection = collectionsClient.postCollection(request, userId) + + assertThat(createdCollection.variants, hasSize(1)) + assertThat(createdCollection.variants[0], org.hamcrest.Matchers.instanceOf(Variant.QueryVariant::class.java)) + } + + @Test + fun `WHEN creating collection with only mutation list variants THEN succeeds`() { + val userId = getNewUserId() + val request = dummyCollectionRequest.copy( + variants = listOf(dummyMutationListVariantRequest), + ) + + val createdCollection = collectionsClient.postCollection(request, userId) + + assertThat(createdCollection.variants, hasSize(1)) + assertThat( + createdCollection.variants[0], + org.hamcrest.Matchers.instanceOf(Variant.MutationListVariant::class.java), + ) + } + + @Test + fun `WHEN creating collection with no variants THEN succeeds`() { + val userId = getNewUserId() + val request = dummyCollectionRequest.copy(variants = emptyList()) + + val createdCollection = collectionsClient.postCollection(request, userId) + + assertThat(createdCollection.variants, empty()) + } + + @Test + fun `WHEN creating collection with unknown organism THEN returns 400`() { + val userId = getNewUserId() + collectionsClient.postCollectionRaw(dummyCollectionRequest.copy(organism = "unknown organism"), userId) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("\$.detail").value("Organism 'unknown organism' is not supported")) + } + + @Test + fun `GIVEN collections for multiple users WHEN getting collections THEN users get separate collections`() { + val userA = getNewUserId() + val userB = getNewUserId() + + val collectionA = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "User A Collection"), + userA, + ) + val collectionB = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "User B Collection"), + userB, + ) + + val collectionsForUserA = collectionsClient.getCollections(userId = userA) + val collectionsForUserB = collectionsClient.getCollections(userId = userB) + + assertThat(collectionsForUserA, hasItem(collectionA)) + assertThat(collectionsForUserA, not(hasItem(collectionB))) + + assertThat(collectionsForUserB, hasItem(collectionB)) + assertThat(collectionsForUserB, not(hasItem(collectionA))) + } + + @Test + fun `GIVEN multiple collections WHEN getting all THEN returns all`() { + val userA = getNewUserId() + val userB = getNewUserId() + + val covidCollectionA = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Covid A", organism = KnownTestOrganisms.Covid.name), + userA, + ) + val mpoxCollectionA = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Mpox A", organism = KnownTestOrganisms.Mpox.name), + userA, + ) + val covidCollectionB = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Covid B", organism = KnownTestOrganisms.Covid.name), + userB, + ) + + val allCollections = collectionsClient.getCollections() + + assertThat(allCollections, hasItem(covidCollectionA)) + assertThat(allCollections, hasItem(mpoxCollectionA)) + assertThat(allCollections, hasItem(covidCollectionB)) + } + + @Test + fun `GIVEN collections for multiple users WHEN getting by userId THEN returns only that user's collections`() { + val userA = getNewUserId() + val userB = getNewUserId() + + val collectionA = collectionsClient.postCollection(dummyCollectionRequest.copy(name = "User A"), userA) + val collectionB = collectionsClient.postCollection(dummyCollectionRequest.copy(name = "User B"), userB) + + val collectionsForUserA = collectionsClient.getCollections(userId = userA) + + assertThat(collectionsForUserA, hasItem(collectionA)) + assertThat(collectionsForUserA, not(hasItem(collectionB))) + } + + @Test + fun `WHEN getting collections for user with no collections THEN returns empty array`() { + val nonexistentUserId = getNewUserId() + + val collections = collectionsClient.getCollections(userId = nonexistentUserId) + + assertThat(collections, empty()) + } + + @Test + fun `GIVEN covid and mpox collections WHEN getting by organism THEN returns only that organism's collections`() { + val userId = getNewUserId() + + val covidCollection = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Covid", organism = KnownTestOrganisms.Covid.name), + userId, + ) + val mpoxCollection = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Mpox", organism = KnownTestOrganisms.Mpox.name), + userId, + ) + + val covidCollections = collectionsClient.getCollections(organism = KnownTestOrganisms.Covid.name) + + assertThat(covidCollections, hasItem(covidCollection)) + assertThat(covidCollections, not(hasItem(mpoxCollection))) + } + + @Test + fun `GIVEN no collections for organism WHEN getting collections for organism THEN returns empty array`() { + val collections = collectionsClient.getCollections(organism = KnownTestOrganisms.WestNile.name) + + assertThat(collections, empty()) + } + + @Test + fun `GIVEN multiple collections WHEN filtering by userId AND organism THEN returns subset`() { + val userA = getNewUserId() + val userB = getNewUserId() + + val covidCollectionA = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Covid A", organism = KnownTestOrganisms.Covid.name), + userA, + ) + val mpoxCollectionA = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Mpox A", organism = KnownTestOrganisms.Mpox.name), + userA, + ) + val covidCollectionB = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Covid B", organism = KnownTestOrganisms.Covid.name), + userB, + ) + + val filteredCollections = collectionsClient.getCollections( + userId = userA, + organism = KnownTestOrganisms.Covid.name, + ) + + assertThat(filteredCollections, hasItem(covidCollectionA)) + assertThat(filteredCollections, not(hasItem(mpoxCollectionA))) + assertThat(filteredCollections, not(hasItem(covidCollectionB))) + } + + @Test + fun `GIVEN collections WHEN filtering by userId and organism with no matches THEN returns empty array`() { + val userA = getNewUserId() + collectionsClient.postCollection( + dummyCollectionRequest.copy(organism = KnownTestOrganisms.Covid.name), + userA, + ) + + val collections = collectionsClient.getCollections(userId = userA, organism = KnownTestOrganisms.Mpox.name) + + assertThat(collections, empty()) + } + + @Test + fun `GIVEN collection with variants WHEN getting collection THEN all variant fields are present`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + val collections = collectionsClient.getCollections(userId = userId) + val retrievedCollection = collections.first { it.id == createdCollection.id } + + assertThat(retrievedCollection.variants, hasSize(2)) + + val queryVariant = retrievedCollection.variants.first { it is Variant.QueryVariant } as Variant.QueryVariant + assertThat(queryVariant.name, equalTo("BA.2 in USA")) + assertThat(queryVariant.description, equalTo("BA.2 lineage cases in USA")) + assertThat(queryVariant.countQuery, equalTo("country='USA' & lineage='BA.2'")) + assertThat(queryVariant.coverageQuery, equalTo("country='USA'")) + + val mutationListVariant = + retrievedCollection.variants.first { it is Variant.MutationListVariant } as Variant.MutationListVariant + assertThat(mutationListVariant.name, equalTo("Omicron mutations")) + assertThat(mutationListVariant.description, equalTo("Key mutations")) + assertThat(mutationListVariant.mutationList.aaMutations, equalTo(listOf("S:N501Y", "S:E484K", "S:K417N"))) + } + + @Test + fun `GIVEN both variant types WHEN getting collection THEN types are discriminated`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + val collections = collectionsClient.getCollections(userId = userId) + val retrievedCollection = collections.first { it.id == createdCollection.id } + + val queryVariants = retrievedCollection.variants.filterIsInstance() + val mutationListVariants = retrievedCollection.variants.filterIsInstance() + + assertThat(queryVariants, hasSize(1)) + assertThat(mutationListVariants, hasSize(1)) + + // Verify QueryVariant has query fields + assertThat(queryVariants[0].countQuery, notNullValue()) + + // Verify MutationListVariant has mutationList field + assertThat(mutationListVariants[0].mutationList, notNullValue()) + } + + @Test + fun `GIVEN collection exists WHEN getting collection by ID THEN returns collection with all fields and variants`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + val retrievedCollection = collectionsClient.getCollection(createdCollection.id) + + assertThat(retrievedCollection.id, equalTo(createdCollection.id)) + assertThat(retrievedCollection.name, equalTo(dummyCollectionRequest.name)) + assertThat(retrievedCollection.ownedBy, equalTo(userId)) + assertThat(retrievedCollection.organism, equalTo(dummyCollectionRequest.organism)) + assertThat(retrievedCollection.description, equalTo(dummyCollectionRequest.description)) + assertThat(retrievedCollection.variants, hasSize(2)) + } + + @Test + fun `GIVEN different user's collection WHEN getting by ID THEN returns (public access)`() { + val userA = getNewUserId() + val createdCollection = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "User A Collection"), + userA, + ) + + val retrievedCollection = collectionsClient.getCollection(createdCollection.id) + + assertThat(retrievedCollection.id, equalTo(createdCollection.id)) + assertThat(retrievedCollection.ownedBy, equalTo(userA)) + } + + @Test + fun `WHEN getting collection with non-existent ID THEN returns 404`() { + val nonExistentId = 999999L + + collectionsClient.getCollectionRaw(nonExistentId) + .andExpect(status().isNotFound) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.detail").value("Collection $nonExistentId not found")) + } + + @Test + fun `WHEN getting collection with non-numeric ID THEN returns 400`() { + mockMvc.perform(get("/collections/not-a-number")) + .andExpect(status().isBadRequest) + } + + // MutationListDefinition Tests + + @Test + fun `WHEN creating variant with lineage filter THEN succeeds`() { + val userId = getNewUserId() + val variantWithLineage = VariantRequest.MutationListVariantRequest( + name = "BA.2 lineage", + description = "BA.2 variant", + mutationList = org.genspectrum.dashboardsbackend.api.MutationListDefinition( + aaMutations = listOf("S:N501Y"), + filters = mapOf("pangoLineage" to "BA.2*"), + ), + ) + val request = dummyCollectionRequest.copy(variants = listOf(variantWithLineage)) + + val createdCollection = collectionsClient.postCollection(request, userId) + + assertThat(createdCollection.variants, hasSize(1)) + val variant = createdCollection.variants[0] as Variant.MutationListVariant + assertThat(variant.mutationList.aaMutations, equalTo(listOf("S:N501Y"))) + assertThat(variant.mutationList.filters!!["pangoLineage"], equalTo("BA.2*")) + } + + @Test + fun `WHEN creating variant with invalid lineage field THEN returns 400`() { + val userId = getNewUserId() + val variantWithInvalidLineage = VariantRequest.MutationListVariantRequest( + name = "Invalid lineage", + description = "Has invalid lineage field", + mutationList = org.genspectrum.dashboardsbackend.api.MutationListDefinition( + aaMutations = emptyList(), + filters = mapOf("invalidLineageField" to "value"), + ), + ) + val request = dummyCollectionRequest.copy(variants = listOf(variantWithInvalidLineage)) + + collectionsClient.postCollectionRaw(request, userId) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect( + jsonPath("$.detail").value( + org.hamcrest.Matchers.containsString("Invalid lineage fields for organism 'Covid'"), + ), + ) + .andExpect( + jsonPath("$.detail").value( + org.hamcrest.Matchers.containsString("invalidLineageField"), + ), + ) + } + + @Test + fun `WHEN creating variant with multiple lineage filters THEN succeeds`() { + val userId = getNewUserId() + val variantWithMultipleLineages = VariantRequest.MutationListVariantRequest( + name = "Multiple lineages", + description = "Has multiple lineage filters", + mutationList = org.genspectrum.dashboardsbackend.api.MutationListDefinition( + aaMutations = listOf("S:K417N"), + filters = mapOf( + "pangoLineage" to "BA.2*", + "nextcladePangoLineage" to "BA.2.75*", + ), + ), + ) + val request = dummyCollectionRequest.copy(variants = listOf(variantWithMultipleLineages)) + + val createdCollection = collectionsClient.postCollection(request, userId) + + val variant = createdCollection.variants[0] as Variant.MutationListVariant + assertThat(variant.mutationList.filters!!["pangoLineage"], equalTo("BA.2*")) + assertThat(variant.mutationList.filters!!["nextcladePangoLineage"], equalTo("BA.2.75*")) + } + + @Test + fun `WHEN creating variant with only aaMutations THEN succeeds`() { + val userId = getNewUserId() + val variantWithOnlyAaMutations = VariantRequest.MutationListVariantRequest( + name = "Only AA mutations", + description = "Only has amino acid mutations", + mutationList = org.genspectrum.dashboardsbackend.api.MutationListDefinition( + aaMutations = listOf("S:N501Y", "S:E484K"), + ), + ) + val request = dummyCollectionRequest.copy(variants = listOf(variantWithOnlyAaMutations)) + + val createdCollection = collectionsClient.postCollection(request, userId) + + val variant = createdCollection.variants[0] as Variant.MutationListVariant + assertThat(variant.mutationList.aaMutations, equalTo(listOf("S:N501Y", "S:E484K"))) + assertThat(variant.mutationList.nucMutations, org.hamcrest.Matchers.nullValue()) + } + + @Test + fun `WHEN creating variant with insertions THEN succeeds`() { + val userId = getNewUserId() + val variantWithInsertions = VariantRequest.MutationListVariantRequest( + name = "With insertions", + description = "Has insertions", + mutationList = org.genspectrum.dashboardsbackend.api.MutationListDefinition( + aaMutations = listOf("S:N501Y"), + aaInsertions = listOf("ins_S:214:EPE"), + nucInsertions = listOf("ins_22204:GAG"), + ), + ) + val request = dummyCollectionRequest.copy(variants = listOf(variantWithInsertions)) + + val createdCollection = collectionsClient.postCollection(request, userId) + + val variant = createdCollection.variants[0] as Variant.MutationListVariant + assertThat(variant.mutationList.aaInsertions, equalTo(listOf("ins_S:214:EPE"))) + assertThat(variant.mutationList.nucInsertions, equalTo(listOf("ins_22204:GAG"))) + } + + @Test + fun `WHEN owner deletes collection THEN succeeds and collection is removed`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + collectionsClient.deleteCollection(createdCollection.id, userId) + + collectionsClient.getCollectionRaw(createdCollection.id) + .andExpect(status().isNotFound) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.detail").value("Collection ${createdCollection.id} not found")) + } + + @Test + fun `WHEN non-owner deletes collection THEN returns 403 forbidden`() { + val owner = getNewUserId() + val nonOwner = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, owner) + + collectionsClient.deleteCollectionRaw(createdCollection.id, nonOwner) + .andExpect(status().isForbidden) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect( + jsonPath("$.detail").value( + org.hamcrest.Matchers.containsString("you don't have permission to delete it"), + ), + ) + } + + @Test + fun `WHEN deleting non-existent collection THEN returns 403`() { + val userId = getNewUserId() + val nonExistentId = 999999L + + collectionsClient.deleteCollectionRaw(nonExistentId, userId) + .andExpect(status().isForbidden) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect( + jsonPath("$.detail").value( + org.hamcrest.Matchers.containsString("not found or you don't have permission"), + ), + ) + } + + @Test + fun `WHEN owner updates all fields THEN collection is updated`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + val updatedVariants = listOf( + VariantUpdate.QueryVariantUpdate( + name = "New Variant", + description = "New description", + countQuery = "country='USA'", + coverageQuery = "country='USA'", + ), + ) + + val update = CollectionUpdate( + name = "Updated Name", + description = "Updated Description", + variants = updatedVariants, + ) + + val updated = collectionsClient.putCollection(update, createdCollection.id, userId) + + assertThat(updated.name, equalTo("Updated Name")) + assertThat(updated.description, equalTo("Updated Description")) + assertThat(updated.variants.size, equalTo(1)) + val firstVariant = updated.variants[0] as Variant.QueryVariant + assertThat(firstVariant.name, equalTo("New Variant")) + } + + @Test + fun `WHEN owner updates only name THEN only name changes`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + val originalVariantCount = createdCollection.variants.size + + val update = CollectionUpdate(name = "Just Name Change") + + val updated = collectionsClient.putCollection(update, createdCollection.id, userId) + + assertThat(updated.name, equalTo("Just Name Change")) + assertThat(updated.description, equalTo(createdCollection.description)) + assertThat(updated.variants.size, equalTo(originalVariantCount)) + } + + @Test + fun `WHEN owner sends empty update THEN nothing changes`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + val update = CollectionUpdate() + + val updated = collectionsClient.putCollection(update, createdCollection.id, userId) + + assertThat(updated, equalTo(createdCollection)) + } + + @Test + fun `WHEN owner adds new variant without ID THEN variant is created`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + val originalVariantCount = createdCollection.variants.size + + val newVariant = VariantUpdate.MutationListVariantUpdate( + id = null, + name = "New Variant", + mutationList = MutationListDefinition( + aaMutations = listOf("S:N501Y"), + ), + ) + + val existingVariants = createdCollection.variants.map { variant -> + when (variant) { + is Variant.QueryVariant -> VariantUpdate.QueryVariantUpdate( + id = variant.id, + name = variant.name, + description = variant.description, + countQuery = variant.countQuery, + coverageQuery = variant.coverageQuery, + ) + is Variant.MutationListVariant -> VariantUpdate.MutationListVariantUpdate( + id = variant.id, + name = variant.name, + description = variant.description, + mutationList = variant.mutationList, + ) + } + } + + val update = CollectionUpdate(variants = existingVariants + newVariant) + + val updated = collectionsClient.putCollection(update, createdCollection.id, userId) + + assertThat(updated.variants.size, equalTo(originalVariantCount + 1)) + assertThat( + updated.variants.any { variant -> + when (variant) { + is Variant.QueryVariant -> variant.name == "New Variant" + is Variant.MutationListVariant -> variant.name == "New Variant" + } + }, + equalTo(true), + ) + } + + @Test + fun `WHEN non-owner updates collection THEN returns 403 forbidden`() { + val owner = getNewUserId() + val nonOwner = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, owner) + + collectionsClient.putCollectionRaw( + CollectionUpdate(name = "Hacked Name"), + createdCollection.id, + nonOwner, + ) + .andExpect(status().isForbidden) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect( + jsonPath("$.detail").value( + org.hamcrest.Matchers.containsString("you don't have permission to update it"), + ), + ) + } + + @Test + fun `WHEN owner updates existing variant with ID THEN variant is updated in place`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + val firstVariant = createdCollection.variants[0] as Variant.QueryVariant + + val updatedVariant = VariantUpdate.QueryVariantUpdate( + id = firstVariant.id, + name = "Updated Name", + description = "Updated Description", + countQuery = "country='France'", + coverageQuery = "country='France'", + ) + + val update = CollectionUpdate(variants = listOf(updatedVariant)) + + val updated = collectionsClient.putCollection(update, createdCollection.id, userId) + + assertThat(updated.variants.size, equalTo(1)) + assertThat(updated.variants[0].id, equalTo(firstVariant.id)) + val queryVariant = updated.variants[0] as Variant.QueryVariant + assertThat(queryVariant.name, equalTo("Updated Name")) + assertThat(queryVariant.countQuery, equalTo("country='France'")) + } + + @Test + fun `WHEN owner omits variant from update THEN variant is deleted`() { + val userId = getNewUserId() + val originalVariants = listOf( + VariantRequest.QueryVariantRequest( + name = "Variant 1", + countQuery = "country='USA'", + ), + VariantRequest.QueryVariantRequest( + name = "Variant 2", + countQuery = "country='Germany'", + ), + ) + val collectionRequest = dummyCollectionRequest.copy(variants = originalVariants) + val createdCollection = collectionsClient.postCollection(collectionRequest, userId) + + val firstVariant = createdCollection.variants[0] + val keepVariant = VariantUpdate.QueryVariantUpdate( + id = firstVariant.id, + name = (firstVariant as Variant.QueryVariant).name, + countQuery = firstVariant.countQuery, + ) + + val update = CollectionUpdate(variants = listOf(keepVariant)) + + val updated = collectionsClient.putCollection(update, createdCollection.id, userId) + + assertThat(updated.variants.size, equalTo(1)) + assertThat(updated.variants[0].id, equalTo(firstVariant.id)) + } + + @Test + fun `WHEN updating with invalid lineage fields THEN returns 400`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + val invalidVariant = VariantUpdate.MutationListVariantUpdate( + name = "Invalid Variant", + mutationList = MutationListDefinition( + aaMutations = listOf("S:N501Y"), + filters = mapOf("invalidField" to "value"), + ), + ) + + val update = CollectionUpdate(variants = listOf(invalidVariant)) + + collectionsClient.putCollectionRaw(update, createdCollection.id, userId) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.detail").value(org.hamcrest.Matchers.containsString("Invalid lineage fields"))) + } + + @Test + fun `WHEN updating variant type THEN returns 400`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + val firstVariant = createdCollection.variants[0] as Variant.QueryVariant + + val invalidUpdate = VariantUpdate.MutationListVariantUpdate( + id = firstVariant.id, + name = "Changed Type", + mutationList = MutationListDefinition( + aaMutations = listOf("S:N501Y"), + ), + ) + + val update = CollectionUpdate(variants = listOf(invalidUpdate)) + + collectionsClient.putCollectionRaw(update, createdCollection.id, userId) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.detail").value(org.hamcrest.Matchers.containsString("Cannot change variant type"))) + } + + @Test + fun `WHEN updating with variant ID from different collection THEN returns 400`() { + val userId = getNewUserId() + val collection1 = collectionsClient.postCollection(dummyCollectionRequest, userId) + val collection2 = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Collection 2"), + userId, + ) + + val variantFromCollection2 = collection2.variants[0] as Variant.QueryVariant + val invalidUpdate = VariantUpdate.QueryVariantUpdate( + id = variantFromCollection2.id, + name = variantFromCollection2.name, + countQuery = variantFromCollection2.countQuery, + ) + + val update = CollectionUpdate(variants = listOf(invalidUpdate)) + + collectionsClient.putCollectionRaw(update, collection1.id, userId) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect( + jsonPath("$.detail").value(org.hamcrest.Matchers.containsString("does not belong to collection")), + ) + } + + @Test + fun `WHEN creating collection with lineage filter in filters field THEN succeeds`() { + val userId = getNewUserId() + val body = """ + { + "name": "Test", + "organism": "Covid", + "description": "Test", + "variants": [{ + "type": "mutationList", + "name": "Test variant", + "mutationList": { + "filters": {"pangoLineage": "B.1.1.7"} + } + }] + } + """.trimIndent() + + mockMvc.perform( + post("/collections?userId=$userId") + .content(body) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isCreated) + } +} diff --git a/backend/src/test/resources/application.yaml b/backend/src/test/resources/application.yaml index ada076c8..afafa819 100644 --- a/backend/src/test/resources/application.yaml +++ b/backend/src/test/resources/application.yaml @@ -11,6 +11,9 @@ dashboards: lapis: url: "https://covid.lapis.dummy" mainDateField: "covid_date" + lineageFields: + - "pangoLineage" + - "nextcladePangoLineage" additionalFilters: someAdditionalFilter: "covid_additional_filter" Mpox: diff --git a/docker-compose.yml b/docker-compose.yml index 9d20579c..377df34f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: depends_on: - database command: - - --spring.datasource.url=jdbc:postgresql://database:5432/subscriptions + - --spring.datasource.url=jdbc:postgresql://database:5432/dashboards-backend-db - --spring.datasource.username=postgres - --spring.datasource.password=unsecure - --spring.profiles.active=dashboards-prod @@ -29,6 +29,6 @@ services: environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: unsecure - POSTGRES_DB: subscriptions + POSTGRES_DB: dashboards-backend-db ports: - "127.0.0.1:9022:5432"